paneship 1.0.0

A blazingly fast, high-performance shell prompt optimized for tmux and large Git repositories
use crate::core::layout::{truncate_plain_to_width, visible_width};
use crate::core::prompt::PromptContext;
use std::fs;
use std::path::Path;
use std::process::Command;

pub fn render_with_max_width(context: &PromptContext, max_visible_width: usize) -> String {
    if max_visible_width == 0 {
        return String::new();
    }

    let mut rendered = Vec::new();
    let separator = "   ";

    for candidate in metadata_parts(context) {
        if candidate.is_empty() {
            continue;
        }

        let assembled = if rendered.is_empty() {
            candidate.clone()
        } else {
            format!("{}{}{}", rendered.join(separator), separator, candidate)
        };

        if visible_width(assembled.as_str()) <= max_visible_width {
            rendered.push(candidate);
        }
    }

    let output = rendered.join(separator);
    if output.is_empty() {
        return String::new();
    }

    if visible_width(output.as_str()) <= max_visible_width {
        output
    } else {
        let plain = crate::core::layout::strip_ansi(output.as_str());
        truncate_plain_to_width(plain.as_str(), max_visible_width)
    }
}

fn metadata_parts(context: &PromptContext) -> Vec<String> {
    let metadata_config = &context.config.metadata;
    let mut parts = Vec::new();

    if let Some((language_name, version)) = detect_language_version(context.cwd.as_path()) {
        let language_style = metadata_config.language_style(language_name.as_str());
        let language_value = format!("{} {version}", language_style.icon);
        parts.push(styled(&language_style.color, &language_value));
    }

    if let Some(duration) = render_command_duration(context) {
        parts.push(duration);
    }

    if let Some(time) = current_time_hhmm() {
        parts.push(styled(&metadata_config.time_color, &format!("󰥔 {time}")));
    }

    parts
}

fn render_command_duration(context: &PromptContext) -> Option<String> {
    let ms = context.last_command_duration_ms?;
    let plain = if ms < 1_000 {
        format!("󰞌 {ms}ms")
    } else {
        let secs = ms / 1_000;
        if secs < 60 {
            format!("󰞌 {secs}s")
        } else {
            let mins = secs / 60;
            let rem_secs = secs % 60;
            format!("󰞌 {mins}m{rem_secs:02}s")
        }
    };

    Some(styled("2;37", plain.as_str()))
}

fn current_time_hhmm() -> Option<String> {
    unsafe {
        let mut now: libc::time_t = 0;
        libc::time(&mut now as *mut libc::time_t);

        let mut local: libc::tm = std::mem::zeroed();

        #[cfg(not(windows))]
        if libc::localtime_r(&now as *const libc::time_t, &mut local as *mut libc::tm).is_null() {
            return None;
        }

        #[cfg(windows)]
        if libc::localtime_s(&mut local as *mut libc::tm, &now as *const libc::time_t) != 0 {
            return None;
        }

        Some(format!("{:02}:{:02}", local.tm_hour, local.tm_min))
    }
}

fn detect_language_version(cwd: &Path) -> Option<(String, String)> {
    detect_rust(cwd)
        .or_else(|| detect_node(cwd))
        .or_else(|| detect_bun(cwd))
        .or_else(|| detect_python(cwd))
        .or_else(|| detect_go(cwd))
        .or_else(|| detect_deno(cwd))
        .or_else(|| detect_ruby(cwd))
        .or_else(|| detect_php(cwd))
        .or_else(|| detect_java(cwd))
}

fn detect_rust(cwd: &Path) -> Option<(String, String)> {
    if let Some(version) = rust_toolchain_version(cwd) {
        return Some(("rust".to_string(), version));
    }

    find_upwards(cwd, "Cargo.toml")?;

    let output = Command::new("rustc")
        .arg("--version")
        .current_dir(cwd)
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }

    let text = String::from_utf8_lossy(&output.stdout);
    let mut parts = text.split_whitespace();
    let _ = parts.next();
    let version = parts.next()?.to_string();
    Some(("rust".to_string(), version))
}

fn detect_node(cwd: &Path) -> Option<(String, String)> {
    find_upwards(cwd, "package.json")?;

    let output = Command::new("node")
        .arg("-v")
        .current_dir(cwd)
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }

    let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
    if version.is_empty() {
        return None;
    }

    Some((
        "node".to_string(),
        version.trim_start_matches('v').to_string(),
    ))
}

fn detect_bun(cwd: &Path) -> Option<(String, String)> {
    if find_upwards(cwd, "bun.lockb").is_none() && find_upwards(cwd, "bun.lock").is_none() {
        return None;
    }

    let output = Command::new("bun")
        .arg("--version")
        .current_dir(cwd)
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }

    let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
    if version.is_empty() {
        return None;
    }

    Some(("bun".to_string(), version))
}

