Skip to main content

heddle_cli_args/cli/cli_args/
shared.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Shared clap Args types — one flag name, one meaning.
3//!
4//! Clap definitions, human help, and the machine catalog all flatten these
5//! types. Behavior-contract tests below lock the shared meaning so the
6//! surfaces cannot drift.
7
8use std::path::{Path, PathBuf};
9
10use clap::{Args, ValueEnum};
11
12/// `--path` / `--symbol` / `--line` code scope.
13///
14/// `--path` names a file. `--symbol` and `--line` further pin that file;
15/// either or both may be set, and both require `--path`.
16#[derive(Clone, Debug, Default, Args)]
17pub struct CodeScopeArgs {
18    /// File path to act on.
19    #[arg(long)]
20    pub path: Option<String>,
21
22    /// Anchor symbol. Requires `--path`.
23    #[arg(long, requires = "path")]
24    pub symbol: Option<String>,
25
26    /// Anchor line (1-indexed). Requires `--path`. May combine with `--symbol`.
27    #[arg(long, requires = "path")]
28    pub line: Option<u32>,
29}
30
31impl CodeScopeArgs {
32    /// True when any scope flag is set.
33    pub fn is_set(&self) -> bool {
34        self.path.is_some() || self.symbol.is_some() || self.line.is_some()
35    }
36}
37
38/// Historical revision selector. Always `--state`.
39///
40/// Accepts short or full state IDs, marker names, `HEAD`, `@`, or `HEAD~N`.
41/// Combined with `--path`, this is the revision to read; without `--path`,
42/// it is the state-level target.
43#[derive(Clone, Debug, Default, Args)]
44pub struct HistoricalRevisionArgs {
45    /// Historical revision or state-level target.
46    #[arg(long)]
47    pub state: Option<String>,
48}
49
50impl HistoricalRevisionArgs {
51    pub fn as_deref(&self) -> Option<&str> {
52        self.state.as_deref()
53    }
54}
55
56/// Split `--path` / `--state` into a file target, a state target, and a
57/// historical revision to read.
58///
59/// `--path` always names a file. `--state` with `--path` is the historical
60/// revision of that file. `--state` alone is a state-level target.
61pub fn split_path_and_revision<'a>(
62    path: Option<&'a str>,
63    state: Option<&'a str>,
64) -> (Option<&'a str>, Option<&'a str>, Option<&'a str>) {
65    match (path, state) {
66        (Some(path), state) => (Some(path), None, state),
67        (None, Some(state)) => (None, Some(state), None),
68        (None, None) => (None, None, None),
69    }
70}
71
72/// `--remote` flag with no injected default.
73///
74/// Resolution order (see [`RemoteChoiceArgs::requested`]): explicit flag,
75/// else the configured default from `heddle remote set-default`, else an
76/// actionable error. Never injects `origin`.
77#[derive(Clone, Debug, Default, Args)]
78pub struct RemoteChoiceArgs {
79    /// Hosted remote name. Omit to use `heddle remote set-default`.
80    #[arg(long)]
81    pub remote: Option<String>,
82}
83
84impl RemoteChoiceArgs {
85    /// Explicit `--remote` value, if the user passed one.
86    pub fn requested(&self) -> Option<&str> {
87        self.remote
88            .as_deref()
89            .map(str::trim)
90            .filter(|value| !value.is_empty())
91    }
92}
93
94/// Inline `--body` / `-m` or `--file` authored text.
95///
96/// One body-input convention across discuss/context writes. `--body` and
97/// `--file` conflict.
98#[derive(Clone, Debug, Default, Args)]
99pub struct AuthoredMessageArgs {
100    /// Inline body text.
101    #[arg(short = 'm', long = "body")]
102    pub body: Option<String>,
103
104    /// Read the body from a file.
105    #[arg(long, value_name = "PATH", conflicts_with = "body")]
106    pub file: Option<PathBuf>,
107}
108
109impl AuthoredMessageArgs {
110    pub fn is_set(&self) -> bool {
111        self.body.is_some() || self.file.is_some()
112    }
113}
114
115/// Hosted server choice shared by grant/promote/import/claim-style commands.
116#[derive(Clone, Debug, Default, Args)]
117pub struct HostedServerArgs {
118    /// Hosted Heddle server. Omit when the destination is a URL, or to use the configured default.
119    #[arg(long)]
120    pub server: Option<String>,
121}
122
123impl HostedServerArgs {
124    pub fn as_deref(&self) -> Option<&str> {
125        self.server.as_deref()
126    }
127}
128
129/// `--dry-run`: perform no mutation.
130#[derive(Clone, Debug, Default, Args)]
131pub struct DryRunArgs {
132    /// Perform no mutation.
133    #[arg(long)]
134    pub dry_run: bool,
135}
136
137impl DryRunArgs {
138    pub fn enabled(&self) -> bool {
139        self.dry_run
140    }
141}
142
143/// Explicit clone protocol. Failures are not retried on the other protocol.
144#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
145pub enum CloneSourceArg {
146    Git,
147    Heddle,
148}
149
150impl CloneSourceArg {
151    pub fn as_str(self) -> &'static str {
152        match self {
153            Self::Git => "git",
154            Self::Heddle => "heddle",
155        }
156    }
157}
158
159/// Derive a destination directory from a clone source when the user omitted one.
160///
161/// Returns `None` when the basename is missing or unsafe (`/`, `.`, `..`,
162/// empty, or still contains a path separator after stripping a trailing
163/// `.git`).
164pub fn safe_clone_destination_basename(remote: &str) -> Option<String> {
165    let path_part = if let Some(rest) = remote
166        .strip_prefix("https://")
167        .or_else(|| remote.strip_prefix("http://"))
168        .or_else(|| remote.strip_prefix("ssh://"))
169        .or_else(|| remote.strip_prefix("git://"))
170        .or_else(|| remote.strip_prefix("file://"))
171    {
172        let rest = rest.strip_prefix("git@").unwrap_or(rest);
173        match rest.split_once('/') {
174            Some((_, path)) if !path.is_empty() => path,
175            _ => return None,
176        }
177    } else {
178        match remote.find(':') {
179            Some(colon_pos) => {
180                let prefix = &remote[..colon_pos];
181                let rest = &remote[colon_pos + 1..];
182                let is_windows_drive = prefix.len() == 1
183                    && prefix
184                        .chars()
185                        .next()
186                        .is_some_and(|c| c.is_ascii_alphabetic())
187                    && (rest.starts_with('\\') || rest.starts_with('/'));
188                let prefix_has_separator = prefix.contains('/') || prefix.contains('\\');
189                if is_windows_drive || prefix_has_separator {
190                    remote
191                } else if rest.is_empty() {
192                    return None;
193                } else {
194                    rest
195                }
196            }
197            None => remote,
198        }
199    };
200    let is_sep = |c: char| c == '/' || c == '\\';
201    let segment = path_part
202        .trim_end_matches(is_sep)
203        .rsplit(is_sep)
204        .find(|part| !part.is_empty())
205        .unwrap_or("");
206    let name = segment.strip_suffix(".git").unwrap_or(segment);
207    if name.is_empty()
208        || name == "."
209        || name == ".."
210        || name == "~"
211        || name == "/"
212        || name.contains('/')
213        || name.contains('\\')
214        || name.contains('\0')
215        || Path::new(name).is_absolute()
216    {
217        return None;
218    }
219    Some(name.to_string())
220}
221
222#[cfg(test)]
223mod tests {
224    use clap::{CommandFactory, Parser};
225
226    use super::*;
227    use crate::cli::{
228        Cli, Commands, ContextCommands, DiscussCommands, ReviewCommands, ThreadCommands,
229    };
230
231    #[test]
232    fn remote_choice_has_no_origin_default() {
233        match Cli::try_parse_from(["heddle", "review", "approve", "feat"])
234            .expect("approve without --remote")
235            .command
236        {
237            Commands::Review {
238                command: ReviewCommands::Approve(args),
239            } => {
240                assert!(args.remote_choice.requested().is_none());
241            }
242            _ => panic!("expected review approve"),
243        }
244        match Cli::try_parse_from([
245            "heddle", "review", "approve", "feat", "--remote", "upstream",
246        ])
247        .expect("approve with --remote")
248        .command
249        {
250            Commands::Review {
251                command: ReviewCommands::Approve(args),
252            } => {
253                assert_eq!(args.remote_choice.requested(), Some("upstream"));
254            }
255            _ => panic!("expected review approve"),
256        }
257    }
258
259    #[test]
260    fn no_remote_flag_injects_origin() {
261        let cmd = Cli::command();
262        fn walk(command: &clap::Command) {
263            for arg in command.get_arguments() {
264                if arg.get_long() == Some("remote")
265                    && (arg.get_short().is_some() || arg.get_long().is_some())
266                    && !arg.is_positional()
267                {
268                    let defaults: Vec<_> = arg
269                        .get_default_values()
270                        .iter()
271                        .map(|v| v.to_string_lossy().into_owned())
272                        .collect();
273                    assert!(
274                        !defaults.iter().any(|v| v == "origin"),
275                        "`{} --remote` must not default to origin (got {defaults:?})",
276                        command.get_name()
277                    );
278                }
279            }
280            for sub in command.get_subcommands() {
281                walk(sub);
282            }
283        }
284        walk(&cmd);
285    }
286
287    #[test]
288    fn scope_and_state_mean_the_same_on_discuss_context_and_blame() {
289        match Cli::try_parse_from([
290            "heddle",
291            "discuss",
292            "new",
293            "--path",
294            "src/auth.rs",
295            "--symbol",
296            "verify",
297            "--line",
298            "12",
299            "--state",
300            "HEAD~1",
301            "--body",
302            "why?",
303        ])
304        .expect("discuss new scope")
305        .command
306        {
307            Commands::Discuss(args) => match args.command {
308                DiscussCommands::New(new_args) => {
309                    assert_eq!(new_args.scope.path.as_deref(), Some("src/auth.rs"));
310                    assert_eq!(new_args.scope.symbol.as_deref(), Some("verify"));
311                    assert_eq!(new_args.scope.line, Some(12));
312                    assert_eq!(new_args.revision.state.as_deref(), Some("HEAD~1"));
313                    assert_eq!(new_args.message.body.as_deref(), Some("why?"));
314                }
315                _ => panic!("expected discuss new"),
316            },
317            _ => panic!("expected discuss"),
318        }
319
320        match Cli::try_parse_from([
321            "heddle",
322            "context",
323            "get",
324            "--path",
325            "src/auth.rs",
326            "--symbol",
327            "verify",
328            "--line",
329            "12",
330            "--state",
331            "HEAD~1",
332        ])
333        .expect("context get scope")
334        .command
335        {
336            Commands::Context {
337                for_thread: None,
338                command: Some(ContextCommands::Get(args)),
339            } => {
340                assert_eq!(args.scope.path.as_deref(), Some("src/auth.rs"));
341                assert_eq!(args.scope.symbol.as_deref(), Some("verify"));
342                assert_eq!(args.scope.line, Some(12));
343                assert_eq!(args.revision.state.as_deref(), Some("HEAD~1"));
344                let (path, state_target, historical) = split_path_and_revision(
345                    args.scope.path.as_deref(),
346                    args.revision.state.as_deref(),
347                );
348                assert_eq!(path, Some("src/auth.rs"));
349                assert!(state_target.is_none());
350                assert_eq!(historical, Some("HEAD~1"));
351            }
352            _ => panic!("expected context get"),
353        }
354
355        match Cli::try_parse_from(["heddle", "blame", "src/auth.rs", "--state", "HEAD~1"])
356            .expect("blame --state")
357            .command
358        {
359            Commands::Blame(args) => {
360                assert_eq!(args.path, "src/auth.rs");
361                assert_eq!(args.revision.state.as_deref(), Some("HEAD~1"));
362            }
363            _ => panic!("expected blame"),
364        }
365
366        assert!(
367            Cli::try_parse_from([
368                "heddle",
369                "context",
370                "get",
371                "--path",
372                "src/auth.rs",
373                "--ref",
374                "HEAD",
375            ])
376            .is_err(),
377            "historical selector is --state, not --ref"
378        );
379    }
380
381    #[test]
382    fn dry_run_is_the_no_mutation_flag() {
383        match Cli::try_parse_from(["heddle", "undo", "--dry-run"])
384            .expect("undo --dry-run")
385            .command
386        {
387            Commands::Undo(args) => assert!(args.dry_run.enabled()),
388            _ => panic!("expected undo"),
389        }
390        match Cli::try_parse_from(["heddle", "thread", "absorb", "child", "--dry-run"])
391            .expect("absorb --dry-run")
392            .command
393        {
394            Commands::Thread {
395                command: ThreadCommands::Absorb(args),
396            } => assert!(args.dry_run.enabled()),
397            _ => panic!("expected absorb"),
398        }
399        assert!(
400            Cli::try_parse_from(["heddle", "undo", "--preview"]).is_err(),
401            "--preview is not a dry-run alias"
402        );
403        assert!(
404            Cli::try_parse_from(["heddle", "thread", "absorb", "child", "--preview"]).is_err(),
405            "--preview is not a dry-run alias"
406        );
407    }
408
409    #[test]
410    fn safe_clone_basename_is_unambiguous_or_none() {
411        assert_eq!(
412            safe_clone_destination_basename("https://host/acme/widgets.git").as_deref(),
413            Some("widgets")
414        );
415        assert_eq!(
416            safe_clone_destination_basename("git@host:acme/widgets.git").as_deref(),
417            Some("widgets")
418        );
419        assert!(safe_clone_destination_basename("https://host/").is_none());
420        assert!(safe_clone_destination_basename(".").is_none());
421        assert!(safe_clone_destination_basename("..").is_none());
422    }
423}