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 =
44        fs::read_to_string(path).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!(
146            out.contains("serde = \"1\""),
147            "preserves existing dep: {out}"
148        );
149        assert!(
150            out.contains("flodl-hf = \"=0.5.2\""),
151            "appends new dep: {out}",
152        );
153        // Inserted within [dependencies] block, not at EOF.
154        let header_pos = out.find("[dependencies]").unwrap();
155        let new_pos = out.find("flodl-hf").unwrap();
156        assert!(new_pos > header_pos);
157    }
158
159    #[test]
160    fn already_present_plain_version_is_noop() {
161        let input = "\
162[dependencies]
163flodl-hf = \"0.5.0\"
164serde = \"1\"
165";
166        let (out, outcome) = insert_dep(input, "flodl-hf", "=0.5.2").unwrap();
167        assert_eq!(outcome, AddDepOutcome::AlreadyPresent);
168        assert_eq!(out, input);
169    }
170
171    #[test]
172    fn already_present_inline_table_is_noop() {
173        let input = "\
174[dependencies]
175flodl-hf = { version = \"0.5.0\", features = [\"hub\"] }
176";
177        let (out, outcome) = insert_dep(input, "flodl-hf", "=0.5.2").unwrap();
178        assert_eq!(outcome, AddDepOutcome::AlreadyPresent);
179        assert_eq!(out, input);
180    }
181
182    #[test]
183    fn already_present_workspace_inheritance_is_noop() {
184        let input = "\
185[dependencies]
186flodl-hf = { workspace = true }
187";
188        let (out, outcome) = insert_dep(input, "flodl-hf", "=0.5.2").unwrap();
189        assert_eq!(outcome, AddDepOutcome::AlreadyPresent);
190        assert_eq!(out, input);
191    }
192
193    #[test]
194    fn missing_table_is_appended_at_eof() {
195        let input = "\
196[package]
197name = \"x\"
198";
199        let (out, outcome) = insert_dep(input, "flodl-hf", "=0.5.2").unwrap();
200        assert_eq!(outcome, AddDepOutcome::Added);
201        assert!(out.contains("[package]"));
202        assert!(out.contains("[dependencies]"));
203        assert!(out.contains("flodl-hf = \"=0.5.2\""));
204        // [dependencies] comes after [package].
205        let pkg = out.find("[package]").unwrap();
206        let dep = out.find("[dependencies]").unwrap();
207        assert!(dep > pkg);
208    }
209
210    #[test]
211    fn empty_dependencies_block_inserts_after_header() {
212        let input = "\
213[package]
214name = \"x\"
215
216[dependencies]
217
218[dev-dependencies]
219serde = \"1\"
220";
221        let (out, outcome) = insert_dep(input, "flodl-hf", "=0.5.2").unwrap();
222        assert_eq!(outcome, AddDepOutcome::Added);
223        // New dep lands inside [dependencies], NOT [dev-dependencies].
224        let dep = out.find("[dependencies]").unwrap();
225        let dev = out.find("[dev-dependencies]").unwrap();
226        let new_dep = out.find("flodl-hf").unwrap();
227        assert!(
228            new_dep > dep && new_dep < dev,
229            "new dep must land inside [dependencies] block: {out}",
230        );
231    }
232
233    #[test]
234    fn neighbouring_crate_name_does_not_false_positive() {
235        // Adding `flodl` must not see `flodl-hf` as already-present.
236        let input = "\
237[dependencies]
238flodl-hf = \"=0.5.2\"
239";
240        let (out, outcome) = insert_dep(input, "flodl", "=0.5.2").unwrap();
241        assert_eq!(outcome, AddDepOutcome::Added);
242        assert!(out.contains("flodl = \"=0.5.2\""));
243        assert!(out.contains("flodl-hf = \"=0.5.2\""));
244    }
245
246    #[test]
247    fn dep_in_other_table_does_not_count_as_present() {
248        // `flodl-hf` under [dev-dependencies] should NOT block adding it
249        // to [dependencies].
250        let input = "\
251[dependencies]
252serde = \"1\"
253
254[dev-dependencies]
255flodl-hf = \"0.5.0\"
256";
257        let (out, outcome) = insert_dep(input, "flodl-hf", "=0.5.2").unwrap();
258        assert_eq!(outcome, AddDepOutcome::Added);
259        // Dep added to [dependencies], not [dev-dependencies] (the dev
260        // entry stays untouched).
261        let main_block_end = out.find("[dev-dependencies]").unwrap();
262        let new_dep = out[..main_block_end].find("flodl-hf").unwrap();
263        // And the [dev-dependencies] entry is still there.
264        assert!(out[main_block_end..].contains("flodl-hf = \"0.5.0\""));
265        let _ = new_dep;
266    }
267
268    #[test]
269    fn preserves_trailing_newline() {
270        let input = "[dependencies]\nserde = \"1\"\n";
271        let (out, _) = insert_dep(input, "flodl-hf", "=0.5.2").unwrap();
272        assert!(out.ends_with('\n'), "trailing newline preserved: {out:?}");
273    }
274
275    #[test]
276    fn preserves_no_trailing_newline() {
277        let input = "[dependencies]\nserde = \"1\"";
278        let (out, _) = insert_dep(input, "flodl-hf", "=0.5.2").unwrap();
279        assert!(!out.ends_with("\n\n"));
280    }
281
282    #[test]
283    fn empty_name_errors() {
284        let err = insert_dep("[dependencies]\n", "", "=0.5.2").unwrap_err();
285        assert!(err.contains("name cannot be empty"));
286    }
287}