diff --git a/src/cli.rs b/src/cli.rs index 7059c66..b368755 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -7,6 +7,7 @@ pub enum Command { Grep { pattern: String, recursive: bool, + full: bool, files: Vec, }, Set { @@ -43,13 +44,8 @@ pub struct Cli { #[derive(Subcommand, Debug)] pub enum Commands { Grep { - pattern: String, - - #[arg(short = 'R')] - recursive: bool, - #[arg(trailing_var_arg = true, allow_hyphen_values = true)] - files: Vec, + args: Vec, }, Set { file: String, @@ -87,15 +83,15 @@ fn command_from_cli(cli: Cli) -> AppResult { fn command_from_parsed(command: Commands) -> AppResult { match command { - Commands::Grep { - pattern, - recursive, - files, - } => Ok(Command::Grep { - pattern, - recursive, - files, - }), + Commands::Grep { args } => { + let (pattern, recursive, full, files) = parse_grep_args(args)?; + Ok(Command::Grep { + pattern, + recursive, + full, + files, + }) + } Commands::Set { file, updates } => Ok(Command::Set { file, updates: parse_updates(updates)?, @@ -134,6 +130,30 @@ fn command_from_parsed(command: Commands) -> AppResult { } } +fn parse_grep_args(args: Vec) -> AppResult<(String, bool, bool, Vec)> { + if args.is_empty() { + return Err(AppError::cli("grep requires at least a pattern")); + } + + let mut pattern = None; + let mut recursive = false; + let mut full = false; + let mut files = Vec::new(); + + for arg in args { + match arg.as_str() { + "-R" => recursive = true, + "--full" => full = true, + _ if pattern.is_none() => pattern = Some(arg), + _ => files.push(arg), + } + } + + pattern + .map(|pattern| (pattern, recursive, full, files)) + .ok_or_else(|| AppError::cli("grep requires a pattern")) +} + fn parse_updates(updates: Vec) -> AppResult> { if updates.is_empty() { return Err(AppError::cli("set requires at least one key=value pair")); @@ -251,6 +271,7 @@ mod tests { Command::Grep { pattern: "pattern".to_string(), recursive: false, + full: false, files: vec!["file.yaml".to_string()], } ); @@ -265,6 +286,7 @@ mod tests { Command::Grep { pattern: "pattern".to_string(), recursive: true, + full: false, files: vec!["dir".to_string()], } ); @@ -287,6 +309,7 @@ mod tests { Command::Grep { pattern: "pattern".to_string(), recursive: false, + full: false, files: vec![ "file1.yaml".to_string(), "file2.yaml".to_string(), @@ -315,6 +338,107 @@ mod tests { Command::Grep { pattern: "pattern".to_string(), recursive: false, + full: false, + files: Vec::new(), + } + ); + } + + #[test] + fn test_parse_grep_with_full_flag() { + let cmd = test_with_args(vec!["ym", "grep", "--full", "pattern", "file.yaml"]).unwrap(); + + assert_eq!( + cmd, + Command::Grep { + pattern: "pattern".to_string(), + recursive: false, + full: true, + files: vec!["file.yaml".to_string()], + } + ); + } + + #[test] + fn test_parse_grep_with_flags_after_pattern() { + let cmd = test_with_args(vec!["ym", "grep", "pattern", ".", "-R"]).unwrap(); + + assert_eq!( + cmd, + Command::Grep { + pattern: "pattern".to_string(), + recursive: true, + full: false, + files: vec![".".to_string()], + } + ); + } + + #[test] + fn test_parse_grep_with_flags_between_args() { + let cmd = test_with_args(vec!["ym", "grep", "pattern", "-R", "file.yaml"]).unwrap(); + + assert_eq!( + cmd, + Command::Grep { + pattern: "pattern".to_string(), + recursive: true, + full: false, + files: vec!["file.yaml".to_string()], + } + ); + } + + #[test] + fn test_parse_grep_with_multiple_flags_after_args() { + let cmd = test_with_args(vec![ + "ym", + "grep", + "pattern", + "file1.yaml", + "file2.yaml", + "-R", + "--full", + ]) + .unwrap(); + + assert_eq!( + cmd, + Command::Grep { + pattern: "pattern".to_string(), + recursive: true, + full: true, + files: vec!["file1.yaml".to_string(), "file2.yaml".to_string()], + } + ); + } + + #[test] + fn test_parse_grep_with_flags_mixed_positions() { + let cmd = + test_with_args(vec!["ym", "grep", "-R", "pattern", "--full", "file.yaml"]).unwrap(); + + assert_eq!( + cmd, + Command::Grep { + pattern: "pattern".to_string(), + recursive: true, + full: true, + files: vec!["file.yaml".to_string()], + } + ); + } + + #[test] + fn test_parse_grep_only_pattern_with_flags() { + let cmd = test_with_args(vec!["ym", "grep", "pattern", "-R", "--full"]).unwrap(); + + assert_eq!( + cmd, + Command::Grep { + pattern: "pattern".to_string(), + recursive: true, + full: true, files: Vec::new(), } ); diff --git a/src/main.rs b/src/main.rs index 226e304..9f283b3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ mod yaml_ops; use cli::{parse_cli, Command}; use error::{AppError, AppResult}; +use yaml_ops::GrepOutputMode; fn get_terminal_width() -> usize { if let Some(size) = termsize::get() { @@ -26,6 +27,14 @@ fn get_terminal_width() -> usize { 80 } +fn no_matches_error() -> AppError { + AppError::message("No matches found") +} + +fn is_no_matches_error(error: &AppError) -> bool { + matches!(error, AppError::Message(message) if message == "No matches found") +} + fn main() { let command = match parse_cli() { Ok(command) => command, @@ -35,9 +44,15 @@ fn main() { } }; - if let Err(error) = execute_command(command) { - eprintln!("Error: {error}"); - process::exit(1); + match execute_command(command) { + Ok(()) => {} + Err(error) if is_no_matches_error(&error) => { + process::exit(2); + } + Err(error) => { + eprintln!("Error: {error}"); + process::exit(1); + } } } @@ -46,8 +61,9 @@ fn execute_command(command: Command) -> AppResult<()> { Command::Grep { pattern, recursive, + full, files, - } => run_grep(&pattern, recursive, &files), + } => run_grep(&pattern, recursive, full, &files), Command::Set { file, updates } => { apply_file_update(&file, |contents| yaml_ops::set_values(contents, &updates)) } @@ -87,19 +103,46 @@ where Ok(()) } -fn run_grep(pattern: &str, recursive: bool, files: &[String]) -> AppResult<()> { +fn run_grep(pattern: &str, recursive: bool, full: bool, files: &[String]) -> AppResult<()> { + let output_mode = if full { + GrepOutputMode::Full + } else { + GrepOutputMode::Inline + }; + if files.is_empty() { - return grep_stdin(pattern); + return grep_stdin(pattern, output_mode); } - let show_filename = should_show_filename(files); + let show_filename = should_show_filename(files, output_mode); + let mut found_any = false; + for file in files { - grep_path(Path::new(file), pattern, recursive, show_filename)?; + match grep_path( + Path::new(file), + pattern, + recursive, + show_filename, + output_mode, + ) { + Ok(()) => found_any = true, + Err(error) if is_no_matches_error(&error) => {} + Err(error) => return Err(error), + } + } + + if found_any { + Ok(()) + } else { + Err(no_matches_error()) } - Ok(()) } -fn should_show_filename(files: &[String]) -> bool { +fn should_show_filename(files: &[String], output_mode: GrepOutputMode) -> bool { + if matches!(output_mode, GrepOutputMode::Full) { + return true; + } + if files.len() != 1 { return true; } @@ -107,7 +150,7 @@ fn should_show_filename(files: &[String]) -> bool { Path::new(&files[0]).is_dir() } -fn grep_stdin(pattern: &str) -> AppResult<()> { +fn grep_stdin(pattern: &str, output_mode: GrepOutputMode) -> AppResult<()> { let mut buffer = String::new(); io::stdin() .read_to_string(&mut buffer) @@ -119,17 +162,23 @@ fn grep_stdin(pattern: &str) -> AppResult<()> { let value = serde_yaml::from_str(&buffer).map_err(|error| AppError::parse_yaml("from stdin", error))?; - print_grep_results(None, pattern, &value) + print_grep_results(None, pattern, &value, output_mode) } -fn grep_path(path: &Path, pattern: &str, recursive: bool, show_filename: bool) -> AppResult<()> { +fn grep_path( + path: &Path, + pattern: &str, + recursive: bool, + show_filename: bool, + output_mode: GrepOutputMode, +) -> AppResult<()> { if path.is_file() { - return grep_file(path, pattern, show_filename); + return grep_file(path, pattern, show_filename, output_mode); } if path.is_dir() { return if recursive { - search_dir(path, pattern, show_filename) + search_dir(path, pattern, show_filename, output_mode) } else { Err(AppError::message(format!( "'{}' is a directory (use -R to search recursively)", @@ -144,54 +193,97 @@ fn grep_path(path: &Path, pattern: &str, recursive: bool, show_filename: bool) - ))) } -fn grep_file(path: &Path, pattern: &str, show_filename: bool) -> AppResult<()> { +fn grep_file( + path: &Path, + pattern: &str, + show_filename: bool, + output_mode: GrepOutputMode, +) -> AppResult<()> { let display = path.to_string_lossy(); let contents = 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))?; + .map_err(|error| AppError::parse_yaml(format!("in '{display}'"), error))?; - print_grep_results(show_filename.then_some(display.as_ref()), pattern, &value) + print_grep_results( + show_filename.then_some(display.as_ref()), + pattern, + &value, + output_mode, + ) } fn print_grep_results( filename: Option<&str>, pattern: &str, value: &serde_yaml::Value, + output_mode: GrepOutputMode, ) -> AppResult<()> { let results = yaml_ops::grep(value, pattern)?; + if results.is_empty() { + return Err(no_matches_error()); + } + let width = get_terminal_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!("{}", formatted); - } + print_grep_result(filename, &key, &value, width, output_mode); } Ok(()) } -fn search_dir(dir: &Path, pattern: &str, show_filename: bool) -> AppResult<()> { +fn print_grep_result( + filename: Option<&str>, + key: &str, + value: &serde_yaml::Value, + width: usize, + output_mode: GrepOutputMode, +) { + let formatted = yaml_ops::format_result(key, value, width, output_mode); + + match (filename, output_mode) { + (Some(filename), GrepOutputMode::Inline) => println!("{filename}:{formatted}"), + (Some(filename), GrepOutputMode::Full) => println!("--- {filename} ---\n{formatted}"), + (None, _) => println!("{formatted}"), + } +} + +fn search_dir( + dir: &Path, + pattern: &str, + show_filename: bool, + output_mode: GrepOutputMode, +) -> AppResult<()> { let entries = fs::read_dir(dir).map_err(|error| AppError::read_dir(dir.display().to_string(), error))?; + let mut found_any = false; + for entry in entries { let entry = entry.map_err(AppError::ReadDirEntry)?; let path = entry.path(); if path.is_dir() { - search_dir(&path, pattern, show_filename)?; + match search_dir(&path, pattern, show_filename, output_mode) { + Ok(()) => found_any = true, + Err(error) if is_no_matches_error(&error) => {} + Err(error) => return Err(error), + } } else if path.is_file() && should_process_file(&path) { - if let Err(error) = grep_file(&path, pattern, show_filename) { - eprintln!("Warning: {error}"); + match grep_file(&path, pattern, show_filename, output_mode) { + Ok(()) => found_any = true, + Err(error) if is_no_matches_error(&error) => {} + Err(error) => return Err(error), } } } - Ok(()) + if found_any { + Ok(()) + } else { + Err(no_matches_error()) + } } fn should_process_file(path: &Path) -> bool { diff --git a/src/yaml_ops.rs b/src/yaml_ops.rs index 35ad7ba..71f5bef 100644 --- a/src/yaml_ops.rs +++ b/src/yaml_ops.rs @@ -11,6 +11,12 @@ use crate::path::{PathSegment, YamlPath}; const PLACEHOLDER_KEY: &str = "__ym_placeholder__"; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GrepOutputMode { + Inline, + Full, +} + pub fn grep(value: &Value, pattern: &str) -> AppResult> { let regex = Regex::new(pattern)?; let mut results = Vec::new(); @@ -390,7 +396,7 @@ fn get_value_at_path(value: &Value, path: &YamlPath) -> AppResult> 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)) + AppError::message(format!("Key '{source_key}' not found in source document")) })?; yaml_set(yaml_content, dest_key, value) @@ -424,10 +430,7 @@ pub fn copy_value( 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 - )) + AppError::message(format!("Key '{source_key}' not found in '{source_file}'")) })?; let updated = yaml_set(&dest_contents, dest_key, value)?; @@ -453,10 +456,7 @@ pub fn move_value( 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 - )) + AppError::message(format!("Key '{source_key}' not found in '{source_file}'")) })?; let dest_contents = if Path::new(dest_file).exists() { @@ -474,20 +474,18 @@ pub fn move_value( Ok(()) } -pub fn format_result(key: &str, value: &Value, terminal_width: usize) -> String { - match value { - 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), - _ => { - let val_str = serde_yaml::to_string(value) - .unwrap_or_else(|_| "".to_string()) - .trim() - .to_string(); - truncate_if_needed(&format!("{}: {}", key, val_str), terminal_width) +pub fn format_result( + key: &str, + value: &Value, + terminal_width: usize, + mode: GrepOutputMode, +) -> String { + match mode { + GrepOutputMode::Inline => { + let result = format!("{key}: {}", format_inline_value(value)); + truncate_if_needed(&result, terminal_width) } + GrepOutputMode::Full => format_full_result(key, value), } } @@ -499,25 +497,135 @@ fn truncate_if_needed(text: &str, terminal_width: usize) -> String { } } -fn format_mapping_result(key: &str, value: &Value) -> String { - let yaml_str = match serde_yaml::to_string(value) { - Ok(result) => result, - Err(_) => return format!("{}: ", key), - }; +fn format_full_result(key: &str, value: &Value) -> String { + let mut rendered = render_yaml_with_indent(key, value, 0); + if rendered.ends_with('\n') { + rendered.pop(); + } + rendered +} - let indented = yaml_str - .lines() - .map(|line| { - if line.is_empty() { - line.to_string() - } else { - format!(" {}", line) +fn format_inline_value(value: &Value) -> String { + match value { + Value::Mapping(map) => { + let entries = map + .iter() + .map(|(key, val)| { + format!("{}: {}", format_inline_key(key), format_inline_value(val)) + }) + .collect::>() + .join(", "); + format!("{{ {entries} }}") + } + Value::Sequence(seq) => { + let items = seq + .iter() + .map(format_inline_value) + .collect::>() + .join(", "); + format!("[{items}]") + } + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => "null".to_string(), + _ => serde_yaml::to_string(value) + .unwrap_or_else(|_| "".to_string()) + .trim() + .replace('\n', " "), + } +} + +fn format_inline_key(key: &Value) -> String { + match key { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => "null".to_string(), + _ => serde_yaml::to_string(key) + .unwrap_or_else(|_| "".to_string()) + .trim() + .replace('\n', " "), + } +} + +fn render_yaml_with_indent(key: &str, value: &Value, indent: usize) -> String { + let prefix = " ".repeat(indent); + + match value { + Value::Mapping(map) => { + let mut output = format!("{prefix}{key}:\n"); + for (child_key, child_value) in map { + output.push_str(&render_yaml_with_indent( + &format_inline_key(child_key), + child_value, + indent + 1, + )); + } + output + } + Value::Sequence(seq) => { + let mut output = format!("{prefix}{key}:\n"); + for item in seq { + output.push_str(&render_sequence_item(item, indent + 1)); } - }) - .collect::>() - .join("\n"); + output + } + _ => format!("{prefix}{key}: {}\n", format_inline_value(value)), + } +} - format!("{}:\n{}", key, indented) +fn render_sequence_item(value: &Value, indent: usize) -> String { + let prefix = " ".repeat(indent); + + match value { + Value::Mapping(map) => { + let mut iter = map.iter(); + if let Some((first_key, first_value)) = iter.next() { + let mut output = format!("{prefix}-"); + match first_value { + Value::Mapping(_) | Value::Sequence(_) => { + output.push('\n'); + output.push_str(&render_yaml_with_indent( + &format_inline_key(first_key), + first_value, + indent + 1, + )); + } + _ => { + output.push_str(&format!( + " {}: {}\n", + format_inline_key(first_key), + format_inline_value(first_value) + )); + } + } + + for (key, val) in iter { + output.push_str(&render_yaml_with_indent( + &format_inline_key(key), + val, + indent + 1, + )); + } + output + } else { + format!("{prefix}- {{}}\n") + } + } + Value::Sequence(seq) => { + if seq.is_empty() { + format!("{prefix}- []\n") + } else { + let mut output = format!("{prefix}-\n"); + for item in seq { + output.push_str(&render_sequence_item(item, indent + 1)); + } + output + } + } + _ => format!("{prefix}- {}\n", format_inline_value(value)), + } } #[cfg(test)] @@ -533,7 +641,7 @@ mod tests { .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos(); - let dir = format!("{}_{}_{}", name, std::process::id(), unique); + let dir = format!("{name}_{}_{}", std::process::id(), unique); let _ = fs::remove_dir_all(&dir); fs::create_dir(&dir).unwrap(); dir @@ -701,8 +809,8 @@ mod tests { #[test] 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); + let source_file = format!("{test_dir}/source.yaml"); + let dest_file = format!("{test_dir}/dest.yaml"); fs::write( &source_file, @@ -725,8 +833,8 @@ mod tests { #[test] 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); + let source_file = format!("{test_dir}/source.yaml"); + let dest_file = format!("{test_dir}/dest.yaml"); fs::write( &source_file, @@ -754,22 +862,97 @@ mod tests { #[test] fn test_format_string_value() { let value = Value::String("hello".to_string()); - assert_eq!(format_result("message", &value, 80), "message: hello"); + assert_eq!( + format_result("message", &value, 80, GrepOutputMode::Inline), + "message: hello" + ); + } + + #[test] + fn test_format_number_value() { + let value = Value::Number(42.into()); + assert_eq!( + format_result("count", &value, 80, GrepOutputMode::Inline), + "count: 42" + ); + } + + #[test] + fn test_format_boolean_true() { + let value = Value::Bool(true); + assert_eq!( + format_result("enabled", &value, 80, GrepOutputMode::Inline), + "enabled: true" + ); + } + + #[test] + fn test_format_boolean_false() { + let value = Value::Bool(false); + assert_eq!( + format_result("enabled", &value, 80, GrepOutputMode::Inline), + "enabled: false" + ); } #[test] - fn test_format_mapping_value() { + fn test_format_null_value() { + let value = Value::Null; + assert_eq!( + format_result("empty", &value, 80, GrepOutputMode::Inline), + "empty: null" + ); + } + + #[test] + fn test_format_mapping_value_inline() { 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")); + let result = format_result("database", &value, 80, GrepOutputMode::Inline); + assert_eq!(result, "database: { host: localhost, port: 5432 }"); + } + + #[test] + fn test_format_mapping_value_full() { + let value = parse_yaml("host: localhost\nport: 5432"); + let result = format_result("database", &value, 80, GrepOutputMode::Full); + assert_eq!(result, "database:\n host: localhost\n port: 5432"); } #[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); + let result = format_result("key", &value, 20, GrepOutputMode::Inline); 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, GrepOutputMode::Inline); + assert_eq!(result, "key: short"); + assert!(!result.ends_with("...")); + } + + #[test] + fn test_format_sequence_inline() { + let value = parse_yaml("- item1\n- item2\n- item3"); + let result = format_result("items", &value, 80, GrepOutputMode::Inline); + assert_eq!(result, "items: [item1, item2, item3]"); + } + + #[test] + fn test_format_nested_mapping_inline() { + let value = parse_yaml("subkey1: true\nsubkey2: false"); + let result = format_result("key", &value, 80, GrepOutputMode::Inline); + assert_eq!(result, "key: { subkey1: true, subkey2: false }"); + } + + #[test] + fn test_format_nested_mapping_full() { + let value = parse_yaml("subkey1: true\nsubkey2: false"); + let result = format_result("key", &value, 80, GrepOutputMode::Full); + assert_eq!(result, "key:\n subkey1: true\n subkey2: false"); } }