Skip to main content

tftio_cli_common/
command.rs

1//! Shared standard CLI commands.
2
3use std::path::PathBuf;
4
5use clap::{CommandFactory, FromArgMatches};
6use clap_complete::Shell;
7
8use crate::{
9    AgentDispatch, AgentModeContext, DoctorChecks, ToolSpec, apply_agent_surface, display_license,
10    generate_completions_from_command, parse_with_agent_surface, parse_with_agent_surface_from,
11    run_doctor, update,
12};
13#[cfg(test)]
14use crate::{CompletionOutput, render_completion_from_command};
15
16/// Shared doctorless adapter for tools that do not expose a doctor command.
17pub struct NoDoctor;
18
19impl DoctorChecks for NoDoctor {
20    fn repo_info() -> crate::RepoInfo {
21        crate::app::WORKSPACE_REPO
22    }
23
24    fn current_version() -> &'static str {
25        "unknown"
26    }
27}
28
29/// Shared metadata commands exposed by workspace binaries.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum StandardCommand {
32    /// Show version information.
33    Version {
34        /// Whether to emit JSON instead of plain text.
35        json: bool,
36    },
37    /// Show license text.
38    License,
39    /// Generate shell completion scripts.
40    Completions {
41        /// Shell to generate completions for.
42        shell: Shell,
43    },
44    /// Run health checks.
45    Doctor,
46    /// Run self-update.
47    Update {
48        /// Optional target version.
49        version: Option<String>,
50        /// Force the installation.
51        force: bool,
52        /// Optional install directory.
53        install_dir: Option<PathBuf>,
54    },
55}
56
57/// Map crate-local metadata commands onto the shared [`StandardCommand`] surface.
58pub trait StandardCommandMap {
59    /// Convert a local metadata command into a shared command.
60    fn to_standard_command(&self, json: bool) -> StandardCommand;
61}
62
63/// Convert a crate-local metadata command into a shared [`StandardCommand`].
64#[must_use]
65pub fn map_standard_command<C>(command: &C, json: bool) -> StandardCommand
66where
67    C: StandardCommandMap + ?Sized,
68{
69    command.to_standard_command(json)
70}
71
72/// Run a mapped standard command when a tool exposes one.
73#[must_use]
74#[allow(clippy::single_option_map)]
75pub fn maybe_run_standard_command<T, D, C>(
76    spec: &ToolSpec,
77    command: Option<&C>,
78    json: bool,
79    doctor: Option<&D>,
80) -> Option<i32>
81where
82    T: CommandFactory,
83    D: DoctorChecks,
84    C: StandardCommandMap + ?Sized,
85{
86    command.map(|command| {
87        run_standard_command::<T, D>(spec, &map_standard_command(command, json), doctor)
88    })
89}
90
91/// Run a mapped standard command for a tool with no doctor support.
92#[must_use]
93#[allow(clippy::single_option_map)]
94pub fn maybe_run_standard_command_no_doctor<T, C>(
95    spec: &ToolSpec,
96    command: Option<&C>,
97    json: bool,
98) -> Option<i32>
99where
100    T: CommandFactory,
101    C: StandardCommandMap + ?Sized,
102{
103    command.map(|command| {
104        run_standard_command_no_doctor::<T>(spec, &map_standard_command(command, json))
105    })
106}
107
108/// Parse a clap CLI against the current normal or agent-filtered surface.
109///
110/// # Errors
111///
112/// Returns a `clap` error when parsing fails.
113pub fn parse_command_with_agent_surface<T>(spec: &ToolSpec) -> Result<AgentDispatch<T>, clap::Error>
114where
115    T: CommandFactory + FromArgMatches,
116{
117    parse_with_agent_surface(spec)
118}
119
120/// Parse argv against the current normal or agent-filtered surface.
121///
122/// # Errors
123///
124/// Returns a `clap` error when parsing fails.
125pub fn parse_command_with_agent_surface_from<T, I>(
126    spec: &ToolSpec,
127    argv: I,
128) -> Result<AgentDispatch<T>, clap::Error>
129where
130    T: CommandFactory + FromArgMatches,
131    I: IntoIterator,
132    I::Item: Into<std::ffi::OsString> + Clone,
133{
134    parse_with_agent_surface_from(spec, argv)
135}
136
137/// Parse argv and hand the typed CLI to a borrowed callback when parsing succeeds.
138///
139/// # Errors
140///
141/// Returns a `clap` error when parsing fails.
142pub fn parse_command_ref_with_agent_surface_from<T, I, R, F>(
143    spec: &ToolSpec,
144    argv: I,
145    run: F,
146) -> Result<AgentDispatch<R>, clap::Error>
147where
148    T: CommandFactory + FromArgMatches,
149    I: IntoIterator,
150    I::Item: Into<std::ffi::OsString> + Clone,
151    F: FnOnce(&T) -> R,
152{
153    match parse_with_agent_surface_from(spec, argv)? {
154        AgentDispatch::Cli(cli) => Ok(AgentDispatch::Cli(run(&cli))),
155        AgentDispatch::Printed(code) => Ok(AgentDispatch::Printed(code)),
156    }
157}
158
159fn render_version(spec: &ToolSpec, json: bool) -> String {
160    if json {
161        format!(r#"{{"version":"{}"}}"#, spec.version)
162    } else {
163        format!("{} {}", spec.bin_name, spec.version)
164    }
165}
166
167fn render_license(spec: &ToolSpec) -> String {
168    display_license(spec.bin_name, spec.license)
169}
170
171fn completion_command_for_spec<T>(spec: &ToolSpec) -> clap::Command
172where
173    T: CommandFactory,
174{
175    let ctx = AgentModeContext::detect();
176    let mut command = T::command();
177    if ctx.active {
178        apply_agent_surface(&mut command, spec, &ctx);
179    }
180    command
181}
182
183#[cfg(test)]
184fn render_standard_completion_for_command<T>(spec: &ToolSpec, shell: Shell) -> CompletionOutput
185where
186    T: CommandFactory,
187{
188    render_completion_from_command(shell, completion_command_for_spec::<T>(spec))
189}
190
191fn generate_standard_completion_for_command<T>(spec: &ToolSpec, shell: Shell)
192where
193    T: CommandFactory,
194{
195    generate_completions_from_command(shell, completion_command_for_spec::<T>(spec));
196}
197
198/// Execute a shared standard command.
199#[must_use]
200pub fn run_standard_command<T, D>(
201    spec: &ToolSpec,
202    command: &StandardCommand,
203    doctor: Option<&D>,
204) -> i32
205where
206    T: CommandFactory,
207    D: DoctorChecks,
208{
209    match command {
210        StandardCommand::Version { json } => {
211            println!("{}", render_version(spec, *json));
212            0
213        }
214        StandardCommand::License => {
215            println!("{}", render_license(spec));
216            0
217        }
218        StandardCommand::Completions { shell } => {
219            generate_standard_completion_for_command::<T>(spec, *shell);
220            0
221        }
222        StandardCommand::Doctor => {
223            let Some(tool) = doctor else {
224                eprintln!("doctor support not configured");
225                return 1;
226            };
227            run_doctor(tool)
228        }
229        StandardCommand::Update {
230            version,
231            force,
232            install_dir,
233        } => update::run_update(
234            &spec.repo,
235            spec.version,
236            version.as_deref(),
237            *force,
238            install_dir.as_deref(),
239        ),
240    }
241}
242
243/// Execute a shared standard command for a tool with no doctor support.
244#[must_use]
245pub fn run_standard_command_no_doctor<T>(spec: &ToolSpec, command: &StandardCommand) -> i32
246where
247    T: CommandFactory,
248{
249    run_standard_command::<T, NoDoctor>(spec, command, None)
250}
251
252/// Implement [`StandardCommandMap`] for a crate-local metadata enum that uses the
253/// workspace-standard variant names.
254#[macro_export]
255macro_rules! impl_standard_command_map {
256    ($type:ty, global_json $(,)?) => {
257        impl $crate::command::StandardCommandMap for $type {
258            fn to_standard_command(&self, json: bool) -> $crate::StandardCommand {
259                match self {
260                    Self::Version => $crate::StandardCommand::Version { json },
261                    Self::License => $crate::StandardCommand::License,
262                    Self::Completions { shell } => {
263                        $crate::StandardCommand::Completions { shell: *shell }
264                    }
265                }
266            }
267        }
268    };
269    ($type:ty, global_json, doctor $(,)?) => {
270        impl $crate::command::StandardCommandMap for $type {
271            fn to_standard_command(&self, json: bool) -> $crate::StandardCommand {
272                match self {
273                    Self::Version => $crate::StandardCommand::Version { json },
274                    Self::License => $crate::StandardCommand::License,
275                    Self::Completions { shell } => {
276                        $crate::StandardCommand::Completions { shell: *shell }
277                    }
278                    Self::Doctor => $crate::StandardCommand::Doctor,
279                }
280            }
281        }
282    };
283    ($type:ty, global_json, doctor, update $(,)?) => {
284        impl $crate::command::StandardCommandMap for $type {
285            fn to_standard_command(&self, json: bool) -> $crate::StandardCommand {
286                match self {
287                    Self::Version => $crate::StandardCommand::Version { json },
288                    Self::License => $crate::StandardCommand::License,
289                    Self::Completions { shell } => {
290                        $crate::StandardCommand::Completions { shell: *shell }
291                    }
292                    Self::Doctor => $crate::StandardCommand::Doctor,
293                    Self::Update {
294                        version,
295                        force,
296                        install_dir,
297                    } => $crate::StandardCommand::Update {
298                        version: version.clone(),
299                        force: *force,
300                        install_dir: install_dir.clone(),
301                    },
302                }
303            }
304        }
305    };
306    ($type:ty, field_json $(,)?) => {
307        impl $crate::command::StandardCommandMap for $type {
308            fn to_standard_command(&self, _json: bool) -> $crate::StandardCommand {
309                match self {
310                    Self::Version { json } => $crate::StandardCommand::Version { json: *json },
311                    Self::License => $crate::StandardCommand::License,
312                    Self::Completions { shell } => {
313                        $crate::StandardCommand::Completions { shell: *shell }
314                    }
315                }
316            }
317        }
318    };
319    ($type:ty, fixed_json = $json:expr $(,)?) => {
320        impl $crate::command::StandardCommandMap for $type {
321            fn to_standard_command(&self, _json: bool) -> $crate::StandardCommand {
322                match self {
323                    Self::Version => $crate::StandardCommand::Version { json: $json },
324                    Self::License => $crate::StandardCommand::License,
325                    Self::Completions { shell } => {
326                        $crate::StandardCommand::Completions { shell: *shell }
327                    }
328                }
329            }
330        }
331    };
332    ($type:ty, fixed_json = $json:expr, doctor $(,)?) => {
333        impl $crate::command::StandardCommandMap for $type {
334            fn to_standard_command(&self, _json: bool) -> $crate::StandardCommand {
335                match self {
336                    Self::Version => $crate::StandardCommand::Version { json: $json },
337                    Self::License => $crate::StandardCommand::License,
338                    Self::Completions { shell } => {
339                        $crate::StandardCommand::Completions { shell: *shell }
340                    }
341                    Self::Doctor => $crate::StandardCommand::Doctor,
342                }
343            }
344        }
345    };
346    ($type:ty, fixed_json = $json:expr, doctor, update $(,)?) => {
347        impl $crate::command::StandardCommandMap for $type {
348            fn to_standard_command(&self, _json: bool) -> $crate::StandardCommand {
349                match self {
350                    Self::Version => $crate::StandardCommand::Version { json: $json },
351                    Self::License => $crate::StandardCommand::License,
352                    Self::Completions { shell } => {
353                        $crate::StandardCommand::Completions { shell: *shell }
354                    }
355                    Self::Doctor => $crate::StandardCommand::Doctor,
356                    Self::Update {
357                        version,
358                        force,
359                        install_dir,
360                    } => $crate::StandardCommand::Update {
361                        version: version.clone(),
362                        force: *force,
363                        install_dir: install_dir.clone(),
364                    },
365                }
366            }
367        }
368    };
369}
370
371#[cfg(test)]
372mod tests {
373    use clap::{Parser, Subcommand};
374
375    use super::*;
376    use crate::{
377        AGENT_TOKEN_ENV, AGENT_TOKEN_EXPECTED_ENV, AgentCapability, AgentDispatch,
378        AgentSurfaceSpec, CommandSelector, FlagSelector, LicenseType, RepoInfo,
379        test_support::env_lock, workspace_tool,
380    };
381
382    const QUERY_COMMAND: CommandSelector = CommandSelector::new(&["query"]);
383    const QUERY_LIMIT_FLAG: FlagSelector = FlagSelector::new(&["query"], "limit");
384    const QUERY_CAPABILITY: AgentCapability = AgentCapability::new(
385        "query-posts",
386        "Read paginated post records",
387        &[QUERY_COMMAND],
388        &[QUERY_LIMIT_FLAG],
389    );
390    const AGENT_SURFACE: AgentSurfaceSpec = AgentSurfaceSpec::new(&[QUERY_CAPABILITY]);
391
392    #[derive(Parser)]
393    struct TestCli;
394
395    #[derive(Debug, Parser, PartialEq, Eq)]
396    #[command(name = "tool")]
397    struct ParseTestCli {
398        #[command(subcommand)]
399        command: ParseTestCommand,
400    }
401
402    #[derive(Debug, Subcommand, PartialEq, Eq)]
403    enum ParseTestCommand {
404        Query {
405            #[arg(long)]
406            limit: u32,
407        },
408        Admin,
409    }
410
411    #[derive(Debug, Parser, PartialEq, Eq)]
412    #[command(name = "tool")]
413    struct CompletionTestCli {
414        #[command(subcommand)]
415        command: CompletionTestCommand,
416    }
417
418    #[derive(Debug, Subcommand, PartialEq, Eq)]
419    enum CompletionTestCommand {
420        Query {
421            #[arg(long)]
422            limit: Option<u32>,
423            #[arg(long)]
424            secret: bool,
425        },
426        Admin,
427    }
428
429    struct TestDoctor;
430
431    impl DoctorChecks for TestDoctor {
432        fn repo_info() -> RepoInfo {
433            RepoInfo::new("owner", "doctor-tool")
434        }
435
436        fn current_version() -> &'static str {
437            "1.0.0"
438        }
439    }
440
441    fn spec() -> ToolSpec {
442        ToolSpec::new(
443            "tool",
444            "Tool",
445            "1.2.3",
446            LicenseType::MIT,
447            RepoInfo::new("owner", "repo"),
448            true,
449            true,
450            true,
451        )
452    }
453
454    fn agent_spec() -> ToolSpec {
455        workspace_tool("tool", "Tool", "1.2.3", LicenseType::MIT, true, true, true)
456            .with_agent_surface(&AGENT_SURFACE)
457    }
458
459    #[allow(unsafe_code)]
460    fn set_tokens(presented: Option<&str>, expected: Option<&str>) {
461        unsafe {
462            std::env::remove_var(AGENT_TOKEN_ENV);
463            std::env::remove_var(AGENT_TOKEN_EXPECTED_ENV);
464            if let Some(presented) = presented {
465                std::env::set_var(AGENT_TOKEN_ENV, presented);
466            }
467            if let Some(expected) = expected {
468                std::env::set_var(AGENT_TOKEN_EXPECTED_ENV, expected);
469            }
470        }
471    }
472
473    #[test]
474    fn version_json_contains_version_key() {
475        let rendered = render_version(&spec(), true);
476        assert!(rendered.contains("\"version\""));
477    }
478
479    #[test]
480    fn license_render_uses_display_license_text() {
481        let rendered = render_license(&spec());
482        assert!(rendered.contains("MIT License"));
483    }
484
485    #[test]
486    fn run_standard_command_version_returns_success() {
487        let exit_code = run_standard_command::<TestCli, TestDoctor>(
488            &spec(),
489            &StandardCommand::Version { json: false },
490            Some(&TestDoctor),
491        );
492        assert_eq!(exit_code, 0);
493    }
494
495    #[test]
496    fn run_standard_command_no_doctor_version_returns_success() {
497        let exit_code = run_standard_command_no_doctor::<TestCli>(
498            &spec(),
499            &StandardCommand::Version { json: true },
500        );
501        assert_eq!(exit_code, 0);
502    }
503
504    #[allow(dead_code)]
505    #[derive(Debug, Clone, PartialEq, Eq)]
506    enum GlobalJsonMetaCommand {
507        Version,
508        License,
509        Completions { shell: Shell },
510    }
511
512    impl_standard_command_map!(GlobalJsonMetaCommand, global_json);
513
514    #[allow(dead_code)]
515    #[derive(Debug, Clone, PartialEq, Eq)]
516    enum FixedJsonMetaCommand {
517        Version,
518        License,
519        Completions { shell: Shell },
520        Doctor,
521    }
522
523    impl_standard_command_map!(FixedJsonMetaCommand, fixed_json = false, doctor);
524
525    #[allow(dead_code)]
526    #[derive(Debug, Clone, PartialEq, Eq)]
527    enum VersionFieldMetaCommand {
528        Version { json: bool },
529        License,
530        Completions { shell: Shell },
531    }
532
533    impl_standard_command_map!(VersionFieldMetaCommand, field_json);
534
535    #[test]
536    fn impl_standard_command_map_uses_global_json_flag() {
537        let command = map_standard_command(&GlobalJsonMetaCommand::Version, true);
538        assert_eq!(command, StandardCommand::Version { json: true });
539    }
540
541    #[test]
542    fn impl_standard_command_map_supports_fixed_json_and_doctor_variants() {
543        let command = map_standard_command(&FixedJsonMetaCommand::Doctor, true);
544        assert_eq!(command, StandardCommand::Doctor);
545    }
546
547    #[allow(dead_code)]
548    #[derive(Debug, Clone, PartialEq, Eq)]
549    enum UpdateMetaCommand {
550        Version,
551        License,
552        Completions {
553            shell: Shell,
554        },
555        Doctor,
556        Update {
557            version: Option<String>,
558            force: bool,
559            install_dir: Option<PathBuf>,
560        },
561    }
562
563    impl_standard_command_map!(UpdateMetaCommand, fixed_json = false, doctor, update);
564
565    #[test]
566    fn impl_standard_command_map_reads_json_from_version_field() {
567        let command = map_standard_command(&VersionFieldMetaCommand::Version { json: true }, false);
568        assert_eq!(command, StandardCommand::Version { json: true });
569    }
570
571    #[test]
572    fn impl_standard_command_map_clones_update_payload() {
573        let command = map_standard_command(
574            &UpdateMetaCommand::Update {
575                version: Some(String::from("1.0.0")),
576                force: true,
577                install_dir: Some(PathBuf::from("/tmp/install")),
578            },
579            false,
580        );
581        assert_eq!(
582            command,
583            StandardCommand::Update {
584                version: Some(String::from("1.0.0")),
585                force: true,
586                install_dir: Some(PathBuf::from("/tmp/install")),
587            }
588        );
589    }
590
591    #[test]
592    fn maybe_run_standard_command_no_doctor_executes_mapped_metadata_command() {
593        let exit_code = maybe_run_standard_command_no_doctor::<TestCli, _>(
594            &spec(),
595            Some(&GlobalJsonMetaCommand::License),
596            false,
597        );
598        assert_eq!(exit_code, Some(0));
599    }
600
601    #[test]
602    fn maybe_run_standard_command_returns_none_without_metadata_command() {
603        let exit_code = maybe_run_standard_command_no_doctor::<TestCli, GlobalJsonMetaCommand>(
604            &spec(),
605            None,
606            false,
607        );
608        assert_eq!(exit_code, None);
609    }
610
611    #[test]
612    fn parse_command_with_agent_surface_from_returns_owned_cli() {
613        let _guard = env_lock();
614        set_tokens(None, None);
615
616        let parsed = parse_command_with_agent_surface_from::<ParseTestCli, _>(
617            &agent_spec(),
618            ["tool", "query", "--limit", "5"],
619        )
620        .expect("parse should succeed");
621
622        assert_eq!(
623            parsed,
624            AgentDispatch::Cli(ParseTestCli {
625                command: ParseTestCommand::Query { limit: 5 },
626            })
627        );
628    }
629
630    #[test]
631    fn parse_command_ref_with_agent_surface_from_borrows_cli() {
632        let _guard = env_lock();
633        set_tokens(Some("shared-token"), Some("shared-token"));
634
635        let parsed = parse_command_ref_with_agent_surface_from::<ParseTestCli, _, _, _>(
636            &agent_spec(),
637            ["tool", "query", "--limit", "7"],
638            |cli| match cli.command {
639                ParseTestCommand::Query { limit } => limit,
640                ParseTestCommand::Admin => 0,
641            },
642        )
643        .expect("parse should succeed");
644
645        assert_eq!(parsed, AgentDispatch::Cli(7));
646    }
647
648    #[test]
649    fn agent_surface_redaction_completion_metadata_path_omits_hidden_entries() {
650        let _guard = env_lock();
651        set_tokens(Some("shared-token"), Some("shared-token"));
652
653        let output =
654            render_standard_completion_for_command::<CompletionTestCli>(&agent_spec(), Shell::Bash);
655
656        assert!(output.script.contains("query"));
657        assert!(!output.script.contains("admin"));
658        assert!(!output.script.contains("--secret"));
659    }
660}