Skip to main content

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}