Skip to main content

relay_knowledge/interfaces/cli/command/
parse.rs

1use super::super::{
2    CliAction, CliCommand, CliError, OutputFormat, files, grammar, knowledge, map, operations,
3    repo, repo_set, setup,
4};
5
6#[cfg(test)]
7#[path = "parse_tests.rs"]
8mod tests;
9
10impl OutputFormat {
11    /// Parses a CLI output format value.
12    pub fn parse(value: &str) -> Result<Self, CliError> {
13        match value {
14            "text" => Ok(Self::Text),
15            "json" => Ok(Self::Json),
16            "markdown" => Ok(Self::Markdown),
17            "streaming-json" => Ok(Self::StreamingJson),
18            other => Err(CliError::InvalidFormat(other.to_owned())),
19        }
20    }
21}
22
23impl CliCommand {
24    /// Parses the CLI arguments after the binary name.
25    pub fn parse<I, S>(args: I) -> Result<Self, CliError>
26    where
27        I: IntoIterator<Item = S>,
28        S: Into<String>,
29    {
30        let tokens = args.into_iter().map(Into::into).collect::<Vec<_>>();
31        let mut action_tokens = Vec::new();
32        let mut format = OutputFormat::default();
33        let mut remote_base_url = None;
34        let mut help = false;
35        let mut version = false;
36        let mut command_seen = false;
37        let mut delimiter_value = false;
38        let mut index = 0;
39
40        while index < tokens.len() {
41            let arg = &tokens[index];
42            if delimiter_value {
43                action_tokens.push(arg.clone());
44                delimiter_value = false;
45                index += 1;
46            } else if arg == "--format" {
47                let value = tokens
48                    .get(index + 1)
49                    .ok_or(CliError::MissingFormatValue)?
50                    .clone();
51                format = OutputFormat::parse(&value)?;
52                index += 2;
53            } else if let Some(value) = arg.strip_prefix("--format=") {
54                format = OutputFormat::parse(value)?;
55                index += 1;
56            } else if arg == "--remote" {
57                remote_base_url = Some(
58                    tokens
59                        .get(index + 1)
60                        .ok_or(CliError::MissingValue("--remote"))?
61                        .clone(),
62                );
63                index += 2;
64            } else if let Some(value) = arg.strip_prefix("--remote=") {
65                if value.trim().is_empty() {
66                    return Err(CliError::MissingValue("--remote"));
67                }
68                remote_base_url = Some(value.to_owned());
69                index += 1;
70            } else if arg == "--help" || arg == "-h" {
71                help = true;
72                index += 1;
73            } else if arg == "--version" && !command_seen {
74                version = true;
75                index += 1;
76            } else if arg == "--" {
77                action_tokens.push(arg.clone());
78                delimiter_value = true;
79                index += 1;
80            } else if option_consumes_value(arg) {
81                action_tokens.push(arg.clone());
82                if let Some(value) = tokens.get(index + 1) {
83                    action_tokens.push(value.clone());
84                    index += 2;
85                } else {
86                    index += 1;
87                }
88            } else {
89                command_seen |= is_command_word(arg);
90                action_tokens.push(arg.clone());
91                index += 1;
92            }
93        }
94
95        let action = if help {
96            CliAction::Help {
97                path: help_path(action_tokens),
98            }
99        } else if version {
100            if let Some(token) = action_tokens.first() {
101                let error = CliError::UnexpectedArgument(token.clone());
102                return Err(grammar::diagnose(&action_tokens, error, format));
103            }
104            CliAction::Version
105        } else {
106            match parse_action(action_tokens.clone()) {
107                Ok(action) => action,
108                Err(error) => return Err(grammar::diagnose(&action_tokens, error, format)),
109            }
110        };
111
112        Ok(Self {
113            action,
114            format,
115            remote_base_url,
116            help,
117        })
118    }
119}
120
121fn option_consumes_value(option: &str) -> bool {
122    matches!(
123        option,
124        "--source"
125            | "--content"
126            | "--entity"
127            | "--limit"
128            | "--freshness"
129            | "--kind"
130            | "--alias"
131            | "--path"
132            | "--language"
133            | "--ref"
134            | "--base"
135            | "--head"
136            | "--changed-path"
137            | "--query"
138            | "--description"
139            | "--id"
140            | "--priority"
141            | "--mcp"
142            | "--state"
143            | "--by"
144            | "--reason"
145            | "--operation"
146            | "--task-id"
147            | "--input"
148            | "--root"
149            | "--scope"
150            | "--topic"
151            | "--uri"
152            | "--target-version"
153            | "--install-dir"
154    )
155}
156
157fn is_command_word(token: &str) -> bool {
158    matches!(
159        token,
160        "status"
161            | "ingest"
162            | "query"
163            | "repo"
164            | "repo-set"
165            | "files"
166            | "map"
167            | "graph"
168            | "index"
169            | "worker"
170            | "proposal"
171            | "audit"
172            | "provider"
173            | "health"
174            | "service"
175            | "setup"
176            | "version"
177            | "help"
178    )
179}
180
181fn parse_action(tokens: Vec<String>) -> Result<CliAction, CliError> {
182    if tokens.is_empty() || tokens == ["status"] {
183        return Ok(CliAction::Status);
184    }
185
186    match tokens[0].as_str() {
187        "status" => Err(CliError::UnexpectedArgument(
188            tokens
189                .get(1)
190                .cloned()
191                .unwrap_or_else(|| "status".to_owned()),
192        )),
193        "ingest" => knowledge::parse_ingest(&tokens[1..]),
194        "query" => knowledge::parse_query(&tokens[1..]),
195        "files" => files::parse_files(&tokens[1..]),
196        "map" => map::parse_map(&tokens[1..]),
197        "repo" => repo::parse_repo(&tokens[1..]).map(CliAction::Repo),
198        "repo-set" => repo_set::parse_repo_set(&tokens[1..]).map(CliAction::RepoSet),
199        "graph" => knowledge::parse_graph(&tokens[1..]),
200        "index" => knowledge::parse_index(&tokens[1..]),
201        "worker" => operations::parse_worker(&tokens[1..]),
202        "proposal" => operations::parse_proposal(&tokens[1..]),
203        "audit" => operations::parse_audit(&tokens[1..]),
204        "provider" => parse_provider(&tokens[1..]),
205        "health" if tokens.len() == 1 => Ok(CliAction::Health),
206        "service" => operations::parse_service(&tokens[1..]),
207        "setup" => setup::parse_setup(&tokens[1..]),
208        "version" if tokens.len() == 1 => Ok(CliAction::Version),
209        "version" if tokens == ["version", "check"] => Ok(CliAction::VersionCheck),
210        "help" => Ok(CliAction::Help {
211            path: help_path(tokens[1..].to_vec()),
212        }),
213        other => Err(CliError::UnexpectedArgument(other.to_owned())),
214    }
215}
216
217fn help_path(tokens: Vec<String>) -> Vec<String> {
218    tokens
219        .into_iter()
220        .filter(|token| token != "--")
221        .filter(|token| !token.starts_with('-'))
222        .collect()
223}
224
225fn parse_provider(tokens: &[String]) -> Result<CliAction, CliError> {
226    if tokens == ["probe"] {
227        return Ok(CliAction::ProviderProbe);
228    }
229
230    Err(CliError::UnexpectedArgument(
231        tokens
232            .first()
233            .cloned()
234            .unwrap_or_else(|| "provider".to_owned()),
235    ))
236}