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/// let _ = generate_completions::<Cli>(clap_complete::Shell::Bash);
99/// ```
100pub fn generate_completions<T: CommandFactory>(shell: Shell) -> io::Result<()> {
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) -> io::Result<()> {
106    let output = render_completion_from_command(shell, command);
107    write_completion(io::stdout(), &output)
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        )
153        .with_agent_surface(&AGENT_SURFACE)
154    }
155
156    #[test]
157    fn test_generate_completions_bash() {
158        // Just verify it doesn't panic
159        let _ = generate_completions::<TestCli>(Shell::Bash);
160    }
161
162    #[test]
163    fn test_generate_completions_zsh() {
164        let _ = generate_completions::<TestCli>(Shell::Zsh);
165    }
166
167    #[test]
168    fn test_generate_completions_fish() {
169        let _ = generate_completions::<TestCli>(Shell::Fish);
170    }
171
172    #[test]
173    fn test_generate_completions_elvish() {
174        let _ = generate_completions::<TestCli>(Shell::Elvish);
175    }
176
177    #[test]
178    fn test_generate_completions_powershell() {
179        let _ = generate_completions::<TestCli>(Shell::PowerShell);
180    }
181
182    #[test]
183    fn test_all_shells_generate_without_panic() {
184        let shells = vec![
185            Shell::Bash,
186            Shell::Zsh,
187            Shell::Fish,
188            Shell::Elvish,
189            Shell::PowerShell,
190        ];
191
192        for shell in shells {
193            let _ = generate_completions::<TestCli>(shell);
194        }
195    }
196
197    #[test]
198    fn render_completion_separates_instructions_from_script() {
199        let output = render_completion::<TestCli>(Shell::Bash);
200
201        assert!(
202            output
203                .instructions
204                .contains("source <(test-cli completions bash)")
205        );
206        assert!(output.script.contains("complete"));
207    }
208
209    #[test]
210    fn agent_surface_redaction_completion_helper_omits_hidden_entries() {
211        let mut command = clap::Command::new("test-cli")
212            .subcommand(
213                clap::Command::new("query")
214                    .arg(Arg::new("limit").long("limit"))
215                    .arg(Arg::new("secret").long("secret")),
216            )
217            .subcommand(clap::Command::new("admin"));
218
219        apply_agent_surface(
220            &mut command,
221            &agent_spec(),
222            &AgentModeContext { active: true },
223        );
224
225        let output = render_completion_from_command(Shell::Bash, command);
226
227        assert!(output.script.contains("query"));
228        assert!(!output.script.contains("admin"));
229        assert!(!output.script.contains("--secret"));
230    }
231}