diff --git a/crates/togl-cli/src/cli.rs b/crates/togl-cli/src/cli.rs index b6b3720..cb42995 100644 --- a/crates/togl-cli/src/cli.rs +++ b/crates/togl-cli/src/cli.rs @@ -47,7 +47,7 @@ pub struct Cli { pub recursive: bool, /// List all section IDs found in files (discovery mode, no toggling) - #[arg(long = "list-sections")] + #[arg(long = "list-sections", group = "operation")] pub list_sections: bool, /// Detail level for --list-sections text output [default: lines] @@ -55,7 +55,7 @@ pub struct Cli { pub fields: ListFields, /// Remove a named section (requires -S ). Recursive with -R. See --remove-mode. - #[arg(long = "remove")] + #[arg(long = "remove", group = "operation")] pub remove: bool, /// What --remove strips: markers | commented | all [default: commented] @@ -63,16 +63,16 @@ pub struct Cli { pub remove_mode: RemoveMode, /// With --remove, exit non-zero if -S matched no sections. - #[arg(long = "require-match")] + #[arg(long = "require-match", requires = "remove")] pub require_match: bool, /// Insert a toggle:start/end marker pair around a single -l range (single file). /// Requires exactly one -S and one -l . Leaves the body uncommented. - #[arg(long = "insert")] + #[arg(long = "insert", group = "operation")] pub insert: bool, /// Description for the inserted section marker (use with --insert). - #[arg(long = "desc")] + #[arg(long = "desc", requires = "insert")] pub desc: Option, /// Force toggle state (on/off/invert) @@ -132,15 +132,15 @@ pub struct Cli { pub backup: Option, /// Extend the last --line range to the end of file - #[arg(long = "to-end")] + #[arg(long = "to-end", requires = "lines")] pub to_end: bool, /// Scan for section IDs without modifying files - #[arg(long = "scan")] + #[arg(long = "scan", group = "operation")] pub scan: bool, /// Validate section integrity without modifying files. Requires --scan. - #[arg(long = "check")] + #[arg(long = "check", requires = "scan")] pub check: bool, /// Enforce exactly 2 variants in the targeted group; error otherwise. @@ -159,7 +159,7 @@ pub struct Cli { /// Disable backup creation in atomic mode. Only valid with --atomic. /// WARNING: Without backups, rollback is not possible if the rename phase fails. - #[arg(long = "no-backup")] + #[arg(long = "no-backup", requires = "atomic")] pub no_backup: bool, /// Recover from an interrupted atomic operation. Default: rollback. @@ -168,7 +168,7 @@ pub struct Cli { /// Complete an interrupted atomic commit instead of rolling back. /// Must be combined with --recover. - #[arg(long = "recover-forward")] + #[arg(long = "recover-forward", requires = "recover")] pub recover_forward: bool, /// Generate shell completions for the given shell to stdout. diff --git a/crates/togl-cli/src/main.rs b/crates/togl-cli/src/main.rs index e5f1339..4ceaa20 100644 --- a/crates/togl-cli/src/main.rs +++ b/crates/togl-cli/src/main.rs @@ -193,12 +193,8 @@ fn run(cli: &Cli) -> Result<()> { if cli.atomic && cli.dry_run { return Err(UsageError("--atomic cannot be combined with --dry-run.".into()).into()); } - if cli.no_backup && !cli.atomic { - return Err(UsageError("--no-backup is only valid with --atomic.".into()).into()); - } - if cli.recover_forward && !cli.recover { - return Err(UsageError("--recover-forward requires --recover.".into()).into()); - } + // --no-backup requires --atomic, --recover-forward requires --recover: + // enforced declaratively in cli.rs via clap `requires`. // Note: --atomic --stdout is not applicable (no --stdout flag exists yet) // Note: --atomic --in-place is not applicable (no --in-place flag exists yet) @@ -249,9 +245,7 @@ fn run(cli: &Cli) -> Result<()> { // Handle --scan mode early (read-only, no toggle options needed). // Per PRD §0.14.2, --scan -S is the detailed group view, so --section is allowed here. if cli.scan { - if cli.insert { - return Err(UsageError("--scan cannot be combined with --insert".into()).into()); - } + // --scan vs --insert: enforced by the `operation` arg-group in cli.rs. if !cli.lines.is_empty() { return Err(UsageError("--scan cannot be combined with --line".into()).into()); } @@ -261,9 +255,7 @@ fn run(cli: &Cli) -> Result<()> { return run_scan(cli); } - if cli.check && !cli.scan { - return Err(UsageError("--check requires --scan".into()).into()); - } + // --check requires --scan: enforced declaratively in cli.rs via clap `requires`. // Validate --comment-style: must be 1 or 3 values if cli.comment_style.len() == 2 { @@ -273,10 +265,7 @@ fn run(cli: &Cli) -> Result<()> { .into()); } - // Validate --to-end requires --line - if cli.to_end && cli.lines.is_empty() { - return Err(UsageError("--to-end requires at least one --line range".into()).into()); - } + // --to-end requires --line: enforced declaratively in cli.rs via clap `requires`. // Validate --pair: pre-execution guard per PRD §0.13.4 if cli.pair { @@ -301,15 +290,11 @@ fn run(cli: &Cli) -> Result<()> { } // --remove validation (P06) if cli.remove { + // Mutual exclusion with other modes (--insert/--list-sections/--scan) is + // enforced by the `operation` arg-group in cli.rs. if cli.sections.len() != 1 { return Err(UsageError("--remove requires exactly one -S ".into()).into()); } - if cli.insert || cli.list_sections || cli.scan { - return Err(UsageError( - "--remove cannot be combined with --insert, --list-sections, or --scan".into(), - ) - .into()); - } if cli.atomic { return Err(UsageError("--remove cannot be combined with --atomic".into()).into()); } @@ -329,11 +314,8 @@ fn run(cli: &Cli) -> Result<()> { // ── --insert mode validation (P05) ── if cli.insert { - if cli.list_sections { - return Err( - UsageError("--insert cannot be combined with --list-sections".into()).into(), - ); - } + // Mutual exclusion with --list-sections (and other modes) is enforced by + // the `operation` arg-group in cli.rs. if cli.force.is_some() { return Err(UsageError( "--insert does not take --force (the body is left uncommented)".into(), @@ -357,9 +339,8 @@ fn run(cli: &Cli) -> Result<()> { if cli.lines.len() != 1 { return Err(UsageError("--insert requires exactly one -l ".into()).into()); } - } else if cli.desc.is_some() { - return Err(UsageError("--desc is only valid with --insert".into()).into()); } + // --desc requires --insert: enforced declaratively in cli.rs via clap `requires`. let opts = ToggleOptions { force: &effective_force, diff --git a/crates/togl-cli/tests/integration.rs b/crates/togl-cli/tests/integration.rs index ed90dce..3470364 100644 --- a/crates/togl-cli/tests/integration.rs +++ b/crates/togl-cli/tests/integration.rs @@ -74,7 +74,9 @@ fn test_to_end_without_line_errors() { .args([path.to_str().unwrap(), "--to-end"]) .assert() .failure() - .stderr(predicates::str::contains("--to-end requires")); + .stderr(predicates::str::contains( + "required arguments were not provided", + )); } // ── Multi-line comment support (Phase 2) ── @@ -1721,7 +1723,7 @@ fn test_no_backup_without_atomic_errors() { .assert() .failure() .stderr(predicates::str::contains( - "--no-backup is only valid with --atomic", + "required arguments were not provided", )); } @@ -1739,7 +1741,7 @@ fn test_recover_forward_without_recover_errors() { .assert() .failure() .stderr(predicates::str::contains( - "--recover-forward requires --recover", + "required arguments were not provided", )); } @@ -2457,7 +2459,9 @@ fn check_without_scan_errors() { .args(["--check", dir.path().to_str().unwrap()]) .assert() .failure() - .stderr(predicate::str::contains("--check requires --scan")); + .stderr(predicate::str::contains( + "required arguments were not provided", + )); } #[test]