paneship 1.0.0

A blazingly fast, high-performance shell prompt optimized for tmux and large Git repositories
mod benchmark;
mod cache;
mod core;
#[cfg(unix)]
mod daemon;
mod modules;
#[cfg(unix)]
mod tmux;

use std::path::PathBuf;

#[cfg(unix)]
use benchmark::BenchmarkOptions;
use core::prompt::PromptContext;

#[derive(Debug, Clone)]
struct RenderOptions {
    exit_code: i32,
    width: Option<usize>,
    cwd: Option<PathBuf>,
    duration_ms: Option<u64>,
}

#[cfg(unix)]
#[derive(Debug, Clone)]
enum CliCommand {
    Init(InitOptions),
    Render(RenderOptions),
    Benchmark(BenchmarkOptions),
    Daemon,
    Help,
}

#[cfg(not(unix))]
#[derive(Debug, Clone)]
enum CliCommand {
    Render(RenderOptions),
    Help,
}

#[cfg(unix)]
#[derive(Debug, Clone)]
enum InitOptions {
    Zsh { onboarding: bool },
}

fn main() {
    let args: Vec<String> = std::env::args().skip(1).collect();
    let command = match parse_cli(args) {
        Ok(command) => command,
        Err(err) => {
            eprintln!("{err}\n\n{}", usage());
            std::process::exit(2);
        }
    };

    match command {
        CliCommand::Render(options) => {
            let context = PromptContext::from_inputs(
                options.cwd,
                options.width,
                options.exit_code,
                options.duration_ms,
            );
            let prompt = core::renderer::render(&context);
            print!("{prompt}");
        }
        #[cfg(unix)]
        CliCommand::Benchmark(options) => match benchmark::run(options) {
            Ok(report) => {
                println!("{report}");
            }
            Err(err) => {
                eprintln!("benchmark failed: {err}");
                std::process::exit(1);
            }
        },
        #[cfg(unix)]
        CliCommand::Daemon => {
            if let Err(err) = daemon::run() {
                eprintln!("daemon error: {err}");
                std::process::exit(1);
            }
        }
        #[cfg(unix)]
        CliCommand::Init(options) => {
            handle_init(options);
        }
        CliCommand::Help => {
            println!("{}", usage());
        }
    }
}

fn parse_cli(args: Vec<String>) -> Result<CliCommand, String> {
    if args.is_empty() {
        return Ok(CliCommand::Render(RenderOptions {
            exit_code: 0,
            width: None,
            cwd: None,
            duration_ms: None,
        }));
    }

    if matches!(args[0].as_str(), "help" | "--help" | "-h") {
        return Ok(CliCommand::Help);
    }

    if args[0] == "benchmark" {
        #[cfg(unix)]
        {
            return parse_benchmark_args(&args[1..]);
        }
        #[cfg(not(unix))]
        {
            return Err("benchmark command is only available on Unix".to_string());
        }
    }

    #[cfg(unix)]
    if args[0] == "daemon" {
        return Ok(CliCommand::Daemon);
    }

    #[cfg(unix)]
    if args[0] == "init" {
        return parse_init_args(&args[1..]);
    }

    if args[0] == "render" {
        return parse_render_args(&args[1..]);
    }

    parse_render_args(&args)
}

#[cfg(unix)]
fn handle_init(options: InitOptions) {
    match options {
        InitOptions::Zsh { onboarding } => {
            let script = zsh_init_script();
            if onboarding {
                if let Err(err) = append_zsh_onboarding(&script) {
                    eprintln!("{err}");
                    std::process::exit(1);
                }
                return;
            }

            print!("{script}");
        }
    }
}

