yoo 0.6.2

A tiny developer companion for better coding sessions.
use std::{
    env, fs, io,
    path::{Path, PathBuf},
};

use serde::Serialize;

use crate::{git, ui::Ui};

#[derive(Debug, Clone, Serialize)]
pub struct FetchReport {
    pub yoo_version: String,
    pub environment: EnvironmentInfo,
    pub project: ProjectInfo,
    pub git: Option<GitSummary>,
}

#[derive(Debug, Clone, Serialize)]
pub struct EnvironmentInfo {
    pub os: String,
    pub architecture: String,
    pub shell: String,
    pub terminal: String,
    pub editor: String,
    pub rustc: Option<String>,
    pub cargo: Option<String>,
    pub git: Option<String>,
}

#[derive(Debug, Clone, Serialize)]
pub struct ProjectInfo {
    pub name: String,
    pub kind: String,
    pub manifest: Option<String>,
    pub version: Option<String>,
    pub directory: String,
}

#[derive(Debug, Clone, Serialize)]
pub struct GitSummary {
    pub branch: String,
    pub changed_files: usize,
}

pub fn collect(directory: &Path) -> FetchReport {
    FetchReport {
        yoo_version: env!("CARGO_PKG_VERSION").to_owned(),
        environment: EnvironmentInfo {
            os: display_os(),
            architecture: env::consts::ARCH.to_owned(),
            shell: detect_shell(),
            terminal: detect_terminal(),
            editor: detect_editor(),
            rustc: command_version("rustc", &["--version"]),
            cargo: command_version("cargo", &["--version"]),
            git: command_version("git", &["--version"]),
        },
        project: detect_project(directory),
        git: git::inspect(directory).map(|info| GitSummary {
            branch: info.branch,
            changed_files: info.changed_files,
        }),
    }
}

pub fn to_json(report: &FetchReport) -> Result<String, serde_json::Error> {
    serde_json::to_string_pretty(report)
}

pub fn print(report: &FetchReport, ui: &Ui) -> io::Result<()> {
    ui.heading("yoo fetch — developer environment")?;
    ui.blank_line()?;

    ui.divider()?;
    ui.info(
        "🖥",
        "OS:",
        &format!(
            "{} ({})",
            report.environment.os, report.environment.architecture
        ),
    )?;
    ui.info("🐚", "Shell:", &report.environment.shell)?;
    ui.info("", "Terminal:", &report.environment.terminal)?;
    ui.info("📝", "Editor:", &report.environment.editor)?;

    ui.divider()?;
    print_tool(ui, "🦀", "Rust:", report.environment.rustc.as_deref())?;
    print_tool(ui, "📦", "Cargo:", report.environment.cargo.as_deref())?;
    print_tool(ui, "🌿", "Git:", report.environment.git.as_deref())?;

    ui.divider()?;
    ui.info("📁", "Project:", &report.project.name)?;
    ui.info("🔧", "Project type:", &report.project.kind)?;

    if let Some(manifest) = report.project.manifest.as_deref() {
        ui.info("📄", "Manifest:", manifest)?;
    }

    if let Some(version) = report.project.version.as_deref() {
        ui.info("🏷", "Project version:", version)?;
    }

    if let Some(git) = report.git.as_ref() {
        let status = if git.changed_files == 0 {
            "clean".to_owned()
        } else {
            format!("{} changed file(s)", git.changed_files)
        };
        ui.info("🌿", "Git branch:", &git.branch)?;
        ui.info("✏️", "Working tree:", &status)?;
    } else {
        ui.info("🌿", "Git:", "not a repository")?;
    }

    ui.divider()?;
    ui.info(
        "",
        "yoo:",
        &format!(
            "v{} · try `yoo project` for a deeper project overview",
            report.yoo_version
        ),
    )?;
    Ok(())
}

fn print_tool(ui: &Ui, icon: &str, label: &str, version: Option<&str>) -> io::Result<()> {
    ui.info(icon, label, version.unwrap_or("not found in PATH"))
}

fn command_version(program: &str, arguments: &[&str]) -> Option<String> {
    git::run_command(program, arguments)
}

fn display_os() -> String {
    match env::consts::OS {
        "windows" => "Windows".to_owned(),
        "macos" => "macOS".to_owned(),
        "linux" => "Linux".to_owned(),
        other => other.to_owned(),
    }
}

fn detect_shell() -> String {
    if cfg!(target_os = "windows") {
        if env::var_os("PSModulePath").is_some() {
            return "PowerShell".to_owned();
        }

        if let Some(shell) = env::var_os("ComSpec") {
            return executable_name(Path::new(&shell))
                .unwrap_or_else(|| "Command Prompt".to_owned());
        }
    }

    env::var_os("SHELL")
        .as_deref()
        .and_then(|shell| executable_name(Path::new(shell)))
        .unwrap_or_else(|| "not detected".to_owned())
}

fn detect_terminal() -> String {
    if env::var_os("WT_SESSION").is_some() {
        return "Windows Terminal".to_owned();
    }

    if let Some(program) = env::var_os("TERM_PROGRAM") {
        let value = program.to_string_lossy().trim().to_owned();
        if !value.is_empty() {
            return value;
        }
    }

    if env::var_os("VSCODE_GIT_IPC_HANDLE").is_some()
        || env::var_os("VSCODE_IPC_HOOK_CLI").is_some()
    {
        return "VS Code integrated terminal".to_owned();
    }

    env::var_os("TERM")
        .map(|term| term.to_string_lossy().trim().to_owned())
        .filter(|term| !term.is_empty() && term != "dumb")
        .unwrap_or_else(|| "not detected".to_owned())
}

