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`, `--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    /// Repository path (default: find .heddle in ancestors).
65    #[arg(short = 'C', long, global = true, value_name = "PATH")]
66    pub repo: Option<std::path::PathBuf>,
67
68    /// Increase verbosity.
69    #[arg(short, long, global = true, action = clap::ArgAction::Count)]
70    pub verbose: u8,
71
72    /// Decrease verbosity.
73    #[arg(short, long, global = true)]
74    pub quiet: bool,
75
76    // Global like --output, and revealed on every `supports_op_id`
77    // command's help — keep it to one line (heddle#652). Replay semantics
78    // (`supports_op_id` advertisement, same-body replay, typed conflicts)
79    // live in `heddle help operation-ids`. Hidden from default `--help` to
80    // keep the human surface uncluttered.
81    /// Operation id (UUID v4) for idempotent retries. See `heddle help operation-ids`
82    #[arg(long, global = true, env = "HEDDLE_OPERATION_ID", hide = true)]
83    pub op_id: Option<String>,
84}
85
86impl Cli {
87    /// Load and cache the process-wide user configuration.
88    pub fn user_config_or_exit() -> &'static config::UserConfig {
89        static USER_CONFIG: OnceLock<config::UserConfig> = OnceLock::new();
90        USER_CONFIG.get_or_init(|| config::UserConfig::load_default().unwrap_or_default())
91    }
92
93    pub fn output_mode(&self) -> Option<config::OutputMode> {
94        self.output.map(Into::into)
95    }
96
97    /// Open the Heddle repository the command should act on: the `--repo`
98    /// path if given, otherwise the current working directory (resolved
99    /// lazily so a supplied `--repo` never touches the cwd).
100    pub fn open_repo(&self) -> anyhow::Result<repo::Repository> {
101        use anyhow::Context as _;
102        let cwd;
103        let repo_path = match self.repo.as_ref() {
104            Some(path) => path,
105            None => {
106                cwd = std::env::current_dir().context("get current working directory")?;
107                &cwd
108            }
109        };
110        let repo = repo::Repository::open(repo_path).context("open Heddle repository")?;
111        let mode = Self::user_config_or_exit()
112            .worktree_status_options(Some(repo.config()))
113            .fsmonitor
114            .mode;
115        Ok(repo.with_fsmonitor_mode(mode))
116    }
117}
118
119/// Small projection of [`Cli`] that hosted commands rely on.
120/// Defining the surface here lets the hosted-client implementation compile
121/// against the parsed CLI state without depending on `cli`.
122///
123/// Keep this trait deliberately small. Every new method is a permanent
124/// contract with the hosted side; before adding one, ask whether the hosted
125/// command should really need that context at all, or whether the caller can
126/// compute it and pass a primitive value.
127pub trait CliContext: Send + Sync {
128    /// `--repo` override; `None` means "use the process's current
129    /// directory."
130    fn repo_path(&self) -> Option<&Path>;
131
132    /// `--op-id` override for idempotent hosted calls. Empty string
133    /// means the caller did not supply one and the server should not
134    /// dedupe.
135    fn operation_id_wire(&self) -> String;
136
137    /// Resolves whether output should be JSON, encapsulating the
138    /// precedence between the `--json` / `--output` cli flags, the
139    /// user's global config, and (when supplied) the repo's
140    /// `output.format` config. Hosted commands typically pass
141    /// `Some(repo.config())` after opening the repo and `None`
142    /// otherwise.
143    fn should_output_json(&self, repo_config: Option<&Config>) -> bool;
144}
145
146impl CliContext for Cli {
147    fn repo_path(&self) -> Option<&std::path::Path> {
148        self.repo.as_deref()
149    }
150
151    fn operation_id_wire(&self) -> String {
152        self.op_id.clone().unwrap_or_default()
153    }
154
155    fn should_output_json(&self, repo_config: Option<&Config>) -> bool {
156        should_output_json(self, repo_config)
157    }
158}
159
160/// Resolve whether command output should use JSON after applying user,
161/// repository, and explicit CLI output settings.
162pub fn should_output_json(cli: &Cli, repo_config: Option<&Config>) -> bool {
163    let mut format = repo_config
164        .and_then(|config| config.output.format)
165        .unwrap_or(Cli::user_config_or_exit().output.format);
166
167    if let Some(output) = cli.output_mode() {
168        format = match output {
169            config::OutputMode::Json | config::OutputMode::JsonCompact => OutputFormat::Json,
170            config::OutputMode::Text => OutputFormat::Text,
171        };
172    }
173
174    matches!(format, OutputFormat::Json)
175}