Skip to main content

tftio_cli_common/
completions.rs

1//! Shell completion generation module.
2//!
3//! This module provides generic shell completion generation for CLI tools using clap.
4//! It works with any clap `CommandFactory` and generates completions for all major shells.
5
6use clap::CommandFactory;
7use clap_complete::Shell;
8use std::io::{self, Write};
9
10/// Completion content rendered in-memory before it is written anywhere.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct CompletionOutput {
13    /// Installation instructions for the selected shell.
14    pub instructions: String,
15    /// Completion script emitted by `clap_complete`.
16    pub script: String,
17}
18
19/// Render completion installation instructions for a clap-based CLI.
20#[must_use]
21pub fn render_completion_instructions(shell: Shell, bin_name: &str) -> String {
22    match shell {
23        Shell::Bash => format!(
24            "# Shell completion for {bin_name}\n#\n# To enable completions, add this to your shell config:\n#\n#   source <({bin_name} completions bash)\n\n"
25        ),
26        Shell::Zsh => format!(
27            "# Shell completion for {bin_name}\n#\n# To enable completions, add this to your shell config:\n#\n#   {bin_name} completions zsh > ~/.zsh/completions/_{bin_name}\n#   # Ensure fpath includes ~/.zsh/completions\n\n"
28        ),
29        Shell::Fish => format!(
30            "# Shell completion for {bin_name}\n#\n# To enable completions, add this to your shell config:\n#\n#   {bin_name} completions fish | source\n\n"
31        ),
32        Shell::PowerShell => format!(
33            "# Shell completion for {bin_name}\n#\n# To enable completions, add this to your shell config:\n#\n#   {bin_name} completions powershell | Out-String | Invoke-Expression\n\n"
34        ),
35        Shell::Elvish => format!(
36            "# Shell completion for {bin_name}\n#\n# To enable completions, add this to your shell config:\n#\n#   {bin_name} completions elvish | eval\n\n"
37        ),
38        other => format!(
39            "# Shell completion for {bin_name}\n#\n# To enable completions, add this to your shell config:\n#\n#   {bin_name} completions {other}\n\n"
40        ),
41    }
42}
43
44/// Render shell completions fully in memory.
45#[must_use]
46pub fn render_completion<T: CommandFactory>(shell: Shell) -> CompletionOutput {
47    render_completion_from_command(shell, T::command())
48}
49
50/// Render shell completions from a pre-built clap command tree.
51#[must_use]
52pub fn render_completion_from_command(
53    shell: Shell,
54    mut command: clap::Command,
55) -> CompletionOutput {
56    let bin_name = command.get_name().to_string();
57    let mut buffer = Vec::new();
58
59    clap_complete::generate(shell, &mut command, bin_name.clone(), &mut buffer);
60
61    CompletionOutput {
62        instructions: render_completion_instructions(shell, &bin_name),
63        script: String::from_utf8(buffer).expect("clap_complete output must be valid UTF-8"),
64    }
65}
66
67/// Write a previously rendered completion output to a writer.
68///
69/// # Errors
70///
71/// Returns an error if writing fails.
72pub fn write_completion(mut writer: impl Write, output: &CompletionOutput) -> io::Result<()> {
73    writer.write_all(output.instructions.as_bytes())?;
74    writer.write_all(output.script.as_bytes())
75}
76
77/// Generate shell completion scripts for a clap-based CLI.
78///
79/// This function generates shell completions and prints both installation instructions
80/// and the completion script to stdout. It supports bash, zsh, fish, elvish, and `PowerShell`.
81///
82/// # Type Parameters
83/// * `T` - A type that implements `CommandFactory` (typically your clap `Cli` struct)
84///
85/// # Arguments
86/// * `shell` - The shell type to generate completions for
87///
88/// # Examples
89/// ```no_run
90/// use clap::Parser;
91/// use tftio_cli_common::completions::generate_completions;
92///
93/// #[derive(Parser)]
94/// struct Cli {
95///     // your CLI definition
96/// }
97///
98/// generate_completions::<Cli>(clap_complete::Shell::Bash);
99/// ```
100pub fn generate_completions<T: CommandFactory>(shell: Shell) {
101    generate_completions_from_command(shell, T::command());
102}
103
104/// Generate shell completion scripts from a pre-built clap command tree.
105pub fn generate_completions_from_command(shell: Shell, command: clap::Command) {
106    let output = render_completion_from_command(shell, command);
107    write_completion(io::stdout(), &output).expect("failed to write completions");
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use crate::agent::apply_agent_surface;
114    use crate::{
115        AgentCapability, AgentModeContext, AgentSurfaceSpec, CommandSelector, FlagSelector,
116        LicenseType, RepoInfo, ToolSpec,
117    };
118    use clap::{Arg, Parser, Subcommand};
119
120    #[derive(Parser)]
121    #[command(name = "test-cli")]
122    struct TestCli {
123        #[command(subcommand)]
124        command: TestCommands,
125    }
126
127    #[derive(Subcommand)]
128    enum TestCommands {
129        Version,
130        Test { arg: String },
131    }
132
133    const QUERY_COMMAND: CommandSelector = CommandSelector::new(&["query"]);
134    const QUERY_LIMIT_FLAG: FlagSelector = FlagSelector::new(&["query"], "limit");
135    const QUERY_CAPABILITY: AgentCapability = AgentCapability::new(
136        "query-posts",
137        "Read paginated post records",
138        &[QUERY_COMMAND],
139        &[QUERY_LIMIT_FLAG],
140    );
141    const AGENT_SURFACE: AgentSurfaceSpec = AgentSurfaceSpec::new(&[QUERY_CAPABILITY]);
142
143    fn agent_spec() -> ToolSpec {
144        ToolSpec::new(
145            "test-cli",
146            "Test CLI",
147            "1.2.3",
148            LicenseType::MIT,
149            RepoInfo::new("owner", "repo"),
150            true,
151            false,
152            true,
153        )
154        .with_agent_surface(&AGENT_SURFACE)
155    }
156
157    #[test]
158    fn test_generate_completions_bash() {
159        // Just verify it doesn't panic
160        generate_completions::<TestCli>(Shell::Bash);
161    }
162
163    #[test]
164    fn test_generate_completions_zsh() {
165        generate_completions::<TestCli>(Shell::Zsh);
166    }
167
168    #[test]
169    fn test_generate_completions_fish() {
170        generate_completions::<TestCli>(Shell::Fish);
171    }
172
173    #[test]
174    fn test_generate_completions_elvish() {
175        generate_completions::<TestCli>(Shell::Elvish);
176    }
177
178    #[test]
179    fn test_generate_completions_powershell() {
180        generate_completions::<TestCli>(Shell::PowerShell);
181    }
182
183    #[test]
184    fn test_all_shells_generate_without_panic() {
185        let shells = vec![
186            Shell::Bash,
187            Shell::Zsh,
188            Shell::Fish,
189            Shell::Elvish,
190            Shell::PowerShell,
191        ];
192
193        for shell in shells {
194            generate_completions::<TestCli>(shell);
195        }
196    }
197
198    #[test]
199    fn render_completion_separates_instructions_from_script() {
200        let output = render_completion::<TestCli>(Shell::Bash);
201
202        assert!(
203            output
204                .instructions
205                .contains("source <(test-cli completions bash)")
206        );
207        assert!(output.script.contains("complete"));
208    }
209
210    #[test]
211    fn agent_surface_redaction_completion_helper_omits_hidden_entries() {
212        let mut command = clap::Command::new("test-cli")
213            .subcommand(
214                clap::Command::new("query")
215                    .arg(Arg::new("limit").long("limit"))
216                    .arg(Arg::new("secret").long("secret")),
217            )
218            .subcommand(clap::Command::new("admin"));
219
220        apply_agent_surface(
221            &mut command,
222            &agent_spec(),
223            &AgentModeContext { active: true },
224        );
225
226        let output = render_completion_from_command(Shell::Bash, command);
227
228        assert!(output.script.contains("query"));
229        assert!(!output.script.contains("admin"));
230        assert!(!output.script.contains("--secret"));
231    }
232}