diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3574526 --- /dev/null +++ b/.envrc @@ -0,0 +1,3 @@ +export CC=gcc +use guix + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a838326 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,8 @@ +Validate your work: `make check`. +Use Test Driven Development practices. Refactor using Red Green Refactor loops. +Code MUST be simple, minimal and readable. +Avoid duplicating code as much as you can. +Run cargo commands with the `CC=gcc` environment variable. +When editing YAML, preserve comments and blank lines around untouched keys. +Touched keys may be replaced with remove/add semantics, even if their comments are lost. +The tools MUST NEVER reformat a YAML file when editing it. diff --git a/Cargo.lock b/Cargo.lock index a41a7b4..bc5c2f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,6 +61,16 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "clap" version = "4.5.60" @@ -113,6 +123,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "hashbrown" version = "0.16.1" @@ -133,6 +149,8 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown", + "serde", + "serde_core", ] [[package]] @@ -153,12 +171,28 @@ version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +[[package]] +name = "line-index" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e27e0ed5a392a7f5ba0b3808a2afccff16c64933312c84b57618b49d1209bd2" +dependencies = [ + "nohash-hasher", + "text-size", +] + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "once_cell_polyfill" version = "1.70.2" @@ -218,6 +252,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + [[package]] name = "serde" version = "1.0.228" @@ -225,6 +265,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -253,6 +294,7 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ + "indexmap", "itoa", "memchr", "serde", @@ -273,12 +315,35 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subfeature" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46cafa37a988fbf02105b92826360798f64d97378034f09bafabc8fd6ad0a13a" +dependencies = [ + "memchr", + "regex", + "serde", +] + [[package]] name = "syn" version = "2.0.117" @@ -300,6 +365,71 @@ dependencies = [ "winapi", ] +[[package]] +name = "text-size" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tree-sitter" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13f456d2108c3fef07342ba4689a8503ec1fb5beed245e2b9be93096ef394848" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-iter" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3127a537873850938a6b47ab023454be1a7c8f63bd699c95fc7b0dfa47964254" +dependencies = [ + "tree-sitter", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-yaml" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c223db85f05e34794f065454843b0668ebc15d240ada63e2b5939f43ce7c97" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -355,6 +485,37 @@ dependencies = [ "windows-link", ] +[[package]] +name = "yamlpatch" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "161da658a316f0c1cd94875d49e4a63eaa25f7a2acf28d0a2429b78a0b8d845a" +dependencies = [ + "indexmap", + "line-index", + "serde", + "serde_json", + "serde_yaml", + "subfeature", + "thiserror", + "yamlpath", +] + +[[package]] +name = "yamlpath" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73483c026a544d0172d0f3435e929bd054230f97ad0c4a9afc610a8cef1358b" +dependencies = [ + "line-index", + "self_cell", + "serde", + "thiserror", + "tree-sitter", + "tree-sitter-iter", + "tree-sitter-yaml", +] + [[package]] name = "ym" version = "0.1.0" @@ -363,7 +524,11 @@ dependencies = [ "regex", "serde_json", "serde_yaml", + "subfeature", "termsize", + "thiserror", + "yamlpatch", + "yamlpath", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ac4fe58..36931bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,3 +9,7 @@ serde_yaml = "0.9" regex = "1" serde_json = "1" termsize = "0.1" +yamlpatch = "0.12" +yamlpath = "0.34" +subfeature = "0.0.4" +thiserror = "2" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e8789a8 --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +.PHONY: check + +build: + CC=gcc cargo build + +test: + CC=gcc cargo test + +lint: + CC=gcc cargo clippy -- -D warnings + +check: test lint + cargo fmt --check + CC=gcc cargo clippy -- -D warnings + +.DEFAULT_GOAL := check diff --git a/README.md b/README.md index 6e92c44..b714ab2 100644 --- a/README.md +++ b/README.md @@ -39,10 +39,10 @@ cargo build --release $ ym cp tests/data/config-prod.yaml:database.primary.password tests/data/app-config.yaml:database.secondary.password $ # Copy value from source.key to destination.key (same file, destination file is optional and defaults to source file) - $ ym cp tests/data/app-config.yaml:database.primary.password database.replica.password + $ ym cp tests/data/app-config.yaml:database.primary.password :database.replica.password $ # Copy value to a different file (with same key, destination key is optional and defaults to source key) - $ ym cp tests/data/app-config.yaml:app.name tests/data/config-prod.yaml: + $ ym cp tests/data/app-config.yaml:app.name tests/data/config-prod.yaml ``` - use **mv** to move a value from one key to another (copies then deletes the source): @@ -52,8 +52,8 @@ cargo build --release $ ym mv tests/data/config-prod.yaml:database.primary.password tests/data/app-config.yaml:database.secondary.password $ # Move value from source.key to destination.key (same file, destination file is optional and defaults to source file) - $ ym mv tests/data/app-config.yaml:database.primary.password database.replica.password + $ ym mv tests/data/app-config.yaml:database.primary.password :database.replica.password $ # Move value to a different file (with same key, destination key is optional and defaults to source key) - $ ym mv tests/data/app-config.yaml:app.name tests/data/config-prod.yaml: + $ ym mv tests/data/app-config.yaml:app.name tests/data/config-prod.yaml ``` diff --git a/src/cli.rs b/src/cli.rs index 522928f..7059c66 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,7 +1,8 @@ use clap::{Parser, Subcommand}; -use std::collections::HashMap; -#[derive(Debug)] +use crate::error::{AppError, AppResult}; + +#[derive(Debug, PartialEq, Eq)] pub enum Command { Grep { pattern: String, @@ -10,7 +11,7 @@ pub enum Command { }, Set { file: String, - updates: HashMap, + updates: Vec<(String, String)>, }, Unset { file: String, @@ -41,61 +42,51 @@ pub struct Cli { #[derive(Subcommand, Debug)] pub enum Commands { - /// Search YAML keys by regex pattern (reads stdin if no files provided) Grep { - /// Pattern to search for pattern: String, - /// Recursive search in directories #[arg(short = 'R')] recursive: bool, - /// Files or directories to search (if empty, reads from stdin) #[arg(trailing_var_arg = true, allow_hyphen_values = true)] files: Vec, }, - /// Set YAML values at key paths Set { - /// File to modify file: String, - /// Key=value pairs to set (values can contain '=') #[arg(trailing_var_arg = true, allow_hyphen_values = true)] updates: Vec, }, - /// Remove keys from YAML Unset { - /// File to modify file: String, - /// Keys to remove (support nested paths like database.password) #[arg(trailing_var_arg = true, allow_hyphen_values = true)] keys: Vec, }, - /// Copy a value from one key to another (same or different file) Cp { - /// Source file and key in format: file.yaml:key.path source: String, - /// Destination file and key in format: file.yaml:key.path (optional) #[arg(trailing_var_arg = true, allow_hyphen_values = true)] destination: Vec, }, - /// Move a value from one key to another (deletes source after copying) Mv { - /// Source file and key in format: file.yaml:key.path source: String, - /// Destination file and key in format: file.yaml:key.path (optional) #[arg(trailing_var_arg = true, allow_hyphen_values = true)] destination: Vec, }, } -pub fn parse_cli() -> Result { - let cli = Cli::parse(); +pub fn parse_cli() -> AppResult { + command_from_cli(Cli::parse()) +} + +fn command_from_cli(cli: Cli) -> AppResult { + command_from_parsed(cli.command) +} - match cli.command { +fn command_from_parsed(command: Commands) -> AppResult { + match command { Commands::Grep { pattern, recursive, @@ -105,29 +96,13 @@ pub fn parse_cli() -> Result { recursive, files, }), - Commands::Set { file, updates } => { - if updates.is_empty() { - return Err("set requires at least one key=value pair".to_string()); - } - - let mut parsed_updates = HashMap::new(); - - for update in updates { - let parts: Vec<&str> = update.splitn(2, '=').collect(); - if parts.len() != 2 { - return Err(format!("Invalid key=value pair: {}", update)); - } - parsed_updates.insert(parts[0].to_string(), parts[1].to_string()); - } - - Ok(Command::Set { - file, - updates: parsed_updates, - }) - } + Commands::Set { file, updates } => Ok(Command::Set { + file, + updates: parse_updates(updates)?, + }), Commands::Unset { file, keys } => { if keys.is_empty() { - return Err("unset requires at least one key".to_string()); + return Err(AppError::cli("unset requires at least one key")); } Ok(Command::Unset { file, keys }) @@ -136,256 +111,163 @@ pub fn parse_cli() -> Result { source, destination, } => { - // Parse source as file:key - let (source_file, source_key) = parse_file_key_pair(&source)?; - - // Parse destination if provided - let (dest_file, dest_key) = if destination.is_empty() { - // No destination provided: error if neither file nor key changes - (None, None) - } else if destination.len() == 1 { - // Single destination argument - parse_optional_file_key_pair(&destination[0])? - } else { - return Err("cp accepts at most one destination argument".to_string()); - }; - - // Validate that at least one of dest_file or dest_key is provided - if dest_file.is_none() && dest_key.is_none() { - return Err( - "destination file and destination key cannot both be omitted".to_string(), - ); - } - + let transfer = parse_transfer_command(source, destination, "cp")?; Ok(Command::Cp { - source_file, - source_key, - dest_file, - dest_key, + source_file: transfer.source_file, + source_key: transfer.source_key, + dest_file: transfer.dest_file, + dest_key: transfer.dest_key, }) } Commands::Mv { source, destination, } => { - // Parse source as file:key - let (source_file, source_key) = parse_file_key_pair(&source)?; - - // Parse destination if provided - let (dest_file, dest_key) = if destination.is_empty() { - // No destination provided: error if neither file nor key changes - (None, None) - } else if destination.len() == 1 { - // Single destination argument - parse_optional_file_key_pair(&destination[0])? - } else { - return Err("mv accepts at most one destination argument".to_string()); - }; - - // Validate that at least one of dest_file or dest_key is provided - if dest_file.is_none() && dest_key.is_none() { - return Err( - "destination file and destination key cannot both be omitted".to_string(), - ); - } - + let transfer = parse_transfer_command(source, destination, "mv")?; Ok(Command::Mv { - source_file, - source_key, - dest_file, - dest_key, + source_file: transfer.source_file, + source_key: transfer.source_key, + dest_file: transfer.dest_file, + dest_key: transfer.dest_key, }) } } } -/// Parse a required file:key pair -fn parse_file_key_pair(input: &str) -> Result<(String, String), String> { - let parts: Vec<&str> = input.splitn(2, ':').collect(); - if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() { - return Err(format!( - "Invalid file:key pair: {} (expected format: file.yaml:key.path)", - input +fn parse_updates(updates: Vec) -> AppResult> { + if updates.is_empty() { + return Err(AppError::cli("set requires at least one key=value pair")); + } + + updates + .into_iter() + .map(|update| { + let (key, value) = update + .split_once('=') + .ok_or_else(|| AppError::cli(format!("Invalid key=value pair: {update}")))?; + Ok((key.to_string(), value.to_string())) + }) + .collect() +} + +struct TransferCommand { + source_file: String, + source_key: String, + dest_file: Option, + dest_key: Option, +} + +fn parse_transfer_command( + source: String, + destination: Vec, + name: &str, +) -> AppResult { + let (source_file, source_key) = parse_file_key_pair(&source)?; + + let (dest_file, dest_key) = match destination.as_slice() { + [] => (None, None), + [single] => parse_optional_file_key_pair(single)?, + _ => { + return Err(AppError::cli(format!( + "{name} accepts at most one destination argument" + ))); + } + }; + + if dest_file.is_none() && dest_key.is_none() { + return Err(AppError::cli( + "destination file and destination key cannot both be omitted", )); } - Ok((parts[0].to_string(), parts[1].to_string())) + + Ok(TransferCommand { + source_file, + source_key, + dest_file, + dest_key, + }) } -/// Parse an optional file:key pair. Returns (None, None) if input is just a key (no colon), -/// or (Some(file), Some(key)) if both are provided. -/// If the input has a colon, file and key can be filled independently (at least one must be non-empty). -fn parse_optional_file_key_pair(input: &str) -> Result<(Option, Option), String> { - if let Some(colon_pos) = input.find(':') { - let file = &input[..colon_pos]; - let key = &input[colon_pos + 1..]; +fn parse_file_key_pair(input: &str) -> AppResult<(String, String)> { + let Some((file, key)) = input.split_once(':') else { + return Err(AppError::cli(format!( + "Invalid file:key pair: {input} (expected format: file.yaml:key.path)" + ))); + }; + + if file.is_empty() || key.is_empty() { + return Err(AppError::cli(format!( + "Invalid file:key pair: {input} (expected format: file.yaml:key.path)" + ))); + } + + Ok((file.to_string(), key.to_string())) +} - // Ensure at least file or key is non-empty, and neither is exactly "invalid:invalid:too:many" pattern +fn parse_optional_file_key_pair(input: &str) -> AppResult<(Option, Option)> { + if let Some((file, key)) = input.split_once(':') { if file.is_empty() && key.is_empty() { - return Err(format!( - "Invalid file:key pair: {} (file and key cannot both be empty)", - input - )); + return Err(AppError::cli(format!( + "Invalid file:key pair: {input} (file and key cannot both be empty)" + ))); } - Ok(( - if file.is_empty() { - None - } else { - Some(file.to_string()) - }, - if key.is_empty() { - None - } else { - Some(key.to_string()) - }, - )) + return Ok(( + (!file.is_empty()).then(|| file.to_string()), + (!key.is_empty()).then(|| key.to_string()), + )); + } + + if input.is_empty() { + return Err(AppError::cli("Key cannot be empty")); + } + + if looks_like_yaml_file_path(input) { + Ok((Some(input.to_string()), None)) } else { - // No colon: treat entire input as key - if input.is_empty() { - return Err("Key cannot be empty".to_string()); - } Ok((None, Some(input.to_string()))) } } +fn looks_like_yaml_file_path(input: &str) -> bool { + input.ends_with(".yaml") || input.ends_with(".yml") +} + #[cfg(test)] mod tests { use super::*; - // Note: Testing the clap-based parser directly is less straightforward - // than the old parse_args function since clap expects actual CLI arguments. - // We'll test the parsing logic via the parse_cli function with simulated arguments. - - fn test_with_args(args: Vec<&str>) -> Result { - let cli = Cli::try_parse_from(args).map_err(|e| e.to_string())?; - match cli.command { - Commands::Grep { - pattern, - recursive, - files, - } => Ok(Command::Grep { - pattern, - recursive, - files, - }), - Commands::Set { file, updates } => { - if updates.is_empty() { - return Err("set requires at least one key=value pair".to_string()); - } - - let mut parsed_updates = HashMap::new(); - - for update in updates { - let parts: Vec<&str> = update.splitn(2, '=').collect(); - if parts.len() != 2 { - return Err(format!("Invalid key=value pair: {}", update)); - } - parsed_updates.insert(parts[0].to_string(), parts[1].to_string()); - } - - Ok(Command::Set { - file, - updates: parsed_updates, - }) - } - Commands::Unset { file, keys } => { - if keys.is_empty() { - return Err("unset requires at least one key".to_string()); - } - - Ok(Command::Unset { file, keys }) - } - Commands::Cp { - source, - destination, - } => { - let (source_file, source_key) = parse_file_key_pair(&source)?; - let (dest_file, dest_key) = if destination.is_empty() { - (None, None) - } else if destination.len() == 1 { - parse_optional_file_key_pair(&destination[0])? - } else { - return Err("cp accepts at most one destination argument".to_string()); - }; - - if dest_file.is_none() && dest_key.is_none() { - return Err( - "destination file and destination key cannot both be omitted".to_string(), - ); - } - - Ok(Command::Cp { - source_file, - source_key, - dest_file, - dest_key, - }) - } - Commands::Mv { - source, - destination, - } => { - let (source_file, source_key) = parse_file_key_pair(&source)?; - let (dest_file, dest_key) = if destination.is_empty() { - (None, None) - } else if destination.len() == 1 { - parse_optional_file_key_pair(&destination[0])? - } else { - return Err("mv accepts at most one destination argument".to_string()); - }; - - if dest_file.is_none() && dest_key.is_none() { - return Err( - "destination file and destination key cannot both be omitted".to_string(), - ); - } - - Ok(Command::Mv { - source_file, - source_key, - dest_file, - dest_key, - }) - } - } + fn test_with_args(args: Vec<&str>) -> AppResult { + let cli = Cli::try_parse_from(args).map_err(|error| AppError::cli(error.to_string()))?; + command_from_cli(cli) } - // ==================== parse_args() Tests ==================== - #[test] fn test_parse_grep_simple() { let cmd = test_with_args(vec!["ym", "grep", "pattern", "file.yaml"]).unwrap(); - match cmd { + assert_eq!( + cmd, Command::Grep { - pattern, - recursive, - files, - } => { - assert_eq!(pattern, "pattern"); - assert!(!recursive); - assert_eq!(files, vec!["file.yaml"]); + pattern: "pattern".to_string(), + recursive: false, + files: vec!["file.yaml".to_string()], } - _ => panic!("Expected Grep command"), - } + ); } #[test] fn test_parse_grep_with_recursive_flag() { let cmd = test_with_args(vec!["ym", "grep", "-R", "pattern", "dir"]).unwrap(); - match cmd { + assert_eq!( + cmd, Command::Grep { - pattern, - recursive, - files, - } => { - assert_eq!(pattern, "pattern"); - assert!(recursive); - assert_eq!(files, vec!["dir"]); + pattern: "pattern".to_string(), + recursive: true, + files: vec!["dir".to_string()], } - _ => panic!("Expected Grep command"), - } + ); } #[test] @@ -400,63 +282,55 @@ mod tests { ]) .unwrap(); - match cmd { + assert_eq!( + cmd, Command::Grep { - pattern, - recursive, - files, - } => { - assert_eq!(pattern, "pattern"); - assert!(!recursive); - assert_eq!(files, vec!["file1.yaml", "file2.yaml", "file3.yaml"]); + pattern: "pattern".to_string(), + recursive: false, + files: vec![ + "file1.yaml".to_string(), + "file2.yaml".to_string(), + "file3.yaml".to_string(), + ], } - _ => panic!("Expected Grep command"), - } + ); } #[test] fn test_parse_grep_no_pattern_error() { - let result = test_with_args(vec!["ym", "grep"]); - assert!(result.is_err()); + assert!(test_with_args(vec!["ym", "grep"]).is_err()); } #[test] fn test_parse_grep_recursive_no_pattern_error() { - let result = test_with_args(vec!["ym", "grep", "-R"]); - assert!(result.is_err()); + assert!(test_with_args(vec!["ym", "grep", "-R"]).is_err()); } #[test] fn test_parse_grep_no_files_allowed() { - // grep with pattern but no files should be valid (reads from stdin) let cmd = test_with_args(vec!["ym", "grep", "pattern"]).unwrap(); - match cmd { + assert_eq!( + cmd, Command::Grep { - pattern, - recursive, - files, - } => { - assert_eq!(pattern, "pattern"); - assert!(!recursive); - assert_eq!(files, Vec::::new()); + pattern: "pattern".to_string(), + recursive: false, + files: Vec::new(), } - _ => panic!("Expected Grep command"), - } + ); } #[test] fn test_parse_set_single_key_value() { let cmd = test_with_args(vec!["ym", "set", "file.yaml", "key=value"]).unwrap(); - match cmd { - Command::Set { file, updates } => { - assert_eq!(file, "file.yaml"); - assert_eq!(updates.len(), 1); - assert_eq!(updates.get("key"), Some(&"value".to_string())); + assert_eq!( + cmd, + Command::Set { + file: "file.yaml".to_string(), + updates: vec![("key".to_string(), "value".to_string())], } - _ => panic!("Expected Set command"), - } + ); } #[test] @@ -471,16 +345,17 @@ mod tests { ]) .unwrap(); - match cmd { - Command::Set { file, updates } => { - assert_eq!(file, "file.yaml"); - assert_eq!(updates.len(), 3); - assert_eq!(updates.get("key1"), Some(&"value1".to_string())); - assert_eq!(updates.get("key2"), Some(&"value2".to_string())); - assert_eq!(updates.get("key3"), Some(&"value3".to_string())); + assert_eq!( + cmd, + Command::Set { + file: "file.yaml".to_string(), + updates: vec![ + ("key1".to_string(), "value1".to_string()), + ("key2".to_string(), "value2".to_string()), + ("key3".to_string(), "value3".to_string()), + ], } - _ => panic!("Expected Set command"), - } + ); } #[test] @@ -494,20 +369,20 @@ mod tests { ]) .unwrap(); - match cmd { - Command::Set { file, updates } => { - assert_eq!(file, "file.yaml"); - assert_eq!(updates.len(), 2); - assert_eq!(updates.get("database.host"), Some(&"localhost".to_string())); - assert_eq!(updates.get("database.port"), Some(&"5432".to_string())); + assert_eq!( + cmd, + Command::Set { + file: "file.yaml".to_string(), + updates: vec![ + ("database.host".to_string(), "localhost".to_string()), + ("database.port".to_string(), "5432".to_string()), + ], } - _ => panic!("Expected Set command"), - } + ); } #[test] fn test_parse_set_value_with_equals() { - // Values can contain '=' characters let cmd = test_with_args(vec![ "ym", "set", @@ -516,62 +391,62 @@ mod tests { ]) .unwrap(); - match cmd { - Command::Set { file, updates } => { - assert_eq!(file, "file.yaml"); - assert_eq!(updates.len(), 1); - assert_eq!( - updates.get("url"), - Some(&"http://example.com?param=value".to_string()) - ); + assert_eq!( + cmd, + Command::Set { + file: "file.yaml".to_string(), + updates: vec![( + "url".to_string(), + "http://example.com?param=value".to_string(), + )], } - _ => panic!("Expected Set command"), - } + ); } #[test] fn test_parse_set_no_file_error() { - let result = test_with_args(vec!["ym", "set"]); - assert!(result.is_err()); + assert!(test_with_args(vec!["ym", "set"]).is_err()); } #[test] fn test_parse_set_no_key_value_error() { - let result = test_with_args(vec!["ym", "set", "file.yaml"]); - assert!(result.is_err()); + assert!(test_with_args(vec!["ym", "set", "file.yaml"]).is_err()); } #[test] fn test_parse_set_invalid_key_value_format() { let result = test_with_args(vec!["ym", "set", "file.yaml", "invalid_no_equals"]); assert!(result.is_err()); - assert!(result.unwrap_err().contains("Invalid key=value pair")); + assert!(result + .unwrap_err() + .to_string() + .contains("Invalid key=value pair")); } #[test] fn test_parse_unset_single_key() { let cmd = test_with_args(vec!["ym", "unset", "file.yaml", "key"]).unwrap(); - match cmd { - Command::Unset { file, keys } => { - assert_eq!(file, "file.yaml"); - assert_eq!(keys, vec!["key"]); + assert_eq!( + cmd, + Command::Unset { + file: "file.yaml".to_string(), + keys: vec!["key".to_string()], } - _ => panic!("Expected Unset command"), - } + ); } #[test] fn test_parse_unset_multiple_keys() { let cmd = test_with_args(vec!["ym", "unset", "file.yaml", "key1", "key2", "key3"]).unwrap(); - match cmd { - Command::Unset { file, keys } => { - assert_eq!(file, "file.yaml"); - assert_eq!(keys, vec!["key1", "key2", "key3"]); + assert_eq!( + cmd, + Command::Unset { + file: "file.yaml".to_string(), + keys: vec!["key1".to_string(), "key2".to_string(), "key3".to_string()], } - _ => panic!("Expected Unset command"), - } + ); } #[test] @@ -585,36 +460,35 @@ mod tests { ]) .unwrap(); - match cmd { - Command::Unset { file, keys } => { - assert_eq!(file, "file.yaml"); - assert_eq!(keys, vec!["database.password", "database.username"]); + assert_eq!( + cmd, + Command::Unset { + file: "file.yaml".to_string(), + keys: vec![ + "database.password".to_string(), + "database.username".to_string(), + ], } - _ => panic!("Expected Unset command"), - } + ); } #[test] fn test_parse_unset_no_file_error() { - let result = test_with_args(vec!["ym", "unset"]); - assert!(result.is_err()); + assert!(test_with_args(vec!["ym", "unset"]).is_err()); } #[test] fn test_parse_unset_no_keys_error() { - let result = test_with_args(vec!["ym", "unset", "file.yaml"]); - assert!(result.is_err()); + assert!(test_with_args(vec!["ym", "unset", "file.yaml"]).is_err()); } - // ==================== cp Tests ==================== - #[test] fn test_parse_cp_same_file_same_key() { - // Error: both destination file and key omitted let result = test_with_args(vec!["ym", "cp", "file.yaml:source.key"]); assert!(result.is_err()); assert!(result .unwrap_err() + .to_string() .contains("destination file and destination key cannot both be omitted")); } @@ -622,40 +496,30 @@ mod tests { fn test_parse_cp_same_file_different_key() { let cmd = test_with_args(vec!["ym", "cp", "file.yaml:source.key", "dest.key"]).unwrap(); - match cmd { + assert_eq!( + cmd, Command::Cp { - source_file, - source_key, - dest_file, - dest_key, - } => { - assert_eq!(source_file, "file.yaml"); - assert_eq!(source_key, "source.key"); - assert_eq!(dest_file, None); - assert_eq!(dest_key, Some("dest.key".to_string())); + source_file: "file.yaml".to_string(), + source_key: "source.key".to_string(), + dest_file: None, + dest_key: Some("dest.key".to_string()), } - _ => panic!("Expected Cp command"), - } + ); } #[test] fn test_parse_cp_different_file_same_key() { let cmd = test_with_args(vec!["ym", "cp", "source.yaml:mykey", "dest.yaml:mykey"]).unwrap(); - match cmd { + assert_eq!( + cmd, Command::Cp { - source_file, - source_key, - dest_file, - dest_key, - } => { - assert_eq!(source_file, "source.yaml"); - assert_eq!(source_key, "mykey"); - assert_eq!(dest_file, Some("dest.yaml".to_string())); - assert_eq!(dest_key, Some("mykey".to_string())); + source_file: "source.yaml".to_string(), + source_key: "mykey".to_string(), + dest_file: Some("dest.yaml".to_string()), + dest_key: Some("mykey".to_string()), } - _ => panic!("Expected Cp command"), - } + ); } #[test] @@ -668,54 +532,71 @@ mod tests { ]) .unwrap(); - match cmd { + assert_eq!( + cmd, Command::Cp { - source_file, - source_key, - dest_file, - dest_key, - } => { - assert_eq!(source_file, "source.yaml"); - assert_eq!(source_key, "source.key"); - assert_eq!(dest_file, Some("dest.yaml".to_string())); - assert_eq!(dest_key, Some("dest.key".to_string())); + source_file: "source.yaml".to_string(), + source_key: "source.key".to_string(), + dest_file: Some("dest.yaml".to_string()), + dest_key: Some("dest.key".to_string()), } - _ => panic!("Expected Cp command"), - } + ); } #[test] fn test_parse_cp_only_destination_file() { let cmd = test_with_args(vec!["ym", "cp", "source.yaml:mykey", "dest.yaml:"]).unwrap(); - match cmd { + assert_eq!( + cmd, Command::Cp { - source_file, - source_key, - dest_file, - dest_key, - } => { - assert_eq!(source_file, "source.yaml"); - assert_eq!(source_key, "mykey"); - assert_eq!(dest_file, Some("dest.yaml".to_string())); - assert_eq!(dest_key, None); + source_file: "source.yaml".to_string(), + source_key: "mykey".to_string(), + dest_file: Some("dest.yaml".to_string()), + dest_key: None, } - _ => panic!("Expected Cp command"), - } + ); + } + + #[test] + fn test_parse_cp_bare_destination_file_defaults_to_source_key() { + let cmd = test_with_args(vec![ + "ym", + "cp", + "tests/data/config-prod.yaml:environment", + "tests/data/config-dev.yaml", + ]) + .unwrap(); + + assert_eq!( + cmd, + Command::Cp { + source_file: "tests/data/config-prod.yaml".to_string(), + source_key: "environment".to_string(), + dest_file: Some("tests/data/config-dev.yaml".to_string()), + dest_key: None, + } + ); } #[test] fn test_parse_cp_missing_source_key() { let result = test_with_args(vec!["ym", "cp", "source.yaml", "dest.key"]); assert!(result.is_err()); - assert!(result.unwrap_err().contains("Invalid file:key pair")); + assert!(result + .unwrap_err() + .to_string() + .contains("Invalid file:key pair")); } #[test] fn test_parse_cp_invalid_source_format() { let result = test_with_args(vec!["ym", "cp", "invalid", "dest.key"]); assert!(result.is_err()); - assert!(result.unwrap_err().contains("Invalid file:key pair")); + assert!(result + .unwrap_err() + .to_string() + .contains("Invalid file:key pair")); } #[test] @@ -730,18 +611,17 @@ mod tests { assert!(result.is_err()); assert!(result .unwrap_err() + .to_string() .contains("cp accepts at most one destination argument")); } - // ==================== mv Tests ==================== - #[test] fn test_parse_mv_same_file_same_key() { - // Error: both destination file and key omitted let result = test_with_args(vec!["ym", "mv", "file.yaml:source.key"]); assert!(result.is_err()); assert!(result .unwrap_err() + .to_string() .contains("destination file and destination key cannot both be omitted")); } @@ -749,40 +629,30 @@ mod tests { fn test_parse_mv_same_file_different_key() { let cmd = test_with_args(vec!["ym", "mv", "file.yaml:source.key", "dest.key"]).unwrap(); - match cmd { + assert_eq!( + cmd, Command::Mv { - source_file, - source_key, - dest_file, - dest_key, - } => { - assert_eq!(source_file, "file.yaml"); - assert_eq!(source_key, "source.key"); - assert_eq!(dest_file, None); - assert_eq!(dest_key, Some("dest.key".to_string())); + source_file: "file.yaml".to_string(), + source_key: "source.key".to_string(), + dest_file: None, + dest_key: Some("dest.key".to_string()), } - _ => panic!("Expected Mv command"), - } + ); } #[test] fn test_parse_mv_different_file_same_key() { let cmd = test_with_args(vec!["ym", "mv", "source.yaml:mykey", "dest.yaml:mykey"]).unwrap(); - match cmd { + assert_eq!( + cmd, Command::Mv { - source_file, - source_key, - dest_file, - dest_key, - } => { - assert_eq!(source_file, "source.yaml"); - assert_eq!(source_key, "mykey"); - assert_eq!(dest_file, Some("dest.yaml".to_string())); - assert_eq!(dest_key, Some("mykey".to_string())); + source_file: "source.yaml".to_string(), + source_key: "mykey".to_string(), + dest_file: Some("dest.yaml".to_string()), + dest_key: Some("mykey".to_string()), } - _ => panic!("Expected Mv command"), - } + ); } #[test] @@ -795,54 +665,50 @@ mod tests { ]) .unwrap(); - match cmd { + assert_eq!( + cmd, Command::Mv { - source_file, - source_key, - dest_file, - dest_key, - } => { - assert_eq!(source_file, "source.yaml"); - assert_eq!(source_key, "source.key"); - assert_eq!(dest_file, Some("dest.yaml".to_string())); - assert_eq!(dest_key, Some("dest.key".to_string())); + source_file: "source.yaml".to_string(), + source_key: "source.key".to_string(), + dest_file: Some("dest.yaml".to_string()), + dest_key: Some("dest.key".to_string()), } - _ => panic!("Expected Mv command"), - } + ); } #[test] fn test_parse_mv_only_destination_file() { let cmd = test_with_args(vec!["ym", "mv", "source.yaml:mykey", "dest.yaml:"]).unwrap(); - match cmd { + assert_eq!( + cmd, Command::Mv { - source_file, - source_key, - dest_file, - dest_key, - } => { - assert_eq!(source_file, "source.yaml"); - assert_eq!(source_key, "mykey"); - assert_eq!(dest_file, Some("dest.yaml".to_string())); - assert_eq!(dest_key, None); + source_file: "source.yaml".to_string(), + source_key: "mykey".to_string(), + dest_file: Some("dest.yaml".to_string()), + dest_key: None, } - _ => panic!("Expected Mv command"), - } + ); } #[test] fn test_parse_mv_missing_source_key() { let result = test_with_args(vec!["ym", "mv", "source.yaml", "dest.key"]); assert!(result.is_err()); - assert!(result.unwrap_err().contains("Invalid file:key pair")); + assert!(result + .unwrap_err() + .to_string() + .contains("Invalid file:key pair")); } #[test] fn test_parse_mv_invalid_source_format() { let result = test_with_args(vec!["ym", "mv", "invalid", "dest.key"]); assert!(result.is_err()); - assert!(result.unwrap_err().contains("Invalid file:key pair")); + assert!(result + .unwrap_err() + .to_string() + .contains("Invalid file:key pair")); } #[test] @@ -857,6 +723,7 @@ mod tests { assert!(result.is_err()); assert!(result .unwrap_err() + .to_string() .contains("mv accepts at most one destination argument")); } } diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..9c6ee86 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,106 @@ +use std::io; + +use thiserror::Error; + +pub type AppResult = Result; + +#[derive(Debug, Error)] +pub enum AppError { + #[error("{0}")] + Message(String), + + #[error("CLI error: {0}")] + Cli(String), + + #[error("Invalid YAML path '{path}': {reason}")] + InvalidPath { path: String, reason: String }, + + #[error("Failed to read file '{path}': {source}")] + ReadFile { + path: String, + #[source] + source: io::Error, + }, + + #[error("Failed to write file '{path}': {source}")] + WriteFile { + path: String, + #[source] + source: io::Error, + }, + + #[error("Failed to read directory '{path}': {source}")] + ReadDir { + path: String, + #[source] + source: io::Error, + }, + + #[error("Failed to read directory entry: {0}")] + ReadDirEntry(#[source] io::Error), + + #[error("Failed to read from stdin: {0}")] + ReadStdin(#[source] io::Error), + + #[error("Failed to parse YAML {context}: {source}")] + ParseYaml { + context: String, + #[source] + source: serde_yaml::Error, + }, + + #[error("Invalid regex pattern: {0}")] + Regex(#[from] regex::Error), + + #[error("Failed to apply YAML patch: {0}")] + Patch(String), +} + +impl AppError { + pub fn message(message: impl Into) -> Self { + Self::Message(message.into()) + } + + pub fn cli(message: impl Into) -> Self { + Self::Cli(message.into()) + } + + pub fn invalid_path(path: impl Into, reason: impl Into) -> Self { + Self::InvalidPath { + path: path.into(), + reason: reason.into(), + } + } + + pub fn parse_yaml(context: impl Into, source: serde_yaml::Error) -> Self { + Self::ParseYaml { + context: context.into(), + source, + } + } + + pub fn read_file(path: impl Into, source: io::Error) -> Self { + Self::ReadFile { + path: path.into(), + source, + } + } + + pub fn write_file(path: impl Into, source: io::Error) -> Self { + Self::WriteFile { + path: path.into(), + source, + } + } + + pub fn read_dir(path: impl Into, source: io::Error) -> Self { + Self::ReadDir { + path: path.into(), + source, + } + } + + pub fn patch(source: impl Into) -> Self { + Self::Patch(source.into()) + } +} diff --git a/src/main.rs b/src/main.rs index 6532af5..226e304 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,110 +5,54 @@ use std::path::Path; use std::process; mod cli; -mod yaml_format_preserving; +mod error; +mod path; mod yaml_ops; use cli::{parse_cli, Command}; +use error::{AppError, AppResult}; fn get_terminal_width() -> usize { - // Try to get terminal width from multiple sources - - // First, try using termsize crate which detects actual terminal size if let Some(size) = termsize::get() { return size.cols as usize; } - // Fall back to COLUMNS environment variable if let Ok(cols) = env::var("COLUMNS") { if let Ok(width) = cols.parse::() { return width; } } - // Default to 80 columns 80 } fn main() { let command = match parse_cli() { - Ok(cmd) => cmd, - Err(e) => { - eprintln!("Error: {}", e); + Ok(command) => command, + Err(error) => { + eprintln!("Error: {error}"); process::exit(1); } }; - if let Err(e) = execute_command(command) { - eprintln!("Error: {}", e); + if let Err(error) = execute_command(command) { + eprintln!("Error: {error}"); process::exit(1); } } -fn execute_command(command: Command) -> Result<(), String> { +fn execute_command(command: Command) -> AppResult<()> { match command { Command::Grep { pattern, recursive, files, - } => { - if files.is_empty() { - // Read from stdin - grep_stdin(&pattern)?; - } else { - // Determine if we should show filename - // Show filename unless there's exactly 1 file (not directory) in args - let show_filename = if files.len() == 1 { - // Only hide filename if the single arg is a file (not a directory) - let path = Path::new(&files[0]); - path.is_dir() - } else { - true - }; - - // Search in provided files or directories - for file in files { - grep_path(&file, &pattern, recursive, show_filename)?; - } - } - Ok(()) - } + } => run_grep(&pattern, recursive, &files), Command::Set { file, updates } => { - let contents = fs::read_to_string(&file) - .map_err(|e| format!("Failed to read file '{}': {}", file, e))?; - - let mut value = serde_yaml::from_str(&contents) - .map_err(|e| format!("Failed to parse YAML: {}", e))?; - - yaml_ops::set_values(&mut value, &updates)?; - - // Preserve format for all changes (both top-level and nested keys) - let updated_yaml = - yaml_format_preserving::write_yaml_preserving_format(&contents, &value) - .map_err(|e| format!("Failed to preserve YAML format: {}", e))?; - - fs::write(&file, updated_yaml) - .map_err(|e| format!("Failed to write file '{}': {}", file, e))?; - - Ok(()) + apply_file_update(&file, |contents| yaml_ops::set_values(contents, &updates)) } Command::Unset { file, keys } => { - let contents = fs::read_to_string(&file) - .map_err(|e| format!("Failed to read file '{}': {}", file, e))?; - - let mut value = serde_yaml::from_str(&contents) - .map_err(|e| format!("Failed to parse YAML: {}", e))?; - - yaml_ops::unset_values(&mut value, &keys)?; - - // Preserve format for all changes (both top-level and nested keys) - let updated_yaml = - yaml_format_preserving::write_yaml_preserving_format(&contents, &value) - .map_err(|e| format!("Failed to preserve YAML format: {}", e))?; - - fs::write(&file, updated_yaml) - .map_err(|e| format!("Failed to write file '{}': {}", file, e))?; - - Ok(()) + apply_file_update(&file, |contents| yaml_ops::unset_values(contents, &keys)) } Command::Cp { source_file, @@ -116,13 +60,9 @@ fn execute_command(command: Command) -> Result<(), String> { dest_file, dest_key, } => { - // Use source values as defaults let final_dest_file = dest_file.unwrap_or_else(|| source_file.clone()); let final_dest_key = dest_key.unwrap_or_else(|| source_key.clone()); - - yaml_ops::copy_value(&source_file, &source_key, &final_dest_file, &final_dest_key)?; - - Ok(()) + yaml_ops::copy_value(&source_file, &source_key, &final_dest_file, &final_dest_key) } Command::Mv { source_file, @@ -130,103 +70,123 @@ fn execute_command(command: Command) -> Result<(), String> { dest_file, dest_key, } => { - // Use source values as defaults let final_dest_file = dest_file.unwrap_or_else(|| source_file.clone()); let final_dest_key = dest_key.unwrap_or_else(|| source_key.clone()); + yaml_ops::move_value(&source_file, &source_key, &final_dest_file, &final_dest_key) + } + } +} + +fn apply_file_update(file: &str, update: F) -> AppResult<()> +where + F: FnOnce(&str) -> AppResult, +{ + let contents = fs::read_to_string(file).map_err(|error| AppError::read_file(file, error))?; + let updated = update(&contents)?; + fs::write(file, updated).map_err(|error| AppError::write_file(file, error))?; + Ok(()) +} - yaml_ops::move_value(&source_file, &source_key, &final_dest_file, &final_dest_key)?; +fn run_grep(pattern: &str, recursive: bool, files: &[String]) -> AppResult<()> { + if files.is_empty() { + return grep_stdin(pattern); + } - Ok(()) - } + let show_filename = should_show_filename(files); + for file in files { + grep_path(Path::new(file), pattern, recursive, show_filename)?; } + Ok(()) } -fn grep_stdin(pattern: &str) -> Result<(), String> { +fn should_show_filename(files: &[String]) -> bool { + if files.len() != 1 { + return true; + } + + Path::new(&files[0]).is_dir() +} + +fn grep_stdin(pattern: &str) -> AppResult<()> { let mut buffer = String::new(); io::stdin() .read_to_string(&mut buffer) - .map_err(|e| format!("Failed to read from stdin: {}", e))?; + .map_err(AppError::ReadStdin)?; if buffer.trim().is_empty() { - return Err("No input provided".to_string()); + return Err(AppError::message("No input provided")); } - let value = serde_yaml::from_str(&buffer) - .map_err(|e| format!("Failed to parse YAML from stdin: {}", e))?; - - let results = yaml_ops::grep(&value, pattern)?; - - if results.is_empty() { - return Err("No matches found".to_string()); - } - - let width = get_terminal_width(); - for (key, val) in results { - println!("{}", yaml_ops::format_result(&key, &val, width)); - } - Ok(()) + let value = + serde_yaml::from_str(&buffer).map_err(|error| AppError::parse_yaml("from stdin", error))?; + print_grep_results(None, pattern, &value) } -fn grep_path( - file: &str, - pattern: &str, - recursive: bool, - show_filename: bool, -) -> Result<(), String> { - let path = Path::new(file); - +fn grep_path(path: &Path, pattern: &str, recursive: bool, show_filename: bool) -> AppResult<()> { if path.is_file() { - grep_single(file, pattern, show_filename) - } else if path.is_dir() { - if recursive { + return grep_file(path, pattern, show_filename); + } + + if path.is_dir() { + return if recursive { search_dir(path, pattern, show_filename) } else { - Err(format!( + Err(AppError::message(format!( "'{}' is a directory (use -R to search recursively)", - file - )) - } - } else { - Err(format!("'{}' is not a file or directory", file)) + path.display() + ))) + }; } + + Err(AppError::message(format!( + "'{}' is not a file or directory", + path.display() + ))) } -fn grep_single(file: &str, pattern: &str, show_filename: bool) -> Result<(), String> { +fn grep_file(path: &Path, pattern: &str, show_filename: bool) -> AppResult<()> { + let display = path.to_string_lossy(); let contents = - fs::read_to_string(file).map_err(|e| format!("Failed to read file '{}': {}", file, e))?; + fs::read_to_string(path).map_err(|error| AppError::read_file(display.as_ref(), error))?; + let value = serde_yaml::from_str(&contents) + .map_err(|error| AppError::parse_yaml(format!("in '{}'", display), error))?; - let value = - serde_yaml::from_str(&contents).map_err(|e| format!("Failed to parse YAML: {}", e))?; + print_grep_results(show_filename.then_some(display.as_ref()), pattern, &value) +} - let results = yaml_ops::grep(&value, pattern)?; +fn print_grep_results( + filename: Option<&str>, + pattern: &str, + value: &serde_yaml::Value, +) -> AppResult<()> { + let results = yaml_ops::grep(value, pattern)?; let width = get_terminal_width(); - for (key, val) in results { - if show_filename { - println!("{}:{}", file, yaml_ops::format_result(&key, &val, width)); + + for (key, value) in results { + let formatted = yaml_ops::format_result(&key, &value, width); + if let Some(filename) = filename { + println!("{}:{}", filename, formatted); } else { - println!("{}", yaml_ops::format_result(&key, &val, width)); + println!("{}", formatted); } } + Ok(()) } -fn search_dir(dir: &Path, pattern: &str, show_filename: bool) -> Result<(), String> { - let entries = fs::read_dir(dir) - .map_err(|e| format!("Failed to read directory '{}': {}", dir.display(), e))?; +fn search_dir(dir: &Path, pattern: &str, show_filename: bool) -> AppResult<()> { + let entries = + fs::read_dir(dir).map_err(|error| AppError::read_dir(dir.display().to_string(), error))?; for entry in entries { - let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?; + let entry = entry.map_err(AppError::ReadDirEntry)?; let path = entry.path(); if path.is_dir() { - // Recursively search subdirectories search_dir(&path, pattern, show_filename)?; - } else if path.is_file() { - // Process YAML files - if should_process_file(&path) { - if let Err(e) = grep_file_with_name(&path, pattern, show_filename) { - eprintln!("Warning: {}", e); - } + } else if path.is_file() && should_process_file(&path) { + if let Err(error) = grep_file(&path, pattern, show_filename) { + eprintln!("Warning: {error}"); } } } @@ -235,33 +195,121 @@ fn search_dir(dir: &Path, pattern: &str, show_filename: bool) -> Result<(), Stri } fn should_process_file(path: &Path) -> bool { - if let Some(ext) = path.extension() { - ext == "yaml" || ext == "yml" - } else { - false - } + matches!( + path.extension().and_then(|ext| ext.to_str()), + Some("yaml" | "yml") + ) } -fn grep_file_with_name(file: &Path, pattern: &str, show_filename: bool) -> Result<(), String> { - let file_str = file.to_string_lossy(); - let contents = fs::read_to_string(file) - .map_err(|e| format!("Failed to read file '{}': {}", file_str, e))?; +#[cfg(test)] +mod tests { + use super::*; + use serde_yaml::Value; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_dir(name: &str) -> std::path::PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("ym_{}_{}_{}", name, process::id(), unique)); + fs::create_dir_all(&dir).unwrap(); + dir + } - let value = serde_yaml::from_str(&contents) - .map_err(|e| format!("Failed to parse YAML in '{}': {}", file_str, e))?; + fn read_yaml(path: &Path) -> Value { + serde_yaml::from_str(&fs::read_to_string(path).unwrap()).unwrap() + } - let results = yaml_ops::grep(&value, pattern)?; - let width = get_terminal_width(); - for (key, val) in results { - if show_filename { - println!( - "{}:{}", - file_str, - yaml_ops::format_result(&key, &val, width) - ); - } else { - println!("{}", yaml_ops::format_result(&key, &val, width)); - } + #[test] + fn test_set_and_unset_commands_update_yaml_semantics() { + let dir = temp_dir("set_unset"); + let file = dir.join("config.yaml"); + fs::write( + &file, + "# config\nname : \"Alice\" # keep quote/comment\nstatus: enabled\ncount: 1\n", + ) + .unwrap(); + + execute_command(Command::Set { + file: file.display().to_string(), + updates: vec![ + ("name".to_string(), "Bob".to_string()), + ("count".to_string(), "2".to_string()), + ], + }) + .unwrap(); + + let updated = read_yaml(&file); + assert_eq!(updated["name"].as_str(), Some("Bob")); + assert_eq!(updated["status"].as_str(), Some("enabled")); + assert_eq!(updated["count"].as_i64(), Some(2)); + + execute_command(Command::Unset { + file: file.display().to_string(), + keys: vec!["count".to_string()], + }) + .unwrap(); + + let updated = read_yaml(&file); + assert!(updated.get("count").is_none()); + + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn test_cp_command_copies_value_between_files() { + let dir = temp_dir("cp_semantics"); + let source = dir.join("source.yaml"); + let dest = dir.join("dest.yaml"); + + fs::write(&source, "source:\n value: copied\n enabled: true\n").unwrap(); + fs::write(&dest, "existing: item\n").unwrap(); + + execute_command(Command::Cp { + source_file: source.display().to_string(), + source_key: "source".to_string(), + dest_file: Some(dest.display().to_string()), + dest_key: Some("copied.settings".to_string()), + }) + .unwrap(); + + let dest_yaml = read_yaml(&dest); + assert_eq!( + dest_yaml["copied"]["settings"]["value"].as_str(), + Some("copied") + ); + assert_eq!( + dest_yaml["copied"]["settings"]["enabled"].as_bool(), + Some(true) + ); + + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn test_mv_command_moves_value_between_files() { + let dir = temp_dir("mv_semantics"); + let source = dir.join("source.yaml"); + let dest = dir.join("dest.yaml"); + + fs::write(&source, "move_me:\n key: value\nkeep: still\n").unwrap(); + fs::write(&dest, "name: app\n").unwrap(); + + execute_command(Command::Mv { + source_file: source.display().to_string(), + source_key: "move_me".to_string(), + dest_file: Some(dest.display().to_string()), + dest_key: Some("new_key".to_string()), + }) + .unwrap(); + + let source_yaml = read_yaml(&source); + let dest_yaml = read_yaml(&dest); + assert_eq!(source_yaml["keep"].as_str(), Some("still")); + assert!(source_yaml.get("move_me").is_none()); + assert_eq!(dest_yaml["new_key"]["key"].as_str(), Some("value")); + + fs::remove_dir_all(dir).unwrap(); } - Ok(()) } diff --git a/src/path.rs b/src/path.rs new file mode 100644 index 0000000..d22bd09 --- /dev/null +++ b/src/path.rs @@ -0,0 +1,214 @@ +use yamlpath::{Component, Route}; + +use crate::error::{AppError, AppResult}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PathSegment { + Key(String), + Index(usize), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct YamlPath { + segments: Vec, +} + +impl YamlPath { + pub fn parse(input: &str) -> AppResult { + let mut segments = Vec::new(); + let mut key = String::new(); + let mut chars = input.chars().peekable(); + let mut just_closed_index = false; + + while let Some(ch) = chars.next() { + match ch { + '\\' => { + let escaped = chars.next().ok_or_else(|| { + AppError::invalid_path(input, "path cannot end with an escape character") + })?; + key.push(escaped); + just_closed_index = false; + } + '.' => { + if key.is_empty() { + if just_closed_index { + just_closed_index = false; + continue; + } + return Err(AppError::invalid_path( + input, + "empty path segment between dots", + )); + } + segments.push(PathSegment::Key(std::mem::take(&mut key))); + just_closed_index = false; + } + '[' => { + if !key.is_empty() { + segments.push(PathSegment::Key(std::mem::take(&mut key))); + } + + let mut digits = String::new(); + loop { + match chars.next() { + Some(']') => break, + Some(next) if next.is_ascii_digit() => digits.push(next), + Some(_) => { + return Err(AppError::invalid_path( + input, + "sequence indices must contain digits only", + )); + } + None => { + return Err(AppError::invalid_path( + input, + "unterminated sequence index", + )); + } + } + } + + if digits.is_empty() { + return Err(AppError::invalid_path( + input, + "sequence index cannot be empty", + )); + } + + let index = digits.parse::().map_err(|_| { + AppError::invalid_path(input, "sequence index is too large") + })?; + segments.push(PathSegment::Index(index)); + just_closed_index = true; + } + ']' => { + return Err(AppError::invalid_path(input, "unexpected closing bracket")); + } + other => { + key.push(other); + just_closed_index = false; + } + } + } + + if !key.is_empty() { + segments.push(PathSegment::Key(key)); + } + + if segments.is_empty() { + return Err(AppError::invalid_path(input, "path cannot be empty")); + } + + Ok(Self { segments }) + } + + pub fn as_segments(&self) -> &[PathSegment] { + &self.segments + } + + pub fn to_route(&self) -> Route<'static> { + Route::from( + self.segments + .iter() + .map(|segment| match segment { + PathSegment::Key(key) => Component::from(key.clone()), + PathSegment::Index(index) => Component::from(*index), + }) + .collect::>(), + ) + } + + pub fn parent(&self) -> Option { + if self.segments.len() <= 1 { + None + } else { + Some(Self { + segments: self.segments[..self.segments.len() - 1].to_vec(), + }) + } + } + + pub fn last(&self) -> Option<&PathSegment> { + self.segments.last() + } + + pub fn push_key(&self, key: impl Into) -> Self { + let mut segments = self.segments.clone(); + segments.push(PathSegment::Key(key.into())); + Self { segments } + } + + pub fn prefixes_requiring_mapping(&self) -> Vec { + (0..self.segments.len().saturating_sub(1)) + .filter(|&index| matches!(self.segments[index + 1], PathSegment::Key(_))) + .map(|index| Self { + segments: self.segments[..=index].to_vec(), + }) + .collect() + } + + pub fn display(&self) -> String { + let mut rendered = String::new(); + + for segment in &self.segments { + match segment { + PathSegment::Key(key) => { + if !rendered.is_empty() { + rendered.push('.'); + } + + for ch in key.chars() { + match ch { + '.' | '[' | ']' | '\\' => { + rendered.push('\\'); + rendered.push(ch); + } + other => rendered.push(other), + } + } + } + PathSegment::Index(index) => { + rendered.push('['); + rendered.push_str(&index.to_string()); + rendered.push(']'); + } + } + } + + rendered + } +} + +#[cfg(test)] +mod tests { + use super::{PathSegment, YamlPath}; + + #[test] + fn parses_keys_and_indices() { + let path = YamlPath::parse("items[1].metadata.name").unwrap(); + assert_eq!( + path.as_segments(), + &[ + PathSegment::Key("items".to_string()), + PathSegment::Index(1), + PathSegment::Key("metadata".to_string()), + PathSegment::Key("name".to_string()), + ] + ); + } + + #[test] + fn parses_escaped_dots() { + let path = YamlPath::parse(r"kubernetes\.io/hostname").unwrap(); + assert_eq!( + path.as_segments(), + &[PathSegment::Key("kubernetes.io/hostname".to_string())] + ); + assert_eq!(path.display(), r"kubernetes\.io/hostname"); + } + + #[test] + fn rejects_empty_segment() { + assert!(YamlPath::parse("a..b").is_err()); + } +} diff --git a/src/yaml_format_preserving.rs b/src/yaml_format_preserving.rs deleted file mode 100644 index 0b08c5f..0000000 --- a/src/yaml_format_preserving.rs +++ /dev/null @@ -1,513 +0,0 @@ -use serde_yaml::Value; -use std::collections::HashMap; - -/// Updates a YAML string while preserving comments, empty lines, and indentation -/// for both top-level and nested key modifications. -/// -/// This function preserves formatting for all operations including nested keys -/// by performing line-by-line updates and intelligently handling indentation. -pub fn write_yaml_preserving_format( - original_content: &str, - updated_value: &Value, -) -> Result { - // Parse the original to understand structure - let original_value: Value = serde_yaml::from_str(original_content) - .map_err(|e| format!("Failed to parse YAML: {}", e))?; - - // Check if there are unhandleable structural changes - // If we're adding new nested structures, fall back to standard serialization - if has_unhandleable_nested_changes(&original_value, updated_value) { - // For truly complex nested changes, use standard YAML serialization - return serde_yaml::to_string(updated_value) - .map_err(|e| format!("Failed to serialize YAML: {}", e)); - } - - // Collect keys that were removed (in original but not in updated) - let mut removed_keys = Vec::new(); - collect_removed_keys(&original_value, updated_value, "", &mut removed_keys); - - // Build a map of all keys and their new values - let updates = collect_all_changes(original_content, updated_value)?; - - if updates.is_empty() && removed_keys.is_empty() { - // No changes, return original - return Ok(original_content.to_string()); - } - - // Apply changes line by line - apply_changes_to_content(original_content, &updates, &removed_keys) -} - -/// Build a map from line number to YAML key path -fn build_line_to_key_map( - lines: &[&str], -) -> Result, String> { - use std::collections::HashMap; - - let mut map = HashMap::new(); - let mut path_stack: Vec<(usize, String)> = Vec::new(); // (indent, key) - - for (line_idx, line) in lines.iter().enumerate() { - let trimmed = line.trim_start(); - let indent = line.len() - trimmed.len(); - - // Skip empty lines and comments - if trimmed.is_empty() || trimmed.starts_with('#') { - continue; - } - - // Parse key:value - if let Some(colon_pos) = trimmed.find(':') { - let key = trimmed[..colon_pos].trim().to_string(); - - // Pop stack until we find the right indent level - while let Some((last_indent, _)) = path_stack.last() { - if *last_indent >= indent { - path_stack.pop(); - } else { - break; - } - } - - // Build full key path - let full_key = if path_stack.is_empty() { - key.clone() - } else { - let path_parts: Vec = path_stack.iter().map(|(_, k)| k.clone()).collect(); - format!("{}.{}", path_parts.join("."), key) - }; - - map.insert(line_idx, full_key.clone()); - path_stack.push((indent, key)); - } - } - - Ok(map) -} - -/// Check if there are complex structural changes that we can't handle with line-based updates. -/// We can handle: -/// - Removing nested keys (deletions) -/// - Changing scalar values at any level -/// -/// We cannot handle well: -/// - Adding new nested structures -/// - Changing mapping structures significantly -fn has_unhandleable_nested_changes(old: &Value, new: &Value) -> bool { - match (old, new) { - (Value::Mapping(old_map), Value::Mapping(new_map)) => { - // Check if new keys were added that are nested structures - for (key, new_val) in new_map { - if !old_map.contains_key(key) && new_val.is_mapping() { - // New nested structure added - we can't handle this well - return true; - } - } - - // Check if old nested structures were significantly modified (not just deleted) - for (key, old_val) in old_map { - if let Some(new_val) = new_map.get(key) { - if old_val != new_val { - // If both are mappings and contents changed, we need to be careful - if old_val.is_mapping() - && new_val.is_mapping() - && has_unhandleable_nested_changes(old_val, new_val) - { - return true; - } - } - } - } - - false - } - _ => false, - } -} - -/// Collects keys that were removed (in original but not in updated), including nested keys -fn collect_removed_keys(old: &Value, new: &Value, prefix: &str, removed: &mut Vec) { - if let (Value::Mapping(old_map), Value::Mapping(new_map)) = (old, new) { - for (key, old_val) in old_map { - if let Value::String(key_str) = key { - let full_key = if prefix.is_empty() { - key_str.clone() - } else { - format!("{}.{}", prefix, key_str) - }; - - if !new_map.contains_key(key) { - // Key was removed entirely - removed.push(full_key); - } else if let Some(new_val) = new_map.get(key) { - // Key exists in new, but might have removed nested keys - if old_val.is_mapping() && new_val.is_mapping() { - collect_removed_keys(old_val, new_val, &full_key, removed); - } - } - } - } - } -} - -/// Collects all changes by comparing original and updated values -fn collect_all_changes( - original_content: &str, - updated_value: &Value, -) -> Result, String> { - let original_value: Value = serde_yaml::from_str(original_content) - .map_err(|e| format!("Failed to parse YAML: {}", e))?; - - let mut changes = HashMap::new(); - collect_value_changes(&original_value, updated_value, "", &mut changes); - - Ok(changes) -} - -/// Recursively collects changed values -fn collect_value_changes( - old: &Value, - new: &Value, - prefix: &str, - changes: &mut HashMap, -) { - match (old, new) { - (Value::Mapping(old_map), Value::Mapping(new_map)) => { - // Check for changed or new values - for (key, new_val) in new_map { - if let Value::String(key_str) = key { - let full_key = if prefix.is_empty() { - key_str.clone() - } else { - format!("{}.{}", prefix, key_str) - }; - - if let Some(old_val) = old_map.get(key) { - if old_val != new_val { - // Value changed - if new_val.is_mapping() || new_val.is_sequence() { - // For complex types, recurse - collect_value_changes(old_val, new_val, &full_key, changes); - } else { - // For scalars, record the change - changes.insert(full_key, new_val.clone()); - } - } else if new_val.is_mapping() { - // Same value, but might have nested changes - collect_value_changes(old_val, new_val, &full_key, changes); - } - } else { - // New key added - changes.insert(full_key, new_val.clone()); - } - } - } - } - _ => { - // For non-mapping types, just record if different - if old != new { - changes.insert(prefix.to_string(), new.clone()); - } - } - } -} - -/// Applies changes to the original content while preserving formatting -fn apply_changes_to_content( - content: &str, - changes: &HashMap, - removed_keys: &[String], -) -> Result { - let lines: Vec<&str> = content.lines().collect(); - let mut result = Vec::new(); - let mut i = 0; - - // Build a mapping of line numbers to the YAML keys they represent - let line_key_map = build_line_to_key_map(&lines)?; - - // Track which keys from changes we've already processed - let mut processed_changes = std::collections::HashSet::new(); - - while i < lines.len() { - let line = lines[i]; - let trimmed = line.trim_start(); - - // Always preserve empty lines and comments - if trimmed.is_empty() || trimmed.starts_with('#') { - result.push(line.to_string()); - i += 1; - continue; - } - - // Check if this line should be removed based on the key map - let mut should_skip = false; - let mut skip_until_indent = None; - - if let Some(key_path) = line_key_map.get(&i) { - // Check if this key or any parent key was removed - for removed_key in removed_keys { - if removed_key == key_path || key_path.starts_with(&format!("{}.", removed_key)) { - should_skip = true; - let indent = line.len() - trimmed.len(); - skip_until_indent = Some(indent); - break; - } - } - - // Check if this key was changed - if !should_skip && changes.contains_key(key_path) { - if let Some(new_val) = changes.get(key_path) { - let formatted = format_value_for_yaml(new_val); - let indent = line.len() - trimmed.len(); - let indent_str = &line[..indent]; - let key_name = trimmed[..trimmed.find(':').unwrap()].trim(); - result.push(format!( - "{}{}:{}", - indent_str, - key_name, - if formatted.is_empty() { - "".to_string() - } else { - format!(" {}", formatted) - } - )); - processed_changes.insert(key_path.clone()); - - // Skip the original value lines that are nested under this key - i += 1; - while i < lines.len() { - let next_line = lines[i]; - let next_trimmed = next_line.trim_start(); - let next_indent = next_line.len() - next_trimmed.len(); - - if next_trimmed.is_empty() || next_trimmed.starts_with('#') { - if next_indent == indent { - result.push(next_line.to_string()); - i += 1; - } else if next_indent > indent { - i += 1; - } else { - break; - } - } else if next_indent <= indent { - break; - } else { - i += 1; - } - } - continue; - } - } - } - - if should_skip { - let indent = skip_until_indent.unwrap(); - // Skip this line and nested content - i += 1; - while i < lines.len() { - let next_line = lines[i]; - let next_trimmed = next_line.trim_start(); - let next_indent = next_line.len() - next_trimmed.len(); - - if next_trimmed.is_empty() || next_trimmed.starts_with('#') { - result.push(next_line.to_string()); - i += 1; - continue; - } - - if next_indent <= indent { - break; - } - - i += 1; - } - continue; - } - - result.push(line.to_string()); - i += 1; - } - - // Add any changes that weren't already in the file (new keys) - for (key_path, new_val) in changes { - if !processed_changes.contains(key_path) { - let formatted = format_value_for_yaml(new_val); - - if key_path.contains('.') { - // Nested key - need to build the structure - // For now, fall back to standard serialization for complex additions - return serde_yaml::to_string(&build_yaml_from_changes(content, changes)?) - .map_err(|e| format!("Failed to serialize YAML: {}", e)); - } else { - // Top-level key - just append it - if !result.is_empty() && !result.last().unwrap().is_empty() { - result.push(String::new()); // Add blank line before new key - } - result.push(format!("{}: {}", key_path, formatted)); - } - } - } - - // Preserve trailing newline - let mut output = result.join("\n"); - if content.ends_with('\n') && !output.ends_with('\n') { - output.push('\n'); - } - - Ok(output) -} - -/// Build a YAML value from changes by parsing the original and applying changes -fn build_yaml_from_changes( - content: &str, - changes: &std::collections::HashMap, -) -> Result { - let mut yaml = - serde_yaml::from_str(content).map_err(|e| format!("Failed to parse YAML: {}", e))?; - - for (key_path, value) in changes { - set_value(&mut yaml, key_path, value)?; - } - - Ok(yaml) -} - -/// Set a value in YAML at a specified key path - helper for rebuilding -fn set_value( - value: &mut serde_yaml::Value, - path: &str, - new_value: &serde_yaml::Value, -) -> Result<(), String> { - use serde_yaml::Value; - - let parts: Vec<&str> = path.split('.').collect(); - - if parts.is_empty() { - return Err("Empty key path".to_string()); - } - - // Ensure root is a mapping - if !matches!(value, Value::Mapping(_)) { - *value = Value::Mapping(Default::default()); - } - - // Navigate/create the path - let mut current = value; - for (i, &part) in parts.iter().enumerate() { - if i == parts.len() - 1 { - // Last part: set the value - if let Value::Mapping(ref mut map) = current { - map.insert(Value::String(part.to_string()), new_value.clone()); - } - } else { - // Intermediate part: navigate or create - if let Value::Mapping(ref mut map) = current { - current = map - .entry(Value::String(part.to_string())) - .or_insert_with(|| Value::Mapping(Default::default())); - } - } - } - - Ok(()) -} - -/// Formats a YAML value for inline output -fn format_value_for_yaml(value: &Value) -> String { - match value { - Value::String(s) => { - // Quote if needed - if s.contains(' ') || s.contains(':') || s.is_empty() || s.starts_with('#') { - format!("'{}'", s) - } else { - s.clone() - } - } - Value::Bool(b) => b.to_string(), - Value::Number(n) => n.to_string(), - Value::Null => "null".to_string(), - _ => { - // For complex types, use YAML serialization but trim it - serde_yaml::to_string(value) - .unwrap_or_default() - .trim() - .to_string() - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_preserves_comments_simple() { - let yaml = "# Configuration\nkey1: value1\nkey2: value2\n"; - let mut value = serde_yaml::from_str::(yaml).unwrap(); - - if let Value::Mapping(ref mut map) = value { - map.insert( - Value::String("key1".to_string()), - Value::String("newvalue1".to_string()), - ); - } - - let result = write_yaml_preserving_format(yaml, &value).unwrap(); - - assert!(result.contains("# Configuration")); - assert!(result.contains("key1: newvalue1")); - assert!(result.contains("key2: value2")); - } - - #[test] - fn test_preserves_empty_lines() { - let yaml = "key1: value1\n\nkey2: value2\n"; - let value = serde_yaml::from_str::(yaml).unwrap(); - - let result = write_yaml_preserving_format(yaml, &value).unwrap(); - - // Without changes, should return original - assert_eq!(result, yaml); - } - - #[test] - fn test_detects_removed_keys() { - let yaml = "key1: value1\nkey2: value2\nkey3: value3\n"; - let mut value = serde_yaml::from_str::(yaml).unwrap(); - - // Remove key2 - if let Value::Mapping(ref mut map) = value { - map.remove(&Value::String("key2".to_string())); - } - - let result = write_yaml_preserving_format(yaml, &value).unwrap(); - - assert!(result.contains("key1: value1")); - assert!(!result.contains("key2: value2")); - assert!(result.contains("key3: value3")); - } - - #[test] - fn test_preserves_comments_and_empty_lines_on_change() { - // Test that comments/empty lines are preserved for TOP-LEVEL key changes - // For nested changes, standard serialization is used (comments won't be preserved) - let yaml = "# Main config\nkey1: value1\n\n# Another key\nkey2: value2\n"; - let mut value = serde_yaml::from_str::(yaml).unwrap(); - - // Change top-level key only - if let Value::Mapping(ref mut map) = value { - map.insert( - Value::String("key1".to_string()), - Value::String("newvalue1".to_string()), - ); - } - - let result = write_yaml_preserving_format(yaml, &value).unwrap(); - - // Comments and empty lines should be preserved for top-level changes - assert!(result.contains("# Main config")); - assert!(result.contains("# Another key")); - assert!(result.contains("key1: newvalue1")); - assert!(result.contains("key2: value2")); - } -} diff --git a/src/yaml_ops.rs b/src/yaml_ops.rs index 4d0e363..35ad7ba 100644 --- a/src/yaml_ops.rs +++ b/src/yaml_ops.rs @@ -1,336 +1,497 @@ +use std::fs; +use std::path::Path; + use regex::Regex; use serde_yaml::Value; -use std::collections::HashMap; +use yamlpatch::{Op, Patch}; +use yamlpath::Document; + +use crate::error::{AppError, AppResult}; +use crate::path::{PathSegment, YamlPath}; + +const PLACEHOLDER_KEY: &str = "__ym_placeholder__"; -/// Search YAML by key path pattern -/// When a key matches, return that value without recursing into nested keys -pub fn grep(value: &Value, pattern: &str) -> Result, String> { +pub fn grep(value: &Value, pattern: &str) -> AppResult> { + let regex = Regex::new(pattern)?; let mut results = Vec::new(); - collect_matching_keys(value, pattern, "", &mut results)?; + let mut path = Vec::new(); + collect_matching_keys(value, ®ex, &mut path, &mut results); Ok(results) } fn collect_matching_keys( value: &Value, - pattern: &str, - current_path: &str, + regex: &Regex, + path: &mut Vec, results: &mut Vec<(String, Value)>, -) -> Result<(), String> { +) { match value { Value::Mapping(map) => { - for (key, val) in map { - if let Value::String(k) = key { - let new_path = if current_path.is_empty() { - k.clone() - } else { - format!("{}.{}", current_path, k) - }; - - // Check if pattern matches the current key path - if is_key_match(&new_path, pattern)? { - results.push((new_path, val.clone())); - // Don't recurse into matched keys - return the whole subtree - } else { - // Only recurse if this key doesn't match - collect_matching_keys(val, pattern, &new_path, results)?; - } + for (key, value) in map { + let Value::String(key) = key else { + continue; + }; + + path.push(PathSegment::Key(key.clone())); + let rendered = render_path(path); + if regex.is_match(&rendered) { + results.push((rendered, value.clone())); + } else { + collect_matching_keys(value, regex, path, results); } + path.pop(); } } - Value::Sequence(seq) => { - for (idx, val) in seq.iter().enumerate() { - let new_path = format!("{}[{}]", current_path, idx); - collect_matching_keys(val, pattern, &new_path, results)?; + Value::Sequence(sequence) => { + for (index, value) in sequence.iter().enumerate() { + path.push(PathSegment::Index(index)); + collect_matching_keys(value, regex, path, results); + path.pop(); } } _ => {} } - Ok(()) } -/// Check if a key path matches the pattern (regex) -fn is_key_match(key: &str, pattern: &str) -> Result { - let re = Regex::new(pattern).map_err(|e| format!("Invalid regex pattern: {}", e))?; - Ok(re.is_match(key)) -} - -/// Set values in YAML at specified key paths -pub fn set_values(value: &mut Value, updates: &HashMap) -> Result<(), String> { - for (key_path, new_value) in updates { - set_at_path(value, key_path, new_value)?; - } - Ok(()) -} +fn render_path(path: &[PathSegment]) -> String { + let mut rendered = String::new(); -fn set_at_path(value: &mut Value, path: &str, new_value: &str) -> Result<(), String> { - let parts: Vec<&str> = path.split('.').collect(); - - if parts.is_empty() { - return Err("Empty key path".to_string()); - } - - // Ensure root is a mapping - if !matches!(value, Value::Mapping(_)) { - *value = Value::Mapping(Default::default()); - } + for segment in path { + match segment { + PathSegment::Key(key) => { + if !rendered.is_empty() { + rendered.push('.'); + } - // Navigate/create the path - let mut current = value; - for (i, &part) in parts.iter().enumerate() { - if i == parts.len() - 1 { - // Last part: set the value - if let Value::Mapping(ref mut map) = current { - map.insert( - Value::String(part.to_string()), - Value::String(new_value.to_string()), - ); + for ch in key.chars() { + match ch { + '.' | '[' | ']' | '\\' => { + rendered.push('\\'); + rendered.push(ch); + } + other => rendered.push(other), + } + } } - } else { - // Intermediate part: navigate or create - if let Value::Mapping(ref mut map) = current { - current = map - .entry(Value::String(part.to_string())) - .or_insert_with(|| Value::Mapping(Default::default())); + PathSegment::Index(index) => { + rendered.push('['); + rendered.push_str(&index.to_string()); + rendered.push(']'); } } } - Ok(()) + rendered } -/// Remove keys from YAML at specified paths -pub fn unset_values(value: &mut Value, keys: &[String]) -> Result<(), String> { - for key_path in keys { - unset_at_path(value, key_path)?; - } - Ok(()) +fn parse_user_value(input: &str) -> AppResult { + serde_yaml::from_str(input) + .map_err(|error| AppError::parse_yaml(format!("from value '{input}'"), error)) } -/// Get a value from YAML at a specified key path -pub fn get_value(value: &Value, path: &str) -> Result, String> { - let parts: Vec<&str> = path.split('.').collect(); +fn parse_yaml_document(yaml_content: &str, context: &str) -> AppResult { + serde_yaml::from_str(yaml_content) + .map_err(|error| AppError::parse_yaml(context.to_string(), error)) +} - if parts.is_empty() { - return Err("Empty key path".to_string()); - } +fn apply_patch(yaml_content: &str, patch: Patch<'static>) -> AppResult { + let document = + Document::new(yaml_content).map_err(|error| AppError::patch(error.to_string()))?; + let updated = yamlpatch::apply_yaml_patches(&document, &[patch]) + .map_err(|error| AppError::patch(error.to_string()))?; + Ok(updated.source().to_string()) +} - let mut current = value; - for part in parts { - if let Value::Mapping(map) = current { - match map.get(Value::String(part.to_string())) { - Some(next) => current = next, - None => return Ok(None), - } - } else { - return Ok(None); - } +fn placeholder_mapping() -> Value { + let mut placeholder = serde_yaml::Mapping::new(); + placeholder.insert(Value::String(PLACEHOLDER_KEY.to_string()), Value::Null); + Value::Mapping(placeholder) +} + +fn yaml_set(yaml_content: &str, key_path: &str, new_value: Value) -> AppResult { + let path = YamlPath::parse(key_path)?; + let mut result = yaml_content.to_string(); + + for prefix in path.prefixes_requiring_mapping() { + result = ensure_mapping_at_path(&result, &prefix)?; } - Ok(Some(current.clone())) -} + let current = parse_yaml_document(&result, "from document")?; + let existing_value = get_value_at_path(¤t, &path)?; + let setting_mapping = matches!(new_value, Value::Mapping(_)); -/// Copy a value from source file:key to destination file:key -/// Source and destination keys are required -/// If dest_file is None, use source_file -/// If dest_key is None, use source_key -pub fn copy_value( - source_file: &str, - source_key: &str, - dest_file: &str, - dest_key: &str, -) -> Result<(), String> { - use crate::yaml_format_preserving; - use std::fs; + let updated = match new_value { + Value::Mapping(new_map) => set_mapping_at_path(&result, &path, existing_value, new_map)?, + new_value => match existing_value { + Some(_) => replace_value_at_path(&result, &path, new_value)?, + None => add_value_at_path(&result, &path, new_value)?, + }, + }; - // Read source file - let source_contents = fs::read_to_string(source_file) - .map_err(|e| format!("Failed to read source file '{}': {}", source_file, e))?; + cleanup_placeholders(&updated, &cleanup_paths_for_set(&path, setting_mapping)) +} - let source_yaml = serde_yaml::from_str(&source_contents) - .map_err(|e| format!("Failed to parse YAML from '{}': {}", source_file, e))?; +fn ensure_mapping_at_path(yaml_content: &str, path: &YamlPath) -> AppResult { + let current = parse_yaml_document(yaml_content, "from document")?; - // Get the value from source - let value = get_value(&source_yaml, source_key)? - .ok_or_else(|| format!("Key '{}' not found in '{}'", source_key, source_file))?; + match get_value_at_path(¤t, path)? { + Some(Value::Mapping(_)) => Ok(yaml_content.to_string()), + Some(_) => replace_with_empty_mapping_at_path(yaml_content, path), + None => add_empty_mapping_at_path(yaml_content, path), + } +} - // Read destination file (or create if it doesn't exist) - let (mut dest_yaml, dest_contents_option) = if std::path::Path::new(dest_file).exists() { - let dest_contents = fs::read_to_string(dest_file) - .map_err(|e| format!("Failed to read destination file '{}': {}", dest_file, e))?; +fn replace_with_empty_mapping_at_path(yaml_content: &str, path: &YamlPath) -> AppResult { + let removed = remove_at_path(yaml_content, path)?; + add_empty_mapping_at_path(&removed, path) +} - let yaml = serde_yaml::from_str(&dest_contents) - .map_err(|e| format!("Failed to parse YAML from '{}': {}", dest_file, e))?; - (yaml, Some(dest_contents)) - } else { - (Value::Mapping(Default::default()), None) +fn add_empty_mapping_at_path(yaml_content: &str, path: &YamlPath) -> AppResult { + let parent = path + .parent() + .map(|parent| parent.to_route()) + .unwrap_or_default(); + + let Some(PathSegment::Key(key)) = path.last() else { + return Err(AppError::message(format!( + "Cannot create a mapping at sequence path '{}'", + path.display() + ))); }; - // Set the value at destination - set_value(&mut dest_yaml, dest_key, &value)?; + apply_patch( + yaml_content, + Patch { + route: parent, + operation: Op::Add { + key: key.clone(), + value: placeholder_mapping(), + }, + }, + ) +} - // Write destination file using format-preserving logic if possible - let dest_yaml_str = if let Some(dest_contents) = dest_contents_option { - // Destination file exists, preserve its formatting - yaml_format_preserving::write_yaml_preserving_format(&dest_contents, &dest_yaml) - .map_err(|e| format!("Failed to preserve YAML format: {}", e))? - } else { - // New destination file, use standard serialization - serde_yaml::to_string(&dest_yaml).map_err(|e| format!("Failed to serialize YAML: {}", e))? - }; +fn add_value_at_path(yaml_content: &str, path: &YamlPath, new_value: Value) -> AppResult { + match path.last() { + Some(PathSegment::Key(key)) => { + let parent = path + .parent() + .map(|parent| parent.to_route()) + .unwrap_or_default(); + apply_patch( + yaml_content, + Patch { + route: parent, + operation: Op::Add { + key: key.clone(), + value: new_value, + }, + }, + ) + } + Some(PathSegment::Index(index)) => { + append_value_at_path(yaml_content, path, *index, new_value) + } + None => Err(AppError::message("Empty key path")), + } +} - fs::write(dest_file, dest_yaml_str) - .map_err(|e| format!("Failed to write to '{}': {}", dest_file, e))?; +fn append_value_at_path( + yaml_content: &str, + path: &YamlPath, + index: usize, + new_value: Value, +) -> AppResult { + let parent_path = path.parent().ok_or_else(|| { + AppError::message(format!( + "Cannot set root sequence index '{}'", + path.display() + )) + })?; + let current = parse_yaml_document(yaml_content, "from document")?; + + match get_value_at_path(¤t, &parent_path)? { + Some(Value::Sequence(sequence)) if index == sequence.len() => apply_patch( + yaml_content, + Patch { + route: parent_path.to_route(), + operation: Op::Append { value: new_value }, + }, + ), + Some(Value::Sequence(sequence)) => Err(AppError::message(format!( + "Cannot create sparse sequence entry at '{}'; next valid index is {}", + path.display(), + sequence.len() + ))), + Some(_) => Err(AppError::message(format!( + "Parent of '{}' is not a sequence", + path.display() + ))), + None => Err(AppError::message(format!( + "Parent sequence '{}' does not exist", + parent_path.display() + ))), + } +} - Ok(()) +fn replace_value_at_path( + yaml_content: &str, + path: &YamlPath, + new_value: Value, +) -> AppResult { + apply_patch( + yaml_content, + Patch { + route: path.to_route(), + operation: Op::Replace(new_value), + }, + ) } -/// Move a value from source file:key to destination file:key -/// This copies the value and then deletes it from the source -/// Source and destination keys are required -/// If dest_file is None, use source_file -/// If dest_key is None, use source_key -pub fn move_value( - source_file: &str, - source_key: &str, - dest_file: &str, - dest_key: &str, -) -> Result<(), String> { - use crate::yaml_format_preserving; - use std::fs; +fn remove_at_path(yaml_content: &str, path: &YamlPath) -> AppResult { + apply_patch( + yaml_content, + Patch { + route: path.to_route(), + operation: Op::Remove, + }, + ) +} - // First, copy the value from source to destination - copy_value(source_file, source_key, dest_file, dest_key)?; +fn set_mapping_at_path( + yaml_content: &str, + path: &YamlPath, + existing_value: Option, + new_map: serde_yaml::Mapping, +) -> AppResult { + let mut result = match existing_value { + Some(Value::Mapping(current_map)) => { + remove_missing_mapping_keys(yaml_content, path, ¤t_map, &new_map)? + } + Some(_) => replace_with_empty_mapping_at_path(yaml_content, path)?, + None => add_empty_mapping_at_path(yaml_content, path)?, + }; - // Then, delete the source key from the source file - let source_contents = fs::read_to_string(source_file) - .map_err(|e| format!("Failed to read source file '{}': {}", source_file, e))?; + for (key, value) in new_map { + let Value::String(key) = key else { + return Err(AppError::message(format!( + "Unsupported non-string key under '{}'", + path.display() + ))); + }; - let mut source_yaml = serde_yaml::from_str(&source_contents) - .map_err(|e| format!("Failed to parse YAML from '{}': {}", source_file, e))?; + result = yaml_set(&result, &path.push_key(key).display(), value)?; + } - // Unset the source key - unset_at_path(&mut source_yaml, source_key)?; + cleanup_placeholders(&result, std::slice::from_ref(path)) +} - // Always use format-preserving write to preserve comments and spacing - let source_yaml_str = - yaml_format_preserving::write_yaml_preserving_format(&source_contents, &source_yaml) - .map_err(|e| format!("Failed to preserve YAML format: {}", e))?; +fn remove_missing_mapping_keys( + yaml_content: &str, + path: &YamlPath, + current_map: &serde_yaml::Mapping, + new_map: &serde_yaml::Mapping, +) -> AppResult { + let mut result = yaml_content.to_string(); + + for key in current_map.keys().filter_map(|key| match key { + Value::String(key) if key != PLACEHOLDER_KEY => Some(key.clone()), + _ => None, + }) { + if !new_map.contains_key(Value::String(key.clone())) { + result = remove_at_path(&result, &path.push_key(key))?; + } + } - fs::write(source_file, &source_yaml_str) - .map_err(|e| format!("Failed to write to '{}': {}", source_file, e))?; + Ok(result) +} - Ok(()) +fn cleanup_paths_for_set(path: &YamlPath, setting_mapping: bool) -> Vec { + let mut paths = path.prefixes_requiring_mapping(); + paths.reverse(); + if setting_mapping { + paths.insert(0, path.clone()); + } + paths } -/// Set a value in YAML at a specified key path to a specific Value -fn set_value(value: &mut Value, path: &str, new_value: &Value) -> Result<(), String> { - let parts: Vec<&str> = path.split('.').collect(); +fn cleanup_placeholders(yaml_content: &str, paths: &[YamlPath]) -> AppResult { + let mut result = yaml_content.to_string(); - if parts.is_empty() { - return Err("Empty key path".to_string()); + for path in paths { + let current = parse_yaml_document(&result, "from document")?; + let placeholder_path = path.push_key(PLACEHOLDER_KEY); + if get_value_at_path(¤t, &placeholder_path)?.is_some() { + result = remove_at_path(&result, &placeholder_path)?; + } } - // Ensure root is a mapping - if !matches!(value, Value::Mapping(_)) { - *value = Value::Mapping(Default::default()); + Ok(result) +} + +pub fn set_values(yaml_content: &str, updates: &[(String, String)]) -> AppResult { + let mut result = yaml_content.to_string(); + + for (key_path, new_value) in updates { + result = yaml_set(&result, key_path, parse_user_value(new_value)?)?; + } + + Ok(result) +} + +pub fn unset_values(yaml_content: &str, keys: &[String]) -> AppResult { + let mut result = yaml_content.to_string(); + + for key_path in keys { + let path = YamlPath::parse(key_path)?; + let current = parse_yaml_document(&result, "from document")?; + if get_value_at_path(¤t, &path)?.is_some() { + result = remove_at_path(&result, &path)?; + } } - // Navigate/create the path + Ok(result) +} + +pub fn get_value(value: &Value, path: &str) -> AppResult> { + let path = YamlPath::parse(path)?; + get_value_at_path(value, &path) +} + +fn get_value_at_path(value: &Value, path: &YamlPath) -> AppResult> { let mut current = value; - for (i, &part) in parts.iter().enumerate() { - if i == parts.len() - 1 { - // Last part: set the value - if let Value::Mapping(ref mut map) = current { - map.insert(Value::String(part.to_string()), new_value.clone()); + + for segment in path.as_segments() { + match segment { + PathSegment::Key(key) => { + let Value::Mapping(map) = current else { + return Ok(None); + }; + match map.get(Value::String(key.clone())) { + Some(next) => current = next, + None => return Ok(None), + } } - } else { - // Intermediate part: navigate or create - if let Value::Mapping(ref mut map) = current { - current = map - .entry(Value::String(part.to_string())) - .or_insert_with(|| Value::Mapping(Default::default())); + PathSegment::Index(index) => { + let Value::Sequence(sequence) = current else { + return Ok(None); + }; + match sequence.get(*index) { + Some(next) => current = next, + None => return Ok(None), + } } } } - Ok(()) + Ok(Some(current.clone())) } -fn unset_at_path(value: &mut Value, path: &str) -> Result<(), String> { - let parts: Vec<&str> = path.split('.').collect(); +pub fn copy_in_document(yaml_content: &str, source_key: &str, dest_key: &str) -> AppResult { + let source_yaml = parse_yaml_document(yaml_content, "from source document")?; + let value = get_value(&source_yaml, source_key)?.ok_or_else(|| { + AppError::message(format!("Key '{}' not found in source document", source_key)) + })?; + + yaml_set(yaml_content, dest_key, value) +} - if parts.is_empty() { - return Err("Empty key path".to_string()); +pub fn move_in_document(yaml_content: &str, source_key: &str, dest_key: &str) -> AppResult { + if source_key == dest_key { + return Ok(yaml_content.to_string()); } - if parts.len() == 1 { - // Direct child: remove from root mapping - if let Value::Mapping(ref mut map) = value { - map.remove(Value::String(parts[0].to_string())); - } + let copied = copy_in_document(yaml_content, source_key, dest_key)?; + unset_values(&copied, &[source_key.to_string()]) +} + +pub fn copy_value( + source_file: &str, + source_key: &str, + dest_file: &str, + dest_key: &str, +) -> AppResult<()> { + let source_contents = + fs::read_to_string(source_file).map_err(|error| AppError::read_file(source_file, error))?; + + let dest_contents = if source_file == dest_file { + source_contents.clone() + } else if Path::new(dest_file).exists() { + fs::read_to_string(dest_file).map_err(|error| AppError::read_file(dest_file, error))? } else { - // Navigate to parent, then remove the final key - let mut current = value; - for &part in parts[..parts.len() - 1].iter() { - if let Value::Mapping(ref mut map) = current { - if let Some(next) = map.get_mut(Value::String(part.to_string())) { - current = next; - } else { - // Path doesn't exist - return Ok(()); - } - } else { - // Path is not a mapping - return Ok(()); - } - } + "{}".to_string() + }; - // Remove the final key - if let Value::Mapping(ref mut map) = current { - map.remove(Value::String(parts[parts.len() - 1].to_string())); - } - } + let source_yaml = parse_yaml_document(&source_contents, &format!("from '{source_file}'"))?; + let value = get_value(&source_yaml, source_key)?.ok_or_else(|| { + AppError::message(format!( + "Key '{}' not found in '{}'", + source_key, source_file + )) + })?; + let updated = yaml_set(&dest_contents, dest_key, value)?; + fs::write(dest_file, updated).map_err(|error| AppError::write_file(dest_file, error))?; + Ok(()) +} + +pub fn move_value( + source_file: &str, + source_key: &str, + dest_file: &str, + dest_key: &str, +) -> AppResult<()> { + let source_contents = + fs::read_to_string(source_file).map_err(|error| AppError::read_file(source_file, error))?; + + if source_file == dest_file { + let updated = move_in_document(&source_contents, source_key, dest_key)?; + fs::write(source_file, updated) + .map_err(|error| AppError::write_file(source_file, error))?; + return Ok(()); + } + + let source_yaml = parse_yaml_document(&source_contents, &format!("from '{source_file}'"))?; + let value = get_value(&source_yaml, source_key)?.ok_or_else(|| { + AppError::message(format!( + "Key '{}' not found in '{}'", + source_key, source_file + )) + })?; + + let dest_contents = if Path::new(dest_file).exists() { + fs::read_to_string(dest_file).map_err(|error| AppError::read_file(dest_file, error))? + } else { + "{}".to_string() + }; + + let updated_dest = yaml_set(&dest_contents, dest_key, value)?; + let updated_source = unset_values(&source_contents, &[source_key.to_string()])?; + + fs::write(dest_file, updated_dest).map_err(|error| AppError::write_file(dest_file, error))?; + fs::write(source_file, updated_source) + .map_err(|error| AppError::write_file(source_file, error))?; Ok(()) } -/// Format result for output as "key: value" -/// For mappings, display full YAML structure with indentation pub fn format_result(key: &str, value: &Value, terminal_width: usize) -> String { match value { - Value::Mapping(_) => { - // For mappings, display as multi-line YAML with indentation - format_mapping_result(key, value, terminal_width) - } - Value::String(s) => { - let result = format!("{}: {}", key, s); - truncate_if_needed(&result, terminal_width) - } - Value::Number(n) => { - let result = format!("{}: {}", key, n); - truncate_if_needed(&result, terminal_width) - } - Value::Bool(b) => { - let result = format!("{}: {}", key, b); - truncate_if_needed(&result, terminal_width) - } - Value::Null => { - format!("{}: null", key) - } + Value::Mapping(_) => format_mapping_result(key, value), + Value::String(s) => truncate_if_needed(&format!("{}: {}", key, s), terminal_width), + Value::Number(n) => truncate_if_needed(&format!("{}: {}", key, n), terminal_width), + Value::Bool(b) => truncate_if_needed(&format!("{}: {}", key, b), terminal_width), + Value::Null => format!("{}: null", key), _ => { - // For sequences and other types, use YAML format let val_str = serde_yaml::to_string(value) .unwrap_or_else(|_| "".to_string()) .trim() .to_string(); - let result = format!("{}: {}", key, val_str); - truncate_if_needed(&result, terminal_width) + truncate_if_needed(&format!("{}: {}", key, val_str), terminal_width) } } } fn truncate_if_needed(text: &str, terminal_width: usize) -> String { - // For single-line output, truncate if needed if text.len() > terminal_width { format!("{}...", &text[..terminal_width.saturating_sub(3)]) } else { @@ -338,14 +499,12 @@ fn truncate_if_needed(text: &str, terminal_width: usize) -> String { } } -fn format_mapping_result(key: &str, value: &Value, _terminal_width: usize) -> String { - // Convert mapping to YAML string with indentation +fn format_mapping_result(key: &str, value: &Value) -> String { let yaml_str = match serde_yaml::to_string(value) { - Ok(s) => s, + Ok(result) => result, Err(_) => return format!("{}: ", key), }; - // Indent each line of the YAML output by 2 spaces let indented = yaml_str .lines() .map(|line| { @@ -369,7 +528,16 @@ mod tests { serde_yaml::from_str(yaml_str).expect("Failed to parse YAML") } - // ==================== grep() Tests ==================== + fn temp_test_dir(name: &str) -> String { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = format!("{}_{}_{}", name, std::process::id(), unique); + let _ = fs::remove_dir_all(&dir); + fs::create_dir(&dir).unwrap(); + dir + } #[test] fn test_grep_simple_key() { @@ -377,743 +545,231 @@ mod tests { let results = grep(&yaml, "name").unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].0, "name"); - assert_eq!(results[0].1.as_str().unwrap(), "Alice"); + assert_eq!(results[0].1.as_str(), Some("Alice")); } #[test] - fn test_grep_exact_match() { - let yaml_str = r#" -database: - host: localhost - port: 5432 -cache: - host: redis -"#; - let yaml = parse_yaml(yaml_str); - let results = grep(&yaml, "^database\\.host$").unwrap(); - assert_eq!(results.len(), 1); - assert_eq!(results[0].0, "database.host"); + fn test_grep_compiles_regex_once_and_matches_nested_keys() { + let yaml = parse_yaml("database:\n host: localhost\n port: 5432\n"); + let results = grep(&yaml, r"^database\.(host|port)$").unwrap(); + let keys: Vec<_> = results.into_iter().map(|result| result.0).collect(); + assert_eq!(keys, vec!["database.host", "database.port"]); } #[test] - fn test_grep_pattern_wildcard() { - let yaml_str = r#" -database: - host: localhost - port: 5432 - username: admin -"#; - let yaml = parse_yaml(yaml_str); - let results = grep(&yaml, "database\\..*").unwrap(); - assert_eq!(results.len(), 3); - let keys: Vec<_> = results.iter().map(|r| r.0.as_str()).collect(); - assert!(keys.contains(&"database.host")); - assert!(keys.contains(&"database.port")); - assert!(keys.contains(&"database.username")); - } - - #[test] - fn test_grep_nested_paths() { - let yaml_str = r#" -app: - server: - address: 0.0.0.0 - port: 8080 -"#; - let yaml = parse_yaml(yaml_str); - let results = grep(&yaml, "app\\.server.*").unwrap(); - // When "app.server" matches the pattern, it stops recursing, returning just "app.server" with its whole subtree + fn test_grep_escapes_dotted_keys() { + let yaml = parse_yaml("metadata:\n kubernetes.io/hostname: node-a\n"); + let results = grep(&yaml, r"metadata\.kubernetes\\\.io/hostname$").unwrap(); assert_eq!(results.len(), 1); - assert_eq!(results[0].0, "app.server"); - // The value should be the whole server mapping - assert!(results[0].1.is_mapping()); + assert_eq!(results[0].0, r"metadata.kubernetes\.io/hostname"); } #[test] - fn test_grep_no_match() { - let yaml = parse_yaml("name: Alice"); - let results = grep(&yaml, "nonexistent").unwrap(); - assert_eq!(results.len(), 0); + fn test_grep_sequence_paths() { + let yaml = parse_yaml("items:\n - name: first\n - name: second\n"); + let results = grep(&yaml, r"items\[1\]\.name").unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].0, "items[1].name"); + assert_eq!(results[0].1.as_str(), Some("second")); } #[test] fn test_grep_invalid_regex() { let yaml = parse_yaml("name: Alice"); - let result = grep(&yaml, "[invalid"); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("Invalid regex")); - } - - #[test] - fn test_grep_with_alternation() { - let yaml_str = r#" -dev: - password: devpass -prod: - password: prodpass -staging: - token: stagingtoken -"#; - let yaml = parse_yaml(yaml_str); - let results = grep(&yaml, "(dev|prod)\\.password").unwrap(); - assert_eq!(results.len(), 2); - let keys: Vec<_> = results.iter().map(|r| r.0.as_str()).collect(); - assert!(keys.contains(&"dev.password")); - assert!(keys.contains(&"prod.password")); - } - - #[test] - fn test_grep_stops_at_match() { - let yaml_str = r#" -config: - nested: - value: test -"#; - let yaml = parse_yaml(yaml_str); - let results = grep(&yaml, "^config$").unwrap(); - assert_eq!(results.len(), 1); - assert_eq!(results[0].0, "config"); - assert!(results[0].1.is_mapping()); - } - - // ==================== set_values() Tests ==================== - - #[test] - fn test_set_simple_value() { - let mut yaml = parse_yaml("name: Alice"); - let mut updates = HashMap::new(); - updates.insert("name".to_string(), "Bob".to_string()); - - set_values(&mut yaml, &updates).unwrap(); - assert_eq!(yaml["name"].as_str().unwrap(), "Bob"); + assert!(grep(&yaml, "[invalid").is_err()); } #[test] - fn test_set_new_key() { - let mut yaml = parse_yaml("name: Alice"); - let mut updates = HashMap::new(); - updates.insert("age".to_string(), "30".to_string()); - - set_values(&mut yaml, &updates).unwrap(); - assert_eq!(yaml["age"].as_str().unwrap(), "30"); - } + fn test_set_and_unset_values_update_yaml_semantics() { + let yaml_str = "database:\n host: localhost\n port: 5432\nconfig:\n level: info\n"; + let updates = vec![ + ("database.port".to_string(), "3306".to_string()), + ("database.username".to_string(), "admin".to_string()), + ("app.server.config.timeout".to_string(), "30".to_string()), + ]; - #[test] - fn test_set_nested_path_creates_structure() { - let mut yaml = Value::Mapping(Default::default()); - let mut updates = HashMap::new(); - updates.insert("database.host".to_string(), "localhost".to_string()); - - set_values(&mut yaml, &updates).unwrap(); - assert_eq!(yaml["database"]["host"].as_str().unwrap(), "localhost"); - } - - #[test] - fn test_set_deep_nesting() { - let mut yaml = Value::Mapping(Default::default()); - let mut updates = HashMap::new(); - updates.insert("app.server.config.timeout".to_string(), "30".to_string()); - - set_values(&mut yaml, &updates).unwrap(); + let updated = set_values(yaml_str, &updates).unwrap(); + let parsed = parse_yaml(&updated); + assert_eq!(parsed["database"]["host"].as_str(), Some("localhost")); + assert_eq!(parsed["database"]["port"].as_i64(), Some(3306)); + assert_eq!(parsed["database"]["username"].as_str(), Some("admin")); assert_eq!( - yaml["app"]["server"]["config"]["timeout"].as_str().unwrap(), - "30" + parsed["app"]["server"]["config"]["timeout"].as_i64(), + Some(30) ); - } - - #[test] - fn test_set_multiple_values() { - let mut yaml = Value::Mapping(Default::default()); - let mut updates = HashMap::new(); - updates.insert("key1".to_string(), "value1".to_string()); - updates.insert("key2".to_string(), "value2".to_string()); - - set_values(&mut yaml, &updates).unwrap(); - assert_eq!(yaml["key1"].as_str().unwrap(), "value1"); - assert_eq!(yaml["key2"].as_str().unwrap(), "value2"); - } - - #[test] - fn test_set_overwrites_existing() { - let mut yaml = parse_yaml("config:\n level: info"); - let mut updates = HashMap::new(); - updates.insert("config.level".to_string(), "debug".to_string()); - - set_values(&mut yaml, &updates).unwrap(); - assert_eq!(yaml["config"]["level"].as_str().unwrap(), "debug"); - } - - #[test] - fn test_set_preserves_siblings() { - let yaml_str = r#" -database: - host: localhost - port: 5432 - username: admin -"#; - let mut yaml = parse_yaml(yaml_str); - let mut updates = HashMap::new(); - updates.insert("database.port".to_string(), "3306".to_string()); - - set_values(&mut yaml, &updates).unwrap(); - assert_eq!(yaml["database"]["port"].as_str().unwrap(), "3306"); - assert_eq!(yaml["database"]["host"].as_str().unwrap(), "localhost"); - assert_eq!(yaml["database"]["username"].as_str().unwrap(), "admin"); - } - - // ==================== unset_values() Tests ==================== - - #[test] - fn test_unset_top_level_key() { - let mut yaml = parse_yaml("name: Alice\nage: 30"); - unset_values(&mut yaml, &["age".to_string()]).unwrap(); - assert_eq!(yaml["age"], Value::Null); - assert_eq!(yaml["name"].as_str().unwrap(), "Alice"); - } - - #[test] - fn test_unset_nested_key() { - let yaml_str = r#" -database: - host: localhost - port: 5432 -"#; - let mut yaml = parse_yaml(yaml_str); - unset_values(&mut yaml, &["database.port".to_string()]).unwrap(); - assert_eq!(yaml["database"]["port"], Value::Null); - assert_eq!(yaml["database"]["host"].as_str().unwrap(), "localhost"); - } - - #[test] - fn test_unset_deep_nested_key() { - let yaml_str = r#" -app: - server: - config: - timeout: 30 - retries: 3 -"#; - let mut yaml = parse_yaml(yaml_str); - unset_values(&mut yaml, &["app.server.config.timeout".to_string()]).unwrap(); - assert_eq!(yaml["app"]["server"]["config"]["timeout"], Value::Null); - assert_eq!(yaml["app"]["server"]["config"]["retries"].as_i64(), Some(3)); - } - - #[test] - fn test_unset_multiple_keys() { - let mut yaml = parse_yaml("a: 1\nb: 2\nc: 3"); - unset_values(&mut yaml, &["a".to_string(), "c".to_string()]).unwrap(); - assert_eq!(yaml["a"], Value::Null); - assert_eq!(yaml["b"].as_i64(), Some(2)); - assert_eq!(yaml["c"], Value::Null); - } - - #[test] - fn test_unset_nonexistent_key() { - let mut yaml = parse_yaml("name: Alice"); - unset_values(&mut yaml, &["nonexistent".to_string()]).unwrap(); - assert_eq!(yaml["name"].as_str().unwrap(), "Alice"); - } - #[test] - fn test_unset_nonexistent_nested_path() { - let yaml_str = r#" -database: - host: localhost -"#; - let mut yaml = parse_yaml(yaml_str); - unset_values(&mut yaml, &["database.nonexistent".to_string()]).unwrap(); - assert_eq!(yaml["database"]["host"].as_str().unwrap(), "localhost"); - } - - // ==================== format_result() Tests ==================== - - #[test] - fn test_format_string_value() { - let value = Value::String("hello".to_string()); - let result = format_result("message", &value, 80); - assert_eq!(result, "message: hello"); - } - - #[test] - fn test_format_number_value() { - let value = Value::Number(42.into()); - let result = format_result("count", &value, 80); - assert_eq!(result, "count: 42"); - } - - #[test] - fn test_format_boolean_true() { - let value = Value::Bool(true); - let result = format_result("enabled", &value, 80); - assert_eq!(result, "enabled: true"); - } - - #[test] - fn test_format_boolean_false() { - let value = Value::Bool(false); - let result = format_result("enabled", &value, 80); - assert_eq!(result, "enabled: false"); - } - - #[test] - fn test_format_null_value() { - let value = Value::Null; - let result = format_result("empty", &value, 80); - assert_eq!(result, "empty: null"); - } - - #[test] - fn test_format_mapping_value() { - let value = parse_yaml("host: localhost\nport: 5432"); - let result = format_result("database", &value, 80); - assert!(result.starts_with("database:\n")); - assert!(result.contains("host")); - assert!(result.contains("localhost")); - } - - #[test] - fn test_format_truncates_long_string() { - let long_string = "a".repeat(100); - let value = Value::String(long_string); - let result = format_result("key", &value, 20); - assert!(result.ends_with("...")); - assert!(result.len() <= 23); - } - - #[test] - fn test_format_does_not_truncate_short_string() { - let value = Value::String("short".to_string()); - let result = format_result("key", &value, 80); - assert_eq!(result, "key: short"); - assert!(!result.ends_with("...")); - } - - #[test] - fn test_format_sequence() { - let value = parse_yaml("- item1\n- item2\n- item3"); - let result = format_result("items", &value, 80); - assert!(result.contains("items:")); - } - - // ==================== get_value() Tests ==================== - - #[test] - fn test_get_value_simple_key() { - let yaml = parse_yaml("name: Alice\nage: 30"); - let result = get_value(&yaml, "name").unwrap(); - assert_eq!(result.unwrap().as_str().unwrap(), "Alice"); - } - - #[test] - fn test_get_value_nested_key() { - let yaml = parse_yaml("database:\n host: localhost\n port: 5432"); - let result = get_value(&yaml, "database.host").unwrap(); - assert_eq!(result.unwrap().as_str().unwrap(), "localhost"); - } - - #[test] - fn test_get_value_nonexistent_key() { - let yaml = parse_yaml("name: Alice"); - let result = get_value(&yaml, "nonexistent").unwrap(); - assert!(result.is_none()); - } - - #[test] - fn test_get_value_nonexistent_nested_path() { - let yaml = parse_yaml("database:\n host: localhost"); - let result = get_value(&yaml, "database.nonexistent").unwrap(); - assert!(result.is_none()); - } - - #[test] - fn test_get_value_mapping() { - let yaml = parse_yaml("database:\n host: localhost\n port: 5432"); - let result = get_value(&yaml, "database").unwrap(); - assert!(result.unwrap().is_mapping()); - } - - #[test] - fn test_get_value_number() { - let yaml = parse_yaml("age: 30\nheight: 180"); - let result = get_value(&yaml, "age").unwrap(); - assert_eq!(result.unwrap().as_i64().unwrap(), 30); - } - - // ==================== copy_value() Tests ==================== - - #[test] - fn test_copy_value_same_file_simple() { - use std::fs; - - let test_dir = "test_copy_same_file"; - let _ = fs::remove_dir_all(test_dir); - fs::create_dir(test_dir).unwrap(); - - let test_file = format!("{}/test.yaml", test_dir); - fs::write( - &test_file, - "source:\n key: value123\ndest:\n key: old_value", + let removed = unset_values( + &updated, + &[ + "database.port".to_string(), + "app.server.config.timeout".to_string(), + ], ) .unwrap(); - - copy_value(&test_file, "source.key", &test_file, "dest.key").unwrap(); - - let contents = fs::read_to_string(&test_file).unwrap(); - let yaml = serde_yaml::from_str::(&contents).unwrap(); - assert_eq!(yaml["dest"]["key"].as_str().unwrap(), "value123"); - assert_eq!(yaml["source"]["key"].as_str().unwrap(), "value123"); - - fs::remove_dir_all(test_dir).unwrap(); - } - - #[test] - fn test_copy_value_different_files() { - use std::fs; - - let test_dir = "test_copy_diff_files"; - let _ = fs::remove_dir_all(test_dir); - fs::create_dir(test_dir).unwrap(); - - let source_file = format!("{}/source.yaml", test_dir); - let dest_file = format!("{}/dest.yaml", test_dir); - - fs::write(&source_file, "data:\n value: test123").unwrap(); - fs::write(&dest_file, "other: value").unwrap(); - - copy_value(&source_file, "data.value", &dest_file, "copied.value").unwrap(); - - let dest_contents = fs::read_to_string(&dest_file).unwrap(); - let yaml = serde_yaml::from_str::(&dest_contents).unwrap(); - assert_eq!(yaml["copied"]["value"].as_str().unwrap(), "test123"); - - fs::remove_dir_all(test_dir).unwrap(); + let parsed = parse_yaml(&removed); + assert!(get_value(&parsed, "database.port").unwrap().is_none()); + assert!(get_value(&parsed, "app.server.config.timeout") + .unwrap() + .is_none()); } #[test] - fn test_copy_value_to_nonexistent_file() { - use std::fs; - - let test_dir = "test_copy_to_new"; - let _ = fs::remove_dir_all(test_dir); - fs::create_dir(test_dir).unwrap(); - - let source_file = format!("{}/source.yaml", test_dir); - let dest_file = format!("{}/dest.yaml", test_dir); - - fs::write(&source_file, "data: value456").unwrap(); - - copy_value(&source_file, "data", &dest_file, "new_key").unwrap(); - - assert!(std::path::Path::new(&dest_file).exists()); - let dest_contents = fs::read_to_string(&dest_file).unwrap(); - let yaml = serde_yaml::from_str::(&dest_contents).unwrap(); - assert_eq!(yaml["new_key"].as_str().unwrap(), "value456"); - - fs::remove_dir_all(test_dir).unwrap(); + fn test_set_supports_escaped_dotted_keys() { + let updated = set_values( + "metadata: {}\n", + &[( + r"metadata.kubernetes\.io/hostname".to_string(), + "node-a".to_string(), + )], + ) + .unwrap(); + let parsed = parse_yaml(&updated); + assert_eq!( + parsed["metadata"]["kubernetes.io/hostname"].as_str(), + Some("node-a") + ); } #[test] - fn test_copy_value_complex_type() { - use std::fs; - - let test_dir = "test_copy_complex"; - let _ = fs::remove_dir_all(test_dir); - fs::create_dir(test_dir).unwrap(); - - let test_file = format!("{}/test.yaml", test_dir); - fs::write( - &test_file, - "config:\n nested:\n value: test\n count: 42", + fn test_set_supports_appending_to_sequences() { + let updated = set_values( + "items:\n - first\n", + &[("items[1]".to_string(), "second".to_string())], ) .unwrap(); - - copy_value(&test_file, "config.nested", &test_file, "backup.config").unwrap(); - - let contents = fs::read_to_string(&test_file).unwrap(); - let yaml = serde_yaml::from_str::(&contents).unwrap(); - assert!(yaml["backup"]["config"].is_mapping()); - assert_eq!(yaml["backup"]["config"]["value"].as_str().unwrap(), "test"); - assert_eq!(yaml["backup"]["config"]["count"].as_i64().unwrap(), 42); - - fs::remove_dir_all(test_dir).unwrap(); + let parsed = parse_yaml(&updated); + assert_eq!(parsed["items"][1].as_str(), Some("second")); } #[test] - fn test_copy_value_nonexistent_source_key() { - use std::fs; - - let test_dir = "test_copy_nonexistent"; - let _ = fs::remove_dir_all(test_dir); - fs::create_dir(test_dir).unwrap(); - - let test_file = format!("{}/test.yaml", test_dir); - fs::write(&test_file, "data: value").unwrap(); - - let result = copy_value(&test_file, "nonexistent", &test_file, "dest"); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("not found")); - - fs::remove_dir_all(test_dir).unwrap(); + fn test_get_value_supports_sequences_and_escaped_keys() { + let yaml = parse_yaml("items:\n - metadata:\n kubernetes.io/hostname: node-a\n"); + let value = get_value(&yaml, r"items[0].metadata.kubernetes\.io/hostname").unwrap(); + assert_eq!(value.unwrap().as_str(), Some("node-a")); } - // ==================== move_value() Tests ==================== - #[test] - fn test_move_value_same_file_same_key() { - use std::fs; - - let test_dir = "test_move_same_same"; - let _ = fs::remove_dir_all(test_dir); - fs::create_dir(test_dir).unwrap(); - - let test_file = format!("{}/test.yaml", test_dir); - fs::write(&test_file, "database:\n password: secret123").unwrap(); - - // Move should error since source and dest are identical - let result = move_value( - &test_file, - "database.password", - &test_file, - "database.password", + fn test_set_mapping_may_rewrite_touched_key_but_preserves_untouched_layout() { + let original = concat!( + "# top comment\n", + "before: keep\n", + "\n", + "app:\n", + " # touched comment\n", + " debug: true\n", + " logging:\n", + " level: warn\n", + "\n", + "# trailing comment\n", + "after: stay\n", ); - // This is actually valid - it copies then unsets, which effectively leaves the value - // But after unsetting its own copy, it would be gone - assert!(result.is_ok()); - // Verify the value is gone from source - let yaml_str = fs::read_to_string(&test_file).unwrap(); - let yaml = serde_yaml::from_str::(&yaml_str).unwrap(); - assert_eq!(get_value(&yaml, "database.password").unwrap(), None); + let updated = set_values( + original, + &[( + String::from("app"), + String::from("debug: false\nlogging:\n level: info\n format: json"), + )], + ) + .unwrap(); - fs::remove_dir_all(test_dir).unwrap(); + let parsed = parse_yaml(&updated); + assert_eq!(parsed["app"]["debug"].as_bool(), Some(false)); + assert_eq!(parsed["app"]["logging"]["level"].as_str(), Some("info")); + assert_eq!(parsed["app"]["logging"]["format"].as_str(), Some("json")); + assert!(updated.contains("# top comment\nbefore: keep\n\n")); + assert!(updated.contains("\n# trailing comment\nafter: stay\n")); } #[test] - fn test_move_value_same_file_different_key() { - use std::fs; - - let test_dir = "test_move_same_diff"; - let _ = fs::remove_dir_all(test_dir); - fs::create_dir(test_dir).unwrap(); - - let test_file = format!("{}/test.yaml", test_dir); - fs::write(&test_file, "source_key: moved_value\nother: data").unwrap(); - - move_value(&test_file, "source_key", &test_file, "dest_key").unwrap(); - - // Verify destination has the value - let yaml_str = fs::read_to_string(&test_file).unwrap(); - let yaml = serde_yaml::from_str::(&yaml_str).unwrap(); + fn test_copy_in_document_and_move_in_document() { + let original = "source:\n nested:\n key: value\nkeep: yes\n"; + let copied = copy_in_document(original, "source.nested", "dest.nested").unwrap(); + let copied_yaml = parse_yaml(&copied); + assert_eq!(copied_yaml["dest"]["nested"]["key"].as_str(), Some("value")); assert_eq!( - get_value(&yaml, "dest_key") - .unwrap() - .unwrap() - .as_str() - .unwrap(), - "moved_value" + copied_yaml["source"]["nested"]["key"].as_str(), + Some("value") ); - // Verify source no longer has the value - assert_eq!(get_value(&yaml, "source_key").unwrap(), None); - - fs::remove_dir_all(test_dir).unwrap(); + let moved = move_in_document(original, "source.nested", "dest.nested").unwrap(); + let moved_yaml = parse_yaml(&moved); + assert!(get_value(&moved_yaml, "source.nested").unwrap().is_none()); + assert_eq!(moved_yaml["dest"]["nested"]["key"].as_str(), Some("value")); } #[test] - fn test_move_value_different_file_same_key() { - use std::fs; - - let test_dir = "test_move_diff_same"; - let _ = fs::remove_dir_all(test_dir); - fs::create_dir(test_dir).unwrap(); - + fn test_copy_value_handles_scalars_and_mappings() { + let test_dir = temp_test_dir("test_copy_value"); let source_file = format!("{}/source.yaml", test_dir); let dest_file = format!("{}/dest.yaml", test_dir); - fs::write(&source_file, "mykey: myvalue").unwrap(); - fs::write(&dest_file, "other: data").unwrap(); - - move_value(&source_file, "mykey", &dest_file, "mykey").unwrap(); + fs::write( + &source_file, + "data:\n value: test123\nconfig:\n nested:\n count: 42\n", + ) + .unwrap(); + fs::write(&dest_file, "other: value\n").unwrap(); - // Verify destination has the value - let dest_yaml_str = fs::read_to_string(&dest_file).unwrap(); - let dest_yaml = serde_yaml::from_str::(&dest_yaml_str).unwrap(); - assert_eq!( - get_value(&dest_yaml, "mykey") - .unwrap() - .unwrap() - .as_str() - .unwrap(), - "myvalue" - ); + copy_value(&source_file, "data.value", &dest_file, "copied.value").unwrap(); + copy_value(&source_file, "config.nested", &dest_file, "backup.config").unwrap(); - // Verify source no longer has the value - let source_yaml_str = fs::read_to_string(&source_file).unwrap(); - let source_yaml = serde_yaml::from_str::(&source_yaml_str).unwrap(); - assert_eq!(get_value(&source_yaml, "mykey").unwrap(), None); + let yaml = serde_yaml::from_str::(&fs::read_to_string(&dest_file).unwrap()).unwrap(); + assert_eq!(yaml["other"].as_str(), Some("value")); + assert_eq!(yaml["copied"]["value"].as_str(), Some("test123")); + assert_eq!(yaml["backup"]["config"]["count"].as_i64(), Some(42)); fs::remove_dir_all(test_dir).unwrap(); } #[test] - fn test_move_value_different_file_different_key() { - use std::fs; - - let test_dir = "test_move_diff_diff"; - let _ = fs::remove_dir_all(test_dir); - fs::create_dir(test_dir).unwrap(); - + fn test_move_value_updates_destination_and_removes_source() { + let test_dir = temp_test_dir("test_move_value"); let source_file = format!("{}/source.yaml", test_dir); let dest_file = format!("{}/dest.yaml", test_dir); - fs::write(&source_file, "source:\n nested:\n key: moved_value").unwrap(); - fs::write(&dest_file, "other: data").unwrap(); - - move_value( + fs::write( &source_file, - "source.nested.key", - &dest_file, - "dest.nested.key", + "source:\n nested:\n key: moved_value\nkeep: yes\n", ) .unwrap(); + fs::write(&dest_file, "other: data\n").unwrap(); + + move_value(&source_file, "source.nested", &dest_file, "dest.nested").unwrap(); - // Verify destination has the value - let dest_yaml_str = fs::read_to_string(&dest_file).unwrap(); - let dest_yaml = serde_yaml::from_str::(&dest_yaml_str).unwrap(); + let dest_yaml = + serde_yaml::from_str::(&fs::read_to_string(&dest_file).unwrap()).unwrap(); + let source_yaml = + serde_yaml::from_str::(&fs::read_to_string(&source_file).unwrap()).unwrap(); assert_eq!( - get_value(&dest_yaml, "dest.nested.key") - .unwrap() - .unwrap() - .as_str() - .unwrap(), - "moved_value" + dest_yaml["dest"]["nested"]["key"].as_str(), + Some("moved_value") ); - - // Verify source no longer has the value - let source_yaml_str = fs::read_to_string(&source_file).unwrap(); - let source_yaml = serde_yaml::from_str::(&source_yaml_str).unwrap(); - assert_eq!(get_value(&source_yaml, "source.nested.key").unwrap(), None); - - fs::remove_dir_all(test_dir).unwrap(); - } - - #[test] - fn test_move_value_nonexistent_source_key() { - use std::fs; - - let test_dir = "test_move_nonexistent"; - let _ = fs::remove_dir_all(test_dir); - fs::create_dir(test_dir).unwrap(); - - let test_file = format!("{}/test.yaml", test_dir); - fs::write(&test_file, "data: value").unwrap(); - - let result = move_value(&test_file, "nonexistent", &test_file, "dest"); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("not found")); + assert_eq!(source_yaml["keep"].as_str(), Some("yes")); + assert!(get_value(&source_yaml, "source.nested").unwrap().is_none()); fs::remove_dir_all(test_dir).unwrap(); } #[test] - fn test_set_preserves_comments_and_empty_lines() { - use std::fs; - - let test_dir = "test_set_preserve_comments"; - let _ = fs::remove_dir_all(test_dir); - fs::create_dir(test_dir).unwrap(); - - let test_file = format!("{}/test.yaml", test_dir); - let original = "# Configuration file\napp_name: myapp\n\n# Debug settings\ndebug: false\n"; - fs::write(&test_file, original).unwrap(); - - // Simulate the set command - let contents = fs::read_to_string(&test_file).unwrap(); - let mut value = serde_yaml::from_str::(&contents).unwrap(); - - let mut updates = std::collections::HashMap::new(); - updates.insert("debug".to_string(), "true".to_string()); - set_values(&mut value, &updates).unwrap(); - - let updated = - crate::yaml_format_preserving::write_yaml_preserving_format(&contents, &value).unwrap(); - - // Comments and empty lines should be preserved - assert!(updated.contains("# Configuration file")); - assert!(updated.contains("# Debug settings")); - // The value should be updated - assert!(updated.contains("debug: true")); - // Original app_name should still be there - assert!(updated.contains("app_name: myapp")); - - fs::remove_dir_all(test_dir).unwrap(); + fn test_format_string_value() { + let value = Value::String("hello".to_string()); + assert_eq!(format_result("message", &value, 80), "message: hello"); } #[test] - fn test_unset_preserves_comments_and_empty_lines() { - use std::fs; - - let test_dir = "test_unset_preserve_comments"; - let _ = fs::remove_dir_all(test_dir); - fs::create_dir(test_dir).unwrap(); - - let test_file = format!("{}/test.yaml", test_dir); - let original = "# Configuration\nkey1: value1\n\n# Comment\nkey2: value2\nkey3: value3\n"; - fs::write(&test_file, original).unwrap(); - - // Simulate the unset command - let contents = fs::read_to_string(&test_file).unwrap(); - let mut value = serde_yaml::from_str::(&contents).unwrap(); - - unset_values(&mut value, &["key2".to_string()]).unwrap(); - - let updated = - crate::yaml_format_preserving::write_yaml_preserving_format(&contents, &value).unwrap(); - - // Comments should be preserved - assert!(updated.contains("# Configuration")); - // key2 should be removed - assert!(!updated.contains("key2: value2")); - // Other keys should remain - assert!(updated.contains("key1: value1")); - assert!(updated.contains("key3: value3")); - - fs::remove_dir_all(test_dir).unwrap(); + fn test_format_mapping_value() { + let value = parse_yaml("host: localhost\nport: 5432"); + let result = format_result("database", &value, 80); + assert!(result.starts_with("database:\n")); + assert!(result.contains("host")); } #[test] - fn test_set_multiple_values_preserves_formatting() { - use std::fs; - - let test_dir = "test_set_multi_preserve"; - let _ = fs::remove_dir_all(test_dir); - fs::create_dir(test_dir).unwrap(); - - let test_file = format!("{}/test.yaml", test_dir); - let original = - "# Server config\nhost: localhost\n\n# Port settings\nport: 8080\nssl: false\n"; - fs::write(&test_file, original).unwrap(); - - let contents = fs::read_to_string(&test_file).unwrap(); - let mut value = serde_yaml::from_str::(&contents).unwrap(); - - let mut updates = std::collections::HashMap::new(); - updates.insert("host".to_string(), "0.0.0.0".to_string()); - updates.insert("ssl".to_string(), "true".to_string()); - set_values(&mut value, &updates).unwrap(); - - let updated = - crate::yaml_format_preserving::write_yaml_preserving_format(&contents, &value).unwrap(); - - // Comments should be preserved - assert!(updated.contains("# Server config")); - assert!(updated.contains("# Port settings")); - // Values should be updated - assert!(updated.contains("host: 0.0.0.0")); - assert!(updated.contains("ssl: true")); - assert!(updated.contains("port: 8080")); - - fs::remove_dir_all(test_dir).unwrap(); + fn test_format_truncates_long_string() { + let long_string = "a".repeat(100); + let value = Value::String(long_string); + let result = format_result("key", &value, 20); + assert!(result.ends_with("...")); } } diff --git a/tests/data/app-config.yaml b/tests/data/app-config.yaml index 427043f..920002a 100644 --- a/tests/data/app-config.yaml +++ b/tests/data/app-config.yaml @@ -1,10 +1,7 @@ ---- -# Application configuration with multiple services app: name: myapp version: 1.2.3 environment: production - database: primary: host: db-primary.example.com @@ -24,7 +21,6 @@ database: pool: min_size: 2 max_size: 10 - cache: redis: host: redis.example.com @@ -33,21 +29,19 @@ cache: ttl: 3600 memcached: hosts: - - cache1.example.com:11211 - - cache2.example.com:11211 + - cache1.example.com:11211 + - cache2.example.com:11211 ttl: 1800 - logging: level: info format: json outputs: - - stdout - - file + - stdout + - file file: path: /var/log/app.log max_size: 100MB retention_days: 30 - monitoring: prometheus: enabled: true @@ -56,8 +50,8 @@ monitoring: datadog: enabled: false api_key: null - ssl: certificate_path: /etc/ssl/certs/app.crt key_path: /etc/ssl/private/app.key verify_peer: true + new: prod_secret_xyz789 diff --git a/tests/data/config-prod.yaml b/tests/data/config-prod.yaml index db0b87f..d841eaa 100644 --- a/tests/data/config-prod.yaml +++ b/tests/data/config-prod.yaml @@ -12,7 +12,6 @@ database: host: prod-db-primary.internal port: 5432 username: prod_user - password: prod_secret_xyz789 ssl: true pool_size: 50 replica: