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