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    /// 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.
49    #[arg(long, value_name = "PATH=VALUE")]
50    pub set: Vec<String>,
51
52    /// Delete the node at PATH (repeatable).
53    #[arg(long, value_name = "PATH")]
54    pub delete: Vec<String>,
55
56    /// 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.
57    #[arg(long, value_name = "PATH=VALUE")]
58    pub add: Vec<String>,
59
60    /// Move the array element selected by PATH to the front of its list (repeatable).
61    #[arg(long, value_name = "PATH")]
62    pub move_first: Vec<String>,
63
64    /// Move the array element selected by PATH to the end of its list (repeatable).
65    #[arg(long, value_name = "PATH")]
66    pub move_last: Vec<String>,
67
68    /// Move the array element selected by PATH one position earlier (repeatable).
69    #[arg(long, value_name = "PATH")]
70    pub move_up: Vec<String>,
71
72    /// Move the array element selected by PATH one position later (repeatable).
73    #[arg(long, value_name = "PATH")]
74    pub move_down: Vec<String>,
75
76    /// Force the document format instead of detecting it from the file extension.
77    #[arg(long, value_enum)]
78    pub format: Option<DocFormat>,
79
80    /// Verdict expectation over the total number of changes: any|none|N|=N|+N|-N (default: any).
81    #[arg(long)]
82    pub expect: Option<String>,
83
84    /// Show what would change and the verdict, but write nothing.
85    #[arg(long)]
86    pub dry_run: bool,
87
88    /// Suppress the per-file lines; print only the summary.
89    #[arg(long)]
90    pub quiet: bool,
91
92    /// Emit a structured JSON result instead of text.
93    #[arg(long)]
94    pub json: bool,
95
96    /// 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.
97    #[arg(long, value_name = "SECS")]
98    pub timeout: Option<f64>,
99
100    #[command(flatten)]
101    pub heartbeat: HeartbeatOpts,
102
103    /// Print agent usage docs (md or json) and exit.
104    #[arg(long, value_enum, num_args = 0..=1, default_missing_value = "md")]
105    pub explain: Option<Format>,
106}
107
108/// Document format. JSON, JSONC, and JSONL parse through the same lenient
109/// `jsonc-parser` tree; YAML uses the pure-Rust `yaml-edit` backend.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
111pub enum DocFormat {
112    Json,
113    Jsonc,
114    Jsonl,
115    Yaml,
116}
117
118impl DocFormat {
119    /// Detect a format from a file extension.
120    pub fn from_ext(ext: &str) -> Option<DocFormat> {
121        match ext.to_ascii_lowercase().as_str() {
122            "json" => Some(DocFormat::Json),
123            "jsonc" => Some(DocFormat::Jsonc),
124            "jsonl" | "ndjson" => Some(DocFormat::Jsonl),
125            "yaml" | "yml" => Some(DocFormat::Yaml),
126            _ => None,
127        }
128    }
129}