Skip to main content

flodl_cli/util/
cargo_toml.rs

1//! Minimal text-based Cargo.toml editor.
2//!
3//! flodl-cli is zero-external-crate by policy, so we don't pull in the
4//! `toml` / `toml_edit` crates just to append a dependency. The editor
5//! is intentionally narrow: append a dep to `[dependencies]` if it
6//! isn't already declared, preserving every other byte of the file.
7//!
8//! Anything more sophisticated (feature edits, version bumps, table
9//! reshaping) is out of scope and should fall back to manual edits or
10//! a real toml crate when the need arises.
11
12use std::fs;
13use std::path::Path;
14
15/// Result of an [`add_dep`] call.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum AddDepOutcome {
18    /// The dependency line was appended.
19    Added,
20    /// `name` was already declared under `[dependencies]`; file untouched.
21    AlreadyPresent,
22}
23
24/// Append `name = "<version>"` to `[dependencies]` in the Cargo.toml at
25/// `path` if `name` isn't already declared there.
26///
27/// `version` is the bare version string (e.g. `"=0.5.2"`); quoting is
28/// added by this function. For richer dep specs (table form, features),
29/// extend this API rather than asking callers to pre-format strings.
30///
31/// Behaviour:
32/// - `[dependencies]` table present, `name` absent → insert `name = "version"`
33///   on a new line at the end of the table block, return [`AddDepOutcome::Added`].
34/// - `[dependencies]` present and `name` already declared (any RHS shape:
35///   plain string, inline table, workspace inheritance) → return
36///   [`AddDepOutcome::AlreadyPresent`], file untouched.
37/// - `[dependencies]` table absent → append `\n[dependencies]\nname = "version"\n`
38///   at end of file, return [`AddDepOutcome::Added`].
39///
40/// Errors on IO failures or when the file isn't valid UTF-8.
41pub fn add_dep(path: &Path, name: &str, version: &str) -> Result<AddDepOutcome, String> {
42    let content = fs::read_to_string(path)
43        .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
44    let (new_content, outcome) = insert_dep(&content, name, version)?;
45    if outcome == AddDepOutcome::Added {
46        fs::write(path, new_content)
47            .map_err(|e| format!("cannot write {}: {e}", path.display()))?;
48    }
49    Ok(outcome)
50}
51
52/// Pure string transformation behind [`add_dep`]. Exposed for testing
53/// without filesystem IO.
54fn insert_dep(content: &str, name: &str, version: &str) -> Result<(String, AddDepOutcome), String> {
55    if name.is_empty() {
56        return Err("dep name cannot be empty".into());
57    }
58
59    let lines: Vec<&str> = content.lines().collect();
60
61    // Find [dependencies] header and the line index where its block ends
62    // (exclusive — first line of the next table, or lines.len()).
63    let dep_header = lines.iter().position(|l| l.trim() == "[dependencies]");
64
65    if let Some(header_idx) = dep_header {
66        let block_end = lines[header_idx + 1..]
67            .iter()
68            .position(|l| l.trim_start().starts_with('['))
69            .map(|i| header_idx + 1 + i)
70            .unwrap_or(lines.len());
71
72        // Already declared?
73        for line in &lines[header_idx + 1..block_end] {
74            if line_declares_dep(line, name) {
75                return Ok((content.to_string(), AddDepOutcome::AlreadyPresent));
76            }
77        }
78
79        // Find insertion point: last non-blank line within the block,
80        // inserting AFTER it. Falls back to right after the header when
81        // the block has nothing but blanks.
82        let mut insert_at = header_idx + 1;
83        for (offset, line) in lines[header_idx + 1..block_end].iter().enumerate() {
84            if !line.trim().is_empty() {
85                insert_at = header_idx + 1 + offset + 1;
86            }
87        }
88
89        let new_line = format!("{name} = \"{version}\"");
90        let mut out = lines[..insert_at].join("\n");
91        if !out.is_empty() {
92            out.push('\n');
93        }
94        out.push_str(&new_line);
95        if insert_at < lines.len() {
96            out.push('\n');
97            out.push_str(&lines[insert_at..].join("\n"));
98        }
99        if content.ends_with('\n') && !out.ends_with('\n') {
100            out.push('\n');
101        }
102        return Ok((out, AddDepOutcome::Added));
103    }
104
105    // No [dependencies] table — append one at EOF.
106    let mut out = content.to_string();
107    if !out.is_empty() && !out.ends_with('\n') {
108        out.push('\n');
109    }
110    if !out.is_empty() && !out.ends_with("\n\n") {
111        out.push('\n');
112    }
113    out.push_str(&format!("[dependencies]\n{name} = \"{version}\"\n"));
114    Ok((out, AddDepOutcome::Added))
115}
116
117/// True when `line` declares the dependency `name` (any RHS shape).
118/// Matches `name = ...` exactly so neighbours like `flodl-hf` don't
119/// false-positive on `flodl`. Handles leading whitespace.
120fn line_declares_dep(line: &str, name: &str) -> bool {
121    let t = line.trim_start();
122    let Some(after_key) = t.strip_prefix(name) else {
123        return false;
124    };
125    let rest = after_key.trim_start();
126    rest.starts_with('=')
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn appends_to_existing_dependencies_block() {
135        let input = "\
136[package]
137name = \"x\"
138
139[dependencies]
140serde = \"1\"
141";
142        let (out, outcome) = insert_dep(input, "flodl-hf", "=0.5.2").unwrap();
143        assert_eq!(outcome, AddDepOutcome::Added);
144        assert!(out.contains("serde = \"1\""), "preserves existing dep: {out}");
145        assert!(
146            out.contains("flodl-hf = \"=0.5.2\""),
147            "appends new dep: {out}",
148        );
149        // Inserted within [dependencies] block, not at EOF.
150        let header_pos = out.find("[dependencies]").unwrap();
151        let new_pos = out.find("flodl-hf").unwrap();
152        assert!(new_pos > header_pos);
153    }
154
155    #[test]
156    fn already_present_plain_version_is_noop() {
157        let input = "\
158[dependencies]
159flodl-hf = \"0.5.0\"
160serde = \"1\"
161";
162        let (out, outcome) = insert_dep(input, "flodl-hf", "=0.5.2").unwrap();
163        assert_eq!(outcome, AddDepOutcome::AlreadyPresent);
164        assert_eq!(out, input);
165    }
166
167    #[test]
168    fn already_present_inline_table_is_noop() {
169        let input = "\
170[dependencies]
171flodl-hf = { version = \"0.5.0\", features = [\"hub\"] }
172";
173        let (out, outcome) = insert_dep(input, "flodl-hf", "=0.5.2").unwrap();
174        assert_eq!(outcome, AddDepOutcome::AlreadyPresent);
175        assert_eq!(out, input);
176    }
177
178    #[test]
179    fn already_present_workspace_inheritance_is_noop() {
180        let input = "\
181[dependencies]
182flodl-hf = { workspace = true }
183";
184        let (out, outcome) = insert_dep(input, "flodl-hf", "=0.5.2").unwrap();
185        assert_eq!(outcome, AddDepOutcome::AlreadyPresent);
186        assert_eq!(out, input);
187    }
188
189    #[test]
190    fn missing_table_is_appended_at_eof() {
191        let input = "\
192[package]
193name = \"x\"
194";
195        let (out, outcome) = insert_dep(input, "flodl-hf", "=0.5.2").unwrap();
196        assert_eq!(outcome, AddDepOutcome::Added);
197        assert!(out.contains("[package]"));
198        assert!(out.contains("[dependencies]"));
199        assert!(out.contains("flodl-hf = \"=0.5.2\""));
200        // [dependencies] comes after [package].
201        let pkg = out.find("[package]").unwrap();
202        let dep = out.find("[dependencies]").unwrap();
203        assert!(dep > pkg);
204    }
205
206    #[test]
207    fn empty_dependencies_block_inserts_after_header() {
208        let input = "\
209[package]
210name = \"x\"
211
212[dependencies]
213
214[dev-dependencies]
215serde = \"1\"
216";
217        let (out, outcome) = insert_dep(input, "flodl-hf", "=0.5.2").unwrap();
218        assert_eq!(outcome, AddDepOutcome::Added);
219        // New dep lands inside [dependencies], NOT [dev-dependencies].
220        let dep = out.find("[dependencies]").unwrap();
221        let dev = out.find("[dev-dependencies]").unwrap();
222        let new_dep = out.find("flodl-hf").unwrap();
223        assert!(
224            new_dep > dep && new_dep < dev,
225            "new dep must land inside [dependencies] block: {out}",
226        );
227    }
228
229    #[test]
230    fn neighbouring_crate_name_does_not_false_positive() {
231        // Adding `flodl` must not see `flodl-hf` as already-present.
232        let input = "\
233[dependencies]
234flodl-hf = \"=0.5.2\"
235";
236        let (out, outcome) = insert_dep(input, "flodl", "=0.5.2").unwrap();
237        assert_eq!(outcome, AddDepOutcome::Added);
238        assert!(out.contains("flodl = \"=0.5.2\""));
239        assert!(out.contains("flodl-hf = \"=0.5.2\""));
240    }
241
242    #[test]
243    fn dep_in_other_table_does_not_count_as_present() {
244        // `flodl-hf` under [dev-dependencies] should NOT block adding it
245        // to [dependencies].
246        let input = "\
247[dependencies]
248serde = \"1\"
249
250[dev-dependencies]
251flodl-hf = \"0.5.0\"
252";
253        let (out, outcome) = insert_dep(input, "flodl-hf", "=0.5.2").unwrap();
254        assert_eq!(outcome, AddDepOutcome::Added);
255        // Dep added to [dependencies], not [dev-dependencies] (the dev
256        // entry stays untouched).
257        let main_block_end = out.find("[dev-dependencies]").unwrap();
258        let new_dep = out[..main_block_end].find("flodl-hf").unwrap();
259        // And the [dev-dependencies] entry is still there.
260        assert!(out[main_block_end..].contains("flodl-hf = \"0.5.0\""));
261        let _ = new_dep;
262    }
263
264    #[test]
265    fn preserves_trailing_newline() {
266        let input = "[dependencies]\nserde = \"1\"\n";
267        let (out, _) = insert_dep(input, "flodl-hf", "=0.5.2").unwrap();
268        assert!(out.ends_with('\n'), "trailing newline preserved: {out:?}");
269    }
270
271    #[test]
272    fn preserves_no_trailing_newline() {
273        let input = "[dependencies]\nserde = \"1\"";
274        let (out, _) = insert_dep(input, "flodl-hf", "=0.5.2").unwrap();
275        assert!(!out.ends_with("\n\n"));
276    }
277
278    #[test]
279    fn empty_name_errors() {
280        let err = insert_dep("[dependencies]\n", "", "=0.5.2").unwrap_err();
281        assert!(err.contains("name cannot be empty"));
282    }
283}