#[cfg(unix)]
fn zsh_init_script() -> String {
    [
        "if ! pgrep -x \"paneship\" > /dev/null; then",
        "    paneship daemon > /dev/null 2>&1 &",
        "    disown",
        "fi",
        "",
        "zmodload zsh/datetime",
        "autoload -Uz add-zsh-hook",
        "typeset -gF PANESHIP_CMD_START=0",
        "",
        "paneship_preexec() {",
        "    PANESHIP_CMD_START=$EPOCHREALTIME",
        "}",
        "",
        "paneship_precmd() {",
        "    if (( PANESHIP_CMD_START > 0 )); then",
        "        local -F now=$EPOCHREALTIME",
        "        local -F elapsed=$(( now - PANESHIP_CMD_START ))",
        "        local elapsed_ms=$(( elapsed * 1000 ))",
        "        if (( elapsed_ms < 0 )); then",
        "            elapsed_ms=0",
        "        fi",
        "        export PANESHIP_LAST_CMD_DURATION_MS=$elapsed_ms",
        "    else",
        "        unset PANESHIP_LAST_CMD_DURATION_MS",
        "    fi",
        "}",
        "",
        "add-zsh-hook preexec paneship_preexec",
        "add-zsh-hook precmd paneship_precmd",
        "",
        "PROMPT='$(paneship render --exit-code $? --width $COLUMNS)'",
    ]
    .join("\n")
}

#[cfg(unix)]
fn append_zsh_onboarding(script: &str) -> Result<(), String> {
    use std::fs::OpenOptions;
    use std::io::Write;

    let home = std::env::var("HOME").map_err(|_| "Unable to find $HOME".to_string())?;
    let zshrc_path = format!("{home}/.zshrc");

    let start_marker = "# >>> paneship initialize >>>";
    let end_marker = "# <<< paneship initialize <<<";
    let block = format!("{start_marker}\n{script}\n{end_marker}\n");

    let existing = std::fs::read_to_string(&zshrc_path).unwrap_or_default();
    if existing.contains(start_marker)
        || existing.contains("paneship render --exit-code $? --width $COLUMNS")
    {
        println!("Paneship onboarding is already configured in {zshrc_path}");
        return Ok(());
    }

    let mut file = OpenOptions::new()
        .append(true)
        .create(true)
        .open(&zshrc_path)
        .map_err(|err| format!("Failed to open {zshrc_path}: {err}"))?;

    if !existing.is_empty() && !existing.ends_with('\n') {
        writeln!(file).map_err(|err| format!("Failed to write to {zshrc_path}: {err}"))?;
    }

    write!(file, "{block}").map_err(|err| format!("Failed to write to {zshrc_path}: {err}"))?;

    println!("Paneship onboarding config appended to {zshrc_path}");
    println!("Restart your shell or run: source {zshrc_path}");

    Ok(())
}

fn parse_render_args(args: &[String]) -> Result<CliCommand, String> {
    let mut options = RenderOptions {
        exit_code: 0,
        width: None,
        cwd: None,
        duration_ms: None,
    };

    let mut idx = 0;
    while idx < args.len() {
        match args[idx].as_str() {
            "--exit-code" | "-s" => {
                idx += 1;
                let value = args
                    .get(idx)
                    .ok_or_else(|| "missing value for --exit-code".to_string())?;
                options.exit_code = value
                    .parse::<i32>()
                    .map_err(|_| format!("invalid exit code: {value}"))?;
            }
            "--width" | "-w" => {
                idx += 1;
                let value = args
                    .get(idx)
                    .ok_or_else(|| "missing value for --width".to_string())?;
                options.width = Some(
                    value
                        .parse::<usize>()
                        .map_err(|_| format!("invalid width: {value}"))?,
                );
            }
            "--cwd" => {
                idx += 1;
                let value = args
                    .get(idx)
                    .ok_or_else(|| "missing value for --cwd".to_string())?;
                options.cwd = Some(PathBuf::from(value));
            }
            "--duration-ms" => {
                idx += 1;
                let value = args
                    .get(idx)
                    .ok_or_else(|| "missing value for --duration-ms".to_string())?;
                options.duration_ms = Some(
                    value
                        .parse::<u64>()
                        .map_err(|_| format!("invalid duration ms: {value}"))?,
                );
            }
            unknown => {
                return Err(format!("unknown render argument: {unknown}"));
            }
        }
        idx += 1;
    }

    Ok(CliCommand::Render(options))
}

