diff --git a/Cargo.lock b/Cargo.lock index e742b03..1c2028b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -435,18 +435,20 @@ dependencies = [ [[package]] name = "cirup_cli" -version = "0.5.0" +version = "0.6.0" dependencies = [ "cirup_core", "clap", "embed-resource", "env_logger", "log", + "serde_json", + "tempfile", ] [[package]] name = "cirup_core" -version = "0.5.0" +version = "0.6.0" dependencies = [ "dot_json", "lazy_static", diff --git a/README.md b/README.md index 60aa56d..3eaa7ee 100644 --- a/README.md +++ b/README.md @@ -31,12 +31,26 @@ cirup --help ## Global options -- `-v`, `-vv`, ...: increase log verbosity. +- `--output-format `: control stdout format. Default is `jsonl`. +- `--dry-run`: compute results without writing output files. If a command normally writes to a file, the result is rendered to stdout instead. +- `--check`: imply `--dry-run` and exit with code `2` when the command would produce changes. +- `--summary`: print a structured execution summary instead of full result rows. Combine it with `--dry-run` for inspect-only workflows. +- `--count-only`: print only the number of matching results to stdout. +- `--key-filter `: keep only results whose key matches a simple regex-style pattern. Repeatable. Supported syntax: literals, `^`, `$`, `.`, and `.*`. +- `--value-filter `: keep only results whose value matches the same simple regex-style syntax. Repeatable. +- `--limit `: keep only the first `n` matching results. +- `--quiet`: only print errors to stderr. +- `--log-level `: set stderr verbosity explicitly. +- `-v`, `-vv`, ...: increase log verbosity starting from the default `warn` level. - `-C`, `--show-changes`: for `file-diff`, include keys that exist in both files but have different values. - `--touch`: force writing output files even when generated bytes are identical. - `--output-encoding `: control output file encoding. `utf8` behaves like `utf8-no-bom`. -By default, cirup avoids rewriting output files when content has not changed. +By default, cirup writes JSONL to stdout, logs at `warn` level, and avoids rewriting output files when content has not changed. + +`--summary` emits compact metadata such as counts, write intent, and whether output would be truncated. `--check` is intended for automation and returns exit code `2` when a command would produce changes. + +`--key-filter` and `--value-filter` are intentionally limited to a SQL-translatable subset. Unsupported syntax such as `|`, `()`, `[]`, `{m,n}`, `+`, and lookarounds is rejected instead of being interpreted as full regex. ## Common operations @@ -46,12 +60,24 @@ By default, cirup avoids rewriting output files when content has not changed. cirup file-print input.resx ``` +Show a human-readable table instead: + +```bash +cirup --output-format table file-print input.resx +``` + Write the printed content to a file: ```bash cirup file-print input.resx output.json ``` +Preview the same operation without writing the output file: + +```bash +cirup --dry-run file-print input.resx output.json +``` + ### Convert between formats ```bash @@ -81,6 +107,18 @@ Show keys present in `file1` but missing in `file2`: cirup file-diff file1.resx file2.resx ``` +Count missing keys without writing the full result set: + +```bash +cirup --count-only file-diff file1.resx file2.resx +``` + +Ask only whether differences exist: + +```bash +cirup --check file-diff file1.resx file2.resx +``` + Write diff to file: ```bash @@ -125,6 +163,30 @@ Show keys that are in `new`, not in `old`, and also present in `base`: cirup diff-with-base old.resx new.resx base.resx ``` +Limit stdout to a subset of keys: + +```bash +cirup --key-filter ^lbl --limit 25 diff-with-base old.resx new.resx base.resx +``` + +Match keys containing a simple wildcard sequence: + +```bash +cirup --key-filter 'User.*Name' file-print input.resx +``` + +Filter by translated value content: + +```bash +cirup --value-filter '^Hello' file-print input.resx +``` + +Emit a structured summary for an in-place sort without modifying the file: + +```bash +cirup --dry-run --summary file-sort strings.json +``` + ## Output file touch behavior - Default: if output bytes are unchanged, cirup does **not** rewrite the file. diff --git a/cirup_cli/Cargo.toml b/cirup_cli/Cargo.toml index d18436b..210d4f6 100644 --- a/cirup_cli/Cargo.toml +++ b/cirup_cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cirup_cli" -version = "0.5.0" +version = "0.6.0" authors = ["Marc-AndrĂ© Moreau "] edition = "2024" @@ -16,6 +16,10 @@ env_logger = "0.11" [build-dependencies] embed-resource = "3.0.6" +[dev-dependencies] +serde_json = "1.0" +tempfile = "3.20" + [target.'cfg(all(target_os = "windows", target_arch = "aarch64"))'.dependencies.cirup_core] path = "../cirup_core" default-features = false diff --git a/cirup_cli/src/main.rs b/cirup_cli/src/main.rs index 29a1a1e..7db1293 100644 --- a/cirup_cli/src/main.rs +++ b/cirup_cli/src/main.rs @@ -7,6 +7,44 @@ use log::error; use cirup_core::{OutputEncoding, query}; +#[derive(Debug, Copy, Clone, PartialEq, Eq, ValueEnum)] +enum CliOutputFormat { + Table, + Json, + Jsonl, +} + +impl From for query::QueryOutputFormat { + fn from(value: CliOutputFormat) -> Self { + match value { + CliOutputFormat::Table => query::QueryOutputFormat::Table, + CliOutputFormat::Json => query::QueryOutputFormat::Json, + CliOutputFormat::Jsonl => query::QueryOutputFormat::Jsonl, + } + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, ValueEnum)] +enum CliLogLevel { + Error, + Warn, + Info, + Debug, + Trace, +} + +impl CliLogLevel { + fn as_filter(self) -> &'static str { + match self { + CliLogLevel::Error => "error", + CliLogLevel::Warn => "warn", + CliLogLevel::Info => "info", + CliLogLevel::Debug => "debug", + CliLogLevel::Trace => "trace", + } + } +} + #[derive(Debug, Copy, Clone, PartialEq, Eq, ValueEnum)] enum CliOutputEncoding { #[value(name = "utf8-no-bom")] @@ -32,6 +70,18 @@ struct Cli { #[arg(short = 'v', long = "verbose", global = true, action = ArgAction::Count, help = "Sets the level of verbosity")] verbose: u8, + #[arg(long = "quiet", global = true, action = ArgAction::SetTrue, conflicts_with_all = ["verbose", "log_level"], help = "only print errors")] + quiet: bool, + + #[arg( + long = "log-level", + global = true, + value_enum, + conflicts_with = "verbose", + help = "set stderr logging level explicitly" + )] + log_level: Option, + #[arg(short = 'C', long = "show-changes", global = true, action = ArgAction::SetTrue, help = "additionally print keys that have values in [file2] but that do not match the values in [file1]")] show_changes: bool, @@ -47,6 +97,50 @@ struct Cli { )] output_encoding: CliOutputEncoding, + #[arg( + long = "output-format", + global = true, + value_enum, + default_value = "jsonl", + help = "stdout output format: jsonl (default), json, table" + )] + output_format: CliOutputFormat, + + #[arg(long = "dry-run", global = true, action = ArgAction::SetTrue, help = "compute results without writing output files")] + dry_run: bool, + + #[arg(long = "check", global = true, action = ArgAction::SetTrue, help = "exit with code 2 if the command would produce changes; implies --dry-run")] + check: bool, + + #[arg(long = "summary", global = true, action = ArgAction::SetTrue, help = "print a structured execution summary instead of full result rows")] + summary: bool, + + #[arg(long = "count-only", global = true, action = ArgAction::SetTrue, help = "print only the number of matching results to stdout")] + count_only: bool, + + #[arg( + long = "key-filter", + global = true, + action = ArgAction::Append, + help = "repeatable simple regex-style key filter supporting literals, ^, $, . and .*" + )] + key_filter: Vec, + + #[arg( + long = "value-filter", + global = true, + action = ArgAction::Append, + help = "repeatable simple regex-style value filter supporting literals, ^, $, . and .*" + )] + value_filter: Vec, + + #[arg( + long = "limit", + global = true, + help = "limit the number of results written to stdout or output file" + )] + limit: Option, + #[command(subcommand)] command: Commands, } @@ -112,95 +206,100 @@ enum Commands { DiffWithBase { old: String, new: String, base: String }, } -fn print(input: &str, out_file: Option<&str>, touch: bool, output_encoding: OutputEncoding) { - let query = query::query_print(input); - query.run_interactive_with_encoding(out_file, touch, output_encoding); -} - -fn diff(file_one: &str, file_two: &str, out_file: Option<&str>, touch: bool, output_encoding: OutputEncoding) { - let query = query::query_diff(file_one, file_two); - query.run_interactive_with_encoding(out_file, touch, output_encoding); -} - -fn change(file_one: &str, file_two: &str, out_file: Option<&str>, touch: bool, output_encoding: OutputEncoding) { - let query = query::query_change(file_one, file_two); - query.run_interactive_with_encoding(out_file, touch, output_encoding); -} - -fn merge(file_one: &str, file_two: &str, out_file: Option<&str>, touch: bool, output_encoding: OutputEncoding) { - let query = query::query_merge(file_one, file_two); - query.run_interactive_with_encoding(out_file, touch, output_encoding); -} - -fn intersect(file_one: &str, file_two: &str, out_file: Option<&str>, touch: bool, output_encoding: OutputEncoding) { - let query = query::query_intersect(file_one, file_two); - query.run_interactive_with_encoding(out_file, touch, output_encoding); -} - -fn subtract(file_one: &str, file_two: &str, out_file: Option<&str>, touch: bool, output_encoding: OutputEncoding) { - let query = query::query_subtract(file_one, file_two); - query.run_interactive_with_encoding(out_file, touch, output_encoding); -} - -fn convert(file_one: &str, out_file: &str, touch: bool, output_encoding: OutputEncoding) { - let query = query::query_convert(file_one); - query.run_interactive_with_encoding(Some(out_file), touch, output_encoding); -} - -fn sort(file_one: &str, out_file: Option<&str>, touch: bool, output_encoding: OutputEncoding) { - let query = query::query_sort(file_one); - - if out_file.is_some() { - query.run_interactive_with_encoding(out_file, touch, output_encoding); - } else { - query.run_interactive_with_encoding(Some(file_one), touch, output_encoding); +fn query_options(cli: &Cli) -> query::QueryRunOptions { + query::QueryRunOptions { + output_format: cli.output_format.into(), + count_only: cli.count_only, + dry_run: cli.dry_run || cli.check, + check: cli.check, + summary: cli.summary, + key_filters: cli.key_filter.clone(), + value_filters: cli.value_filter.clone(), + limit: cli.limit, + operation_name: None, + input_files: Vec::new(), + output_file: None, } } -fn diff_with_base(old: &str, new: &str, base: &str) { - let query = query::query_diff_with_base(old, new, base); - query.run_triple_interactive(); -} - -fn run(cli: &Cli) -> Result<(), Box> { +fn run(cli: &Cli) -> Result> { let output_encoding: OutputEncoding = cli.output_encoding.into(); + let options = query_options(cli); match &cli.command { Commands::FilePrint { file, output } => { - print(file, output.as_deref(), cli.touch, output_encoding); - Ok(()) + let options = options.with_context("file-print", &[file], output.as_deref()); + let query = query::query_print(file); + query + .run_interactive_with_options(output.as_deref(), cli.touch, output_encoding, &options) + .map_err(Into::into) } Commands::FileDiff { file1, file2, output } => { + let operation_name = if cli.show_changes { + "file-diff-changes" + } else { + "file-diff" + }; + let options = options.with_context(operation_name, &[file1, file2], output.as_deref()); if cli.show_changes { - change(file1, file2, output.as_deref(), cli.touch, output_encoding); + let query = query::query_change(file1, file2); + query + .run_interactive_with_options(output.as_deref(), cli.touch, output_encoding, &options) + .map_err(Into::into) } else { - diff(file1, file2, output.as_deref(), cli.touch, output_encoding); + let query = query::query_diff(file1, file2); + query + .run_interactive_with_options(output.as_deref(), cli.touch, output_encoding, &options) + .map_err(Into::into) } - Ok(()) } Commands::FileMerge { file1, file2, output } => { - merge(file1, file2, output.as_deref(), cli.touch, output_encoding); - Ok(()) + let options = options.with_context("file-merge", &[file1, file2], output.as_deref()); + let query = query::query_merge(file1, file2); + query + .run_interactive_with_options(output.as_deref(), cli.touch, output_encoding, &options) + .map_err(Into::into) } Commands::FileIntersect { file1, file2, output } => { - intersect(file1, file2, output.as_deref(), cli.touch, output_encoding); - Ok(()) + let options = options.with_context("file-intersect", &[file1, file2], output.as_deref()); + let query = query::query_intersect(file1, file2); + query + .run_interactive_with_options(output.as_deref(), cli.touch, output_encoding, &options) + .map_err(Into::into) } Commands::FileSubtract { file1, file2, output } => { - subtract(file1, file2, output.as_deref(), cli.touch, output_encoding); - Ok(()) + let options = options.with_context("file-subtract", &[file1, file2], output.as_deref()); + let query = query::query_subtract(file1, file2); + query + .run_interactive_with_options(output.as_deref(), cli.touch, output_encoding, &options) + .map_err(Into::into) } Commands::FileConvert { file, output } => { - convert(file, output, cli.touch, output_encoding); - Ok(()) + let options = options.with_context("file-convert", &[file], Some(output)); + let query = query::query_convert(file); + query + .run_interactive_with_options(Some(output), cli.touch, output_encoding, &options) + .map_err(Into::into) } Commands::FileSort { file, output } => { - sort(file, output.as_deref(), cli.touch, output_encoding); - Ok(()) + let target = output.as_deref().or(Some(file.as_str())); + let options = options.with_context("file-sort", &[file], target); + let query = query::query_sort(file); + + if output.is_some() { + query + .run_interactive_with_options(output.as_deref(), cli.touch, output_encoding, &options) + .map_err(Into::into) + } else { + query + .run_interactive_with_options(Some(file), cli.touch, output_encoding, &options) + .map_err(Into::into) + } } Commands::DiffWithBase { old, new, base } => { - diff_with_base(old, new, base); - Ok(()) + let options = options.with_context("diff-with-base", &[old, new, base], None); + let query = query::query_diff_with_base(old, new, base); + query.run_triple_interactive_with_options(&options).map_err(Into::into) } } } @@ -208,17 +307,30 @@ fn run(cli: &Cli) -> Result<(), Box> { fn main() -> ExitCode { let cli = Cli::parse(); - let min_log_level = match cli.verbose { - 0 => "info", - 1 => "debug", - _ => "trace", + let min_log_level = if cli.quiet { + "error" + } else if let Some(log_level) = cli.log_level { + log_level.as_filter() + } else { + match cli.verbose { + 0 => "warn", + 1 => "info", + 2 => "debug", + _ => "trace", + } }; let mut builder = Builder::from_env(Env::default().default_filter_or(min_log_level)); builder.init(); match run(&cli) { - Ok(()) => ExitCode::SUCCESS, + Ok(report) => { + if cli.check && report.indicates_change() { + ExitCode::from(2) + } else { + ExitCode::SUCCESS + } + } Err(e) => { error!("an unexpected error occured ({})", e); ExitCode::FAILURE @@ -236,6 +348,7 @@ mod tests { assert!(cli.show_changes); assert!(!cli.touch); + assert_eq!(cli.output_format, CliOutputFormat::Jsonl); match cli.command { Commands::FileDiff { file1, file2, output } => { assert_eq!(file1, "a.json"); @@ -266,6 +379,7 @@ mod tests { assert!(cli.touch); assert_eq!(cli.output_encoding, CliOutputEncoding::Utf8NoBom); + assert_eq!(cli.output_format, CliOutputFormat::Jsonl); match cli.command { Commands::FileSort { file, output } => { assert_eq!(file, "a.json"); @@ -297,4 +411,177 @@ mod tests { ]); assert_eq!(utf8.output_encoding, CliOutputEncoding::Utf8); } + + #[test] + fn parse_log_level_value() { + let cli = Cli::parse_from(["cirup", "--log-level", "debug", "file-print", "a.json"]); + + assert_eq!(cli.log_level, Some(CliLogLevel::Debug)); + assert_eq!(cli.verbose, 0); + assert!(!cli.quiet); + } + + #[test] + fn parse_output_format_filters_and_limit() { + let cli = Cli::parse_from([ + "cirup", + "--output-format", + "table", + "--key-filter", + "^lbl", + "--value-filter", + ".*Hello$", + "--limit", + "10", + "file-print", + "a.json", + ]); + + assert_eq!(cli.output_format, CliOutputFormat::Table); + assert_eq!(cli.key_filter, vec![String::from("^lbl")]); + assert_eq!(cli.value_filter, vec![String::from(".*Hello$")]); + assert_eq!(cli.limit, Some(10)); + } + + #[test] + fn parse_repeated_key_and_value_filters() { + let cli = Cli::parse_from([ + "cirup", + "--key-filter", + "^lbl", + "--key-filter", + ".*Title$", + "--value-filter", + "^English$", + "--value-filter", + ".*French.*", + "file-print", + "a.json", + ]); + + assert_eq!(cli.key_filter, vec![String::from("^lbl"), String::from(".*Title$")]); + assert_eq!( + cli.value_filter, + vec![String::from("^English$"), String::from(".*French.*")] + ); + } + + #[test] + fn parse_quiet_and_count_only() { + let cli = Cli::parse_from([ + "cirup", + "--quiet", + "--count-only", + "diff-with-base", + "old.json", + "new.json", + "base.json", + ]); + + assert!(cli.quiet); + assert!(cli.count_only); + } + + #[test] + fn parse_file_commands_with_outputs() { + let convert = Cli::parse_from(["cirup", "file-convert", "a.json", "b.restext"]); + match convert.command { + Commands::FileConvert { file, output } => { + assert_eq!(file, "a.json"); + assert_eq!(output, "b.restext"); + } + _ => panic!("expected file-convert command"), + } + + let merge = Cli::parse_from(["cirup", "file-merge", "a.json", "b.json", "out.json"]); + match merge.command { + Commands::FileMerge { file1, file2, output } => { + assert_eq!(file1, "a.json"); + assert_eq!(file2, "b.json"); + assert_eq!(output.as_deref(), Some("out.json")); + } + _ => panic!("expected file-merge command"), + } + + let intersect = Cli::parse_from(["cirup", "file-intersect", "a.json", "b.json", "out.json"]); + match intersect.command { + Commands::FileIntersect { file1, file2, output } => { + assert_eq!(file1, "a.json"); + assert_eq!(file2, "b.json"); + assert_eq!(output.as_deref(), Some("out.json")); + } + _ => panic!("expected file-intersect command"), + } + + let subtract = Cli::parse_from(["cirup", "file-subtract", "a.json", "b.json", "out.json"]); + match subtract.command { + Commands::FileSubtract { file1, file2, output } => { + assert_eq!(file1, "a.json"); + assert_eq!(file2, "b.json"); + assert_eq!(output.as_deref(), Some("out.json")); + } + _ => panic!("expected file-subtract command"), + } + } + + #[test] + fn parse_dry_run_check_and_summary() { + let cli = Cli::parse_from(["cirup", "--dry-run", "--check", "--summary", "file-sort", "a.json"]); + + assert!(cli.dry_run); + assert!(cli.check); + assert!(cli.summary); + } + + #[test] + fn query_options_make_check_imply_dry_run() { + let cli = Cli::parse_from(["cirup", "--check", "file-print", "a.json"]); + let options = query_options(&cli); + + assert!(options.dry_run); + assert!(options.check); + assert!(!options.summary); + } + + #[test] + fn quiet_conflicts_with_verbose_and_log_level() { + let verbose_error = Cli::try_parse_from(["cirup", "--quiet", "--verbose", "file-print", "a.json"]) + .expect_err("expected quiet/verbose conflict"); + assert!(verbose_error.to_string().contains("cannot be used with")); + + let log_level_error = Cli::try_parse_from(["cirup", "--quiet", "--log-level", "info", "file-print", "a.json"]) + .expect_err("expected quiet/log-level conflict"); + assert!(log_level_error.to_string().contains("cannot be used with")); + } + + #[test] + fn log_level_conflicts_with_verbose() { + let error = Cli::try_parse_from(["cirup", "--log-level", "trace", "-v", "file-print", "a.json"]) + .expect_err("expected log-level/verbose conflict"); + + assert!(error.to_string().contains("cannot be used with")); + } + + #[test] + fn removed_key_prefix_and_contains_flags_are_rejected() { + let prefix_error = Cli::try_parse_from(["cirup", "--key-prefix", "lbl", "file-print", "a.json"]) + .expect_err("expected removed key-prefix flag to fail"); + assert!(prefix_error.to_string().contains("unexpected argument '--key-prefix'")); + + let pattern_error = Cli::try_parse_from(["cirup", "--key-pattern", "^lbl", "file-print", "a.json"]) + .expect_err("expected removed key-pattern flag to fail"); + assert!( + pattern_error + .to_string() + .contains("unexpected argument '--key-pattern'") + ); + + let contains_error = Cli::try_parse_from(["cirup", "--key-contains", "Hello", "file-print", "a.json"]) + .expect_err("expected removed key-contains flag to fail"); + assert!( + contains_error + .to_string() + .contains("unexpected argument '--key-contains'") + ); + } } diff --git a/cirup_cli/tests/cli_e2e.rs b/cirup_cli/tests/cli_e2e.rs new file mode 100644 index 0000000..ff576d9 --- /dev/null +++ b/cirup_cli/tests/cli_e2e.rs @@ -0,0 +1,146 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use serde_json::Value; +use tempfile::tempdir; + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf() +} + +fn fixture_path(name: &str) -> PathBuf { + repo_root().join("cirup_core").join("test").join(name) +} + +fn cirup_command() -> Command { + Command::new(env!("CARGO_BIN_EXE_cirup")) +} + +fn run_cirup(args: &[&str]) -> Output { + cirup_command().args(args).output().expect("run cirup") +} + +fn stdout_string(output: &Output) -> String { + String::from_utf8(output.stdout.clone()).expect("stdout should be valid utf-8") +} + +fn stderr_string(output: &Output) -> String { + String::from_utf8(output.stderr.clone()).expect("stderr should be valid utf-8") +} + +#[test] +fn check_returns_exit_code_two_for_detected_changes() { + let new_file = fixture_path("test_new.resx"); + let old_file = fixture_path("test_old.resx"); + + let output = run_cirup(&[ + "--check", + "file-diff", + &new_file.to_string_lossy(), + &old_file.to_string_lossy(), + ]); + + assert_eq!(output.status.code(), Some(2)); + assert!(stdout_string(&output).is_empty()); + assert!(stderr_string(&output).is_empty()); +} + +#[test] +fn check_summary_reports_change_detection() { + let new_file = fixture_path("test_new.resx"); + let old_file = fixture_path("test_old.resx"); + + let output = run_cirup(&[ + "--check", + "--summary", + "--output-format", + "json", + "file-diff", + &new_file.to_string_lossy(), + &old_file.to_string_lossy(), + ]); + + assert_eq!(output.status.code(), Some(2)); + + let report: Value = serde_json::from_str(&stdout_string(&output)).expect("summary json"); + assert_eq!(report["operation"], "file-diff"); + assert_eq!(report["result_kind"], "resource"); + assert_eq!(report["output_count"], 3); + assert_eq!(report["check"], true); + assert_eq!(report["dry_run"], true); + assert_eq!(report["change_detected"], true); +} + +#[test] +fn count_only_prints_diff_count() { + let new_file = fixture_path("test_new.resx"); + let old_file = fixture_path("test_old.resx"); + + let output = run_cirup(&[ + "--count-only", + "file-diff", + &new_file.to_string_lossy(), + &old_file.to_string_lossy(), + ]); + + assert!(output.status.success()); + assert_eq!(stdout_string(&output), "3\n"); + assert!(stderr_string(&output).is_empty()); +} + +#[test] +fn output_format_json_prints_filtered_resource_array() { + let input = fixture_path("test.json"); + + let output = run_cirup(&[ + "--output-format", + "json", + "--key-filter", + "^lblBoat$", + "file-print", + &input.to_string_lossy(), + ]); + + assert!(output.status.success()); + + let rows: Value = serde_json::from_str(&stdout_string(&output)).expect("resource json"); + let array = rows.as_array().expect("json array output"); + + assert_eq!(array.len(), 1); + assert_eq!(array[0]["name"], "lblBoat"); + assert_eq!(array[0]["value"], "I'm on a boat."); +} + +#[test] +fn dry_run_summary_reports_in_place_write_without_modifying_file() { + let temp = tempdir().expect("tempdir"); + let file = temp.path().join("strings.json"); + fs::write(&file, "{\n \"z\": \"last\",\n \"a\": \"first\"\n}\n").expect("write temp file"); + let original = fs::read_to_string(&file).expect("read original file"); + + let output = run_cirup(&[ + "--dry-run", + "--summary", + "--output-format", + "json", + "file-sort", + &file.to_string_lossy(), + ]); + + assert!(output.status.success()); + + let report: Value = serde_json::from_str(&stdout_string(&output)).expect("summary json"); + assert_eq!(report["operation"], "file-sort"); + assert_eq!(report["dry_run"], true); + assert_eq!(report["would_write"], true); + assert_eq!(report["wrote_output"], false); + assert_eq!(report["change_detected"], true); + assert_eq!(report["output_file"], file.to_string_lossy().as_ref()); + + let after = fs::read_to_string(&file).expect("read file after dry-run"); + assert_eq!(after, original); +} diff --git a/cirup_core/Cargo.toml b/cirup_core/Cargo.toml index 0dca18b..5b06f37 100644 --- a/cirup_core/Cargo.toml +++ b/cirup_core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cirup_core" -version = "0.5.0" +version = "0.6.0" authors = ["Marc-AndrĂ© Moreau "] edition = "2024" diff --git a/cirup_core/src/file.rs b/cirup_core/src/file.rs index 93487de..7aade8e 100644 --- a/cirup_core/src/file.rs +++ b/cirup_core/src/file.rs @@ -111,6 +111,21 @@ fn output_bytes_for_format( } } +fn output_bytes_for_file(filename: &str, resources: &[Resource], output_encoding: OutputEncoding) -> Option> { + let path = Path::new(filename); + let extension = path + .extension() + .and_then(|extension| extension.to_str()) + .unwrap_or_default(); + let format_type = get_format_type_from_extension(extension); + + if format_type == FormatType::Unknown { + return None; + } + + Some(output_bytes_for_format(format_type, resources, output_encoding)) +} + #[cfg(test)] pub(crate) fn load_resource_str(text: &str, extension: &str) -> Result, Box> { match extension { @@ -163,18 +178,9 @@ pub(crate) fn save_resource_file_with_encoding( touch: bool, output_encoding: OutputEncoding, ) { - let path = Path::new(filename); - let extension = path - .extension() - .and_then(|extension| extension.to_str()) - .unwrap_or_default(); - let format_type = get_format_type_from_extension(extension); - - if format_type == FormatType::Unknown { + let Some(output_bytes) = output_bytes_for_file(filename, resources, output_encoding) else { return; - } - - let output_bytes = output_bytes_for_format(format_type, resources, output_encoding); + }; let output_hash = sha256_hash(&output_bytes); let existing_bytes = fs::read(filename).ok(); @@ -183,6 +189,21 @@ pub(crate) fn save_resource_file_with_encoding( } } +pub(crate) fn would_save_resource_file_with_encoding( + filename: &str, + resources: &[Resource], + touch: bool, + output_encoding: OutputEncoding, +) -> bool { + let Some(output_bytes) = output_bytes_for_file(filename, resources, output_encoding) else { + return false; + }; + + let output_hash = sha256_hash(&output_bytes); + let existing_bytes = fs::read(filename).ok(); + should_write_output(output_hash, existing_bytes.as_deref(), touch) +} + lazy_static! { static ref HASHMAP: Mutex> = { let map = HashMap::new(); @@ -311,3 +332,26 @@ fn save_resource_file_touches_unchanged_file_when_touch_is_true() { let _ = fs::remove_file(&filename); assert!(second_modified > first_modified); } + +#[test] +fn would_save_resource_file_reports_false_for_unchanged_output() { + let filename = temp_output_file_path("json"); + let resources = vec![Resource::new("hello", "world")]; + + save_resource_file(&filename, &resources, false); + + let would_write = would_save_resource_file_with_encoding(&filename, &resources, false, OutputEncoding::Utf8NoBom); + + let _ = fs::remove_file(&filename); + assert!(!would_write); +} + +#[test] +fn would_save_resource_file_reports_true_for_missing_output() { + let filename = temp_output_file_path("json"); + let resources = vec![Resource::new("hello", "world")]; + + let would_write = would_save_resource_file_with_encoding(&filename, &resources, false, OutputEncoding::Utf8NoBom); + + assert!(would_write); +} diff --git a/cirup_core/src/query.rs b/cirup_core/src/query.rs index 88fb7ed..d07a7c4 100644 --- a/cirup_core/src/query.rs +++ b/cirup_core/src/query.rs @@ -1,18 +1,469 @@ #![allow(clippy::self_named_module_files)] +use std::io; + use prettytable::{Cell, Row, Table}; +use regex::Regex; use crate::config::{QueryBackendKind, QueryConfig}; -use crate::file::{OutputEncoding, save_resource_file, save_resource_file_with_encoding}; +use crate::file::{ + OutputEncoding, save_resource_file, save_resource_file_with_encoding, would_save_resource_file_with_encoding, +}; use crate::query_backend::{QueryBackend, build_backend}; use crate::{Resource, Triple}; -#[allow(clippy::print_stdout)] -pub fn print_resources_pretty(resources: &[Resource]) { +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum QueryOutputFormat { + Table, + Json, + #[default] + Jsonl, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct QueryRunOptions { + pub output_format: QueryOutputFormat, + pub count_only: bool, + pub dry_run: bool, + pub check: bool, + pub summary: bool, + pub key_filters: Vec, + pub value_filters: Vec, + pub limit: Option, + pub operation_name: Option, + pub input_files: Vec, + pub output_file: Option, +} + +impl QueryRunOptions { + #[must_use] + pub fn with_context(mut self, operation_name: &str, input_files: &[&str], output_file: Option<&str>) -> Self { + self.operation_name = Some(operation_name.to_owned()); + self.input_files = input_files.iter().map(|value| (*value).to_owned()).collect(); + self.output_file = output_file.map(str::to_owned); + self + } + + fn validate_for_output(&self, out_file: Option<&str>) -> Result<(), io::Error> { + if self.count_only && out_file.is_some() { + return Err(io::Error::other("--count-only cannot be combined with an output file")); + } + + if self.count_only && self.summary { + return Err(io::Error::other("--count-only cannot be combined with --summary")); + } + + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum TextPatternSegment { + Literal(String), + AnyOne, + AnyMany, +} + +#[derive(Debug, Clone)] +struct CompiledTextPattern { + regex: Regex, + glob_pattern: String, +} + +#[derive(Debug, Clone, Default)] +struct CompiledTextFilter { + patterns: Vec, +} + +impl CompiledTextFilter { + fn matches(&self, value: &str) -> bool { + self.patterns.iter().any(|pattern| pattern.regex.is_match(value)) + } + + fn sql_condition(&self, value_expr: &str) -> String { + let clauses = self + .patterns + .iter() + .map(|pattern| format!("{value_expr} GLOB {}", sql_quote_literal(&pattern.glob_pattern))) + .collect::>(); + + if clauses.len() == 1 { + clauses[0].clone() + } else { + format!("({})", clauses.join(" OR ")) + } + } + + fn is_empty(&self) -> bool { + self.patterns.is_empty() + } +} + +fn make_error(message: impl Into) -> io::Error { + io::Error::other(message.into()) +} + +fn sql_quote_literal(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} + +fn escape_glob_char(ch: char) -> String { + match ch { + '*' => String::from("[*]"), + '?' => String::from("[?]"), + '[' => String::from("[[]"), + ']' => String::from("[]]"), + _ => ch.to_string(), + } +} + +fn compress_glob_stars(glob_pattern: &str) -> String { + let mut output = String::new(); + let mut previous_star = false; + + for ch in glob_pattern.chars() { + if ch == '*' { + if previous_star { + continue; + } + + previous_star = true; + } else { + previous_star = false; + } + + output.push(ch); + } + + output +} + +fn is_escaped_char(chars: &[char], index: usize) -> bool { + let mut backslash_count = 0; + let mut cursor = index; + + while cursor > 0 { + cursor -= 1; + if chars[cursor] == '\\' { + backslash_count += 1; + } else { + break; + } + } + + backslash_count % 2 == 1 +} + +fn compile_text_pattern(flag_name: &str, pattern: &str) -> Result { + if pattern.is_empty() { + return Err(make_error(format!("{flag_name} cannot be empty"))); + } + + let chars = pattern.chars().collect::>(); + let anchored_start = chars.first() == Some(&'^'); + let anchored_end = chars.last() == Some(&'$') && !is_escaped_char(&chars, chars.len() - 1); + + let start_index = usize::from(anchored_start); + let end_index = if anchored_end { chars.len() - 1 } else { chars.len() }; + + if start_index > end_index { + return Err(make_error(format!( + "invalid {flag_name} '{}': missing pattern body", + pattern + ))); + } + + let mut segments = Vec::new(); + let mut literal_buffer = String::new(); + let mut index = start_index; + + while index < end_index { + let ch = chars[index]; + + if ch == '\\' { + index += 1; + if index >= end_index { + return Err(make_error(format!( + "invalid {flag_name} '{}': trailing escape sequence", + pattern, + ))); + } + + literal_buffer.push(chars[index]); + index += 1; + continue; + } + + if ch == '.' { + if !literal_buffer.is_empty() { + segments.push(TextPatternSegment::Literal(std::mem::take(&mut literal_buffer))); + } + + if index + 1 < end_index && chars[index + 1] == '*' { + segments.push(TextPatternSegment::AnyMany); + index += 2; + } else { + segments.push(TextPatternSegment::AnyOne); + index += 1; + } + continue; + } + + if matches!(ch, '*' | '+' | '?' | '|' | '(' | ')' | '[' | ']' | '{' | '}') { + return Err(make_error(format!( + "invalid {flag_name} '{}': unsupported syntax '{}'; supported syntax is literals, ^, $, . and .*", + pattern, ch, + ))); + } + + if ch == '^' { + return Err(make_error(format!( + "invalid {flag_name} '{}': '^' is only supported at the start", + pattern + ))); + } + + if ch == '$' { + return Err(make_error(format!( + "invalid {flag_name} '{}': '$' is only supported at the end", + pattern + ))); + } + + literal_buffer.push(ch); + index += 1; + } + + if !literal_buffer.is_empty() { + segments.push(TextPatternSegment::Literal(literal_buffer)); + } + + let mut regex_pattern = String::new(); + if anchored_start { + regex_pattern.push('^'); + } + + let mut glob_pattern = String::new(); + if !anchored_start { + glob_pattern.push('*'); + } + + for segment in &segments { + match segment { + TextPatternSegment::Literal(value) => { + regex_pattern.push_str(®ex::escape(value)); + for ch in value.chars() { + glob_pattern.push_str(&escape_glob_char(ch)); + } + } + TextPatternSegment::AnyOne => { + regex_pattern.push('.'); + glob_pattern.push('?'); + } + TextPatternSegment::AnyMany => { + regex_pattern.push_str(".*"); + glob_pattern.push('*'); + } + } + } + + if anchored_end { + regex_pattern.push('$'); + } else { + glob_pattern.push('*'); + } + + let regex = Regex::new(®ex_pattern).map_err(|error| { + make_error(format!( + "invalid {flag_name} '{}': failed to compile generated regex '{}': {}", + pattern, regex_pattern, error, + )) + })?; + + Ok(CompiledTextPattern { + regex, + glob_pattern: compress_glob_stars(&glob_pattern), + }) +} + +fn compile_text_filter(flag_name: &str, patterns: &[String]) -> Result, io::Error> { + if patterns.is_empty() { + return Ok(None); + } + + let patterns = patterns + .iter() + .map(|pattern| compile_text_pattern(flag_name, pattern)) + .collect::, _>>()?; + + Ok(Some(CompiledTextFilter { patterns })) +} + +#[derive(Debug, Clone, Default)] +struct CompiledQueryFilters { + key_filter: Option, + value_filter: Option, +} + +impl CompiledQueryFilters { + fn is_empty(&self) -> bool { + self.key_filter.is_none() && self.value_filter.is_none() + } +} + +fn compile_query_filters(options: &QueryRunOptions) -> Result { + Ok(CompiledQueryFilters { + key_filter: compile_text_filter("--key-filter", &options.key_filters)?, + value_filter: compile_text_filter("--value-filter", &options.value_filters)?, + }) +} + +fn canonical_sql(input: &str) -> String { + input + .split_whitespace() + .collect::>() + .join(" ") + .to_ascii_lowercase() +} + +fn wrap_resource_query_with_filters(query: &str, filters: &CompiledQueryFilters) -> String { + if filters.is_empty() { + return query.to_owned(); + } + + let mut conditions = Vec::new(); + + if let Some(key_filter) = filters.key_filter.as_ref() + && !key_filter.is_empty() + { + conditions.push(key_filter.sql_condition("filtered.key")); + } + + if let Some(value_filter) = filters.value_filter.as_ref() + && !value_filter.is_empty() + { + conditions.push(value_filter.sql_condition("filtered.val")); + } + + if conditions.is_empty() { + return query.to_owned(); + } + + let mut wrapped = format!( + "WITH filtered(key, val) AS ({query}) SELECT key, val FROM filtered WHERE {}", + conditions.join(" AND ") + ); + + if canonical_sql(query) == canonical_sql(SORT_QUERY) { + wrapped.push_str(" ORDER BY key"); + } + + wrapped +} + +fn wrap_triple_query_with_filters(query: &str, filters: &CompiledQueryFilters) -> String { + if filters.is_empty() { + return query.to_owned(); + } + + let mut conditions = Vec::new(); + + if let Some(key_filter) = filters.key_filter.as_ref() + && !key_filter.is_empty() + { + conditions.push(key_filter.sql_condition("filtered.key")); + } + + if let Some(value_filter) = filters.value_filter.as_ref() + && !value_filter.is_empty() + { + conditions.push(value_filter.sql_condition("filtered.val")); + } + + if conditions.is_empty() { + return query.to_owned(); + } + + format!( + "WITH filtered(key, val, base) AS ({query}) SELECT key, val, base FROM filtered WHERE {}", + conditions.join(" AND ") + ) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct QueryExecutionCounts { + matched_count: usize, + filtered_count: usize, + output_count: usize, + truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct QueryExecutionReport { + pub operation: Option, + pub result_kind: String, + pub input_files: Vec, + pub output_file: Option, + pub matched_count: usize, + pub filtered_count: usize, + pub output_count: usize, + pub truncated: bool, + pub dry_run: bool, + pub check: bool, + pub would_write: bool, + pub wrote_output: bool, + pub change_detected: bool, +} + +impl QueryExecutionReport { + fn from_options( + options: &QueryRunOptions, + result_kind: &str, + counts: QueryExecutionCounts, + would_write: bool, + wrote_output: bool, + ) -> Self { + let change_detected = if options.output_file.is_some() { + would_write + } else { + counts.output_count > 0 + }; + + Self { + operation: options.operation_name.clone(), + result_kind: result_kind.to_owned(), + input_files: options.input_files.clone(), + output_file: options.output_file.clone(), + matched_count: counts.matched_count, + filtered_count: counts.filtered_count, + output_count: counts.output_count, + truncated: counts.truncated, + dry_run: options.dry_run, + check: options.check, + would_write, + wrote_output, + change_detected, + } + } + + pub fn indicates_change(&self) -> bool { + self.change_detected + } +} + +fn ensure_trailing_newline(mut text: String) -> String { + if !text.ends_with('\n') { + text.push('\n'); + } + + text +} + +fn resources_to_table(resources: &[Resource]) -> String { let mut table: Table = Table::new(); - table.add_row(row!["name", "value"]); // table header + table.add_row(row!["name", "value"]); for resource in resources { let mut row = Row::empty(); @@ -21,19 +472,173 @@ pub fn print_resources_pretty(resources: &[Resource]) { table.add_row(row); } - println!("{}", table); + ensure_trailing_newline(table.to_string()) } -#[allow(clippy::print_stdout)] -pub fn print_triples_pretty(triples: &[Triple]) { +fn triples_to_table(triples: &[Triple]) -> String { + let mut table: Table = Table::new(); + + table.add_row(row!["name", "value", "base"]); + for triple in triples { - println!("name: {}", triple.name); - println!("base: {}", triple.base); - println!("value: {}", triple.value); - println!(); + let mut row = Row::empty(); + row.add_cell(Cell::new(triple.name.as_str())); + row.add_cell(Cell::new(triple.value.as_str())); + row.add_cell(Cell::new(triple.base.as_str())); + table.add_row(row); + } + + ensure_trailing_newline(table.to_string()) +} + +fn render_jsonl(values: &[T]) -> String { + let mut output = String::new(); + + for value in values { + output.push_str(&serde_json::to_string(value).expect("failed to serialize JSONL row")); + output.push('\n'); + } + + output +} + +fn render_resources(resources: &[Resource], output_format: QueryOutputFormat) -> String { + match output_format { + QueryOutputFormat::Table => resources_to_table(resources), + QueryOutputFormat::Json => ensure_trailing_newline( + serde_json::to_string(resources).expect("failed to serialize resource list to JSON"), + ), + QueryOutputFormat::Jsonl => render_jsonl(resources), } } +fn render_triples(triples: &[Triple], output_format: QueryOutputFormat) -> String { + match output_format { + QueryOutputFormat::Table => triples_to_table(triples), + QueryOutputFormat::Json => { + ensure_trailing_newline(serde_json::to_string(triples).expect("failed to serialize triple list to JSON")) + } + QueryOutputFormat::Jsonl => render_jsonl(triples), + } +} + +fn render_count(count: usize) -> String { + format!("{count}\n") +} + +fn report_to_table(report: &QueryExecutionReport) -> String { + let mut table: Table = Table::new(); + + table.add_row(row!["field", "value"]); + + let rows = [ + ("operation", report.operation.as_deref().unwrap_or_default().to_owned()), + ("result_kind", report.result_kind.clone()), + ("input_files", report.input_files.join(",")), + ("output_file", report.output_file.clone().unwrap_or_default()), + ("matched_count", report.matched_count.to_string()), + ("filtered_count", report.filtered_count.to_string()), + ("output_count", report.output_count.to_string()), + ("truncated", report.truncated.to_string()), + ("dry_run", report.dry_run.to_string()), + ("check", report.check.to_string()), + ("would_write", report.would_write.to_string()), + ("wrote_output", report.wrote_output.to_string()), + ("change_detected", report.change_detected.to_string()), + ]; + + for (field, value) in rows { + let mut row = Row::empty(); + row.add_cell(Cell::new(field)); + row.add_cell(Cell::new(value.as_str())); + table.add_row(row); + } + + ensure_trailing_newline(table.to_string()) +} + +fn render_report(report: &QueryExecutionReport, output_format: QueryOutputFormat) -> String { + match output_format { + QueryOutputFormat::Table => report_to_table(report), + QueryOutputFormat::Json => ensure_trailing_newline( + serde_json::to_string(report).expect("failed to serialize execution report to JSON"), + ), + QueryOutputFormat::Jsonl => render_jsonl(std::slice::from_ref(report)), + } +} + +fn filter_resources( + mut resources: Vec, + filters: &CompiledQueryFilters, + limit: Option, +) -> (QueryExecutionCounts, Vec) { + let matched_count = resources.len(); + if let Some(key_filter) = filters.key_filter.as_ref() { + resources.retain(|resource| key_filter.matches(&resource.name)); + } + if let Some(value_filter) = filters.value_filter.as_ref() { + resources.retain(|resource| value_filter.matches(&resource.value)); + } + let filtered_count = resources.len(); + let mut truncated = false; + + if let Some(limit) = limit { + truncated = filtered_count > limit; + resources.truncate(limit); + } + + ( + QueryExecutionCounts { + matched_count, + filtered_count, + output_count: resources.len(), + truncated, + }, + resources, + ) +} + +fn filter_triples( + mut triples: Vec, + filters: &CompiledQueryFilters, + limit: Option, +) -> (QueryExecutionCounts, Vec) { + let matched_count = triples.len(); + if let Some(key_filter) = filters.key_filter.as_ref() { + triples.retain(|triple| key_filter.matches(&triple.name)); + } + if let Some(value_filter) = filters.value_filter.as_ref() { + triples.retain(|triple| value_filter.matches(&triple.value)); + } + let filtered_count = triples.len(); + let mut truncated = false; + + if let Some(limit) = limit { + truncated = filtered_count > limit; + triples.truncate(limit); + } + + ( + QueryExecutionCounts { + matched_count, + filtered_count, + output_count: triples.len(), + truncated, + }, + triples, + ) +} + +#[allow(clippy::print_stdout)] +pub fn print_resources_pretty(resources: &[Resource]) { + print!("{}", resources_to_table(resources)); +} + +#[allow(clippy::print_stdout)] +pub fn print_triples_pretty(triples: &[Triple]) { + print!("{}", triples_to_table(triples)); +} + fn default_query_backend() -> QueryBackendKind { std::env::var("CIRUP_QUERY_BACKEND") .ok() @@ -286,6 +891,20 @@ impl CirupQuery { self.engine.query_triple(&self.query) } + pub fn run_with_options(&self, options: &QueryRunOptions) -> Vec { + let filters = compile_query_filters(options).expect("invalid text filter"); + let query = wrap_resource_query_with_filters(&self.query, &filters); + let (_, resources) = filter_resources(self.engine.query_resource(&query), &filters, options.limit); + resources + } + + pub fn run_triple_with_options(&self, options: &QueryRunOptions) -> Vec { + let filters = compile_query_filters(options).expect("invalid text filter"); + let query = wrap_triple_query_with_filters(&self.query, &filters); + let (_, triples) = filter_triples(self.engine.query_triple(&query), &filters, options.limit); + triples + } + pub fn run_interactive(&self, out_file: Option<&str>, touch: bool) { let resources = self.run(); @@ -306,10 +925,98 @@ impl CirupQuery { } } + #[allow(clippy::print_stdout)] + pub fn run_interactive_with_options( + &self, + out_file: Option<&str>, + touch: bool, + output_encoding: OutputEncoding, + options: &QueryRunOptions, + ) -> Result { + options.validate_for_output(out_file)?; + let filters = compile_query_filters(options)?; + let query = wrap_resource_query_with_filters(&self.query, &filters); + + let (counts, resources) = filter_resources(self.engine.query_resource(&query), &filters, options.limit); + let would_write = out_file + .map(|path| would_save_resource_file_with_encoding(path, &resources, touch, output_encoding)) + .unwrap_or(false); + let mut wrote_output = false; + let report = QueryExecutionReport::from_options(options, "resource", counts, would_write, false); + + if options.count_only { + print!("{}", render_count(counts.output_count)); + return Ok(report); + } + + if options.check { + if options.summary { + print!("{}", render_report(&report, options.output_format)); + } + return Ok(report); + } + + if let Some(out_file) = out_file { + if options.dry_run { + if !options.summary { + print!("{}", render_resources(&resources, options.output_format)); + } + } else { + save_resource_file_with_encoding(out_file, &resources, touch, output_encoding); + wrote_output = would_write; + } + } else if !options.summary { + print!("{}", render_resources(&resources, options.output_format)); + } + + let report = QueryExecutionReport::from_options(options, "resource", counts, would_write, wrote_output); + + if options.summary { + print!("{}", render_report(&report, options.output_format)); + } + + Ok(report) + } + pub fn run_triple_interactive(&self) { let triples = self.run_triple(); print_triples_pretty(&triples); } + + #[allow(clippy::print_stdout)] + pub fn run_triple_interactive_with_options( + &self, + options: &QueryRunOptions, + ) -> Result { + options.validate_for_output(None)?; + let filters = compile_query_filters(options)?; + let query = wrap_triple_query_with_filters(&self.query, &filters); + + let (counts, triples) = filter_triples(self.engine.query_triple(&query), &filters, options.limit); + let report = QueryExecutionReport::from_options(options, "triple", counts, false, false); + + if options.count_only { + print!("{}", render_count(counts.output_count)); + return Ok(report); + } + + if options.check { + if options.summary { + print!("{}", render_report(&report, options.output_format)); + } + return Ok(report); + } + + if !options.summary { + print!("{}", render_triples(&triples, options.output_format)); + } + + if options.summary { + print!("{}", render_report(&report, options.output_format)); + } + + Ok(report) + } } #[cfg(test)] @@ -407,3 +1114,174 @@ fn test_query_turso_remote_env_gated() { assert_eq!(actual, expected); } + +#[test] +fn test_query_run_options_filter_and_limit_resources() { + let query = query_print_with_backend("test.json", QueryBackendKind::Rusqlite); + let options = QueryRunOptions { + key_filters: vec![String::from("^lbl.*Yolo$")], + limit: Some(1), + ..QueryRunOptions::default() + }; + + let resources = query.run_with_options(&options); + + assert_eq!(resources.len(), 1); + assert_eq!(resources[0].name, String::from("lblYolo")); +} + +#[test] +fn test_render_resources_jsonl() { + let resources = vec![Resource::new("hello", "world"), Resource::new("goodbye", "moon")]; + let output = render_resources(&resources, QueryOutputFormat::Jsonl); + + assert_eq!( + output, + "{\"name\":\"hello\",\"value\":\"world\"}\n{\"name\":\"goodbye\",\"value\":\"moon\"}\n" + ); +} + +#[test] +fn test_render_triples_json() { + let triples = vec![Triple::new("hello", "world", "base")]; + let output = render_triples(&triples, QueryOutputFormat::Json); + + assert_eq!(output, "[{\"name\":\"hello\",\"value\":\"world\",\"base\":\"base\"}]\n"); +} + +#[test] +fn test_count_only_rejects_output_file() { + let options = QueryRunOptions { + count_only: true, + ..QueryRunOptions::default() + }; + + let error = options + .validate_for_output(Some("out.json")) + .expect_err("expected validation error"); + assert_eq!(error.to_string(), "--count-only cannot be combined with an output file"); +} + +#[test] +fn test_summary_rejects_count_only() { + let options = QueryRunOptions { + count_only: true, + summary: true, + ..QueryRunOptions::default() + }; + + let error = options + .validate_for_output(None) + .expect_err("expected validation error"); + assert_eq!(error.to_string(), "--count-only cannot be combined with --summary"); +} + +#[test] +fn test_report_detects_change_for_stdout_results() { + let report = QueryExecutionReport::from_options( + &QueryRunOptions::default().with_context("file-diff", &["a.json", "b.json"], None), + "resource", + QueryExecutionCounts { + matched_count: 3, + filtered_count: 2, + output_count: 2, + truncated: false, + }, + false, + false, + ); + + assert!(report.indicates_change()); +} + +#[test] +fn test_report_renders_as_json_summary() { + let report = QueryExecutionReport::from_options( + &QueryRunOptions::default().with_context("file-sort", &["a.json"], Some("a.json")), + "resource", + QueryExecutionCounts { + matched_count: 4, + filtered_count: 4, + output_count: 4, + truncated: false, + }, + true, + false, + ); + + let output = render_report(&report, QueryOutputFormat::Json); + + assert!(output.contains("\"operation\":\"file-sort\"")); + assert!(output.contains("\"would_write\":true")); + assert!(output.ends_with('\n')); +} + +#[test] +fn test_compile_text_pattern_supports_simple_regex_subset() { + let compiled = compile_text_pattern("--key-filter", "^lbl.*Yolo$").expect("expected valid pattern"); + + assert!(compiled.regex.is_match("lblMyYolo")); + assert!(!compiled.regex.is_match("prefix_lblMyYolo")); + assert_eq!(compiled.glob_pattern, "lbl*Yolo"); +} + +#[test] +fn test_compile_text_pattern_rejects_unsupported_syntax() { + let error = compile_text_pattern("--key-filter", "foo|bar").expect_err("expected invalid pattern"); + + assert!(error.to_string().contains("unsupported syntax '|'")); +} + +#[test] +fn test_compile_text_filter_repeats_with_or_semantics() { + let options = QueryRunOptions { + key_filters: vec![String::from("^lbl"), String::from("World$")], + ..QueryRunOptions::default() + }; + let text_filter = compile_text_filter("--key-filter", &options.key_filters) + .expect("expected valid filter") + .expect("expected compiled patterns"); + + assert!(text_filter.matches("lblHello")); + assert!(text_filter.matches("HelloWorld")); + assert!(!text_filter.matches("other")); +} + +#[test] +fn test_wrap_resource_query_with_key_filter_uses_glob_condition() { + let options = QueryRunOptions { + key_filters: vec![String::from("^lbl")], + ..QueryRunOptions::default() + }; + let filters = compile_query_filters(&options).expect("expected compiled filters"); + let wrapped = wrap_resource_query_with_filters(PRINT_QUERY, &filters); + + assert!(wrapped.contains("filtered.key GLOB 'lbl*'")); + assert!(wrapped.starts_with("WITH filtered(key, val) AS (SELECT * FROM A)")); +} + +#[test] +fn test_wrap_resource_query_with_value_filter_uses_glob_condition() { + let options = QueryRunOptions { + value_filters: vec![String::from("^Hello")], + ..QueryRunOptions::default() + }; + let filters = compile_query_filters(&options).expect("expected compiled filters"); + let wrapped = wrap_resource_query_with_filters(PRINT_QUERY, &filters); + + assert!(wrapped.contains("filtered.val GLOB 'Hello*'")); +} + +#[test] +fn test_value_filter_matches_resource_values() { + let query = query_print_with_backend("test.json", QueryBackendKind::Rusqlite); + let options = QueryRunOptions { + value_filters: vec![String::from("^English$")], + ..QueryRunOptions::default() + }; + + let resources = query.run_with_options(&options); + + assert!(!resources.is_empty()); + assert!(resources.iter().all(|resource| resource.value == "English")); +} diff --git a/cirup_core/src/resource.rs b/cirup_core/src/resource.rs index f202252..0295583 100644 --- a/cirup_core/src/resource.rs +++ b/cirup_core/src/resource.rs @@ -1,6 +1,8 @@ use std::fmt; -#[derive(Clone)] +use serde::Serialize; + +#[derive(Clone, Serialize)] pub struct Resource { pub name: String, pub value: String, diff --git a/cirup_core/src/triple.rs b/cirup_core/src/triple.rs index a0ccd1c..67a0d76 100644 --- a/cirup_core/src/triple.rs +++ b/cirup_core/src/triple.rs @@ -1,6 +1,8 @@ use std::fmt; -#[derive(Clone)] +use serde::Serialize; + +#[derive(Clone, Serialize)] pub struct Triple { pub name: String, pub value: String, diff --git a/nuget/Devolutions.Cirup.Build.Package.csproj b/nuget/Devolutions.Cirup.Build.Package.csproj index 5292c25..8f9022e 100644 --- a/nuget/Devolutions.Cirup.Build.Package.csproj +++ b/nuget/Devolutions.Cirup.Build.Package.csproj @@ -8,7 +8,7 @@ true Devolutions.Cirup.Build - 0.5.0 + 0.6.0 Devolutions Cross-platform cirup executable packaged for MSBuild pre-build RESX sorting. README.md diff --git a/nuget/samples/Devolutions.Cirup.Build.E2E/Devolutions.Cirup.Build.E2E.csproj b/nuget/samples/Devolutions.Cirup.Build.E2E/Devolutions.Cirup.Build.E2E.csproj index b317180..acd5a7a 100644 --- a/nuget/samples/Devolutions.Cirup.Build.E2E/Devolutions.Cirup.Build.E2E.csproj +++ b/nuget/samples/Devolutions.Cirup.Build.E2E/Devolutions.Cirup.Build.E2E.csproj @@ -5,7 +5,7 @@ enable enable false - 0.5.0 + 0.6.0 diff --git a/nuget/test-e2e.ps1 b/nuget/test-e2e.ps1 index 480d6ad..4dacde7 100644 --- a/nuget/test-e2e.ps1 +++ b/nuget/test-e2e.ps1 @@ -1,5 +1,5 @@ param( - [string]$Version = "0.5.0", + [string]$Version = "0.6.0", [string]$Configuration = "Release" ) diff --git a/nuget/tool/Devolutions.Cirup.Tool.csproj b/nuget/tool/Devolutions.Cirup.Tool.csproj index eb74f98..108d8de 100644 --- a/nuget/tool/Devolutions.Cirup.Tool.csproj +++ b/nuget/tool/Devolutions.Cirup.Tool.csproj @@ -7,7 +7,7 @@ cirup Devolutions.Cirup.Tool - 0.5.0 + 0.6.0 Devolutions RID-specific dotnet tool wrapper around prebuilt cirup native executables. README.md