Skip to content

Commit 2a8100e

Browse files
authored
feat: add -m, -e, and --dry-run CLI mapping flags (#48)
feat: add -m, -e, and --dry-run CLI mapping flags (closes #19)
1 parent a05a0ef commit 2a8100e

File tree

2 files changed

+484
-0
lines changed

2 files changed

+484
-0
lines changed

src/cli.rs

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,18 @@ pub struct Cli {
9898
/// List supported formats
9999
#[arg(long = "formats")]
100100
pub formats: bool,
101+
102+
/// Apply a mapping file (.morph)
103+
#[arg(short = 'm', long = "mapping")]
104+
pub mapping: Option<PathBuf>,
105+
106+
/// Inline mapping expression (can be repeated; applied in order after -m)
107+
#[arg(short = 'e', long = "expr", action = clap::ArgAction::Append)]
108+
pub expr: Vec<String>,
109+
110+
/// Parse and validate the mapping without executing
111+
#[arg(long = "dry-run")]
112+
pub dry_run: bool,
101113
}
102114

103115
impl Cli {
@@ -211,6 +223,43 @@ pub fn write_output(cli: &Cli, output: &str) -> crate::error::Result<()> {
211223
}
212224
}
213225

226+
/// Build a combined mapping program from -m and -e flags.
227+
/// Returns Ok(None) if no mapping flags were given.
228+
pub fn build_mapping_program(
229+
cli: &Cli,
230+
) -> crate::error::Result<Option<crate::mapping::ast::Program>> {
231+
let has_mapping = cli.mapping.is_some();
232+
let has_exprs = !cli.expr.is_empty();
233+
234+
if !has_mapping && !has_exprs {
235+
return Ok(None);
236+
}
237+
238+
let mut all_statements = Vec::new();
239+
240+
// Load mapping file first
241+
if let Some(ref path) = cli.mapping {
242+
let source = std::fs::read_to_string(path).map_err(|e| {
243+
crate::error::MorphError::Io(std::io::Error::new(
244+
e.kind(),
245+
format!("{}: {e}", path.display()),
246+
))
247+
})?;
248+
let program = crate::mapping::parser::parse_str(&source)?;
249+
all_statements.extend(program.statements);
250+
}
251+
252+
// Then append inline expressions
253+
for expr_str in &cli.expr {
254+
let program = crate::mapping::parser::parse_str(expr_str)?;
255+
all_statements.extend(program.statements);
256+
}
257+
258+
Ok(Some(crate::mapping::ast::Program {
259+
statements: all_statements,
260+
}))
261+
}
262+
214263
/// Run the full pipeline based on CLI args.
215264
pub fn run(cli: &Cli) -> crate::error::Result<()> {
216265
if cli.formats {
@@ -221,12 +270,35 @@ pub fn run(cli: &Cli) -> crate::error::Result<()> {
221270
return Ok(());
222271
}
223272

273+
// Build mapping program (if any flags given)
274+
let mapping_program = build_mapping_program(cli)?;
275+
276+
// --dry-run: validate mapping and exit
277+
if cli.dry_run {
278+
match &mapping_program {
279+
Some(_) => {
280+
println!("mapping valid");
281+
return Ok(());
282+
}
283+
None => {
284+
println!("mapping valid");
285+
return Ok(());
286+
}
287+
}
288+
}
289+
224290
let in_fmt = cli.resolve_input_format()?;
225291
let out_fmt = cli.resolve_output_format()?;
226292

227293
let input_data = read_input(cli)?;
228294
let value = parse_input(&input_data, in_fmt)?;
229295

296+
// Apply mapping if present
297+
let value = match mapping_program {
298+
Some(ref program) => crate::mapping::eval::eval(program, &value)?,
299+
None => value,
300+
};
301+
230302
// Determine pretty-printing: explicit flags > default based on TTY
231303
let pretty = if cli.pretty {
232304
true
@@ -417,4 +489,155 @@ mod tests {
417489
assert_eq!(Format::Toml.to_string(), "toml");
418490
assert_eq!(Format::Csv.to_string(), "csv");
419491
}
492+
493+
// -- Mapping CLI flags --------------------------------------------------
494+
495+
#[test]
496+
fn arg_parsing_mapping_file() {
497+
let cli = Cli::try_parse_from([
498+
"morph",
499+
"-i",
500+
"in.json",
501+
"-o",
502+
"out.json",
503+
"-m",
504+
"transform.morph",
505+
])
506+
.unwrap();
507+
assert_eq!(cli.mapping, Some(PathBuf::from("transform.morph")));
508+
}
509+
510+
#[test]
511+
fn arg_parsing_single_expr() {
512+
let cli =
513+
Cli::try_parse_from(["morph", "-f", "json", "-t", "json", "-e", "rename .x -> .y"])
514+
.unwrap();
515+
assert_eq!(cli.expr, vec!["rename .x -> .y"]);
516+
}
517+
518+
#[test]
519+
fn arg_parsing_multiple_expr() {
520+
let cli = Cli::try_parse_from([
521+
"morph",
522+
"-f",
523+
"json",
524+
"-t",
525+
"json",
526+
"-e",
527+
"rename .x -> .y",
528+
"-e",
529+
"drop .z",
530+
])
531+
.unwrap();
532+
assert_eq!(cli.expr, vec!["rename .x -> .y", "drop .z"]);
533+
}
534+
535+
#[test]
536+
fn arg_parsing_dry_run() {
537+
let cli = Cli::try_parse_from([
538+
"morph",
539+
"--dry-run",
540+
"-e",
541+
"drop .x",
542+
"-f",
543+
"json",
544+
"-t",
545+
"json",
546+
])
547+
.unwrap();
548+
assert!(cli.dry_run);
549+
}
550+
551+
#[test]
552+
fn arg_parsing_mapping_and_expr_combined() {
553+
let cli = Cli::try_parse_from([
554+
"morph",
555+
"-m",
556+
"base.morph",
557+
"-e",
558+
"drop .extra",
559+
"-f",
560+
"json",
561+
"-t",
562+
"yaml",
563+
])
564+
.unwrap();
565+
assert_eq!(cli.mapping, Some(PathBuf::from("base.morph")));
566+
assert_eq!(cli.expr, vec!["drop .extra"]);
567+
}
568+
569+
#[test]
570+
fn no_mapping_flags_returns_none() {
571+
let cli = Cli::try_parse_from(["morph", "-f", "json", "-t", "yaml"]).unwrap();
572+
let program = build_mapping_program(&cli).unwrap();
573+
assert!(program.is_none());
574+
}
575+
576+
#[test]
577+
fn build_mapping_from_expr() {
578+
let cli = Cli::try_parse_from([
579+
"morph",
580+
"-f",
581+
"json",
582+
"-t",
583+
"json",
584+
"-e",
585+
"rename .old -> .new",
586+
])
587+
.unwrap();
588+
let program = build_mapping_program(&cli).unwrap();
589+
assert!(program.is_some());
590+
assert_eq!(program.unwrap().statements.len(), 1);
591+
}
592+
593+
#[test]
594+
fn build_mapping_multiple_exprs_in_order() {
595+
let cli = Cli::try_parse_from([
596+
"morph",
597+
"-f",
598+
"json",
599+
"-t",
600+
"json",
601+
"-e",
602+
"rename .a -> .b",
603+
"-e",
604+
"drop .c",
605+
])
606+
.unwrap();
607+
let program = build_mapping_program(&cli).unwrap();
608+
assert!(program.is_some());
609+
assert_eq!(program.unwrap().statements.len(), 2);
610+
}
611+
612+
#[test]
613+
fn build_mapping_invalid_expr_returns_error() {
614+
let cli = Cli::try_parse_from([
615+
"morph",
616+
"-f",
617+
"json",
618+
"-t",
619+
"json",
620+
"-e",
621+
"invalid!!!syntax",
622+
])
623+
.unwrap();
624+
let result = build_mapping_program(&cli);
625+
assert!(result.is_err());
626+
}
627+
628+
#[test]
629+
fn build_mapping_nonexistent_file_returns_error() {
630+
let cli = Cli::try_parse_from([
631+
"morph",
632+
"-f",
633+
"json",
634+
"-t",
635+
"json",
636+
"-m",
637+
"/nonexistent/path/transform.morph",
638+
])
639+
.unwrap();
640+
let result = build_mapping_program(&cli);
641+
assert!(result.is_err());
642+
}
420643
}

0 commit comments

Comments
 (0)