use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use super::{analyze_with, config as discover_config, generator};
pub struct DiscoverOpts<'a> {
pub file: &'a str,
pub apply: bool,
pub missing: bool,
pub rules_override: Option<&'a str>,
pub watch: bool,
pub output_format: &'a str,
}
fn run_watch_loop(opts: &DiscoverOpts<'_>) -> i32 {
use notify::{Event, EventKind, RecursiveMode, Watcher};
use std::sync::mpsc;
use std::time::Duration;
let project_dir = crate::paths::config_parent_dir(opts.file);
let exit = run_discover_once(opts);
if opts.output_format == "json" {
return exit;
}
let (tx, rx) = mpsc::channel::<()>();
let mut watcher = match notify::recommended_watcher(move |res: notify::Result<Event>| {
if let Ok(ev) = res {
if matches!(
ev.kind,
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
) {
let _ = tx.send(());
}
}
}) {
Ok(w) => w,
Err(e) => {
if crate::observability::telemetry_gate::is_enabled() {
tracing::error!(
event = "discover.watch.start_failed",
error = %e,
);
}
eprintln!("Failed to start watcher: {e}");
return crate::error_codes::CONFIG_ERROR;
}
};
if let Err(e) = watcher.watch(&project_dir, RecursiveMode::Recursive) {
if crate::observability::telemetry_gate::is_enabled() {
tracing::error!(
event = "discover.watch.subscribe_failed",
path = %project_dir.display(),
error = %e,
);
}
eprintln!("Failed to watch {}: {e}", project_dir.display());
return crate::error_codes::CONFIG_ERROR;
}
println!("\nWatching {} (Ctrl-C to exit)\n", project_dir.display());
loop {
if rx.recv().is_err() {
if crate::observability::telemetry_gate::is_enabled() {
tracing::error!(
event = "discover.watch.channel_closed",
reason = "all_senders_dropped",
);
}
eprintln!("watcher channel closed unexpectedly — exiting");
return crate::error_codes::CONFIG_ERROR;
}
let debounce_until = std::time::Instant::now() + Duration::from_millis(750);
while let Ok(()) =
rx.recv_timeout(debounce_until.saturating_duration_since(std::time::Instant::now()))
{
}
println!("--- re-scan ---");
let _ = run_discover_once(opts);
}
}
pub fn run_discover(file: &str, apply: bool, missing: bool, output_format: &str) -> i32 {
run_discover_full(DiscoverOpts {
file,
apply,
missing,
rules_override: None,
watch: false,
output_format,
})
}
pub fn run_discover_full(opts: DiscoverOpts<'_>) -> i32 {
if opts.watch {
return run_watch_loop(&opts);
}
run_discover_once(&opts)
}
fn run_discover_once(opts: &DiscoverOpts<'_>) -> i32 {
let file = opts.file;
let apply = opts.apply;
let missing = opts.missing;
let output_format = opts.output_format;
let project_dir: PathBuf = crate::paths::config_parent_dir(file);
let existing_text = std::fs::read_to_string(file).ok();
let existing_table: Option<toml::Table> = existing_text.as_deref().and_then(|t| t.parse().ok());
let (already_configured, already_configured_versions) = existing_table
.as_ref()
.map(parse_provisioner_pins_from_table)
.unwrap_or_default();
let known = known_tool_set();
let mut discover_cfg = existing_table
.as_ref()
.and_then(parse_discover_block_from_table);
if let Some(override_path) = opts.rules_override {
discover_cfg = Some(discover_config::DiscoverConfig {
rules: Some(override_path.to_string()),
..discover_cfg.unwrap_or_default()
});
}
let (effective_rules, rule_advisories) =
discover_config::load_effective_rules(&project_dir, discover_cfg.as_ref());
for adv in &rule_advisories {
eprintln!(" warning: {adv}");
}
if crate::observability::telemetry_gate::is_enabled() {
static EMITTED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
let _ = EMITTED.get_or_init(|| {
let default_count = super::rules::default_rules().len();
let total_count = effective_rules.len();
let custom_count = total_count.saturating_sub(default_count);
tracing::debug!(
event = "discover.rules_loaded",
default_rule_count = default_count,
custom_rule_count = custom_count,
total_rule_count = total_count,
);
});
}
let report = analyze_with(
&project_dir,
&already_configured,
&already_configured_versions,
known,
&effective_rules,
);
if output_format == "json" {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"project_dir": project_dir.display().to_string(),
"detections": &report.detections,
"required": &report.required,
"recommended": &report.recommended,
"already_configured": &report.already_configured,
"uninstallable": &report.uninstallable,
"applied": apply,
}))
.unwrap_or_else(|_| "{}".to_string())
);
} else if missing {
render_missing_only(&report);
} else {
render_pretty(&report, file, apply);
}
if apply {
let apply_started = std::time::Instant::now();
let telemetry_on = crate::observability::telemetry_gate::is_enabled();
let (new_text, note) = match existing_text {
Some(ref text) => match generator::merge_into_existing(text, &report) {
generator::MergeOutcome::Noop => (text.clone(), Some("no new tools to add")),
generator::MergeOutcome::Merged(s) => (s, None),
generator::MergeOutcome::BailedToFresh(s) => (
s,
Some(
"existing [provisioner] block couldn't be safely edited; \
falling back to a fresh-rendered jarvy.toml (your other sections were preserved \
only if they round-tripped through the TOML parser)",
),
),
generator::MergeOutcome::ExistingUnparseable => {
if output_format == "json" {
println!(
"{}",
serde_json::json!({
"status": "refused",
"reason": "existing jarvy.toml is not valid TOML",
"path": file,
})
);
} else {
eprintln!("Refusing to --apply: existing {} is not valid TOML.", file);
eprintln!("Fix the parse error first (or move the file aside) and re-run.");
}
return crate::error_codes::CONFIG_ERROR;
}
},
None => (generator::render_fresh(&report), None),
};
if let Err(e) = atomic_write(Path::new(file), &new_text) {
eprintln!("Failed to write {}: {}", file, e);
return crate::error_codes::CONFIG_ERROR;
}
if telemetry_on {
let detections_by_rule: String = report.detections.iter().fold(
String::with_capacity(report.detections.len() * 16),
|mut acc, d| {
if !acc.is_empty() {
acc.push(',');
}
acc.push_str(d.tool.as_ref());
acc
},
);
tracing::info!(
event = "discover.applied",
tools_added = report.required.len(),
recommended_added = report.recommended.len(),
already_configured = report.already_configured.len(),
recommended_dropped_dup = report.recommended_dropped_dup,
detections_by_rule = %detections_by_rule,
target = if note == Some("no new tools to add") {
"noop"
} else if matches!(note, Some(s) if s.starts_with("existing [provisioner]")) {
"bailed_to_fresh"
} else {
"merged"
},
duration_ms = apply_started.elapsed().as_millis() as u64,
);
}
if output_format != "json" {
println!("\nWrote {}", file);
if let Some(msg) = note {
eprintln!(" note: {msg}");
}
}
}
0
}
const SENSITIVE_TOP_LEVEL_KEYS: &[&str] = &["secrets", "credentials", "tokens", "api_keys", "auth"];
fn refuse_if_sensitive(text: &str) -> std::io::Result<()> {
if let Ok(table) = text.parse::<toml::Table>() {
for key in table.keys() {
let lower = key.to_ascii_lowercase();
if SENSITIVE_TOP_LEVEL_KEYS.contains(&lower.as_str()) {
if crate::observability::telemetry_gate::is_enabled() {
tracing::error!(
event = "discover.sensitive_key_refused",
key = %key,
key_lower = %lower,
);
}
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"refusing to write jarvy.toml containing top-level \
`[{key}]` section — the file is chmod'd 0644 \
(world-readable) on the invariant that discover \
writes only sanitized tool names + versions. If \
you need to persist secrets, use a separate \
file with 0600 perms (see src/env/secrets.rs)."
),
));
}
}
}
Ok(())
}
fn atomic_write(target: &Path, content: &str) -> std::io::Result<()> {
refuse_if_sensitive(content)?;
use std::io::Write;
let parent = target.parent().unwrap_or(Path::new("."));
let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
tmp.as_file_mut().write_all(content.as_bytes())?;
tmp.as_file_mut().flush()?;
tmp.persist(target)
.map_err(|e| std::io::Error::other(format!("persist failed: {e}")))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Err(e) = std::fs::set_permissions(target, std::fs::Permissions::from_mode(0o644)) {
if crate::observability::telemetry_gate::is_enabled() {
tracing::warn!(
event = "discover.jarvy_toml_perms_unsafe",
target = %target.display(),
error = %e,
fs_hint = "chmod_failed",
);
}
} else if let Ok(meta) = std::fs::metadata(target) {
let mode = meta.permissions().mode() & 0o777;
if mode != 0o644 && crate::observability::telemetry_gate::is_enabled() {
tracing::warn!(
event = "discover.jarvy_toml_perms_unsafe",
target = %target.display(),
mode = format!("{mode:o}"),
fs_hint = "chmod_ignored",
);
}
}
}
Ok(())
}
fn render_pretty(report: &super::DiscoverReport, file: &str, apply: bool) {
println!("Project Analysis");
println!("================\n");
if report.detections.is_empty() {
println!("No supported technologies detected.");
println!("(Looked for: Cargo.toml, package.json, pyproject.toml, go.mod,");
println!(" Gemfile, Dockerfile, k8s/, *.tf, Makefile, Justfile, etc.)");
return;
}
println!("Detected Technologies:");
for d in &report.detections {
let v = d.version.as_deref().unwrap_or("(version not pinned)");
println!(" {:<14} {:<12} (from {})", d.tool, v, d.source);
}
if !report.already_configured.is_empty() {
println!("\nAlready in jarvy.toml:");
for name in &report.already_configured {
println!(" {name}");
}
}
if !report.required.is_empty() {
println!("\nRequired (would be added):");
for s in &report.required {
println!(" {} = \"{}\" # {}", s.name, s.version, s.reason);
}
}
if !report.recommended.is_empty() {
println!("\nRecommended companions:");
for s in &report.recommended {
println!(" {} = \"{}\" # {}", s.name, s.version, s.reason);
}
}
if !report.uninstallable.is_empty() {
println!("\nDetected but jarvy has no first-party installer for these:");
for s in &report.uninstallable {
println!(" {:<14} (from {}) — {}", s.name, s.source, s.reason);
}
}
if !apply {
println!("\nRun `jarvy discover --apply --file {file}` to update jarvy.toml.");
}
}
fn render_missing_only(report: &super::DiscoverReport) {
for s in report.required.iter().chain(report.recommended.iter()) {
println!("{} = \"{}\"", s.name, s.version);
}
}
#[cfg(test)]
fn parse_provisioner_keys(text: &str) -> HashSet<String> {
parse_provisioner_pins(text).0
}
#[cfg(test)]
fn parse_provisioner_pins(text: &str) -> (HashSet<String>, HashMap<String, String>) {
text.parse::<toml::Table>()
.as_ref()
.map(parse_provisioner_pins_from_table)
.unwrap_or_default()
}
fn parse_provisioner_pins_from_table(
t: &toml::Table,
) -> (HashSet<String>, HashMap<String, String>) {
let mut keys = HashSet::new();
let mut versions = HashMap::new();
if let Some(toml::Value::Table(p)) = t.get("provisioner") {
for (name, value) in p {
let version = match value {
toml::Value::String(s) => s.clone(),
toml::Value::Table(t) => t
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
other => other.to_string(),
};
if !version.is_empty() {
versions.insert(name.clone(), version);
}
keys.insert(name.clone());
}
}
(keys, versions)
}
#[cfg(test)]
#[allow(dead_code)]
fn parse_discover_block(text: &str) -> Option<discover_config::DiscoverConfig> {
let table = text.parse::<toml::Table>().ok()?;
parse_discover_block_from_table(&table)
}
fn parse_discover_block_from_table(table: &toml::Table) -> Option<discover_config::DiscoverConfig> {
let block = table.get("discover").cloned()?;
block.try_into().ok()
}
static KNOWN_TOOLS_CACHE: std::sync::OnceLock<HashSet<String>> = std::sync::OnceLock::new();
fn known_tool_set() -> &'static HashSet<String> {
KNOWN_TOOLS_CACHE.get_or_init(|| {
crate::tools::register_all();
let mut set: HashSet<String> = HashSet::new();
for name in crate::tools::registry::registered_tool_names() {
let (has_underscore, has_dash) = name.bytes().fold((false, false), |(u, d), b| {
(u | (b == b'_'), d | (b == b'-'))
});
if has_underscore {
let alias = name.replace('_', "-");
assert!(
!set.contains(&alias) || set.contains(&name),
"dash/underscore alias collision: `{alias}` conflicts \
with an existing registered tool. Two distinct tool \
names must not normalise to the same dash/underscore \
form — detection rules matching either would silently \
dispatch to whichever registered first."
);
set.insert(alias);
}
if has_dash {
let alias = name.replace('-', "_");
assert!(
!set.contains(&alias) || set.contains(&name),
"dash/underscore alias collision: `{alias}` conflicts \
with an existing registered tool."
);
set.insert(alias);
}
set.insert(name);
}
set
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn parse_provisioner_keys_returns_empty_for_empty_text() {
assert!(parse_provisioner_keys("").is_empty());
}
#[test]
fn parse_provisioner_keys_lists_pinned_tools() {
let text = r#"
[provisioner]
git = "latest"
docker = "latest"
"#;
let keys = parse_provisioner_keys(text);
assert!(keys.contains("git"));
assert!(keys.contains("docker"));
}
#[test]
fn known_tool_set_contains_both_dash_and_underscore_forms() {
let s = known_tool_set();
for (dash, under) in [
("release-plz", "release_plz"),
("cargo-nextest", "cargo_nextest"),
("golangci-lint", "golangci_lint"),
] {
assert!(
s.contains(dash),
"known_tool_set missing dash form `{dash}` — detection \
rules use the dash form; without this alias the rule \
would silently drop its detection as `unknown tool`"
);
assert!(
s.contains(under),
"known_tool_set missing underscore form `{under}` — the \
handler is registered under the underscore form; \
without this alias, rule-name → handler dispatch fails"
);
}
}
#[test]
fn atomic_write_refuses_sensitive_sections() {
for sensitive_key in ["secrets", "credentials", "tokens", "api_keys", "auth"] {
let tmp = tempdir().unwrap();
let toml = tmp.path().join("jarvy.toml");
let poisoned = format!(
"[provisioner]\ngit = \"latest\"\n\n[{sensitive_key}]\napi = \"leak-me\"\n"
);
let e = atomic_write(&toml, &poisoned)
.expect_err("atomic_write must refuse a config containing sensitive sections");
assert!(
e.to_string().contains(sensitive_key),
"error must name the offending section `{sensitive_key}`; got: {e}"
);
assert!(
!toml.exists(),
"no file must land on disk when refuse fires (target: {toml:?})"
);
}
}
#[test]
fn atomic_write_refuses_sensitive_sections_case_insensitive() {
for variant in ["Secrets", "CREDENTIALS", "Tokens", "Api_Keys", "AUTH"] {
let tmp = tempdir().unwrap();
let toml = tmp.path().join("jarvy.toml");
let poisoned = format!("[{variant}]\napi = \"leak-me\"\n");
let e = atomic_write(&toml, &poisoned)
.expect_err("atomic_write must refuse case-varied sensitive keys");
assert!(
e.to_string()
.to_lowercase()
.contains(&variant.to_ascii_lowercase()),
"error must reference the offending section (case-preserved); \
variant `{variant}`; got: {e}"
);
}
}
#[test]
fn atomic_write_does_not_walk_nested_sensitive_tables() {
let tmp = tempdir().unwrap();
let toml = tmp.path().join("jarvy.toml");
let content = "[provisioner]\ngit = \"latest\"\n\n[project.secrets]\napi = \"?\"\n";
atomic_write(&toml, content).expect(
"nested [project.secrets] is not a top-level section and must \
be permitted; if this test starts failing, someone tightened \
the check to a recursive walk — update this test accordingly",
);
}
#[test]
fn known_tool_set_is_memoized_across_calls() {
let a = known_tool_set() as *const _;
let b = known_tool_set() as *const _;
assert_eq!(
a, b,
"known_tool_set must return the SAME address across calls \
— a rebuild-per-call regression would produce different \
pointers even when the values match"
);
}
#[test]
fn run_discover_apply_creates_file_in_empty_dir() {
let tmp = tempdir().unwrap();
let toml = tmp.path().join("jarvy.toml");
fs::write(tmp.path().join("Cargo.toml"), "[package]\nname=\"x\"").unwrap();
let exit = run_discover(toml.to_str().unwrap(), true, false, "pretty");
assert_eq!(exit, 0);
let written = fs::read_to_string(&toml).unwrap();
assert!(written.contains("[provisioner]"));
assert!(written.contains("rust ="), "got:\n{written}");
}
#[test]
fn apply_refuses_when_existing_toml_unparseable() {
let tmp = tempdir().unwrap();
let toml = tmp.path().join("jarvy.toml");
let bad = "[provisioner\nbroken =";
fs::write(&toml, bad).unwrap();
fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
let exit = run_discover(toml.to_str().unwrap(), true, false, "pretty");
assert_eq!(exit, crate::error_codes::CONFIG_ERROR);
assert_eq!(fs::read_to_string(&toml).unwrap(), bad);
}
#[test]
fn apply_atomic_write_lands_complete_file() {
let tmp = tempdir().unwrap();
let toml = tmp.path().join("jarvy.toml");
fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
let exit = run_discover(toml.to_str().unwrap(), true, false, "pretty");
assert_eq!(exit, 0);
let written = fs::read_to_string(&toml).unwrap();
let parsed: toml::Table = written.parse().expect("jarvy.toml must parse");
assert!(parsed.contains_key("provisioner"));
}
#[cfg(unix)]
#[test]
fn apply_writes_jarvy_toml_world_readable() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempdir().unwrap();
let toml = tmp.path().join("jarvy.toml");
fs::write(tmp.path().join("Cargo.toml"), "").unwrap();
let exit = run_discover(toml.to_str().unwrap(), true, false, "pretty");
assert_eq!(exit, 0);
let mode = fs::metadata(&toml).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o644, "jarvy.toml perms must be 0644, got {mode:o}");
}
}