fn detect_editor() -> String {
    if env::var_os("VSCODE_GIT_IPC_HANDLE").is_some()
        || env::var_os("VSCODE_IPC_HOOK_CLI").is_some()
    {
        return "Visual Studio Code".to_owned();
    }

    env::var_os("VISUAL")
        .or_else(|| env::var_os("EDITOR"))
        .as_deref()
        .and_then(|editor| executable_name(Path::new(editor)))
        .unwrap_or_else(|| "not detected".to_owned())
}

fn executable_name(path: &Path) -> Option<String> {
    path.file_stem()
        .or_else(|| path.file_name())
        .and_then(|name| name.to_str())
        .map(|name| name.trim().to_owned())
        .filter(|name| !name.is_empty())
}

pub fn detect_project(directory: &Path) -> ProjectInfo {
    let fallback_name = directory_name(directory);

    if let Some(contents) = read_file(directory.join("Cargo.toml")) {
        return ProjectInfo {
            name: find_toml_string(&contents, "name").unwrap_or(fallback_name),
            kind: "Rust".to_owned(),
            manifest: Some("Cargo.toml".to_owned()),
            version: find_toml_string(&contents, "version"),
            directory: directory.display().to_string(),
        };
    }

    if let Some(contents) = read_file(directory.join("package.json")) {
        let package: serde_json::Value = serde_json::from_str(&contents).unwrap_or_default();
        return ProjectInfo {
            name: package
                .get("name")
                .and_then(serde_json::Value::as_str)
                .map(str::to_owned)
                .unwrap_or(fallback_name),
            kind: "Node.js".to_owned(),
            manifest: Some("package.json".to_owned()),
            version: package
                .get("version")
                .and_then(serde_json::Value::as_str)
                .map(str::to_owned),
            directory: directory.display().to_string(),
        };
    }

    if directory.join("pyproject.toml").is_file() {
        return project_from_marker(directory, fallback_name, "Python", "pyproject.toml");
    }

    if directory.join("go.mod").is_file() {
        return project_from_marker(directory, fallback_name, "Go", "go.mod");
    }

    if directory.join("pom.xml").is_file()
        || directory.join("build.gradle").is_file()
        || directory.join("build.gradle.kts").is_file()
    {
        let manifest = if directory.join("pom.xml").is_file() {
            "pom.xml"
        } else if directory.join("build.gradle.kts").is_file() {
            "build.gradle.kts"
        } else {
            "build.gradle"
        };
        return project_from_marker(directory, fallback_name, "Java", manifest);
    }

    if let Some(project_file) = first_project_file(directory, |path| {
        matches!(
            path.extension().and_then(|extension| extension.to_str()),
            Some("sln" | "csproj")
        )
    }) {
        let manifest = project_file
            .file_name()
            .and_then(|name| name.to_str())
            .unwrap_or(".NET project")
            .to_owned();
        return project_from_marker(directory, fallback_name, ".NET", &manifest);
    }

    ProjectInfo {
        name: fallback_name,
        kind: "Generic directory".to_owned(),
        manifest: None,
        version: None,
        directory: directory.display().to_string(),
    }
}

fn project_from_marker(directory: &Path, name: String, kind: &str, manifest: &str) -> ProjectInfo {
    ProjectInfo {
        name,
        kind: kind.to_owned(),
        manifest: Some(manifest.to_owned()),
        version: None,
        directory: directory.display().to_string(),
    }
}

fn first_project_file(directory: &Path, predicate: impl Fn(&Path) -> bool) -> Option<PathBuf> {
    let entries = fs::read_dir(directory).ok()?;

    entries
        .filter_map(Result::ok)
        .map(|entry| entry.path())
        .find(|path| predicate(path))
}

fn directory_name(directory: &Path) -> String {
    directory
        .file_name()
        .and_then(|name| name.to_str())
        .filter(|name| !name.is_empty())
        .unwrap_or("current directory")
        .to_owned()
}

fn read_file(path: PathBuf) -> Option<String> {
    fs::read_to_string(path).ok()
}

pub fn find_toml_string(contents: &str, key: &str) -> Option<String> {
    let mut in_package = false;

    for line in contents.lines() {
        let line = line.trim();

        if line.starts_with('[') {
            in_package = line == "[package]";
            continue;
        }

        if !in_package || line.starts_with('#') {
            continue;
        }

        let Some((found_key, value)) = line.split_once('=') else {
            continue;
        };

        if found_key.trim() == key {
            return parse_toml_string(value.trim());
        }
    }

    None
}

fn parse_toml_string(value: &str) -> Option<String> {
    let quote = value.chars().next()?;

    if quote == '\'' {
        let end = value[1..].find('\'')? + 1;
        return Some(value[1..end].to_owned());
    }

    if quote != '"' {
        return None;
    }

    let mut escaped = false;
    for (offset, character) in value[1..].char_indices() {
        if character == '"' && !escaped {
            let end = offset + 2;
            return serde_json::from_str(&value[..end]).ok();
        }

        escaped = character == '\\' && !escaped;
        if character != '\\' {
            escaped = false;
        }
    }

    None
}

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

    #[test]
    fn parses_only_package_toml_strings_without_extra_dependencies() {
        let manifest = r#"
[workspace.package]
name = "wrong"

[package]
name = 'yoo'
version = "0.6.1" # inline comment
"#;

        assert_eq!(find_toml_string(manifest, "name").as_deref(), Some("yoo"));
        assert_eq!(
            find_toml_string(manifest, "version").as_deref(),
            Some("0.6.1")
        );
    }

    #[test]
    fn collect_never_panics_for_a_temp_directory() {
        let report = collect(&std::env::temp_dir());
        assert!(!report.environment.os.is_empty());
        assert!(!report.project.name.is_empty());
    }
}