linthis 0.22.1

A fast, cross-platform multi-language linter and formatter
Documentation
// Copyright 2024 zhlinh and linthis Project Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found at
//
// https://opensource.org/license/MIT

//! Render `ShellState` → source-file content per shell.
//!
//! Pure functions — caller is responsible for writing to disk.

use super::state::{Shell, ShellFlags};

/// Stable header so generated files are obviously machine-managed.
const HEADER_PREFIX: &str = "\
# Generated by linthis — DO NOT EDIT MANUALLY.\n\
# Source of truth: ~/.linthis/shell-state.toml\n\
# Toggle with: linthis shell add|remove ac|alias\n\n";

const HEADER_PREFIX_PS: &str = "\
# Generated by linthis - DO NOT EDIT MANUALLY.\n\
# Source of truth: ~/.linthis/shell-state.toml\n\
# Toggle with: linthis shell add|remove ac|alias\n\n";

/// Render the source file for `shell` given its flags.
///
/// Returns `None` when both flags are off — caller should DELETE any
/// existing source file in that case rather than leave an empty stub.
pub fn render(shell: Shell, flags: ShellFlags) -> Option<String> {
    if flags.is_empty() {
        return None;
    }
    Some(match shell {
        Shell::Bash | Shell::Zsh => render_posix(shell, flags),
        Shell::Fish => render_fish(flags),
        Shell::PowerShell => render_powershell(flags),
    })
}

fn render_posix(shell: Shell, flags: ShellFlags) -> String {
    let mut out = String::from(HEADER_PREFIX);
    if flags.ac {
        out.push_str("# --- ac ---\n");
        out.push_str(&format!(
            "eval \"$(linthis shell completion {} 2>/dev/null)\"\n\n",
            shell.key()
        ));
    }
    if flags.alias {
        out.push_str("# --- alias ---\n");
        out.push_str("alias lt='linthis'\n");
        out.push_str("alias lts='linthis -s'\n");
        out.push_str("alias ltm='linthis -m'\n");
        out.push_str("ltr() { linthis report show \"$@\"; }\n");
    }
    out
}

fn render_fish(flags: ShellFlags) -> String {
    let mut out = String::from(HEADER_PREFIX);
    if flags.ac {
        out.push_str("# --- ac ---\n");
        out.push_str("linthis shell completion fish 2>/dev/null | source\n\n");
    }
    if flags.alias {
        out.push_str("# --- alias ---\n");
        out.push_str("alias lt 'linthis'\n");
        out.push_str("alias lts 'linthis -s'\n");
        out.push_str("alias ltm 'linthis -m'\n");
        out.push_str("function ltr; linthis report show $argv; end\n");
    }
    out
}

fn render_powershell(flags: ShellFlags) -> String {
    let mut out = String::from(HEADER_PREFIX_PS);
    if flags.ac {
        out.push_str("# --- ac ---\n");
        out.push_str(
            "(& linthis shell completion powershell 2>$null) | Out-String | Invoke-Expression\n\n",
        );
    }
    if flags.alias {
        out.push_str("# --- alias ---\n");
        out.push_str("Set-Alias -Name lt -Value linthis\n");
        out.push_str("function lts { linthis -s @args }\n");
        out.push_str("function ltm { linthis -m @args }\n");
        out.push_str("function ltr { linthis report show @args }\n");
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    fn flags(ac: bool, alias: bool) -> ShellFlags {
        ShellFlags { ac, alias }
    }

    #[test]
    fn empty_flags_returns_none_so_caller_deletes_file() {
        assert!(render(Shell::Bash, flags(false, false)).is_none());
        assert!(render(Shell::Fish, flags(false, false)).is_none());
    }

    #[test]
    fn bash_ac_only_emits_completion_no_alias() {
        let s = render(Shell::Bash, flags(true, false)).unwrap();
        assert!(s.contains("eval \"$(linthis shell completion bash 2>/dev/null)\""));
        assert!(!s.contains("alias lt"));
        assert!(!s.contains("ltr()"));
    }

    #[test]
    fn bash_alias_only_emits_aliases_no_completion() {
        let s = render(Shell::Bash, flags(false, true)).unwrap();
        assert!(!s.contains("eval \"$(linthis shell completion"));
        assert!(s.contains("alias lt='linthis'"));
        assert!(s.contains("alias lts='linthis -s'"));
        assert!(s.contains("alias ltm='linthis -m'"));
    }

    #[test]
    fn ltr_is_function_not_alias_for_argv_passthrough() {
        let bash = render(Shell::Bash, flags(false, true)).unwrap();
        assert!(bash.contains("ltr() { linthis report show \"$@\"; }"));
        assert!(!bash.contains("alias ltr="));

        let fish = render(Shell::Fish, flags(false, true)).unwrap();
        assert!(fish.contains("function ltr; linthis report show $argv; end"));
        assert!(!fish.contains("alias ltr"));

        let ps = render(Shell::PowerShell, flags(false, true)).unwrap();
        assert!(ps.contains("function ltr { linthis report show @args }"));
    }

    #[test]
    fn zsh_completion_uses_zsh_keyword() {
        let s = render(Shell::Zsh, flags(true, false)).unwrap();
        assert!(s.contains("linthis shell completion zsh"));
        assert!(!s.contains("completion bash"));
    }

    #[test]
    fn fish_uses_pipe_to_source_not_eval() {
        let s = render(Shell::Fish, flags(true, false)).unwrap();
        assert!(s.contains("linthis shell completion fish 2>/dev/null | source"));
    }

    #[test]
    fn powershell_uses_invoke_expression() {
        let s = render(Shell::PowerShell, flags(true, false)).unwrap();
        assert!(s.contains("Invoke-Expression"));
    }

    #[test]
    fn header_warns_against_manual_edit() {
        let s = render(Shell::Bash, flags(true, true)).unwrap();
        assert!(s.starts_with("# Generated by linthis — DO NOT EDIT MANUALLY."));
        assert!(s.contains("Toggle with: linthis shell add|remove ac|alias"));
    }

    #[test]
    fn header_has_no_leading_whitespace_on_continuation_lines() {
        let s = render(Shell::Bash, flags(true, true)).unwrap();
        for line in s.lines() {
            // Comment lines that begin with `#` must start at column 0.
            // (Body lines that don't start with `#` are allowed to be indented.)
            if line.starts_with(char::is_whitespace) {
                assert!(
                    !line.trim_start().starts_with('#'),
                    "comment line has leading whitespace: {line:?}"
                );
            }
        }
    }

    #[test]
    fn powershell_header_uses_ascii_dash_not_em_dash() {
        let ps = render(Shell::PowerShell, flags(true, false)).unwrap();
        assert!(ps.starts_with("# Generated by linthis - DO NOT EDIT MANUALLY."));
        assert!(!ps.contains("# Generated by linthis — DO NOT EDIT MANUALLY."));
    }
}