coding_tools/cli/ct_patch.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Jonathan Shook
3
4//! The `ct-patch` command grammar (see [`crate::cli`]); the `ct-patch` bin is a
5//! thin parse-and-dispatch wrapper over this `Cli`.
6
7use std::path::PathBuf;
8
9use clap::Parser;
10
11use crate::explain::Format;
12use crate::pattern;
13use crate::pulse::HeartbeatOpts;
14
15#[derive(Parser, Debug)]
16#[command(
17 name = "ct-patch",
18 version,
19 about = "Set/add/delete/move nodes by path in JSON/JSONC/JSONL/YAML, preserving comments and formatting.",
20 long_about = "ct-patch makes structured edits to JSON, JSONC, JSONL, and YAML files (also reachable \
21 as `ct patch`): address a node by path (keys, [N] indices, or [key=value] predicates) \
22 and --set, --add, --delete, or --move-*. JSON-family edits are byte-range splices so \
23 everything outside the changed node is preserved; YAML uses the pure-Rust yaml-edit \
24 backend. Gated by --expect and previewable with --dry-run. See `ct-patch --explain` \
25 for agent-oriented documentation."
26)]
27pub struct Cli {
28 /// Root to patch; a file patches just that file, a directory is descended.
29 #[arg(long, default_value = ".")]
30 pub base: PathBuf,
31
32 /// Limit to files whose name matches; '|'-separated alternatives, each substring->glob->regex promoted and anchored.
33 #[arg(long)]
34 pub name: Option<String>,
35
36 /// Pin how --name is interpreted (promotion off): literal, glob, or regex.
37 #[arg(long, value_enum)]
38 pub mode: Option<pattern::Mode>,
39
40 /// Include dot-entries (names starting with '.'); default skips them.
41 #[arg(long)]
42 pub hidden: bool,
43
44 /// Follow symlinks while traversing.
45 #[arg(long)]
46 pub follow: bool,
47
48 /// Walk gitignored / .ignore files too (the .git directory is always skipped); by default the walk skips what git would.
49 #[arg(long)]
50 pub no_ignore: bool,
51
52 /// Set PATH to VALUE (repeatable). VALUE is parsed as JSON, or taken as a string if it is not valid JSON. file:PATH reads the value verbatim as a string; text:VALUE escapes the prefix.
53 #[arg(long, value_name = "PATH=VALUE")]
54 pub set: Vec<String>,
55
56 /// Delete the node at PATH (repeatable).
57 #[arg(long, value_name = "PATH")]
58 pub delete: Vec<String>,
59
60 /// Append VALUE to the array at PATH, no index needed (repeatable). VALUE is parsed as JSON or taken as a string; file:PATH reads it verbatim as a string.
61 #[arg(long, value_name = "PATH=VALUE")]
62 pub add: Vec<String>,
63
64 /// Move the array element selected by PATH to the front of its list (repeatable).
65 #[arg(long, value_name = "PATH")]
66 pub move_first: Vec<String>,
67
68 /// Move the array element selected by PATH to the end of its list (repeatable).
69 #[arg(long, value_name = "PATH")]
70 pub move_last: Vec<String>,
71
72 /// Move the array element selected by PATH one position earlier (repeatable).
73 #[arg(long, value_name = "PATH")]
74 pub move_up: Vec<String>,
75
76 /// Move the array element selected by PATH one position later (repeatable).
77 #[arg(long, value_name = "PATH")]
78 pub move_down: Vec<String>,
79
80 /// Force the document format instead of detecting it from the file extension.
81 #[arg(long, value_enum)]
82 pub format: Option<DocFormat>,
83
84 /// Verdict expectation over the total number of changes: any|none|N|=N|+N|-N (default: any).
85 #[arg(long)]
86 pub expect: Option<String>,
87
88 /// Show what would change and the verdict, but write nothing.
89 #[arg(long)]
90 pub dry_run: bool,
91
92 /// Suppress the per-file lines; print only the summary.
93 #[arg(long)]
94 pub quiet: bool,
95
96 /// Emit a structured JSON result instead of text.
97 #[arg(long)]
98 pub json: bool,
99
100 /// Like `--json`, but pretty-printed (indented).
101 #[arg(long)]
102 pub json_pretty: bool,
103
104 /// Abort with exit 2 if the scan exceeds SECS seconds (fractional allowed). Never interrupts the write phase: once a SUCCESS verdict starts writing, every write completes.
105 #[arg(long, value_name = "SECS")]
106 pub timeout: Option<f64>,
107
108 #[command(flatten)]
109 pub heartbeat: HeartbeatOpts,
110
111 /// Print agent usage docs (md or json) and exit.
112 #[arg(long, value_enum, num_args = 0..=1, default_missing_value = "md")]
113 pub explain: Option<Format>,
114}
115
116/// Document format. JSON, JSONC, and JSONL parse through the same lenient
117/// `jsonc-parser` tree; YAML uses the pure-Rust `yaml-edit` backend.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
119pub enum DocFormat {
120 Json,
121 Jsonc,
122 Jsonl,
123 Yaml,
124}
125
126impl DocFormat {
127 /// Detect a format from a file extension.
128 pub fn from_ext(ext: &str) -> Option<DocFormat> {
129 match ext.to_ascii_lowercase().as_str() {
130 "json" => Some(DocFormat::Json),
131 "jsonc" => Some(DocFormat::Jsonc),
132 "jsonl" | "ndjson" => Some(DocFormat::Jsonl),
133 "yaml" | "yml" => Some(DocFormat::Yaml),
134 _ => None,
135 }
136 }
137}