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;
#[derive(Debug, Clone, Serialize)]
pub struct ExportedTool {
pub name: String,
pub version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
}
#[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 {
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
}
}
}
pub fn export_tools(
filter_tools: Option<Vec<String>>,
_include_all: bool,
verbose: bool,
) -> ExportResult {
let mut detected_tools = Vec::new();
let tools_to_check: Vec<(&str, &str)> = if let Some(ref filter) = filter_tools {
filter
.iter()
.filter_map(|name| get_tool_spec(name).map(|spec| (spec.name, spec.command)))
.collect()
} else {
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,
});
}
}
let manual_tools = [("brew", "brew"), ("rust", "rustc"), ("nvm", "nvm")];
for (name, command) in manual_tools {
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
};
if !detected_tools.iter().any(|t| t.name == name) {
detected_tools.push(ExportedTool {
name: name.to_string(),
version,
path,
});
}
}
}
detected_tools.sort_by(|a, b| a.name.cmp(&b.name));
let count = detected_tools.len();
telemetry::export_completed(count, "toml");
ExportResult {
tools: detected_tools,
count,
verbose,
}
}
fn get_installed_version(command: &str) -> Option<String> {
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
}
})
}
}
fn chrono_lite_date() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let days_since_epoch = now / 86400;
let years = days_since_epoch / 365;
let year = 1970 + years;
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();
assert!(date.len() >= 8);
assert!(date.contains('-'));
}
}