jarvy 0.0.5

Jarvy is a fast, cross-platform CLI that installs and manages developer tools across macOS and Linux.
Documentation
//! Generate jarvy.toml from currently installed tools
//!
//! Detects installed tools and generates a valid jarvy.toml configuration.

use crate::output::{ExitCode, Outputable};
use crate::telemetry;
use crate::tools::common::has;
use crate::tools::spec::{get_tool_spec, iter_tools};
use serde::Serialize;
use std::collections::HashMap;

/// An exported tool entry
#[derive(Debug, Clone, Serialize)]
pub struct ExportedTool {
    pub name: String,
    pub version: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub path: Option<String>,
}

/// Export result
#[derive(Debug, Clone, Serialize)]
pub struct ExportResult {
    pub tools: Vec<ExportedTool>,
    pub count: usize,
    #[serde(skip)]
    pub verbose: bool,
}

impl Outputable for ExportResult {
    fn to_human(&self) -> String {
        if self.tools.is_empty() {
            return "# No Jarvy-supported tools detected on this system\n".to_string();
        }

        let mut output = String::new();

        output.push_str(&format!(
            "# Generated by jarvy export on {}\n",
            chrono_lite_date()
        ));
        output.push_str("# Detected tools from current environment\n\n");
        output.push_str("[provisioner]\n");

        for tool in &self.tools {
            if self.verbose {
                if let Some(ref path) = tool.path {
                    output.push_str(&format!("{}# {}\n", "", path));
                }
            }
            output.push_str(&format!("{} = \"{}\"\n", tool.name, tool.version));
        }

        output
    }

    fn to_json(&self) -> String {
        // Return just the tools map for JSON
        let tools_map: HashMap<String, String> = self
            .tools
            .iter()
            .map(|t| (t.name.clone(), t.version.clone()))
            .collect();

        serde_json::to_string_pretty(&serde_json::json!({
            "generated_at": chrono_lite_date(),
            "count": self.count,
            "tools": tools_map,
        }))
        .unwrap_or_else(|e| format!("{{\"error\":\"{}\"}}", e))
    }

    fn exit_code(&self) -> ExitCode {
        if self.tools.is_empty() {
            ExitCode::Warning
        } else {
            ExitCode::Ok
        }
    }
}

/// Export currently installed tools to jarvy.toml format
pub fn export_tools(
    filter_tools: Option<Vec<String>>,
    _include_all: bool,
    verbose: bool,
) -> ExportResult {
    let mut detected_tools = Vec::new();

    // Get list of tools to check
    let tools_to_check: Vec<(&str, &str)> = if let Some(ref filter) = filter_tools {
        // Only check specific tools
        filter
            .iter()
            .filter_map(|name| get_tool_spec(name).map(|spec| (spec.name, spec.command)))
            .collect()
    } else {
        // Check all known tools
        iter_tools()
            .map(|entry| (entry.spec.name, entry.spec.command))
            .collect()
    };

    for (name, command) in tools_to_check {
        if has(command) {
            let version = get_installed_version(command).unwrap_or_else(|| "latest".to_string());
            let path = if verbose {
                which_command(command)
            } else {
                None
            };

            detected_tools.push(ExportedTool {
                name: name.to_lowercase(),
                version,
                path,
            });
        }
    }

    // Also check manual tools (nvm, rust, brew)
    let manual_tools = [("brew", "brew"), ("rust", "rustc"), ("nvm", "nvm")];

    for (name, command) in manual_tools {
        // Skip if we're filtering and this tool isn't in the filter
        if let Some(ref filter) = filter_tools {
            if !filter.iter().any(|f| f.to_lowercase() == name) {
                continue;
            }
        }

        if has(command) {
            let version = get_installed_version(command).unwrap_or_else(|| "latest".to_string());
            let path = if verbose {
                which_command(command)
            } else {
                None
            };

            // Avoid duplicates
            if !detected_tools.iter().any(|t| t.name == name) {
                detected_tools.push(ExportedTool {
                    name: name.to_string(),
                    version,
                    path,
                });
            }
        }
    }

    // Sort by name
    detected_tools.sort_by(|a, b| a.name.cmp(&b.name));

    let count = detected_tools.len();

    // Emit telemetry
    telemetry::export_completed(count, "toml");

    ExportResult {
        tools: detected_tools,
        count,
        verbose,
    }
}