#[cfg(unix)]
fn parse_benchmark_args(args: &[String]) -> Result<CliCommand, String> {
    let mut options = crate::benchmark::BenchmarkOptions::default();
    let mut idx = 0;

    while idx < args.len() {
        match args[idx].as_str() {
            "--iterations" | "-n" => {
                idx += 1;
                let value = args
                    .get(idx)
                    .ok_or_else(|| "missing value for --iterations".to_string())?;
                options.iterations = value
                    .parse::<usize>()
                    .map_err(|_| format!("invalid iterations value: {value}"))?;
            }
            "--panes" | "-p" => {
                idx += 1;
                let value = args
                    .get(idx)
                    .ok_or_else(|| "missing value for --panes".to_string())?;
                options.panes = value
                    .parse::<usize>()
                    .map_err(|_| format!("invalid panes value: {value}"))?;
            }
            "--compare-starship" => {
                options.compare_starship = true;
            }
            "--width" | "-w" => {
                idx += 1;
                let value = args
                    .get(idx)
                    .ok_or_else(|| "missing value for --width".to_string())?;
                options.width = Some(
                    value
                        .parse::<usize>()
                        .map_err(|_| format!("invalid width: {value}"))?,
                );
            }
            "--cwd" => {
                idx += 1;
                let value = args
                    .get(idx)
                    .ok_or_else(|| "missing value for --cwd".to_string())?;
                options.cwd = Some(PathBuf::from(value));
            }
            "--exit-code" | "-s" => {
                idx += 1;
                let value = args
                    .get(idx)
                    .ok_or_else(|| "missing value for --exit-code".to_string())?;
                options.exit_code = value
                    .parse::<i32>()
                    .map_err(|_| format!("invalid exit code: {value}"))?;
            }
            unknown => {
                return Err(format!("unknown benchmark argument: {unknown}"));
            }
        }
        idx += 1;
    }

    if options.iterations == 0 {
        return Err("iterations must be greater than 0".to_string());
    }
    if options.panes == 0 {
        return Err("panes must be greater than 0".to_string());
    }

    Ok(CliCommand::Benchmark(options))
}

#[cfg(unix)]
fn parse_init_args(args: &[String]) -> Result<CliCommand, String> {
    if args.is_empty() {
        return Err("missing init target. Try: paneship init zsh".to_string());
    }

    if args[0] != "zsh" {
        return Err(format!(
            "unsupported init target '{}'. Currently only 'zsh' is supported.",
            args[0]
        ));
    }

    let onboarding = match args.get(1).map(|v| v.as_str()) {
        None => false,
        Some("--onboarding") => true,
        Some("to") if args.get(2).map(|v| v.as_str()) == Some("onboarding") && args.len() == 3 => {
            true
        }
        Some("onboarding") if args.len() == 2 => true,
        Some(_) => {
            return Err(
                "unsupported init syntax. Use: paneship init zsh [--onboarding|to onboarding]"
                    .to_string(),
            )
        }
    };

    Ok(CliCommand::Init(InitOptions::Zsh { onboarding }))
}

fn usage() -> &'static str {
    "Paneship - high-performance shell prompt\n\nUSAGE:\n  paneship [render] [--exit-code <code>] [--width <cols>] [--cwd <path>] [--duration-ms <ms>]\n  paneship init zsh [--onboarding|to onboarding]\n  paneship benchmark [--iterations <n>] [--panes <n>] [--compare-starship] [--width <cols>] [--cwd <path>] [--exit-code <code>]\n  paneship daemon\n  paneship help\n\nOPTIONS:\n  -s, --exit-code <code>    Last command exit code\n  -w, --width <cols>        Prompt width budget\n      --cwd <path>          Directory to render the prompt for\n      --duration-ms <ms>    Last command duration in milliseconds\n\nINIT OPTIONS:\n  paneship init zsh         Print zsh init script (for eval)\n  paneship init zsh to onboarding\n                            Append paneship block to ~/.zshrc\n  paneship init zsh --onboarding\n                            Same as 'to onboarding'\n\nBENCHMARK OPTIONS:\n  -n, --iterations <n>      Renders per pane (default: 200)\n  -p, --panes <n>           Number of concurrent panes (default: 4)\n      --compare-starship    Include direct Starship comparison"
}