Skip to main content

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}