pub mod commands;
pub mod config;
pub mod generator;
pub mod rules;
pub mod scanner;
pub mod version;
pub use rules::{Detection, ToolCategory, default_rules};
use std::collections::{HashMap, HashSet};
use std::path::Path;
#[derive(Debug, Clone, serde::Serialize)]
pub struct ToolSuggestion {
pub name: String,
pub version: String,
pub reason: String,
pub category: ToolCategory,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct UninstallableSuggestion {
pub name: String,
pub source: String,
pub category: ToolCategory,
pub reason: String,
}
#[derive(Debug, Default, serde::Serialize)]
pub struct DiscoverReport {
pub detections: Vec<Detection>,
pub required: Vec<ToolSuggestion>,
pub recommended: Vec<ToolSuggestion>,
pub already_configured: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub uninstallable: Vec<UninstallableSuggestion>,
}
pub fn analyze(
project_dir: &Path,
already_configured: &HashSet<String>,
known_tools: &HashSet<String>,
) -> DiscoverReport {
analyze_with(
project_dir,
already_configured,
&HashMap::new(),
known_tools,
default_rules(),
)
}
pub fn analyze_with(
project_dir: &Path,
already_configured: &HashSet<String>,
already_configured_versions: &HashMap<String, String>,
known_tools: &HashSet<String>,
rules: &[rules::DetectionRule],
) -> DiscoverReport {
let detections = rules::run(project_dir, rules);
let mut required: Vec<ToolSuggestion> = Vec::new();
let mut recommended: Vec<ToolSuggestion> = Vec::new();
let mut already_seen: Vec<String> = Vec::new();
let mut uninstallable: Vec<UninstallableSuggestion> = Vec::new();
let mut recommended_seen: HashSet<&str> = HashSet::new();
for d in &detections {
if already_configured.contains(&d.tool) {
let detected_version = d.version.as_deref();
let pinned = already_configured_versions.get(&d.tool).map(|s| s.as_str());
if version_already_satisfies(pinned, detected_version) {
already_seen.push(d.tool.clone());
} else if known_tools.contains(&d.tool) {
required.push(ToolSuggestion {
name: d.tool.clone(),
version: detected_version.unwrap_or("latest").to_string(),
reason: format!(
"detected from {} (pinned `{}` is more lax)",
d.source,
pinned.unwrap_or("?")
),
category: d.category,
});
}
} else if known_tools.contains(&d.tool) {
required.push(ToolSuggestion {
name: d.tool.clone(),
version: d.version.clone().unwrap_or_else(|| "latest".to_string()),
reason: format!("detected from {}", d.source),
category: d.category,
});
} else {
uninstallable.push(UninstallableSuggestion {
name: d.tool.clone(),
source: d.source.clone(),
category: d.category,
reason: "no jarvy handler".to_string(),
});
}
for suggested in &d.suggests {
if already_configured.contains(suggested) {
continue;
}
if !known_tools.contains(suggested) {
continue;
}
if !recommended_seen.insert(suggested.as_str()) {
continue;
}
recommended.push(ToolSuggestion {
name: suggested.clone(),
version: "latest".to_string(),
reason: format!("commonly used with {}", d.tool),
category: ToolCategory::Dev,
});
}
}
DiscoverReport {
detections,
required,
recommended,
already_configured: already_seen,
uninstallable,
}
}
fn version_already_satisfies(pinned: Option<&str>, detected: Option<&str>) -> bool {
let Some(pinned) = pinned else {
return true;
};
let Some(detected) = detected else {
return true;
};
let trimmed = pinned.trim();
if trimmed.is_empty() || trimmed == "latest" || trimmed == "*" || trimmed == detected {
return true;
}
crate::tools::version::version_satisfies(detected, trimmed)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn registry_with(names: &[&str]) -> HashSet<String> {
names.iter().map(|n| n.to_string()).collect()
}
#[test]
fn detects_rust_from_cargo_toml() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join("Cargo.toml"), "[package]\nname=\"x\"").unwrap();
let report = analyze(
tmp.path(),
&HashSet::new(),
®istry_with(&["rust", "cargo-watch"]),
);
let names: Vec<&str> = report.required.iter().map(|s| s.name.as_str()).collect();
assert!(names.contains(&"rust"), "got {names:?}");
}
#[test]
fn detects_node_from_package_json() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join("package.json"), "{}").unwrap();
let report = analyze(tmp.path(), &HashSet::new(), ®istry_with(&["node"]));
let names: Vec<&str> = report.required.iter().map(|s| s.name.as_str()).collect();
assert!(names.contains(&"node"));
}
#[test]
fn drops_unknown_tools() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
let report = analyze(tmp.path(), &HashSet::new(), &HashSet::new());
assert!(report.required.is_empty());
}
#[test]
fn marks_already_configured_separately() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
let mut configured = HashSet::new();
configured.insert("rust".to_string());
let report = analyze(tmp.path(), &configured, ®istry_with(&["rust"]));
assert!(report.required.is_empty());
assert_eq!(report.already_configured, vec!["rust"]);
}
#[test]
fn version_inferred_from_marker_file() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
fs::write(
tmp.path().join("rust-toolchain.toml"),
"[toolchain]\nchannel = \"1.85.0\"",
)
.unwrap();
let report = analyze(tmp.path(), &HashSet::new(), ®istry_with(&["rust"]));
let rust = report.required.iter().find(|s| s.name == "rust").unwrap();
assert_eq!(rust.version, "1.85.0");
}
#[test]
fn pinned_range_covering_detected_version_does_not_re_suggest() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
fs::write(
tmp.path().join("rust-toolchain.toml"),
"[toolchain]\nchannel = \"1.85.0\"",
)
.unwrap();
let mut configured: HashSet<String> = HashSet::new();
configured.insert("rust".to_string());
let mut versions: HashMap<String, String> = HashMap::new();
versions.insert("rust".to_string(), ">= 1.80, < 2.0".to_string());
let report = analyze_with(
tmp.path(),
&configured,
&versions,
®istry_with(&["rust"]),
default_rules(),
);
assert_eq!(report.already_configured, vec!["rust"]);
assert!(report.required.is_empty(), "got {:?}", report.required);
}
#[test]
fn pinned_range_below_detected_version_surfaces_as_override() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
fs::write(
tmp.path().join("rust-toolchain.toml"),
"[toolchain]\nchannel = \"1.85.0\"",
)
.unwrap();
let mut configured: HashSet<String> = HashSet::new();
configured.insert("rust".to_string());
let mut versions: HashMap<String, String> = HashMap::new();
versions.insert("rust".to_string(), "1.70.0".to_string());
let report = analyze_with(
tmp.path(),
&configured,
&versions,
®istry_with(&["rust"]),
default_rules(),
);
let names: Vec<&str> = report.required.iter().map(|s| s.name.as_str()).collect();
assert!(names.contains(&"rust"), "got required={names:?}");
}
#[test]
fn unknown_tool_lands_in_uninstallable_bucket() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
let report = analyze(tmp.path(), &HashSet::new(), &HashSet::new());
assert!(report.required.is_empty());
let names: Vec<&str> = report
.uninstallable
.iter()
.map(|s| s.name.as_str())
.collect();
assert!(names.contains(&"rust"), "got {names:?}");
}
#[test]
fn recommends_companion_tools_once() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join("Dockerfile"), "FROM alpine").unwrap();
fs::write(tmp.path().join("docker-compose.yml"), "version: '3'").unwrap();
let report = analyze(
tmp.path(),
&HashSet::new(),
®istry_with(&["docker", "docker-compose", "lazydocker"]),
);
let count = report
.recommended
.iter()
.filter(|s| s.name == "docker-compose")
.count();
assert_eq!(count, 1, "got {:?}", report.recommended);
}
}