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, Clone, 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>,
#[serde(default, skip_serializing_if = "is_zero_usize")]
pub recommended_dropped_dup: usize,
}
fn is_zero_usize(n: &usize) -> bool {
*n == 0
}
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 {
let d_tool: &str = d.tool.as_ref();
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.to_string());
} else if known_tools.contains(d_tool) {
required.push(ToolSuggestion {
name: d_tool.to_string(),
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.to_string(),
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.to_string(),
source: d.source.clone(),
category: d.category,
reason: "no jarvy handler".to_string(),
});
}
for suggested in &d.suggests {
let suggested_str: &str = suggested.as_ref();
if already_configured.contains(suggested_str) {
continue;
}
if !known_tools.contains(suggested_str) {
continue;
}
if !recommended_seen.insert(suggested_str) {
continue;
}
recommended.push(ToolSuggestion {
name: suggested_str.to_string(),
version: "latest".to_string(),
reason: format!("commonly used with {}", d.tool),
category: ToolCategory::Dev,
});
}
}
let mut recommended_dropped_dup: usize = 0;
recommended.retain(|s| {
let is_dup = required.iter().any(|r| r.name == s.name);
if is_dup {
recommended_dropped_dup += 1;
if crate::observability::telemetry_gate::is_enabled() {
tracing::debug!(
event = "discover.recommended_dropped",
name = %s.name,
promoted_to = "required",
);
}
}
!is_dup
});
DiscoverReport {
detections,
required,
recommended,
already_configured: already_seen,
uninstallable,
recommended_dropped_dup,
}
}
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 detects_gh_from_dot_github_dir() {
let tmp = tempdir().unwrap();
fs::create_dir(tmp.path().join(".github")).unwrap();
let report = analyze(
tmp.path(),
&HashSet::new(),
®istry_with(&["gh", "release-plz"]),
);
let required: Vec<&str> = report.required.iter().map(|s| s.name.as_str()).collect();
assert!(required.contains(&"gh"), "got required={required:?}");
let recommended: Vec<&str> = report.recommended.iter().map(|s| s.name.as_str()).collect();
assert!(
recommended.contains(&"release-plz"),
"got recommended={recommended:?}"
);
}
#[test]
fn niche_languages_detect_from_canonical_markers() {
let cases: &[(&str, &str)] = &[
("deno", "deno.json"),
("elixir", "mix.exs"),
("erlang", "rebar.config"),
("haskell", "cabal.project"),
("crystal", "shard.yml"),
("gleam", "gleam.toml"),
("lua", ".lua-version"),
("luarocks", "example.rockspec"),
("nim", "example.nimble"),
("ocaml", "dune-project"),
("scala", "build.sbt"),
("zig", "build.zig"),
("julia", "Manifest.toml"),
("cmake", "CMakeLists.txt"),
("skaffold", "skaffold.yaml"),
("bazelisk", "MODULE.bazel"),
("bun", "bun.lockb"),
("infisical", ".infisical.json"),
("release-plz", "release-plz.toml"),
("composer", "composer.json"),
];
let known = registry_with(&[
"deno",
"elixir",
"erlang",
"haskell",
"crystal",
"gleam",
"lua",
"luarocks",
"nim",
"ocaml",
"scala",
"zig",
"julia",
"cmake",
"skaffold",
"bazelisk",
"bun",
"infisical",
"release-plz",
"composer",
"php",
]);
for (tool, marker) in cases {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join(marker), "").unwrap();
let report = analyze(tmp.path(), &HashSet::new(), &known);
let names: Vec<&str> = report.required.iter().map(|s| s.name.as_str()).collect();
assert!(
names.contains(tool),
"{tool} not required after writing {marker}; got required={names:?}"
);
}
}
#[test]
fn dir_marker_rules_detect_from_canonical_dirs() {
let cases: &[(&str, &str)] = &[("git", ".git"), ("gh", ".github"), ("vscode", ".vscode")];
let known = registry_with(&["git", "gh", "vscode", "release-plz"]);
for (tool, dir) in cases {
let tmp = tempdir().unwrap();
fs::create_dir(tmp.path().join(dir)).unwrap();
let report = analyze(tmp.path(), &HashSet::new(), &known);
let names: Vec<&str> = report.required.iter().map(|s| s.name.as_str()).collect();
assert!(
names.contains(tool),
"{tool} not required after creating {dir}/; got required={names:?}"
);
}
}
#[test]
fn discover_does_not_walk_into_dot_git() {
let tmp = tempdir().unwrap();
fs::create_dir(tmp.path().join(".git")).unwrap();
fs::write(tmp.path().join(".git/package.json"), "{}").unwrap();
fs::write(tmp.path().join(".git/mix.exs"), "").unwrap();
fs::write(tmp.path().join(".git/Cargo.toml"), "[package]\nname=\"x\"").unwrap();
let report = analyze(
tmp.path(),
&HashSet::new(),
®istry_with(&["git", "node", "elixir", "rust"]),
);
let names: Vec<&str> = report.required.iter().map(|s| s.name.as_str()).collect();
assert!(names.contains(&"git"), "got {names:?}");
for hostile in ["node", "elixir", "rust"] {
assert!(
!names.contains(&hostile),
"hostile marker inside `.git/{hostile}-marker` must NOT \
trigger `{hostile}` detection — scanner must stay at \
project root. Got: {names:?}"
);
}
}
#[test]
fn elixir_recommends_erlang_companion() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join("mix.exs"), "").unwrap();
let report = analyze(
tmp.path(),
&HashSet::new(),
®istry_with(&["elixir", "erlang"]),
);
let recommended: Vec<&str> = report.recommended.iter().map(|s| s.name.as_str()).collect();
assert!(recommended.contains(&"erlang"), "got {recommended:?}");
}
#[test]
fn julia_does_not_falsely_match_bare_project_toml() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join("Project.toml"), "").unwrap();
let report = analyze(tmp.path(), &HashSet::new(), ®istry_with(&["julia"]));
let names: Vec<&str> = report.required.iter().map(|s| s.name.as_str()).collect();
assert!(
!names.contains(&"julia"),
"bare `Project.toml` must NOT imply Julia (too ambiguous); \
got required={names:?}"
);
}
#[test]
fn polyglot_node_php_rust_go_detects_full_stack() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join("Cargo.toml"), "[package]\nname=\"x\"").unwrap();
fs::write(tmp.path().join("package.json"), "{}").unwrap();
fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
fs::write(tmp.path().join("composer.json"), "{}").unwrap();
fs::write(tmp.path().join("go.mod"), "module x\ngo 1.22\n").unwrap();
let known = registry_with(&[
"rust",
"node",
"php",
"go",
"pnpm",
"composer",
"bacon",
"cargo-nextest",
"golangci-lint",
"air",
"delve",
]);
let report = analyze(tmp.path(), &HashSet::new(), &known);
let required: Vec<&str> = report.required.iter().map(|s| s.name.as_str()).collect();
for expected in ["rust", "node", "php", "go", "pnpm", "composer"] {
assert!(
required.contains(&expected),
"polyglot required must include {expected}; got {required:?}"
);
}
let recommended: Vec<&str> = report.recommended.iter().map(|s| s.name.as_str()).collect();
for expected in ["bacon", "cargo-nextest", "golangci-lint", "air", "delve"] {
assert!(
recommended.contains(&expected),
"polyglot recommended must include {expected}; got {recommended:?}"
);
}
}
#[test]
fn only_pnpm_lockfile_requires_pnpm_not_yarn() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join("package.json"), "{}").unwrap();
fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
let report = analyze(
tmp.path(),
&HashSet::new(),
®istry_with(&["node", "pnpm", "yarn"]),
);
let required: Vec<&str> = report.required.iter().map(|s| s.name.as_str()).collect();
assert!(required.contains(&"pnpm"), "got {required:?}");
assert!(
!required.contains(&"yarn"),
"yarn.lock absent — should not be required; got {required:?}"
);
}
#[test]
fn composer_json_alone_requires_php_and_composer() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join("composer.json"), "{}").unwrap();
let report = analyze(
tmp.path(),
&HashSet::new(),
®istry_with(&["php", "composer"]),
);
let required: Vec<&str> = report.required.iter().map(|s| s.name.as_str()).collect();
assert!(required.contains(&"php"), "got {required:?}");
assert!(required.contains(&"composer"), "got {required:?}");
}
#[test]
fn required_drops_dup_from_recommended() {
struct DedupCase {
markers: &'static [&'static str],
dirs: &'static [&'static str],
tool: &'static str,
why: &'static str,
}
let cases: &[DedupCase] = &[
DedupCase {
markers: &["release-plz.toml"],
dirs: &[".github"],
tool: "release-plz",
why: "own marker `release-plz.toml` + companion via `.github` \
(gh suggests release-plz)",
},
DedupCase {
markers: &["package.json", "pnpm-lock.yaml"],
dirs: &[],
tool: "pnpm",
why: "own marker `pnpm-lock.yaml` — must dedup vs. \
any future node-side suggest of pnpm",
},
DedupCase {
markers: &["package.json", "yarn.lock"],
dirs: &[],
tool: "yarn",
why: "own marker `yarn.lock` — same rationale for yarn",
},
];
for case in cases {
let tmp = tempdir().unwrap();
for dir in case.dirs {
fs::create_dir(tmp.path().join(dir)).unwrap();
}
for marker in case.markers {
fs::write(tmp.path().join(marker), "").unwrap();
}
let report = analyze(
tmp.path(),
&HashSet::new(),
®istry_with(&["gh", "release-plz", "node", "pnpm", "yarn", "bun"]),
);
let required: Vec<&str> = report.required.iter().map(|s| s.name.as_str()).collect();
let recommended: Vec<&str> =
report.recommended.iter().map(|s| s.name.as_str()).collect();
assert!(
required.contains(&case.tool),
"{}: expected `{}` in required (own marker present); \
got required={:?} recommended={:?}",
case.why,
case.tool,
required,
recommended
);
assert!(
!recommended.contains(&case.tool),
"{}: `{}` must be dedup'd out of recommended (would \
duplicate the `[provisioner]` key); got recommended={:?}",
case.why,
case.tool,
recommended
);
}
}
#[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);
}
}