use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AddDepOutcome {
Added,
AlreadyPresent,
}
pub fn add_dep(path: &Path, name: &str, version: &str) -> Result<AddDepOutcome, String> {
let content = fs::read_to_string(path)
.map_err(|e| format!("cannot read {}: {e}", path.display()))?;
let (new_content, outcome) = insert_dep(&content, name, version)?;
if outcome == AddDepOutcome::Added {
fs::write(path, new_content)
.map_err(|e| format!("cannot write {}: {e}", path.display()))?;
}
Ok(outcome)
}
fn insert_dep(content: &str, name: &str, version: &str) -> Result<(String, AddDepOutcome), String> {
if name.is_empty() {
return Err("dep name cannot be empty".into());
}
let lines: Vec<&str> = content.lines().collect();
let dep_header = lines.iter().position(|l| l.trim() == "[dependencies]");
if let Some(header_idx) = dep_header {
let block_end = lines[header_idx + 1..]
.iter()
.position(|l| l.trim_start().starts_with('['))
.map(|i| header_idx + 1 + i)
.unwrap_or(lines.len());
for line in &lines[header_idx + 1..block_end] {
if line_declares_dep(line, name) {
return Ok((content.to_string(), AddDepOutcome::AlreadyPresent));
}
}
let mut insert_at = header_idx + 1;
for (offset, line) in lines[header_idx + 1..block_end].iter().enumerate() {
if !line.trim().is_empty() {
insert_at = header_idx + 1 + offset + 1;
}
}
let new_line = format!("{name} = \"{version}\"");
let mut out = lines[..insert_at].join("\n");
if !out.is_empty() {
out.push('\n');
}
out.push_str(&new_line);
if insert_at < lines.len() {
out.push('\n');
out.push_str(&lines[insert_at..].join("\n"));
}
if content.ends_with('\n') && !out.ends_with('\n') {
out.push('\n');
}
return Ok((out, AddDepOutcome::Added));
}
let mut out = content.to_string();
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
if !out.is_empty() && !out.ends_with("\n\n") {
out.push('\n');
}
out.push_str(&format!("[dependencies]\n{name} = \"{version}\"\n"));
Ok((out, AddDepOutcome::Added))
}
fn line_declares_dep(line: &str, name: &str) -> bool {
let t = line.trim_start();
let Some(after_key) = t.strip_prefix(name) else {
return false;
};
let rest = after_key.trim_start();
rest.starts_with('=')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn appends_to_existing_dependencies_block() {
let input = "\
[package]
name = \"x\"
[dependencies]
serde = \"1\"
";
let (out, outcome) = insert_dep(input, "flodl-hf", "=0.5.2").unwrap();
assert_eq!(outcome, AddDepOutcome::Added);
assert!(out.contains("serde = \"1\""), "preserves existing dep: {out}");
assert!(
out.contains("flodl-hf = \"=0.5.2\""),
"appends new dep: {out}",
);
let header_pos = out.find("[dependencies]").unwrap();
let new_pos = out.find("flodl-hf").unwrap();
assert!(new_pos > header_pos);
}
#[test]
fn already_present_plain_version_is_noop() {
let input = "\
[dependencies]
flodl-hf = \"0.5.0\"
serde = \"1\"
";
let (out, outcome) = insert_dep(input, "flodl-hf", "=0.5.2").unwrap();
assert_eq!(outcome, AddDepOutcome::AlreadyPresent);
assert_eq!(out, input);
}
#[test]
fn already_present_inline_table_is_noop() {
let input = "\
[dependencies]
flodl-hf = { version = \"0.5.0\", features = [\"hub\"] }
";
let (out, outcome) = insert_dep(input, "flodl-hf", "=0.5.2").unwrap();
assert_eq!(outcome, AddDepOutcome::AlreadyPresent);
assert_eq!(out, input);
}
#[test]
fn already_present_workspace_inheritance_is_noop() {
let input = "\
[dependencies]
flodl-hf = { workspace = true }
";
let (out, outcome) = insert_dep(input, "flodl-hf", "=0.5.2").unwrap();
assert_eq!(outcome, AddDepOutcome::AlreadyPresent);
assert_eq!(out, input);
}
#[test]
fn missing_table_is_appended_at_eof() {
let input = "\
[package]
name = \"x\"
";
let (out, outcome) = insert_dep(input, "flodl-hf", "=0.5.2").unwrap();
assert_eq!(outcome, AddDepOutcome::Added);
assert!(out.contains("[package]"));
assert!(out.contains("[dependencies]"));
assert!(out.contains("flodl-hf = \"=0.5.2\""));
let pkg = out.find("[package]").unwrap();
let dep = out.find("[dependencies]").unwrap();
assert!(dep > pkg);
}
#[test]
fn empty_dependencies_block_inserts_after_header() {
let input = "\
[package]
name = \"x\"
[dependencies]
[dev-dependencies]
serde = \"1\"
";
let (out, outcome) = insert_dep(input, "flodl-hf", "=0.5.2").unwrap();
assert_eq!(outcome, AddDepOutcome::Added);
let dep = out.find("[dependencies]").unwrap();
let dev = out.find("[dev-dependencies]").unwrap();
let new_dep = out.find("flodl-hf").unwrap();
assert!(
new_dep > dep && new_dep < dev,
"new dep must land inside [dependencies] block: {out}",
);
}
#[test]
fn neighbouring_crate_name_does_not_false_positive() {
let input = "\
[dependencies]
flodl-hf = \"=0.5.2\"
";
let (out, outcome) = insert_dep(input, "flodl", "=0.5.2").unwrap();
assert_eq!(outcome, AddDepOutcome::Added);
assert!(out.contains("flodl = \"=0.5.2\""));
assert!(out.contains("flodl-hf = \"=0.5.2\""));
}
#[test]
fn dep_in_other_table_does_not_count_as_present() {
let input = "\
[dependencies]
serde = \"1\"
[dev-dependencies]
flodl-hf = \"0.5.0\"
";
let (out, outcome) = insert_dep(input, "flodl-hf", "=0.5.2").unwrap();
assert_eq!(outcome, AddDepOutcome::Added);
let main_block_end = out.find("[dev-dependencies]").unwrap();
let new_dep = out[..main_block_end].find("flodl-hf").unwrap();
assert!(out[main_block_end..].contains("flodl-hf = \"0.5.0\""));
let _ = new_dep;
}
#[test]
fn preserves_trailing_newline() {
let input = "[dependencies]\nserde = \"1\"\n";
let (out, _) = insert_dep(input, "flodl-hf", "=0.5.2").unwrap();
assert!(out.ends_with('\n'), "trailing newline preserved: {out:?}");
}
#[test]
fn preserves_no_trailing_newline() {
let input = "[dependencies]\nserde = \"1\"";
let (out, _) = insert_dep(input, "flodl-hf", "=0.5.2").unwrap();
assert!(!out.ends_with("\n\n"));
}
#[test]
fn empty_name_errors() {
let err = insert_dep("[dependencies]\n", "", "=0.5.2").unwrap_err();
assert!(err.contains("name cannot be empty"));
}
}