use super::{DiscoverReport, ToolSuggestion};
pub fn render_fresh(report: &DiscoverReport) -> String {
let mut out = String::new();
out.push_str("# jarvy.toml (generated by `jarvy discover`)\n");
out.push_str("# Run `jarvy discover` again to update.\n\n");
out.push_str("[provisioner]\n");
if !report.required.is_empty() {
out.push_str("# Detected from project files\n");
for tool in &report.required {
render_tool_line(&mut out, tool);
}
}
if !report.recommended.is_empty() {
out.push_str("\n# Recommended companions\n");
for tool in &report.recommended {
render_tool_line(&mut out, tool);
}
}
out
}
fn render_tool_line(out: &mut String, tool: &ToolSuggestion) {
out.push_str(&format!(
"{} = \"{}\" # {}\n",
tool.name, tool.version, tool.reason
));
}
#[derive(Debug)]
pub enum MergeOutcome {
Noop,
Merged(String),
BailedToFresh(String),
ExistingUnparseable,
}
pub fn merge_into_existing(existing: &str, report: &DiscoverReport) -> MergeOutcome {
let new_lines: Vec<String> = report
.required
.iter()
.chain(report.recommended.iter())
.map(|tool| format!("{} = \"{}\" # {}", tool.name, tool.version, tool.reason))
.collect();
if new_lines.is_empty() {
return MergeOutcome::Noop;
}
let parsed: toml::Table = match existing.parse() {
Ok(t) => t,
Err(_) if existing.trim().is_empty() => toml::Table::new(),
Err(_) => return MergeOutcome::ExistingUnparseable,
};
let provisioner_table = parsed.get("provisioner").and_then(|v| v.as_table());
let already: std::collections::HashSet<&str> = provisioner_table
.map(|p| p.keys().map(String::as_str).collect())
.unwrap_or_default();
let filtered: Vec<String> = new_lines
.into_iter()
.filter(|line| {
let key = line.split_whitespace().next().unwrap_or("");
!already.contains(key)
})
.collect();
if filtered.is_empty() {
return MergeOutcome::Noop;
}
let merged_text = if provisioner_table.is_none() {
append_fresh_provisioner_block(existing, &filtered)
} else {
splice_into_provisioner(existing, &filtered)
};
if merged_text.parse::<toml::Table>().is_err() {
let fresh = render_fresh(report);
return MergeOutcome::BailedToFresh(fresh);
}
MergeOutcome::Merged(merged_text)
}
fn append_fresh_provisioner_block(existing: &str, filtered: &[String]) -> String {
let mut out = existing.trim_end().to_string();
if !out.is_empty() {
out.push_str("\n\n");
}
out.push_str("[provisioner]\n");
out.push_str("# Added by `jarvy discover`\n");
for line in filtered {
out.push_str(line);
out.push('\n');
}
out
}
fn splice_into_provisioner(existing: &str, filtered: &[String]) -> String {
let lines: Vec<&str> = existing.lines().collect();
let mut in_provisioner = false;
let mut insert_at: Option<usize> = None;
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if trimmed == "[provisioner]" {
in_provisioner = true;
continue;
}
if in_provisioner && trimmed.starts_with('[') && trimmed.ends_with(']') {
insert_at = Some(i);
break;
}
}
let mut out = String::with_capacity(
existing.len() + filtered.iter().map(|l| l.len() + 1).sum::<usize>() + 32,
);
let block_header = "# Added by `jarvy discover`";
match insert_at {
Some(idx) => {
for (i, line) in lines.iter().enumerate() {
if i == idx {
out.push_str(block_header);
out.push('\n');
for new_line in filtered {
out.push_str(new_line);
out.push('\n');
}
}
out.push_str(line);
out.push('\n');
}
}
None => {
for line in &lines {
out.push_str(line);
out.push('\n');
}
if !out.is_empty() && !out.ends_with("\n\n") {
out.push('\n');
}
out.push_str(block_header);
out.push('\n');
for new_line in filtered {
out.push_str(new_line);
out.push('\n');
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::discover::rules::ToolCategory;
fn sample_report() -> DiscoverReport {
DiscoverReport {
detections: vec![],
required: vec![ToolSuggestion {
name: "rust".into(),
version: "1.85.0".into(),
reason: "detected from Cargo.toml".into(),
category: ToolCategory::Runtime,
}],
recommended: vec![ToolSuggestion {
name: "cargo-watch".into(),
version: "latest".into(),
reason: "commonly used with rust".into(),
category: ToolCategory::Dev,
}],
already_configured: vec![],
uninstallable: vec![],
recommended_dropped_dup: 0,
}
}
#[test]
fn render_fresh_emits_provisioner_block() {
let out = render_fresh(&sample_report());
assert!(out.contains("[provisioner]"));
assert!(out.contains("rust = \"1.85.0\""));
assert!(out.contains("cargo-watch = \"latest\""));
}
fn merged_text(outcome: MergeOutcome) -> String {
match outcome {
MergeOutcome::Merged(s) | MergeOutcome::BailedToFresh(s) => s,
MergeOutcome::Noop => String::new(),
MergeOutcome::ExistingUnparseable => {
panic!("expected merge, got ExistingUnparseable")
}
}
}
#[test]
fn merge_into_existing_preserves_user_content() {
let existing = "[provisioner]\ngit = \"latest\"\n\n[hooks]\nfoo = \"bar\"\n";
let merged = merged_text(merge_into_existing(existing, &sample_report()));
assert!(merged.contains("git = \"latest\""), "got:\n{merged}");
assert!(merged.contains("rust = \"1.85.0\""), "got:\n{merged}");
assert!(merged.contains("[hooks]"), "got:\n{merged}");
let rust_pos = merged.find("rust = ").unwrap();
let hooks_pos = merged.find("[hooks]").unwrap();
assert!(rust_pos < hooks_pos);
}
#[test]
fn merge_skips_already_pinned_keys() {
let existing = "[provisioner]\nrust = \"1.84.0\"\n";
let merged = merged_text(merge_into_existing(existing, &sample_report()));
assert!(merged.contains("rust = \"1.84.0\""), "got:\n{merged}");
assert!(!merged.contains("1.85.0"), "got:\n{merged}");
assert!(merged.contains("cargo-watch"), "got:\n{merged}");
}
#[test]
fn merge_into_empty_file_creates_provisioner_section() {
let merged = merged_text(merge_into_existing("", &sample_report()));
assert!(merged.contains("[provisioner]"));
assert!(merged.contains("rust = \"1.85.0\""));
}
#[test]
fn empty_report_returns_noop() {
let existing = "[provisioner]\ngit = \"latest\"\n";
let empty = DiscoverReport::default();
assert!(matches!(
merge_into_existing(existing, &empty),
MergeOutcome::Noop
));
}
#[test]
fn merge_treats_provisioner_in_comment_as_absent() {
let existing = "# Uses [provisioner] block style\n[other]\nx = 1\n";
let merged = merged_text(merge_into_existing(existing, &sample_report()));
assert!(merged.contains("[provisioner]"), "got:\n{merged}");
assert!(merged.contains("rust = \"1.85.0\""), "got:\n{merged}");
assert!(merged.contains("# Uses [provisioner] block style"));
}
#[test]
fn merge_refuses_when_existing_unparseable() {
let existing = "[provisioner\nbroken =";
assert!(matches!(
merge_into_existing(existing, &sample_report()),
MergeOutcome::ExistingUnparseable
));
}
#[test]
fn merge_bails_to_fresh_when_splice_would_break_toml() {
let existing = "[provisioner.linux]\napt = \"yes\"\n";
let outcome = merge_into_existing(existing, &sample_report());
match outcome {
MergeOutcome::Merged(text) | MergeOutcome::BailedToFresh(text) => {
assert!(
text.parse::<toml::Table>().is_ok(),
"merged text must always be valid TOML, got:\n{text}"
);
}
other => panic!("expected Merged or BailedToFresh, got {other:?}"),
}
}
#[test]
fn merge_preserves_round_trip_through_toml_parser() {
let existing = "[provisioner]\ngit = \"latest\"\n\n[hooks]\nfoo = \"bar\"\n";
let text = merged_text(merge_into_existing(existing, &sample_report()));
let _: toml::Table = text.parse().expect("merged output must be valid TOML");
}
}