Skip to main content

flodl_cli/util/
cargo_toml.rs

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