Skip to main content

agentic_tools_utils/
cli.rs

1//! CLI and environment parsing helpers.
2//!
3//! This module provides utilities for parsing environment variables
4//! and CLI inputs in consistent ways.
5
6use anyhow::Result;
7use anyhow::anyhow;
8use std::collections::BTreeSet;
9
10/// Parse a comma/whitespace separated string into a lowercase-trimmed set.
11///
12/// # Example
13///
14/// ```
15/// use agentic_tools_utils::cli::parse_comma_set;
16///
17/// let set = parse_comma_set("foo, BAR, baz");
18/// assert!(set.contains("foo"));
19/// assert!(set.contains("bar"));
20/// assert!(set.contains("baz"));
21/// assert_eq!(set.len(), 3);
22/// ```
23pub fn parse_comma_set(input: &str) -> BTreeSet<String> {
24    input
25        .split(|c: char| c == ',' || c.is_whitespace())
26        .filter(|s| !s.trim().is_empty())
27        .map(|s| s.trim().to_lowercase())
28        .collect()
29}
30
31/// Read a boolean from an environment variable.
32///
33/// Accepted truthy values: `1`, `true`, `yes`, `on`
34/// Accepted falsy values: `0`, `false`, `no`, `off`
35/// Any other value or unset variable returns the default.
36///
37/// # Example
38///
39/// ```
40/// use agentic_tools_utils::cli::bool_from_env;
41///
42/// // Returns default when var is not set
43/// let value = bool_from_env("NONEXISTENT_VAR_12345", true);
44/// assert!(value);
45/// ```
46pub fn bool_from_env(var: &str, default: bool) -> bool {
47    match std::env::var(var) {
48        Ok(v) => match v.trim().to_ascii_lowercase().as_str() {
49            "1" | "true" | "yes" | "on" => true,
50            "0" | "false" | "no" | "off" => false,
51            _ => default,
52        },
53        Err(_) => default,
54    }
55}
56
57/// Read a usize from an environment variable.
58///
59/// Returns the default if the variable is not set or cannot be parsed.
60///
61/// # Example
62///
63/// ```
64/// use agentic_tools_utils::cli::usize_from_env;
65///
66/// // Returns default when var is not set
67/// let value = usize_from_env("NONEXISTENT_VAR_12345", 100);
68/// assert_eq!(value, 100);
69/// ```
70pub fn usize_from_env(var: &str, default: usize) -> usize {
71    std::env::var(var)
72        .ok()
73        .and_then(|v| v.trim().parse::<usize>().ok())
74        .unwrap_or(default)
75}
76
77/// Return `Option<BTreeSet>` if the environment variable is present and non-empty.
78///
79/// # Example
80///
81/// ```
82/// use agentic_tools_utils::cli::set_from_env;
83///
84/// // Returns None when var is not set
85/// let value = set_from_env("NONEXISTENT_VAR_12345");
86/// assert!(value.is_none());
87/// ```
88pub fn set_from_env(var: &str) -> Option<BTreeSet<String>> {
89    std::env::var(var)
90        .ok()
91        .map(|s| parse_comma_set(&s))
92        .filter(|s| !s.is_empty())
93}
94
95/// Parsed editor command with program and arguments.
96///
97/// Supports editors with arguments like `code --wait` or `nvim -u NONE`.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct Argv {
100    /// The original raw value (for error messages).
101    pub raw: String,
102    /// The program/binary name.
103    pub program: String,
104    /// Additional arguments to pass before the file path.
105    pub args: Vec<String>,
106}
107
108/// Get the editor command from `$VISUAL` or `$EDITOR`, parsed into program and args.
109///
110/// Precedence (Unix convention):
111/// 1. `$VISUAL` (if set and non-empty)
112/// 2. `$EDITOR` (if set and non-empty)
113/// 3. Falls back to `vi`
114///
115/// Supports editors with arguments like `code --wait` or `nvim -u NONE`.
116///
117/// # Errors
118///
119/// Returns an error if the environment variable value cannot be parsed as shell words
120/// (e.g., unbalanced quotes).
121///
122/// # Example
123///
124/// ```
125/// use agentic_tools_utils::cli::editor_argv;
126///
127/// // With no VISUAL/EDITOR set, returns "vi"
128/// std::env::remove_var("VISUAL");
129/// std::env::remove_var("EDITOR");
130/// let argv = editor_argv().unwrap();
131/// assert_eq!(argv.program, "vi");
132/// assert!(argv.args.is_empty());
133/// ```
134pub fn editor_argv() -> Result<Argv> {
135    let visual = std::env::var("VISUAL").ok();
136    let editor = std::env::var("EDITOR").ok();
137
138    let raw = visual
139        .as_deref()
140        .map(str::trim)
141        .filter(|s| !s.is_empty())
142        .or_else(|| editor.as_deref().map(str::trim).filter(|s| !s.is_empty()))
143        .unwrap_or("vi")
144        .to_string();
145
146    let parts =
147        shlex::split(&raw).ok_or_else(|| anyhow!("Invalid $VISUAL/$EDITOR value: {raw}"))?;
148    let (program, args) = parts
149        .split_first()
150        .ok_or_else(|| anyhow!("Empty $VISUAL/$EDITOR value"))?;
151
152    Ok(Argv {
153        raw,
154        program: program.clone(),
155        args: args.to_vec(),
156    })
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn parse_comma_set_basic() {
165        let set = parse_comma_set("foo,bar,baz");
166        assert_eq!(set.len(), 3);
167        assert!(set.contains("foo"));
168        assert!(set.contains("bar"));
169        assert!(set.contains("baz"));
170    }
171
172    #[test]
173    fn parse_comma_set_with_spaces() {
174        let set = parse_comma_set("foo, bar , baz");
175        assert_eq!(set.len(), 3);
176        assert!(set.contains("foo"));
177        assert!(set.contains("bar"));
178        assert!(set.contains("baz"));
179    }
180
181    #[test]
182    fn parse_comma_set_whitespace_separated() {
183        let set = parse_comma_set("foo bar baz");
184        assert_eq!(set.len(), 3);
185    }
186
187    #[test]
188    fn parse_comma_set_mixed_separators() {
189        let set = parse_comma_set("foo, bar baz");
190        assert_eq!(set.len(), 3);
191    }
192
193    #[test]
194    fn parse_comma_set_lowercases() {
195        let set = parse_comma_set("FOO, Bar, BAZ");
196        assert!(set.contains("foo"));
197        assert!(set.contains("bar"));
198        assert!(set.contains("baz"));
199        assert!(!set.contains("FOO"));
200    }
201
202    #[test]
203    fn parse_comma_set_empty() {
204        let set = parse_comma_set("");
205        assert!(set.is_empty());
206    }
207
208    #[test]
209    fn parse_comma_set_only_separators() {
210        let set = parse_comma_set(", , , ");
211        assert!(set.is_empty());
212    }
213
214    #[test]
215    fn parse_comma_set_duplicates_deduplicated() {
216        let set = parse_comma_set("foo, FOO, Foo");
217        assert_eq!(set.len(), 1);
218        assert!(set.contains("foo"));
219    }
220
221    #[test]
222    fn bool_from_env_returns_default_when_unset() {
223        // Use a var name that definitely doesn't exist
224        let result = bool_from_env("__AGENTIC_TEST_NONEXISTENT_VAR__", true);
225        assert!(result);
226
227        let result = bool_from_env("__AGENTIC_TEST_NONEXISTENT_VAR__", false);
228        assert!(!result);
229    }
230
231    #[test]
232    fn usize_from_env_returns_default_when_unset() {
233        let result = usize_from_env("__AGENTIC_TEST_NONEXISTENT_VAR__", 42);
234        assert_eq!(result, 42);
235    }
236
237    #[test]
238    fn set_from_env_returns_none_when_unset() {
239        let result = set_from_env("__AGENTIC_TEST_NONEXISTENT_VAR__");
240        assert!(result.is_none());
241    }
242
243    // editor_argv tests use a helper that doesn't touch the actual env vars
244    // to avoid needing #[serial] for tests that don't actually call editor_argv()
245
246    fn argv_from(visual: Option<&str>, editor: Option<&str>) -> super::Result<Argv> {
247        let raw = visual
248            .map(str::trim)
249            .filter(|s| !s.is_empty())
250            .or_else(|| editor.map(str::trim).filter(|s| !s.is_empty()))
251            .unwrap_or("vi")
252            .to_string();
253        let parts = shlex::split(&raw).ok_or_else(|| anyhow::anyhow!("Invalid value: {raw}"))?;
254        let (program, args) = parts
255            .split_first()
256            .ok_or_else(|| anyhow::anyhow!("Empty value"))?;
257        Ok(Argv {
258            raw,
259            program: program.clone(),
260            args: args.to_vec(),
261        })
262    }
263
264    #[test]
265    fn test_editor_code_wait() {
266        let argv = argv_from(None, Some("code --wait")).unwrap();
267        assert_eq!(argv.program, "code");
268        assert_eq!(argv.args, vec!["--wait"]);
269    }
270
271    #[test]
272    fn test_editor_visual_precedence() {
273        let argv = argv_from(Some("nvim"), Some("vim")).unwrap();
274        assert_eq!(argv.program, "nvim");
275    }
276
277    #[test]
278    fn test_editor_whitespace_fallback() {
279        let argv = argv_from(Some("  "), Some("  ")).unwrap();
280        assert_eq!(argv.program, "vi");
281    }
282
283    #[test]
284    fn test_editor_quoted_args() {
285        let argv = argv_from(None, Some(r#"nvim -c "set number""#)).unwrap();
286        assert_eq!(argv.program, "nvim");
287        assert_eq!(argv.args, vec!["-c", "set number"]);
288    }
289
290    #[test]
291    fn test_editor_multiple_args() {
292        let argv = argv_from(None, Some("code --wait --new-window")).unwrap();
293        assert_eq!(argv.program, "code");
294        assert_eq!(argv.args, vec!["--wait", "--new-window"]);
295    }
296
297    #[test]
298    fn test_editor_default_vi() {
299        let argv = argv_from(None, None).unwrap();
300        assert_eq!(argv.program, "vi");
301        assert!(argv.args.is_empty());
302    }
303}