Skip to main content

relay_knowledge/interfaces/
cli.rs

1//! CLI adapter for the shared application service.
2
3#[path = "cli_grammar.rs"]
4mod cli_grammar;
5#[path = "cli_render.rs"]
6mod cli_render;
7#[path = "cli_spec.rs"]
8mod cli_spec;
9#[path = "files_cli.rs"]
10mod files_cli;
11#[path = "knowledge_cli.rs"]
12mod knowledge_cli;
13#[path = "map_cli.rs"]
14mod map_cli;
15#[path = "ops_cli.rs"]
16mod ops_cli;
17#[path = "repo_cli.rs"]
18mod repo_cli;
19#[path = "repo_set_cli.rs"]
20mod repo_set_cli;
21#[path = "service_cli.rs"]
22mod service_cli;
23#[path = "setup_cli.rs"]
24mod setup_cli;
25#[path = "version_cli.rs"]
26mod version_cli;
27
28use std::{error::Error, fmt};
29
30use crate::{
31    api::{
32        ApiError, GraphInspectionRequest, HybridRetrievalRequest, IndexRefreshRequest,
33        IngestEvidence, IngestRequest, InterfaceKind, RequestContext,
34    },
35    application::RelayKnowledgeService,
36    domain::{FreshnessPolicy, IndexKind, ProposalState, ServiceManagerAction, WorkerKind},
37};
38
39use cli_render::{render_project_status, render_response, serialize_line};
40
41/// Supported CLI output formats.
42#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
43pub enum OutputFormat {
44    #[default]
45    Text,
46    Json,
47    Markdown,
48    StreamingJson,
49}
50
51impl OutputFormat {
52    fn as_str(self) -> &'static str {
53        match self {
54            Self::Text => "text",
55            Self::Json => "json",
56            Self::Markdown => "markdown",
57            Self::StreamingJson => "streaming-json",
58        }
59    }
60
61    fn is_machine_readable(self) -> bool {
62        matches!(self, Self::Json | Self::StreamingJson)
63    }
64
65    /// Parses a CLI output format value.
66    pub fn parse(value: &str) -> Result<Self, CliError> {
67        match value {
68            "text" => Ok(Self::Text),
69            "json" => Ok(Self::Json),
70            "markdown" => Ok(Self::Markdown),
71            "streaming-json" => Ok(Self::StreamingJson),
72            other => Err(CliError::invalid_format(other)),
73        }
74    }
75}
76
77/// Parsed CLI command.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct CliCommand {
80    pub action: CliAction,
81    pub format: OutputFormat,
82    pub help: bool,
83}
84
85impl CliCommand {
86    /// Parses the CLI arguments after the binary name.
87    pub fn parse<I, S>(args: I) -> Result<Self, CliError>
88    where
89        I: IntoIterator<Item = S>,
90        S: Into<String>,
91    {
92        let tokens = args.into_iter().map(Into::into).collect::<Vec<_>>();
93        let mut action_tokens = Vec::new();
94        let mut format = OutputFormat::default();
95        let mut help = false;
96        let mut version = false;
97        let mut command_seen = false;
98        let mut delimiter_value = false;
99        let mut index = 0;
100
101        while index < tokens.len() {
102            let arg = &tokens[index];
103            if delimiter_value {
104                action_tokens.push(arg.clone());
105                delimiter_value = false;
106                index += 1;
107            } else if arg == "--format" {
108                let value = tokens
109                    .get(index + 1)
110                    .ok_or(CliError::MissingFormatValue)?
111                    .clone();
112                format = OutputFormat::parse(&value)?;
113                index += 2;
114            } else if let Some(value) = arg.strip_prefix("--format=") {
115                format = OutputFormat::parse(value)?;
116                index += 1;
117            } else if arg == "--help" || arg == "-h" {
118                help = true;
119                index += 1;
120            } else if arg == "--version" && !command_seen {
121                version = true;
122                index += 1;
123            } else if arg == "--" {
124                action_tokens.push(arg.clone());
125                delimiter_value = true;
126                index += 1;
127            } else if option_consumes_value(arg) {
128                action_tokens.push(arg.clone());
129                if let Some(value) = tokens.get(index + 1) {
130                    action_tokens.push(value.clone());
131                    index += 2;
132                } else {
133                    index += 1;
134                }
135            } else {
136                command_seen |= is_command_word(arg);
137                action_tokens.push(arg.clone());
138                index += 1;
139            }
140        }
141
142        let action = if help {
143            CliAction::Help {
144                path: help_path(action_tokens),
145            }
146        } else if version {
147            if let Some(token) = action_tokens.first() {
148                let error = CliError::UnexpectedArgument(token.clone());
149                return Err(cli_grammar::diagnose(&action_tokens, error, format));
150            }
151            CliAction::Version
152        } else {
153            match parse_action(action_tokens.clone()) {
154                Ok(action) => action,
155                Err(error) => return Err(cli_grammar::diagnose(&action_tokens, error, format)),
156            }
157        };
158
159        Ok(Self {
160            action,
161            format,
162            help,
163        })
164    }
165}
166
167fn option_consumes_value(option: &str) -> bool {
168    matches!(
169        option,
170        "--source"
171            | "--content"
172            | "--entity"
173            | "--limit"
174            | "--freshness"
175            | "--kind"
176            | "--alias"
177            | "--path"
178            | "--language"
179            | "--ref"
180            | "--base"
181            | "--head"
182            | "--query"
183            | "--description"
184            | "--id"
185            | "--priority"
186            | "--mcp"
187            | "--state"
188            | "--by"
189            | "--reason"
190            | "--operation"
191            | "--input"
192            | "--root"
193            | "--scope"
194            | "--topic"
195            | "--uri"
196    )
197}
198
199fn is_command_word(token: &str) -> bool {
200    matches!(
201        token,
202        "status"
203            | "ingest"
204            | "query"
205            | "repo"
206            | "repo-set"
207            | "files"
208            | "map"
209            | "graph"
210            | "index"
211            | "worker"
212            | "proposal"
213            | "audit"
214            | "provider"
215            | "health"
216            | "service"
217            | "setup"
218            | "version"
219            | "help"
220    )
221}
222
223/// CLI action after global options are removed.
224#[derive(Debug, Clone, PartialEq, Eq)]
225pub enum CliAction {
226    Status,
227    Ingest {
228        source_scope: String,
229        content: String,
230        entity_labels: Vec<String>,
231    },
232    Query {
233        query: String,
234        source_scope: Option<String>,
235        limit: usize,
236        freshness: FreshnessPolicy,
237    },
238    FilesIndex {
239        source_scope: Option<String>,
240        roots: Vec<String>,
241    },
242    FilesQuery {
243        query: String,
244        source_scope: Option<String>,
245        root_id: Option<String>,
246        limit: usize,
247    },
248    GraphInspect,
249    IndexRefresh {
250        kinds: Vec<IndexKind>,
251    },
252    Map(map_cli::MapCommand),
253    WorkerStatus {
254        kind: Option<WorkerKind>,
255    },
256    WorkerRunOnce {
257        kind: Option<WorkerKind>,
258    },
259    ProposalList {
260        state: Option<ProposalState>,
261        limit: usize,
262    },
263    ProposalShow {
264        proposal_id: String,
265    },
266    ProposalAccept {
267        proposal_id: String,
268        actor: String,
269        reason: Option<String>,
270    },
271    ProposalReject {
272        proposal_id: String,
273        actor: String,
274        reason: Option<String>,
275    },
276    ProposalSupersede {
277        proposal_id: String,
278        actor: String,
279        reason: Option<String>,
280    },
281    AuditQuery {
282        operation: Option<String>,
283        limit: usize,
284    },
285    ProviderProbe,
286    Repo(repo_cli::RepoCommand),
287    RepoSet(repo_set_cli::RepoSetCommand),
288    Health,
289    ServiceStatus,
290    ServicePlan {
291        action: ServiceManagerAction,
292    },
293    ServiceDefinitionWrite,
294    ServiceOperatorStatus,
295    ServiceOperatorPause,
296    ServiceOperatorResume,
297    ServiceRun {
298        mcp: ServiceMcpTransport,
299        web: bool,
300    },
301    SetupDoctor,
302    SetupProfile {
303        profile: setup_cli::SetupProfile,
304    },
305    Version,
306    VersionCheck,
307    Help {
308        path: Vec<String>,
309    },
310}
311
312/// MCP transport option for foreground service mode.
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314pub enum ServiceMcpTransport {
315    Configured,
316    StreamableHttp,
317}
318
319/// CLI adapter error.
320#[derive(Debug, Clone, PartialEq, Eq)]
321pub enum CliError {
322    Diagnostic(Box<CliDiagnostic>),
323    InvalidFormat(String),
324    InvalidCodeQueryKind(String),
325    InvalidSoftwareKind(String),
326    InvalidFreshness(String),
327    InvalidIndexKind(String),
328    InvalidMapSourceKind(String),
329    InvalidWorkerKind(String),
330    InvalidProposalState(String),
331    InvalidServiceAction(String),
332    InvalidLimit(String),
333    MissingFormatValue,
334    MissingValue(&'static str),
335    UnsupportedVersionFormat(OutputFormat),
336    UnknownHelpTopic(String),
337    UnexpectedArgument(String),
338    RuntimeConfigFailed(String),
339    ApiFailed(String),
340    ApiError {
341        error: Box<ApiError>,
342        format: OutputFormat,
343    },
344    ServiceRunFailed(String),
345    RenderFailed(String),
346}
347
348impl CliError {
349    fn invalid_format(format: &str) -> Self {
350        Self::InvalidFormat(format.to_owned())
351    }
352
353    pub(super) fn api_failed(error: ApiError, format: OutputFormat) -> Self {
354        Self::ApiError {
355            error: Box::new(error),
356            format,
357        }
358    }
359
360    pub(super) fn invalid_api_argument(message: impl Into<String>, format: OutputFormat) -> Self {
361        Self::api_failed(ApiError::invalid_argument(message), format)
362    }
363
364    /// Returns the process exit code for the error.
365    pub fn exit_code(&self) -> i32 {
366        match self {
367            Self::Diagnostic(_)
368            | Self::InvalidFormat(_)
369            | Self::InvalidCodeQueryKind(_)
370            | Self::InvalidSoftwareKind(_)
371            | Self::InvalidFreshness(_)
372            | Self::InvalidIndexKind(_)
373            | Self::InvalidMapSourceKind(_)
374            | Self::InvalidWorkerKind(_)
375            | Self::InvalidProposalState(_)
376            | Self::InvalidServiceAction(_)
377            | Self::InvalidLimit(_)
378            | Self::MissingFormatValue
379            | Self::MissingValue(_)
380            | Self::UnsupportedVersionFormat(_)
381            | Self::UnknownHelpTopic(_)
382            | Self::UnexpectedArgument(_) => 2,
383            Self::RuntimeConfigFailed(_)
384            | Self::ApiFailed(_)
385            | Self::ApiError { .. }
386            | Self::ServiceRunFailed(_)
387            | Self::RenderFailed(_) => 1,
388        }
389    }
390
391    /// Renders the process stderr payload for this error.
392    pub fn render_stderr(&self) -> String {
393        match self {
394            Self::Diagnostic(diagnostic) => diagnostic.render_stderr(),
395            Self::ApiError { error, format } if format.is_machine_readable() => {
396                serde_json::to_string(error).unwrap_or_else(|_| error.message.clone())
397            }
398            _ => self.to_string(),
399        }
400    }
401}
402
403impl fmt::Display for CliError {
404    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
405        match self {
406            Self::Diagnostic(diagnostic) => write!(formatter, "{}", diagnostic.render_text()),
407            Self::InvalidFormat(format) => write!(
408                formatter,
409                "invalid --format value '{format}', expected text, json, markdown, or streaming-json"
410            ),
411            Self::InvalidCodeQueryKind(value) => write!(
412                formatter,
413                "invalid --kind value '{value}', expected hybrid, symbol, definition, references, callers, callees, imports, or sbom"
414            ),
415            Self::InvalidSoftwareKind(value) => write!(
416                formatter,
417                "invalid --kind value '{value}', expected dependencies, sdks, build, iac, design, or all"
418            ),
419            Self::InvalidFreshness(value) => write!(
420                formatter,
421                "invalid --freshness value '{value}', expected allow-stale, wait-until-fresh, or graph-only"
422            ),
423            Self::InvalidIndexKind(value) => write!(
424                formatter,
425                "invalid --kind value '{value}', expected bm25, semantic, or vector"
426            ),
427            Self::InvalidMapSourceKind(value) => write!(
428                formatter,
429                "invalid --kind value '{value}', expected repo, file, doc, config, db, ci, runtime, wiki, or monitoring"
430            ),
431            Self::InvalidWorkerKind(value) => write!(
432                formatter,
433                "invalid worker kind '{value}', expected embedding, ocr, vision, or extractor"
434            ),
435            Self::InvalidProposalState(value) => write!(
436                formatter,
437                "invalid proposal state '{value}', expected proposed, accepted, rejected, or superseded"
438            ),
439            Self::InvalidServiceAction(value) => write!(
440                formatter,
441                "invalid service action '{value}', expected install or uninstall"
442            ),
443            Self::InvalidLimit(value) => write!(formatter, "invalid --limit value '{value}'"),
444            Self::MissingFormatValue => write!(formatter, "missing value for --format"),
445            Self::MissingValue(flag) => write!(formatter, "missing value for {flag}"),
446            Self::UnsupportedVersionFormat(format) => {
447                write!(
448                    formatter,
449                    "version does not support --format {}",
450                    format.as_str()
451                )
452            }
453            Self::UnknownHelpTopic(topic) => write!(formatter, "unknown help topic '{topic}'"),
454            Self::UnexpectedArgument(argument) => {
455                write!(formatter, "unexpected argument '{argument}'")
456            }
457            Self::RuntimeConfigFailed(message) => {
458                write!(formatter, "failed to load runtime configuration: {message}")
459            }
460            Self::ApiFailed(message) => write!(formatter, "{message}"),
461            Self::ApiError { error, .. } => write!(formatter, "{}", error.message),
462            Self::ServiceRunFailed(message) => write!(formatter, "{message}"),
463            Self::RenderFailed(message) => write!(formatter, "failed to render output: {message}"),
464        }
465    }
466}
467
468impl Error for CliError {}
469
470/// Structured parse diagnostic produced from the CLI grammar.
471#[derive(Debug, Clone, PartialEq, Eq)]
472pub struct CliDiagnostic {
473    message: String,
474    usage: Option<String>,
475    suggestion: Option<String>,
476    matched_path: Vec<String>,
477    unexpected_token: Option<String>,
478    expected: Vec<String>,
479    format: OutputFormat,
480}
481
482impl CliDiagnostic {
483    fn new(
484        message: String,
485        usage: Option<String>,
486        suggestion: Option<String>,
487        matched_path: Vec<String>,
488        unexpected_token: Option<String>,
489        expected: Vec<String>,
490        format: OutputFormat,
491    ) -> Self {
492        Self {
493            message,
494            usage,
495            suggestion,
496            matched_path,
497            unexpected_token,
498            expected,
499            format,
500        }
501    }
502
503    fn render_text(&self) -> String {
504        let mut output = self.message.clone();
505        if let Some(suggestion) = &self.suggestion {
506            output.push_str("\nTry: ");
507            output.push_str(suggestion);
508        }
509        if let Some(usage) = &self.usage {
510            output.push_str("\nUsage: ");
511            output.push_str(usage);
512        }
513
514        output
515    }
516
517    fn render_stderr(&self) -> String {
518        if self.format.is_machine_readable() {
519            return serde_json::json!({
520                "error": self.message,
521                "usage": self.usage,
522                "suggestion": self.suggestion,
523                "matched_path": self.matched_path,
524                "unexpected_token": self.unexpected_token,
525                "expected": self.expected,
526            })
527            .to_string();
528        }
529
530        self.render_text()
531    }
532}
533
534/// Runs the CLI command and renders its response.
535pub async fn run<I, S>(args: I) -> Result<String, CliError>
536where
537    I: IntoIterator<Item = S>,
538    S: Into<String>,
539{
540    let command = CliCommand::parse(args)?;
541    run_command(command).await
542}
543
544/// Rendered stdout/stderr for the process entry point.
545#[derive(Debug, Clone, PartialEq, Eq)]
546pub struct CliProcessOutput {
547    pub stdout: String,
548    pub stderr: String,
549}
550
551/// Runs the CLI command and renders only the command result.
552pub async fn run_process<I, S>(
553    args: I,
554    _interactive_text_output: bool,
555) -> Result<CliProcessOutput, CliError>
556where
557    I: IntoIterator<Item = S>,
558    S: Into<String>,
559{
560    let command = CliCommand::parse(args)?;
561    let stdout = run_command(command).await?;
562
563    Ok(CliProcessOutput {
564        stdout,
565        stderr: String::new(),
566    })
567}
568
569/// Renders best-effort process-only notices after primary command output is emitted.
570pub async fn process_update_notice<I, S>(args: I, interactive_text_output: bool) -> Option<String>
571where
572    I: IntoIterator<Item = S>,
573    S: Into<String>,
574{
575    let command = CliCommand::parse(args).ok()?;
576    version_cli::update_notice_for_process(&command, interactive_text_output).await
577}
578
579async fn run_command(command: CliCommand) -> Result<String, CliError> {
580    if let CliAction::Help { path } = &command.action {
581        return cli_spec::render_help(path, command.format);
582    }
583    if command.action == CliAction::Version {
584        return version_cli::render_version(command.format);
585    }
586    if let CliAction::ServiceRun { mcp, web } = command.action.clone() {
587        return service_cli::run_service(mcp, web).await;
588    }
589    if let CliAction::Map(map_command) = command.action.clone() {
590        let context = RequestContext::for_interface(InterfaceKind::Cli);
591        return map_cli::run_map(map_command, context, command.format).await;
592    }
593
594    let service = RelayKnowledgeService::from_process_environment()
595        .await
596        .map_err(|error| CliError::RuntimeConfigFailed(error.to_string()))?;
597    let context = RequestContext::for_interface(InterfaceKind::Cli);
598
599    run_with_service(&service, command, context).await
600}
601
602/// Runs a parsed CLI command with an already composed service.
603pub async fn run_with_service(
604    service: &RelayKnowledgeService,
605    command: CliCommand,
606    context: RequestContext,
607) -> Result<String, CliError> {
608    let format = command.format;
609    if let Some(output) =
610        ops_cli::run_operational_action(service, &command.action, context.clone(), format).await?
611    {
612        return Ok(output);
613    }
614    if let Some(output) =
615        setup_cli::run_setup_action(service, &command.action, context.clone(), format)?
616    {
617        return Ok(output);
618    }
619    if let Some(output) =
620        files_cli::run_files(service, &command.action, context.clone(), format).await?
621    {
622        return Ok(output);
623    }
624    match command.action {
625        CliAction::Status => {
626            let response = service
627                .project_status(context)
628                .await
629                .map_err(|error| CliError::api_failed(error, format))?;
630
631            render_project_status(&response, format)
632        }
633        CliAction::Ingest {
634            source_scope,
635            content,
636            entity_labels,
637        } => {
638            let response = service
639                .ingest(
640                    IngestRequest {
641                        source_scope,
642                        evidence: vec![IngestEvidence {
643                            id: None,
644                            source_path: None,
645                            span: None,
646                            confidence: None,
647                            status: None,
648                            content,
649                            entity_labels,
650                            extraction: None,
651                        }],
652                        relations: Vec::new(),
653                        claims: Vec::new(),
654                        events: Vec::new(),
655                    },
656                    context,
657                )
658                .await
659                .map_err(|error| CliError::api_failed(error, format))?;
660
661            render_response(
662                "knowledge.ingest",
663                response.metadata.clone(),
664                &response,
665                format,
666            )
667        }
668        CliAction::Query {
669            query,
670            source_scope,
671            limit,
672            freshness,
673        } => {
674            let response = service
675                .retrieve_context(
676                    HybridRetrievalRequest {
677                        query,
678                        source_scope,
679                        limit,
680                        freshness,
681                    },
682                    context,
683                )
684                .await
685                .map_err(|error| CliError::api_failed(error, format))?;
686
687            render_response(
688                "knowledge.retrieve_context",
689                response.metadata.clone(),
690                &response,
691                format,
692            )
693        }
694        CliAction::GraphInspect => {
695            let response = service
696                .inspect_graph(GraphInspectionRequest { source_scope: None }, context)
697                .await
698                .map_err(|error| CliError::api_failed(error, format))?;
699
700            render_response(
701                "graph.inspect",
702                response.metadata.clone(),
703                &response,
704                format,
705            )
706        }
707        CliAction::IndexRefresh { kinds } => {
708            let response = service
709                .refresh_indexes(IndexRefreshRequest { kinds }, context)
710                .await
711                .map_err(|error| CliError::api_failed(error, format))?;
712
713            render_response(
714                "index.refresh",
715                response.metadata.clone(),
716                &response,
717                format,
718            )
719        }
720        CliAction::Map(command) => map_cli::run_map(command, context, format).await,
721        CliAction::Repo(command) => repo_cli::run_repo(service, command, context, format).await,
722        CliAction::RepoSet(command) => {
723            repo_set_cli::run_repo_set(service, command, context, format).await
724        }
725        CliAction::Health => {
726            let response = service
727                .health(context)
728                .await
729                .map_err(|error| CliError::api_failed(error, format))?;
730
731            render_response(
732                "service.health",
733                response.metadata.clone(),
734                &response,
735                format,
736            )
737        }
738        CliAction::ProviderProbe => {
739            let response = service
740                .probe_embedding_provider(context)
741                .await
742                .map_err(|error| CliError::api_failed(error, format))?;
743
744            render_response(
745                "provider.embedding.probe",
746                response.metadata.clone(),
747                &response,
748                format,
749            )
750        }
751        CliAction::VersionCheck => version_cli::run_version_check(service, format).await,
752        CliAction::ServiceRun { .. } => Err(CliError::ServiceRunFailed(
753            "service run requires process runtime".to_owned(),
754        )),
755        CliAction::Help { path } => cli_spec::render_help(&path, format),
756        CliAction::WorkerStatus { .. }
757        | CliAction::FilesIndex { .. }
758        | CliAction::FilesQuery { .. }
759        | CliAction::WorkerRunOnce { .. }
760        | CliAction::ProposalList { .. }
761        | CliAction::ProposalShow { .. }
762        | CliAction::ProposalAccept { .. }
763        | CliAction::ProposalReject { .. }
764        | CliAction::ProposalSupersede { .. }
765        | CliAction::AuditQuery { .. }
766        | CliAction::ServiceStatus
767        | CliAction::ServicePlan { .. }
768        | CliAction::ServiceDefinitionWrite
769        | CliAction::ServiceOperatorStatus
770        | CliAction::ServiceOperatorPause
771        | CliAction::ServiceOperatorResume
772        | CliAction::SetupDoctor
773        | CliAction::SetupProfile { .. } => Err(CliError::ApiFailed(
774            "operational command was not handled by the service adapter".to_owned(),
775        )),
776        CliAction::Version => version_cli::render_version(command.format),
777    }
778}
779
780fn parse_action(tokens: Vec<String>) -> Result<CliAction, CliError> {
781    if tokens.is_empty() || tokens == ["status"] {
782        return Ok(CliAction::Status);
783    }
784
785    match tokens[0].as_str() {
786        "status" => Err(CliError::UnexpectedArgument(
787            tokens
788                .get(1)
789                .cloned()
790                .unwrap_or_else(|| "status".to_owned()),
791        )),
792        "ingest" => knowledge_cli::parse_ingest(&tokens[1..]),
793        "query" => knowledge_cli::parse_query(&tokens[1..]),
794        "files" => files_cli::parse_files(&tokens[1..]),
795        "map" => map_cli::parse_map(&tokens[1..]),
796        "repo" => repo_cli::parse_repo(&tokens[1..]).map(CliAction::Repo),
797        "repo-set" => repo_set_cli::parse_repo_set(&tokens[1..]).map(CliAction::RepoSet),
798        "graph" => knowledge_cli::parse_graph(&tokens[1..]),
799        "index" => knowledge_cli::parse_index(&tokens[1..]),
800        "worker" => ops_cli::parse_worker(&tokens[1..]),
801        "proposal" => ops_cli::parse_proposal(&tokens[1..]),
802        "audit" => ops_cli::parse_audit(&tokens[1..]),
803        "provider" => parse_provider(&tokens[1..]),
804        "health" if tokens.len() == 1 => Ok(CliAction::Health),
805        "service" => ops_cli::parse_service(&tokens[1..]),
806        "setup" => setup_cli::parse_setup(&tokens[1..]),
807        "version" if tokens.len() == 1 => Ok(CliAction::Version),
808        "version" if tokens == ["version", "check"] => Ok(CliAction::VersionCheck),
809        "help" => Ok(CliAction::Help {
810            path: help_path(tokens[1..].to_vec()),
811        }),
812        other => Err(CliError::UnexpectedArgument(other.to_owned())),
813    }
814}
815
816fn help_path(tokens: Vec<String>) -> Vec<String> {
817    tokens
818        .into_iter()
819        .filter(|token| token != "--")
820        .filter(|token| !token.starts_with('-'))
821        .collect()
822}
823
824fn parse_provider(tokens: &[String]) -> Result<CliAction, CliError> {
825    if tokens == ["probe"] {
826        return Ok(CliAction::ProviderProbe);
827    }
828
829    Err(CliError::UnexpectedArgument(
830        tokens
831            .first()
832            .cloned()
833            .unwrap_or_else(|| "provider".to_owned()),
834    ))
835}
836
837pub(super) fn value_after(
838    tokens: &[String],
839    index: usize,
840    flag: &'static str,
841) -> Result<String, CliError> {
842    tokens
843        .get(index + 1)
844        .cloned()
845        .ok_or(CliError::MissingValue(flag))
846}
847
848pub(super) fn parse_freshness(value: &str) -> Result<FreshnessPolicy, CliError> {
849    match value {
850        "allow-stale" => Ok(FreshnessPolicy::AllowStale),
851        "wait-until-fresh" => Ok(FreshnessPolicy::WaitUntilFresh),
852        "graph-only" => Ok(FreshnessPolicy::GraphOnly),
853        other => Err(CliError::InvalidFreshness(other.to_owned())),
854    }
855}
856
857#[cfg(test)]
858use service_cli::ensure_web_remote_bind_allowed;
859
860#[cfg(test)]
861#[path = "cli_naming_tests.rs"]
862mod cli_naming_tests;
863
864#[cfg(test)]
865#[path = "cli_tests.rs"]
866mod cli_tests;
867
868#[cfg(test)]
869#[path = "cli_map_tests.rs"]
870mod cli_map_tests;
871
872#[cfg(test)]
873#[path = "cli_service_tests.rs"]
874mod cli_service_tests;
875
876#[cfg(test)]
877#[path = "cli_version_tests.rs"]
878mod cli_version_tests;