heddle_cli_args/cli/cli_args/cli_base.rs
1// SPDX-License-Identifier: Apache-2.0
2//! Base CLI flags.
3//!
4//! ## Short-flag conventions
5//!
6//! The CLI is small and the short forms have to mean the same thing
7//! everywhere they appear. The table below is the source of truth — new
8//! verbs should reuse these letters before claiming new ones. Verbs that
9//! diverge (e.g. `-n` for `--steps` on `undo`/`redo` vs `--limit` on
10//! `log`/`list`) keep the muscle memory consistent within the verb's own
11//! family: "n" is always "how many," "m" is always "message."
12//!
13//! | Short | Long(s) | Used by |
14//! |-------|-----------------------------------|-------------------------------|
15//! | `-m` | `--message`, `--body`, `--intent` | capture, revert, context, |
16//! | | | discuss |
17//! | `-n` | `--limit` (queries), | log, list, query (limit); |
18//! | | `--steps` (undo/redo) | undo, redo (steps) |
19//! | `-f` | `--force` | capture, push, revert, purge |
20//! | `-s` | `--short` | status |
21//! | `-U` | `--unified` | diff |
22//! | `-C` | `--repo` | global |
23//! | `-v` | `--verbose` (repeatable) | global |
24//! | `-q` | `--quiet` | global |
25//!
26//! Heddle is pre-1.0 and contracts misleading surface area instead of
27//! preserving obsolete spellings. Add a short alias only when the letter is
28//! already reserved for that semantic in the current table above.
29
30use std::{path::Path, sync::OnceLock};
31
32use clap::Parser;
33use repo::{Config, OutputFormat};
34
35use super::{CliOutputMode, Commands};
36
37/// Heddle: An AI-native version control system.
38#[derive(Parser)]
39#[command(name = "heddle")]
40#[command(author, version, about, long_about = None)]
41// We ship our own `Help` subcommand (curated everyday/advanced
42// surface + topic pages). clap's auto-generated `help` subcommand
43// would shadow it; turn it off so `heddle help [topic]` reaches our
44// printer instead.
45#[command(disable_help_subcommand = true)]
46pub struct Cli {
47 #[command(subcommand)]
48 pub command: Commands,
49
50 // This is a `global = true` arg, so clap stamps its help onto every
51 // subcommand's --help. Keep it to ONE line; the full contract
52 // (json vs json-compact fields, no-TTY-autodetect guarantee) lives in
53 // `heddle help output-formats` (help.rs OUTPUT_FORMATS_TOPIC) and the
54 // top-level `heddle help` Output paragraph, stated exactly once each
55 // (heddle#652).
56 /// Output format: `text` (default), `json`, or `json-compact`. See `heddle help output-formats`
57 #[arg(long, global = true, value_enum)]
58 pub output: Option<CliOutputMode>,
59
60 /// Disable colored output.
61 #[arg(long, global = true)]
62 pub no_color: bool,
63
64 // Global short-circuit like `--help`: print the JSON Schema for the
65 // resolved command's `--output json` payload and exit without running
66 // the command. Resolves to the deepest selected verb (including
67 // flag-differentiated payloads like `land --threads`). Kept to one
68 // line on every subcommand's help (heddle#652).
69 /// Print the JSON Schema for this command's `--output json` payload and exit.
70 #[arg(long, global = true)]
71 pub schema: bool,
72
73 /// Repository path (default: find .heddle in ancestors).
74 #[arg(short = 'C', long, global = true, value_name = "PATH")]
75 pub repo: Option<std::path::PathBuf>,
76
77 /// Increase verbosity.
78 #[arg(short, long, global = true, action = clap::ArgAction::Count)]
79 pub verbose: u8,
80
81 /// Decrease verbosity.
82 #[arg(short, long, global = true)]
83 pub quiet: bool,
84
85 // Global like --output, and revealed on every `supports_op_id`
86 // command's help — keep it to one line (heddle#652). Replay semantics
87 // (`supports_op_id` advertisement, same-body replay, typed conflicts)
88 // live in `heddle help operation-ids`. Hidden from default `--help` to
89 // keep the human surface uncluttered.
90 /// Operation id (UUID v4) for idempotent retries. See `heddle help operation-ids`
91 #[arg(long, global = true, env = "HEDDLE_OPERATION_ID", hide = true)]
92 pub op_id: Option<String>,
93}
94
95impl Cli {
96 /// Load and cache the process-wide user configuration.
97 pub fn user_config_or_exit() -> &'static config::UserConfig {
98 static USER_CONFIG: OnceLock<config::UserConfig> = OnceLock::new();
99 USER_CONFIG.get_or_init(|| config::UserConfig::load_default().unwrap_or_default())
100 }
101
102 pub fn output_mode(&self) -> Option<config::OutputMode> {
103 self.output.map(Into::into)
104 }
105
106 /// Open the Heddle repository the command should act on: the `--repo`
107 /// path if given, otherwise the current working directory (resolved
108 /// lazily so a supplied `--repo` never touches the cwd).
109 pub fn open_repo(&self) -> anyhow::Result<repo::Repository> {
110 use anyhow::Context as _;
111 let cwd;
112 let repo_path = match self.repo.as_ref() {
113 Some(path) => path,
114 None => {
115 cwd = std::env::current_dir().context("get current working directory")?;
116 &cwd
117 }
118 };
119 let repo = repo::Repository::open(repo_path).context("open Heddle repository")?;
120 let mode = Self::user_config_or_exit()
121 .worktree_status_options(Some(repo.config()))
122 .fsmonitor
123 .mode;
124 Ok(repo.with_fsmonitor_mode(mode))
125 }
126}
127
128/// Small projection of [`Cli`] that hosted commands rely on.
129/// Defining the surface here lets the hosted-client implementation compile
130/// against the parsed CLI state without depending on `cli`.
131///
132/// Keep this trait deliberately small. Every new method is a permanent
133/// contract with the hosted side; before adding one, ask whether the hosted
134/// command should really need that context at all, or whether the caller can
135/// compute it and pass a primitive value.
136pub trait CliContext: Send + Sync {
137 /// `--repo` override; `None` means "use the process's current
138 /// directory."
139 fn repo_path(&self) -> Option<&Path>;
140
141 /// `--op-id` override for idempotent hosted calls. Empty string
142 /// means the caller did not supply one and the server should not
143 /// dedupe.
144 fn operation_id_wire(&self) -> String;
145
146 /// Resolves whether output should be JSON, encapsulating the
147 /// precedence between the `--json` / `--output` cli flags, the
148 /// user's global config, and (when supplied) the repo's
149 /// `output.format` config. Hosted commands typically pass
150 /// `Some(repo.config())` after opening the repo and `None`
151 /// otherwise.
152 fn should_output_json(&self, repo_config: Option<&Config>) -> bool;
153}
154
155impl CliContext for Cli {
156 fn repo_path(&self) -> Option<&std::path::Path> {
157 self.repo.as_deref()
158 }
159
160 fn operation_id_wire(&self) -> String {
161 self.op_id.clone().unwrap_or_default()
162 }
163
164 fn should_output_json(&self, repo_config: Option<&Config>) -> bool {
165 should_output_json(self, repo_config)
166 }
167}
168
169/// Resolve whether command output should use JSON after applying user,
170/// repository, and explicit CLI output settings.
171pub fn should_output_json(cli: &Cli, repo_config: Option<&Config>) -> bool {
172 let mut format = repo_config
173 .and_then(|config| config.output.format)
174 .unwrap_or(Cli::user_config_or_exit().output.format);
175
176 if let Some(output) = cli.output_mode() {
177 format = match output {
178 config::OutputMode::Json | config::OutputMode::JsonCompact => OutputFormat::Json,
179 config::OutputMode::Text => OutputFormat::Text,
180 };
181 }
182
183 matches!(format, OutputFormat::Json)
184}