Skip to main content

lean_ctx/core/
workspace_args.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Argument grammar of `lean-ctx workspace …` (#2120), shared by the command
3//! and by the agent-shell allowlist, so both judge the same verb.
4
5/// One parsed `lean-ctx workspace` invocation.
6pub struct Parsed {
7    pub verb: String,
8    pub positional: Option<String>,
9    pub file: Option<String>,
10    pub id: Option<String>,
11    pub expect_revision: Option<String>,
12    pub read_only: bool,
13    pub json: bool,
14}
15
16/// Parse the arguments after `lean-ctx workspace`.
17pub fn parse(args: &[String]) -> Result<Parsed, String> {
18    let mut parsed = Parsed {
19        verb: String::new(),
20        positional: None,
21        file: None,
22        id: None,
23        expect_revision: None,
24        read_only: false,
25        json: false,
26    };
27    let mut words = parse_into(args, &mut parsed)?.into_iter();
28    parsed.verb = words.next().ok_or("a command is required")?;
29    parsed.positional = words.next();
30    if let Some(extra) = words.next() {
31        return Err(format!("unexpected argument: {extra}"));
32    }
33    Ok(parsed)
34}
35
36/// Fill `parsed` from flags (`--flag value` or `--flag=value`); returns the
37/// remaining words in order.
38fn parse_into(args: &[String], parsed: &mut Parsed) -> Result<Vec<String>, String> {
39    let mut words = Vec::new();
40    let mut rest = args.iter();
41    while let Some(arg) = rest.next() {
42        match arg.as_str() {
43            "--json" => parsed.json = true,
44            "--read-only" => parsed.read_only = true,
45            "--file" | "--id" | "--expect-revision" => {
46                let value = rest
47                    .next()
48                    .ok_or_else(|| format!("{arg} needs a value"))?
49                    .clone();
50                let slot = match arg.as_str() {
51                    "--file" => &mut parsed.file,
52                    "--id" => &mut parsed.id,
53                    _ => &mut parsed.expect_revision,
54                };
55                if slot.replace(value).is_some() {
56                    return Err(format!("{arg} is given twice"));
57                }
58            }
59            flag if flag.starts_with("--") => match flag.split_once('=') {
60                Some((name, _)) if matches!(name, "--json" | "--read-only") => {
61                    return Err(format!("{name} takes no value"));
62                }
63                Some((name, value)) => {
64                    let pair = [name.to_string(), value.to_string()];
65                    words.extend(parse_into(&pair, parsed)?);
66                }
67                None => return Err(format!("unknown argument: {flag}")),
68            },
69            _ => words.push(arg.clone()),
70        }
71    }
72    Ok(words)
73}
74
75/// Whether an agent shell may run `lean-ctx workspace <args>`: only forms
76/// that read or narrow (`show`, `diagnose`, `remove`, help). Anything that
77/// does not parse is refused — the verb must not hide behind a flag.
78#[doc(hidden)]
79pub fn agent_shell_may_run(args: &[String]) -> bool {
80    if args
81        .first()
82        .is_none_or(|first| matches!(first.as_str(), "-h" | "--help" | "help"))
83    {
84        return true;
85    }
86    parse(args)
87        .is_ok_and(|parsed| matches!(parsed.verb.as_str(), "show" | "diagnose" | "remove" | "rm"))
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    fn words(text: &str) -> Vec<String> {
95        text.split_whitespace().map(str::to_string).collect()
96    }
97
98    #[test]
99    fn agent_shells_may_only_read_or_narrow() {
100        for allowed in [
101            "",
102            "--help",
103            "show",
104            "diagnose --json",
105            "remove --file /w /x",
106        ] {
107            assert!(agent_shell_may_run(&words(allowed)), "{allowed}");
108        }
109        for refused in [
110            "add /etc",
111            "--file /w.json --expect-revision 3 add /etc",
112            "--json add /etc",
113            "--read-only add /etc",
114            "--json=x add /etc",
115            "create --id x /code",
116            "--id x create /code",
117            "frobnicate",
118        ] {
119            assert!(!agent_shell_may_run(&words(refused)), "{refused}");
120        }
121    }
122}