Skip to main content

soma_cli_core/
common_args.rs

1//! Common CLI flag-scanning primitives.
2//!
3//! Minimal, dependency-light helpers for hand-rolled argument parsers that
4//! do not want to pull in a full parser crate. Each helper is generic over
5//! the command name and flag being checked, so a consuming CLI supplies its
6//! own vocabulary (command names, flag spellings) and gets consistent error
7//! wording for free.
8//!
9//! These are not a parser framework — they scan an already-split `&[String]`
10//! slice for a small, fixed set of shapes (no value, one required/optional
11//! value, reject-everything). A product CLI composes them per-subcommand.
12
13use std::error::Error;
14use std::fmt;
15
16/// An error produced while scanning CLI arguments.
17///
18/// Carries a fully-formatted, human-readable message. Implements
19/// [`std::error::Error`] so it converts into `anyhow::Error` (or any other
20/// `Error`-based error type) via `?` without an explicit `map_err`.
21///
22/// The message field is private so every `ArgParseError` is built through
23/// this module's formatting helper, keeping wording consistent — construct
24/// one only via the functions below, and read the message back with
25/// [`ArgParseError::message`].
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct ArgParseError(String);
28
29impl ArgParseError {
30    /// The formatted, human-readable message.
31    pub fn message(&self) -> &str {
32        &self.0
33    }
34}
35
36impl fmt::Display for ArgParseError {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        f.write_str(&self.0)
39    }
40}
41
42impl Error for ArgParseError {}
43
44fn err(message: impl Into<String>) -> ArgParseError {
45    ArgParseError(message.into())
46}
47
48/// Reject a subcommand that takes no arguments at all.
49pub fn reject_args(args: &[String], command: &str) -> Result<(), ArgParseError> {
50    if args.is_empty() {
51        Ok(())
52    } else {
53        Err(err(format!(
54            "{command} does not accept argument `{}`",
55            args[0]
56        )))
57    }
58}
59
60/// Scan for a single boolean flag (present or absent), rejecting anything
61/// else including duplicates.
62pub fn parse_bool_flag(args: &[String], command: &str, flag: &str) -> Result<bool, ArgParseError> {
63    let mut found = false;
64    for arg in args {
65        if arg == flag {
66            if found {
67                return Err(err(format!("{command} received duplicate {flag}")));
68            }
69            found = true;
70        } else {
71            return Err(err(format!("{command} does not accept argument `{arg}`")));
72        }
73    }
74    Ok(found)
75}
76
77/// Scan for an optional `--flag value` pair. Returns `Ok(None)` if the flag
78/// is absent, `Ok(Some(value))` if present with a value, and an error for
79/// duplicates, missing values, or unexpected trailing arguments.
80pub fn parse_optional_value_flag(
81    args: &[String],
82    command: &str,
83    flag: &str,
84) -> Result<Option<String>, ArgParseError> {
85    match args {
86        [] => Ok(None),
87        [found_flag, value] if found_flag == flag => {
88            if value.starts_with("--") {
89                Err(err(format!("{command} requires a value after {flag}")))
90            } else {
91                Ok(Some(value.clone()))
92            }
93        }
94        [found_flag] if found_flag == flag => {
95            Err(err(format!("{command} requires a value after {flag}")))
96        }
97        [found_flag, value, rest @ ..] if found_flag == flag => {
98            if value.starts_with("--") {
99                Err(err(format!("{command} requires a value after {flag}")))
100            } else if rest.iter().any(|arg| arg == flag) {
101                Err(err(format!("{command} received duplicate {flag}")))
102            } else {
103                Err(err(format!(
104                    "{command} does not accept argument `{}`",
105                    rest[0]
106                )))
107            }
108        }
109        [unexpected, ..] => Err(err(format!(
110            "{command} does not accept argument `{unexpected}`"
111        ))),
112    }
113}
114
115/// Scan for a `--flag value` pair the caller treats as required.
116///
117/// This has the same scanning behavior as [`parse_optional_value_flag`] —
118/// "required" is enforced by the caller inspecting the `None` case, keeping
119/// this helper's error wording identical for both required and optional
120/// call sites.
121pub fn parse_required_value_flag(
122    args: &[String],
123    command: &str,
124    flag: &str,
125) -> Result<Option<String>, ArgParseError> {
126    parse_optional_value_flag(args, command, flag)
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    fn v(items: &[&str]) -> Vec<String> {
134        items.iter().map(|s| s.to_string()).collect()
135    }
136
137    #[test]
138    fn reject_args_accepts_empty() {
139        reject_args(&[], "status").unwrap();
140    }
141
142    #[test]
143    fn reject_args_rejects_extra() {
144        let err = reject_args(&v(&["bogus"]), "status").unwrap_err();
145        assert!(err.to_string().contains("status does not accept argument"));
146    }
147
148    #[test]
149    fn bool_flag_found_and_absent() {
150        assert!(parse_bool_flag(&v(&["--json"]), "doctor", "--json").unwrap());
151        assert!(!parse_bool_flag(&[], "doctor", "--json").unwrap());
152    }
153
154    #[test]
155    fn bool_flag_rejects_duplicate() {
156        let err = parse_bool_flag(&v(&["--json", "--json"]), "doctor", "--json").unwrap_err();
157        assert!(err.to_string().contains("duplicate"));
158    }
159
160    #[test]
161    fn optional_value_flag_round_trips() {
162        assert_eq!(
163            parse_optional_value_flag(&v(&["--name", "Alice"]), "greet", "--name").unwrap(),
164            Some("Alice".to_string())
165        );
166        assert_eq!(
167            parse_optional_value_flag(&[], "greet", "--name").unwrap(),
168            None
169        );
170    }
171
172    #[test]
173    fn optional_value_flag_requires_value() {
174        let err = parse_optional_value_flag(&v(&["--name"]), "greet", "--name").unwrap_err();
175        assert!(err.to_string().contains("requires a value"));
176    }
177
178    #[test]
179    fn optional_value_flag_rejects_duplicate() {
180        let err =
181            parse_optional_value_flag(&v(&["--name", "Alice", "--name", "Bob"]), "greet", "--name")
182                .unwrap_err();
183        assert!(err.to_string().contains("duplicate --name"));
184    }
185}