1#[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#[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 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#[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 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
306pub enum ServiceMcpTransport {
307 Configured,
308 StreamableHttp,
309}
310
311#[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 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 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#[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
514pub 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#[derive(Debug, Clone, PartialEq, Eq)]
526pub struct CliProcessOutput {
527 pub stdout: String,
528 pub stderr: String,
529}
530
531pub 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
549pub 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
578pub 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;