fn get_installed_version(command: &str) -> Option<String> {
    // Try common version flags
    for flag in ["--version", "-v", "-V", "version"] {
        if let Ok(output) = std::process::Command::new(command).arg(flag).output() {
            if output.status.success() {
                let stdout = String::from_utf8_lossy(&output.stdout);
                let stderr = String::from_utf8_lossy(&output.stderr);
                let combined = format!("{}{}", stdout, stderr);

                if let Some(version) = extract_version(&combined) {
                    return Some(version);
                }
            }
        }
    }
    None
}

fn extract_version(text: &str) -> Option<String> {
    let re = regex::Regex::new(r"v?(\d+\.\d+(?:\.\d+)?)").ok()?;
    re.captures(text).map(|c| c[1].to_string())
}

fn which_command(command: &str) -> Option<String> {
    #[cfg(unix)]
    {
        std::process::Command::new("which")
            .arg(command)
            .output()
            .ok()
            .and_then(|o| {
                if o.status.success() {
                    String::from_utf8(o.stdout)
                        .ok()
                        .map(|s| s.trim().to_string())
                } else {
                    None
                }
            })
    }
    #[cfg(windows)]
    {
        std::process::Command::new("where")
            .arg(command)
            .output()
            .ok()
            .and_then(|o| {
                if o.status.success() {
                    String::from_utf8(o.stdout)
                        .ok()
                        .and_then(|s| s.lines().next().map(|l| l.trim().to_string()))
                } else {
                    None
                }
            })
    }
}

/// Simple date string without external dependencies
fn chrono_lite_date() -> String {
    use std::time::{SystemTime, UNIX_EPOCH};

    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();

    // Simple date calculation (approximate)
    let days_since_epoch = now / 86400;
    let years = days_since_epoch / 365;
    let year = 1970 + years;

    // Get approximate month and day (simplified)
    let remaining_days = days_since_epoch % 365;
    let month = (remaining_days / 30) + 1;
    let day = (remaining_days % 30) + 1;

    format!("{}-{:02}-{:02}", year, month.min(12), day.min(31))
}

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

    #[test]
    fn test_export_result_to_human() {
        let result = ExportResult {
            tools: vec![
                ExportedTool {
                    name: "git".to_string(),
                    version: "2.43.0".to_string(),
                    path: None,
                },
                ExportedTool {
                    name: "node".to_string(),
                    version: "20.11.0".to_string(),
                    path: None,
                },
            ],
            count: 2,
            verbose: false,
        };

        let output = result.to_human();
        assert!(output.contains("[provisioner]"));
        assert!(output.contains("git = \"2.43.0\""));
        assert!(output.contains("node = \"20.11.0\""));
    }

    #[test]
    fn test_export_result_verbose() {
        let result = ExportResult {
            tools: vec![ExportedTool {
                name: "git".to_string(),
                version: "2.43.0".to_string(),
                path: Some("/usr/bin/git".to_string()),
            }],
            count: 1,
            verbose: true,
        };

        let output = result.to_human();
        assert!(output.contains("/usr/bin/git"));
    }

    #[test]
    fn test_export_result_empty() {
        let result = ExportResult {
            tools: vec![],
            count: 0,
            verbose: false,
        };

        let output = result.to_human();
        assert!(output.contains("No Jarvy-supported tools detected"));
        assert_eq!(result.exit_code(), ExitCode::Warning);
    }

    #[test]
    fn test_extract_version() {
        assert_eq!(
            extract_version("git version 2.43.0"),
            Some("2.43.0".to_string())
        );
        assert_eq!(extract_version("v20.11.0"), Some("20.11.0".to_string()));
        assert_eq!(extract_version("rustc 1.75.0"), Some("1.75.0".to_string()));
    }

    #[test]
    fn test_chrono_lite_date() {
        let date = chrono_lite_date();
        // Should be in YYYY-MM-DD format
        assert!(date.len() >= 8);
        assert!(date.contains('-'));
    }
}