fn detect_python(cwd: &Path) -> Option<(String, String)> {
    if find_upwards(cwd, "pyproject.toml").is_none()
        && find_upwards(cwd, "requirements.txt").is_none()
        && find_upwards(cwd, "setup.py").is_none()
    {
        return None;
    }

    let output = Command::new("python3")
        .arg("--version")
        .current_dir(cwd)
        .output()
        .ok()?;

    let text = if output.status.success() {
        String::from_utf8_lossy(&output.stdout).trim().to_string()
    } else {
        String::from_utf8_lossy(&output.stderr).trim().to_string()
    };

    if text.is_empty() {
        return None;
    }

    let mut parts = text.split_whitespace();
    let _ = parts.next();
    let version = parts.next()?.to_string();
    Some(("python".to_string(), version))
}

fn detect_go(cwd: &Path) -> Option<(String, String)> {
    find_upwards(cwd, "go.mod")?;

    let output = Command::new("go")
        .arg("version")
        .current_dir(cwd)
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }

    let text = String::from_utf8_lossy(&output.stdout);
    let version = text
        .split_whitespace()
        .find(|token| token.starts_with("go1."))?
        .to_string();
    Some((
        "go".to_string(),
        version.trim_start_matches("go").to_string(),
    ))
}

fn detect_deno(cwd: &Path) -> Option<(String, String)> {
    if find_upwards(cwd, "deno.json").is_none() && find_upwards(cwd, "deno.jsonc").is_none() {
        return None;
    }

    let output = Command::new("deno")
        .arg("--version")
        .current_dir(cwd)
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }

    let text = String::from_utf8_lossy(&output.stdout);
    let first_line = text.lines().next()?.trim();
    let version = first_line.split_whitespace().nth(1)?.to_string();
    Some(("deno".to_string(), version))
}

fn detect_ruby(cwd: &Path) -> Option<(String, String)> {
    if find_upwards(cwd, "Gemfile").is_none() && find_upwards(cwd, ".ruby-version").is_none() {
        return None;
    }

    let output = Command::new("ruby")
        .arg("--version")
        .current_dir(cwd)
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }

    let text = String::from_utf8_lossy(&output.stdout);
    let version = text.split_whitespace().nth(1)?.to_string();
    Some(("ruby".to_string(), version))
}

fn detect_php(cwd: &Path) -> Option<(String, String)> {
    find_upwards(cwd, "composer.json")?;

    let output = Command::new("php")
        .arg("-v")
        .current_dir(cwd)
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }

    let text = String::from_utf8_lossy(&output.stdout);
    let first_line = text.lines().next()?.trim();
    let mut parts = first_line.split_whitespace();
    let _ = parts.next();
    let version = parts.next()?.to_string();
    Some(("php".to_string(), version))
}

fn detect_java(cwd: &Path) -> Option<(String, String)> {
    if find_upwards(cwd, "pom.xml").is_none()
        && find_upwards(cwd, "build.gradle").is_none()
        && find_upwards(cwd, "build.gradle.kts").is_none()
    {
        return None;
    }

    let output = Command::new("java")
        .arg("-version")
        .current_dir(cwd)
        .output()
        .ok()?;

    let text = if output.status.success() {
        String::from_utf8_lossy(&output.stderr).to_string()
    } else {
        return None;
    };

    let first_line = text.lines().next()?.trim();
    let quoted = first_line.split('"').nth(1)?.to_string();
    Some(("java".to_string(), quoted))
}

fn rust_toolchain_version(cwd: &Path) -> Option<String> {
    if let Some(path) = find_upwards(cwd, "rust-toolchain.toml") {
        let content = fs::read_to_string(path).ok()?;
        for line in content.lines() {
            let trimmed = line.trim();
            if let Some(value) = trimmed.strip_prefix("channel") {
                let value = value.trim();
                if let Some(raw) = value.strip_prefix('=') {
                    let raw = raw.trim();
                    if raw.len() >= 2 && raw.starts_with('"') && raw.ends_with('"') {
                        return Some(raw[1..raw.len() - 1].to_string());
                    }
                }
            }
        }
    }

    if let Some(path) = find_upwards(cwd, "rust-toolchain") {
        let content = fs::read_to_string(path).ok()?;
        for line in content.lines() {
            let value = line.trim();
            if value.is_empty() || value.starts_with('#') {
                continue;
            }
            if value.starts_with('[') {
                break;
            }
            return Some(value.to_string());
        }
    }

    None
}

fn find_upwards(start: &Path, file_name: &str) -> Option<std::path::PathBuf> {
    for dir in start.ancestors() {
        let candidate = dir.join(file_name);
        if candidate.is_file() {
            return Some(candidate);
        }
    }
    None
}

fn styled(color_code: &str, value: &str) -> String {
    format!("\x1b[{color_code}m{value}\x1b[0m")
}