Skip to main content

appcore_args/
shell.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: shell.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/08/19 12:52:57 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/19 13:34:54 by dnettoRaw
8//      ###########      S: 1.0.1-rc.8
9// =============================================================================
10
11//! Defines bounded shell contracts and behavior for this crate.
12
13use std::fmt;
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub enum Shell {
17    Bash,
18    Zsh,
19    Fish,
20    PowerShell,
21}
22
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct ShellScriptError {
25    message: String,
26}
27
28impl Shell {
29    pub fn parse(value: &str) -> Option<Self> {
30        match value {
31            "bash" => Some(Self::Bash),
32            "zsh" => Some(Self::Zsh),
33            "fish" => Some(Self::Fish),
34            "powershell" | "pwsh" => Some(Self::PowerShell),
35            _ => None,
36        }
37    }
38}
39
40impl fmt::Display for Shell {
41    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
42        formatter.write_str(match self {
43            Self::Bash => "bash",
44            Self::Zsh => "zsh",
45            Self::Fish => "fish",
46            Self::PowerShell => "powershell",
47        })
48    }
49}
50
51pub fn render_dynamic_completion_script(
52    binary: &str,
53    completion_command: &[&str],
54    shell: Shell,
55) -> Result<String, ShellScriptError> {
56    validate_command_token("binary", binary)?;
57    for part in completion_command {
58        validate_command_token("completion command", part)?;
59    }
60    Ok(match shell {
61        Shell::Bash => bash_script(binary, completion_command),
62        Shell::Zsh => zsh_script(binary, completion_command),
63        Shell::Fish => fish_script(binary, completion_command),
64        Shell::PowerShell => powershell_script(binary, completion_command),
65    })
66}
67
68impl fmt::Display for ShellScriptError {
69    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
70        formatter.write_str(&self.message)
71    }
72}
73
74impl std::error::Error for ShellScriptError {}
75
76fn validate_command_token(kind: &str, value: &str) -> Result<(), ShellScriptError> {
77    let valid = !value.is_empty()
78        && value
79            .bytes()
80            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'));
81    if valid {
82        Ok(())
83    } else {
84        Err(ShellScriptError {
85            message: format!("unsafe {kind} token `{value}`"),
86        })
87    }
88}
89
90fn command_prefix(binary: &str, completion_command: &[&str]) -> String {
91    let mut parts = vec![binary.to_string()];
92    parts.extend(completion_command.iter().map(|part| part.to_string()));
93    parts.join(" ")
94}
95
96fn bash_script(binary: &str, completion_command: &[&str]) -> String {
97    let function = function_name(binary);
98    let prefix = command_prefix(binary, completion_command);
99    format!(
100        r#"{function}() {{
101    local IFS=$'\n'
102    COMPREPLY=( $({prefix} bash "$COMP_CWORD" "${{COMP_WORDS[@]}}") )
103}}
104complete -o bashdefault -o default -F {function} {binary}
105"#
106    )
107}
108
109fn zsh_script(binary: &str, completion_command: &[&str]) -> String {
110    let function = function_name(binary);
111    let prefix = command_prefix(binary, completion_command);
112    format!(
113        r#"{function}() {{
114    local -a completions
115    completions=("${{(@f)$({prefix} zsh "$((CURRENT - 1))" "${{words[@]}}")}}")
116    if (( ${{#completions}} )); then
117        compadd -- "${{completions[@]}}"
118    else
119        _files
120    fi
121}}
122compdef {function} {binary}
123"#
124    )
125}
126
127fn fish_script(binary: &str, completion_command: &[&str]) -> String {
128    let prefix = command_prefix(binary, completion_command);
129    format!(
130        "complete -c {binary} -a '(set -l words (commandline -opc); set -a words (commandline -ct); set -l cursor (math (count $words) - 1); {prefix} fish $cursor $words)'\n"
131    )
132}
133
134fn powershell_script(binary: &str, completion_command: &[&str]) -> String {
135    let prefix = command_prefix(binary, completion_command);
136    format!(
137        r#"Register-ArgumentCompleter -Native -CommandName '{binary}' -ScriptBlock {{
138    param($wordToComplete, $commandAst, $cursorPosition)
139    $words = @($commandAst.CommandElements | ForEach-Object {{ $_.ToString() }})
140    $cursorWord = [Math]::Max(0, $words.Count - 1)
141    $custom = @(& {prefix} powershell $cursorWord @words)
142    if ($custom.Count -gt 0) {{
143        $custom | ForEach-Object {{ [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }}
144    }} else {{
145        Get-ChildItem -Name -Path "$wordToComplete*" -ErrorAction SilentlyContinue |
146            ForEach-Object {{ [System.Management.Automation.CompletionResult]::new($_, $_, 'ProviderItem', $_) }}
147    }}
148}}
149"#
150    )
151}
152
153fn function_name(binary: &str) -> String {
154    let normalized = binary
155        .chars()
156        .map(|character| {
157            if character.is_ascii_alphanumeric() {
158                character
159            } else {
160                '_'
161            }
162        })
163        .collect::<String>();
164    format!("_{normalized}_complete")
165}
166
167#[cfg(test)]
168mod tests {
169    use super::{render_dynamic_completion_script, Shell};
170
171    #[test]
172    fn renders_bash_script_for_binary() {
173        let script =
174            render_dynamic_completion_script("appcore-dev", &["complete"], Shell::Bash).unwrap();
175
176        assert!(script
177            .contains("complete -o bashdefault -o default -F _appcore_dev_complete appcore-dev"));
178    }
179
180    #[test]
181    fn generated_scripts_preserve_native_file_completion() {
182        let zsh = render_dynamic_completion_script("demo", &["complete"], Shell::Zsh).unwrap();
183        let fish = render_dynamic_completion_script("demo", &["complete"], Shell::Fish).unwrap();
184        let powershell =
185            render_dynamic_completion_script("demo", &["complete"], Shell::PowerShell).unwrap();
186
187        assert!(zsh.contains("_files"));
188        assert!(!fish.contains(" -f "));
189        assert!(powershell.contains("Get-ChildItem"));
190    }
191
192    #[test]
193    fn rejects_shell_metacharacters_in_command_tokens() {
194        let error =
195            render_dynamic_completion_script("appcore-dev;echo", &["complete"], Shell::PowerShell)
196                .unwrap_err();
197
198        assert!(error.to_string().contains("unsafe binary token"));
199    }
200}