diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-16 15:28:54 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-16 15:28:54 +0900 |
| commit | 1a018be0f4aefe957fad0edcde7eb10b6728069b (patch) | |
| tree | 6381e726be1cbef7bd9d9031bb50b733d2f23b69 /crates/shirabe/src/config/json_config_source.rs | |
| parent | fe461d7eec72881f2e25d73f2719138fa88b3689 (diff) | |
| download | php-shirabe-1a018be0f4aefe957fad0edcde7eb10b6728069b.tar.gz php-shirabe-1a018be0f4aefe957fad0edcde7eb10b6728069b.tar.zst php-shirabe-1a018be0f4aefe957fad0edcde7eb10b6728069b.zip | |
feat(port): port JsonConfigSource.php
Diffstat (limited to 'crates/shirabe/src/config/json_config_source.rs')
| -rw-r--r-- | crates/shirabe/src/config/json_config_source.rs | 423 |
1 files changed, 423 insertions, 0 deletions
diff --git a/crates/shirabe/src/config/json_config_source.rs b/crates/shirabe/src/config/json_config_source.rs index 7820239..264b0e1 100644 --- a/crates/shirabe/src/config/json_config_source.rs +++ b/crates/shirabe/src/config/json_config_source.rs @@ -1 +1,424 @@ //! ref: composer/src/Composer/Config/JsonConfigSource.php + +use anyhow::Result; +use indexmap::IndexMap; +use shirabe_php_shim::{ + array_unshift, call_user_func_array, chmod, explode, file_get_contents, file_put_contents, + implode, is_writable, sprintf, PhpMixed, RuntimeException, Silencer, PHP_EOL, +}; + +use crate::config::config_source_interface::ConfigSourceInterface; +use crate::json::json_file::JsonFile; +use crate::json::json_manipulator::JsonManipulator; +use crate::json::json_validation_exception::JsonValidationException; +use crate::util::filesystem::Filesystem; + +/// JSON Configuration Source +#[derive(Debug)] +pub struct JsonConfigSource { + /// @var JsonFile + file: JsonFile, + + /// @var bool + auth_config: bool, +} + +impl JsonConfigSource { + /// Constructor + pub fn new(file: JsonFile, auth_config: bool) -> Self { + Self { file, auth_config } + } + + /// @param mixed ...$args + fn manipulate_json( + &mut self, + method: &str, + // TODO(phase-b): callback signature uses &mut $config (PHP reference) and variadic args + fallback: Box<dyn Fn(&mut PhpMixed, &mut Vec<PhpMixed>)>, + mut args: Vec<PhpMixed>, + ) -> Result<()> { + let contents; + if self.file.exists() { + if !is_writable(self.file.get_path()) { + return Err(RuntimeException { + message: sprintf( + "The file \"%s\" is not writable.", + &[PhpMixed::String(self.file.get_path().to_string())], + ), + code: 0, + } + .into()); + } + + if !Filesystem::is_readable(self.file.get_path()) { + return Err(RuntimeException { + message: sprintf( + "The file \"%s\" is not readable.", + &[PhpMixed::String(self.file.get_path().to_string())], + ), + code: 0, + } + .into()); + } + + contents = file_get_contents(self.file.get_path()).unwrap_or_default(); + } else if self.auth_config { + contents = "{\n}\n".to_string(); + } else { + contents = "{\n \"config\": {\n }\n}\n".to_string(); + } + + let mut manipulator = JsonManipulator::new(&contents); + + let new_file = !self.file.exists(); + + // override manipulator method for auth config files + let mut method = method.to_string(); + if self.auth_config && method == "addConfigSetting" { + method = "addSubNode".to_string(); + let parts = explode(".", args[0].as_string().unwrap_or("")); + let main_node = parts.get(0).cloned().unwrap_or_default(); + let name = parts.get(1).cloned().unwrap_or_default(); + args = vec![ + PhpMixed::String(main_node), + PhpMixed::String(name), + args[1].clone(), + ]; + } else if self.auth_config && method == "removeConfigSetting" { + method = "removeSubNode".to_string(); + let parts = explode(".", args[0].as_string().unwrap_or("")); + let main_node = parts.get(0).cloned().unwrap_or_default(); + let name = parts.get(1).cloned().unwrap_or_default(); + args = vec![PhpMixed::String(main_node), PhpMixed::String(name)]; + } + + // try to update cleanly + // PHP: call_user_func_array([$manipulator, $method], $args) + let manipulator_result: bool = call_user_func_array( + // TODO(phase-b): callable [manipulator, method] requires bound-method dispatch + todo!("[manipulator, method] callable"), + &PhpMixed::List(args.iter().map(|a| Box::new(a.clone())).collect()), + ) + .as_bool() + .unwrap_or(false); + if manipulator_result { + file_put_contents(self.file.get_path(), manipulator.get_contents().as_bytes()); + } else { + // on failed clean update, call the fallback and rewrite the whole file + let mut config = self.file.read()?; + self.array_unshift_ref(&mut args, &mut config); + fallback(&mut config, &mut args); + // avoid ending up with arrays for keys that should be objects + for prop in [ + "require", + "require-dev", + "conflict", + "provide", + "replace", + "suggest", + "config", + "autoload", + "autoload-dev", + "scripts", + "scripts-descriptions", + "scripts-aliases", + "support", + ] { + if let PhpMixed::Array(map) = &mut config { + if let Some(boxed) = map.get(prop) { + if let PhpMixed::Array(inner) = boxed.as_ref() { + if inner.is_empty() { + // PHP: $config[$prop] = new \stdClass; + map.insert( + prop.to_string(), + Box::new(PhpMixed::Array(IndexMap::new())), + ); + } + } + } + } + } + for prop in ["psr-0", "psr-4"] { + if let PhpMixed::Array(map) = &mut config { + if let Some(autoload) = map.get_mut("autoload") { + if let PhpMixed::Array(autoload_map) = autoload.as_mut() { + if let Some(inner) = autoload_map.get(prop) { + if let PhpMixed::Array(inner_map) = inner.as_ref() { + if inner_map.is_empty() { + autoload_map.insert( + prop.to_string(), + Box::new(PhpMixed::Array(IndexMap::new())), + ); + } + } + } + } + } + if let Some(autoload_dev) = map.get_mut("autoload-dev") { + if let PhpMixed::Array(autoload_dev_map) = autoload_dev.as_mut() { + if let Some(inner) = autoload_dev_map.get(prop) { + if let PhpMixed::Array(inner_map) = inner.as_ref() { + if inner_map.is_empty() { + autoload_dev_map.insert( + prop.to_string(), + Box::new(PhpMixed::Array(IndexMap::new())), + ); + } + } + } + } + } + } + } + for prop in [ + "platform", + "http-basic", + "bearer", + "gitlab-token", + "gitlab-oauth", + "github-oauth", + "custom-headers", + "forgejo-token", + "preferred-install", + ] { + if let PhpMixed::Array(map) = &mut config { + if let Some(cfg) = map.get_mut("config") { + if let PhpMixed::Array(cfg_map) = cfg.as_mut() { + if let Some(inner) = cfg_map.get(prop) { + if let PhpMixed::Array(inner_map) = inner.as_ref() { + if inner_map.is_empty() { + cfg_map.insert( + prop.to_string(), + Box::new(PhpMixed::Array(IndexMap::new())), + ); + } + } + } + } + } + } + } + self.file.write(config, shirabe_php_shim::JSON_UNESCAPED_SLASHES + | shirabe_php_shim::JSON_PRETTY_PRINT + | shirabe_php_shim::JSON_UNESCAPED_UNICODE)?; + } + + // TODO(phase-b): use anyhow::Result<Result<T, E>> to model PHP try/catch + match self.file.validate_schema(JsonFile::LAX_SCHEMA, None) { + Ok(_) => {} + Err(e) => { + // TODO(phase-b): downcast e to JsonValidationException to match the specific catch + let _jve: &JsonValidationException = todo!("downcast e to JsonValidationException"); + // restore contents to the original state + file_put_contents(self.file.get_path(), contents.as_bytes()); + return Err(RuntimeException { + message: format!( + "Failed to update composer.json with a valid format, reverting to the original content. Please report an issue to us with details (command you run and a copy of your composer.json). {}{}", + PHP_EOL, + implode(PHP_EOL, todo!("e.get_errors()")), + ), + code: 0, + } + .into()); + } + } + + if new_file { + let path = self.file.get_path().to_string(); + let _ = Silencer::call(|| { + chmod(&path, 0o600); + Ok(()) + }); + } + + Ok(()) + } + + /// Prepend a reference to an element to the beginning of an array. + /// + /// @param mixed[] $array + /// @param mixed $value + fn array_unshift_ref(&self, array: &mut Vec<PhpMixed>, value: &mut PhpMixed) -> i64 { + let return_val = array_unshift(array, PhpMixed::String(String::new())); + // PHP: $array[0] = &$value; (PHP reference) + // TODO(phase-b): retain reference semantics so later mutations of $value propagate + array[0] = value.clone(); + + return_val.map(|_| 0).unwrap_or(0) + array.len() as i64 + } +} + +impl ConfigSourceInterface for JsonConfigSource { + fn get_name(&self) -> String { + self.file.get_path().to_string() + } + + fn add_repository( + &mut self, + name: &str, + config: Option<IndexMap<String, PhpMixed>>, + append: bool, + ) -> Result<()> { + let name_owned = name.to_string(); + let config_owned = config.clone(); + self.manipulate_json( + "addRepository", + Box::new(move |cfg: &mut PhpMixed, args: &mut Vec<PhpMixed>| { + // TODO(phase-b): port the closure body — args are [$cfg, $repo, $repoConfig, $append] + let _ = (cfg, args); + todo!("addRepository fallback closure body"); + }), + vec![ + PhpMixed::String(name_owned), + config_owned + .map(|m| { + PhpMixed::Array(m.into_iter().map(|(k, v)| (k, Box::new(v))).collect()) + }) + .unwrap_or(PhpMixed::Bool(false)), + PhpMixed::Bool(append), + ], + ) + } + + fn insert_repository( + &mut self, + name: &str, + config: Option<IndexMap<String, PhpMixed>>, + reference_name: &str, + offset: i64, + ) -> Result<()> { + let name_owned = name.to_string(); + let config_owned = config.clone(); + let reference_name_owned = reference_name.to_string(); + self.manipulate_json( + "insertRepository", + Box::new(move |cfg: &mut PhpMixed, args: &mut Vec<PhpMixed>| { + // TODO(phase-b): port the closure body + let _ = (cfg, args); + todo!("insertRepository fallback closure body"); + }), + vec![ + PhpMixed::String(name_owned), + config_owned + .map(|m| { + PhpMixed::Array(m.into_iter().map(|(k, v)| (k, Box::new(v))).collect()) + }) + .unwrap_or(PhpMixed::Bool(false)), + PhpMixed::String(reference_name_owned), + PhpMixed::Int(offset), + ], + ) + } + + fn set_repository_url(&mut self, name: &str, url: &str) -> Result<()> { + let _name_owned = name.to_string(); + let _url_owned = url.to_string(); + self.manipulate_json( + "setRepositoryUrl", + Box::new(move |cfg: &mut PhpMixed, args: &mut Vec<PhpMixed>| { + // PHP: foreach ($config['repositories'] ?? [] as $index => $repository) { ... } + let _ = (cfg, args); + todo!("setRepositoryUrl fallback closure body"); + }), + vec![PhpMixed::String(name.to_string()), PhpMixed::String(url.to_string())], + ) + } + + fn remove_repository(&mut self, name: &str) -> Result<()> { + self.manipulate_json( + "removeRepository", + Box::new(move |cfg: &mut PhpMixed, args: &mut Vec<PhpMixed>| { + let _ = (cfg, args); + todo!("removeRepository fallback closure body"); + }), + vec![PhpMixed::String(name.to_string())], + ) + } + + fn add_config_setting(&mut self, name: &str, value: PhpMixed) -> Result<()> { + let auth_config = self.auth_config; + self.manipulate_json( + "addConfigSetting", + Box::new(move |cfg: &mut PhpMixed, args: &mut Vec<PhpMixed>| { + // PHP: [$key, $host] = explode('.', $key, 2); + let _ = (cfg, args, auth_config); + todo!("addConfigSetting fallback closure body"); + }), + vec![PhpMixed::String(name.to_string()), value], + ) + } + + fn remove_config_setting(&mut self, name: &str) -> Result<()> { + let auth_config = self.auth_config; + self.manipulate_json( + "removeConfigSetting", + Box::new(move |cfg: &mut PhpMixed, args: &mut Vec<PhpMixed>| { + let _ = (cfg, args, auth_config); + todo!("removeConfigSetting fallback closure body"); + }), + vec![PhpMixed::String(name.to_string())], + ) + } + + fn add_property(&mut self, name: &str, value: PhpMixed) -> Result<()> { + self.manipulate_json( + "addProperty", + Box::new(move |cfg: &mut PhpMixed, args: &mut Vec<PhpMixed>| { + let _ = (cfg, args); + todo!("addProperty fallback closure body"); + }), + vec![PhpMixed::String(name.to_string()), value], + ) + } + + fn remove_property(&mut self, name: &str) -> Result<()> { + self.manipulate_json( + "removeProperty", + Box::new(move |cfg: &mut PhpMixed, args: &mut Vec<PhpMixed>| { + let _ = (cfg, args); + todo!("removeProperty fallback closure body"); + }), + vec![PhpMixed::String(name.to_string())], + ) + } + + fn add_link(&mut self, r#type: &str, name: &str, value: &str) -> Result<()> { + self.manipulate_json( + "addLink", + Box::new(move |cfg: &mut PhpMixed, args: &mut Vec<PhpMixed>| { + // PHP: $config[$type][$name] = $value; + let _ = (cfg, args); + todo!("addLink fallback closure body"); + }), + vec![ + PhpMixed::String(r#type.to_string()), + PhpMixed::String(name.to_string()), + PhpMixed::String(value.to_string()), + ], + ) + } + + fn remove_link(&mut self, r#type: &str, name: &str) -> Result<()> { + self.manipulate_json( + "removeSubNode", + Box::new(move |cfg: &mut PhpMixed, args: &mut Vec<PhpMixed>| { + // PHP: unset($config[$type][$name]); + let _ = (cfg, args); + todo!("removeLink fallback (unset subnode) closure body"); + }), + vec![ + PhpMixed::String(r#type.to_string()), + PhpMixed::String(name.to_string()), + ], + )?; + self.manipulate_json( + "removeMainKeyIfEmpty", + Box::new(move |cfg: &mut PhpMixed, args: &mut Vec<PhpMixed>| { + // PHP: if (0 === count($config[$type])) { unset($config[$type]); } + let _ = (cfg, args); + todo!("removeLink fallback (unset main key if empty) closure body"); + }), + vec![PhpMixed::String(r#type.to_string())], + ) + } +} |
