Skip to main content

flodl_cli/util/
fdl_yml.rs

1//! Minimal text-based fdl.yml editor.
2//!
3//! Same policy as [`super::cargo_toml`]: append-only, format-preserving,
4//! no external yaml-edit crate. Scope is appending a top-level command
5//! entry under `commands:` if the entry isn't already declared.
6//!
7//! By fdl.yml convention, a command with neither `run:` nor `path:` and
8//! no preset fields falls through to a Path command with the default
9//! `./<name>/` location, so the appended entry needs no explicit
10//! `path:` to make `fdl <name> <subcmd>` route into `./<name>/fdl.yml`.
11
12use std::fs;
13use std::path::Path;
14
15/// Result of an [`add_command`] call.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum AddCommandOutcome {
18    /// The command entry was appended.
19    Added,
20    /// `name` was already declared under `commands:`; file untouched.
21    AlreadyPresent,
22}
23
24/// Append a top-level command entry under `commands:` in the fdl.yml at
25/// `path` if the entry isn't already declared.
26///
27/// `description` is written as a `description:` subfield. Pass an empty
28/// string to omit it (the entry then has nothing under it, falling back
29/// to the convention-default `path: ./<name>/`).
30///
31/// Behaviour:
32/// - `commands:` table present, `name` absent → append the entry at end
33///   of the commands block, [`AddCommandOutcome::Added`].
34/// - `commands:` present and `name` already declared → file untouched,
35///   [`AddCommandOutcome::AlreadyPresent`].
36/// - `commands:` absent → append `\ncommands:\n  name:\n    description: ...\n`
37///   at end of file, [`AddCommandOutcome::Added`].
38pub fn add_command(
39    path: &Path,
40    name: &str,
41    description: &str,
42) -> Result<AddCommandOutcome, 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_command(&content, name, description)?;
46    if outcome == AddCommandOutcome::Added {
47        fs::write(path, new_content)
48            .map_err(|e| format!("cannot write {}: {e}", path.display()))?;
49    }
50    Ok(outcome)
51}
52
53fn insert_command(
54    content: &str,
55    name: &str,
56    description: &str,
57) -> Result<(String, AddCommandOutcome), String> {
58    if name.is_empty() {
59        return Err("command name cannot be empty".into());
60    }
61
62    let lines: Vec<&str> = content.lines().collect();
63
64    // Find top-level `commands:` (indent 0).
65    let header_idx = lines
66        .iter()
67        .position(|l| l.trim_end() == "commands:" && !l.starts_with([' ', '\t']));
68
69    let Some(header_idx) = header_idx else {
70        // No commands: table — append a fresh one at EOF.
71        let mut out = content.to_string();
72        if !out.is_empty() && !out.ends_with('\n') {
73            out.push('\n');
74        }
75        if !out.is_empty() && !out.ends_with("\n\n") {
76            out.push('\n');
77        }
78        out.push_str("commands:\n");
79        out.push_str(&render_entry("  ", name, description));
80        return Ok((out, AddCommandOutcome::Added));
81    };
82
83    // Block ends at the first line at indent 0 (excluding blanks).
84    let block_end = lines[header_idx + 1..]
85        .iter()
86        .position(|l| !l.is_empty() && !l.starts_with([' ', '\t']))
87        .map(|i| header_idx + 1 + i)
88        .unwrap_or(lines.len());
89
90    // Detect child indent from the first non-blank child; default to two
91    // spaces when the block is empty (matches scaffold convention).
92    let child_indent = lines[header_idx + 1..block_end]
93        .iter()
94        .find(|l| !l.trim().is_empty())
95        .map(|l| {
96            let n = l.chars().take_while(|c| *c == ' ').count();
97            " ".repeat(n)
98        })
99        .unwrap_or_else(|| "  ".to_string());
100
101    // Already declared?
102    let key_token = format!("{name}:");
103    for line in &lines[header_idx + 1..block_end] {
104        if !line.starts_with(&child_indent) {
105            continue;
106        }
107        let after_indent = &line[child_indent.len()..];
108        // Must be a sibling key (no further leading spaces) and match
109        // `name:` or `name :` exactly.
110        if after_indent.starts_with(' ') {
111            continue;
112        }
113        let trimmed = after_indent.trim_start();
114        if trimmed == key_token
115            || trimmed.starts_with(&format!("{key_token} "))
116            || trimmed.starts_with(&format!("{name} :"))
117        {
118            return Ok((content.to_string(), AddCommandOutcome::AlreadyPresent));
119        }
120    }
121
122    // Insert AFTER the last non-blank line in the block.
123    let mut insert_at = header_idx + 1;
124    for (offset, line) in lines[header_idx + 1..block_end].iter().enumerate() {
125        if !line.trim().is_empty() {
126            insert_at = header_idx + 1 + offset + 1;
127        }
128    }
129
130    let entry = render_entry(&child_indent, name, description);
131
132    let mut out = lines[..insert_at].join("\n");
133    if !out.is_empty() {
134        out.push('\n');
135    }
136    // Blank line before the entry when the previous content already
137    // had a non-blank line (visual separator between sibling commands).
138    // Skip when the immediately previous line is already blank.
139    let prev_blank = insert_at == header_idx + 1
140        || lines
141            .get(insert_at - 1)
142            .is_some_and(|l| l.trim().is_empty());
143    if !prev_blank {
144        out.push('\n');
145    }
146    out.push_str(&entry);
147    if insert_at < lines.len() {
148        out.push_str(&lines[insert_at..].join("\n"));
149        if content.ends_with('\n') {
150            out.push('\n');
151        }
152    }
153    Ok((out, AddCommandOutcome::Added))
154}
155
156fn render_entry(child_indent: &str, name: &str, description: &str) -> String {
157    let mut out = format!("{child_indent}{name}:\n");
158    if !description.is_empty() {
159        out.push_str(&format!(
160            "{child_indent}{child_indent}description: {description}\n"
161        ));
162    }
163    out
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn appends_to_existing_commands_block() {
172        let input = "\
173description: my project
174
175commands:
176  build:
177    run: cargo build
178    docker: dev
179";
180        let (out, outcome) = insert_command(input, "flodl-hf", "HF integration").unwrap();
181        assert_eq!(outcome, AddCommandOutcome::Added);
182        assert!(out.contains("build:"), "preserves existing: {out}");
183        assert!(out.contains("flodl-hf:"), "appends: {out}");
184        assert!(out.contains("description: HF integration"));
185        // New entry comes after `build:`.
186        let build = out.find("build:").unwrap();
187        let new = out.find("flodl-hf:").unwrap();
188        assert!(new > build);
189    }
190
191    #[test]
192    fn already_present_is_noop() {
193        let input = "\
194commands:
195  flodl-hf:
196    description: existing entry
197  build:
198    run: cargo build
199";
200        let (out, outcome) = insert_command(input, "flodl-hf", "new desc").unwrap();
201        assert_eq!(outcome, AddCommandOutcome::AlreadyPresent);
202        assert_eq!(out, input);
203    }
204
205    #[test]
206    fn missing_commands_block_appends_at_eof() {
207        let input = "description: my project\n";
208        let (out, outcome) = insert_command(input, "flodl-hf", "HF").unwrap();
209        assert_eq!(outcome, AddCommandOutcome::Added);
210        assert!(out.contains("commands:"));
211        assert!(out.contains("  flodl-hf:"));
212        assert!(out.contains("    description: HF"));
213    }
214
215    #[test]
216    fn empty_commands_block_inserts_first_child() {
217        let input = "commands:\n";
218        let (out, outcome) = insert_command(input, "flodl-hf", "HF").unwrap();
219        assert_eq!(outcome, AddCommandOutcome::Added);
220        // Default 2-space indent kicks in.
221        assert!(out.contains("  flodl-hf:"));
222        assert!(out.contains("    description: HF"));
223    }
224
225    #[test]
226    fn detects_existing_indent_and_matches_it() {
227        // Existing block uses 4-space indent — new entry must follow.
228        let input = "\
229commands:
230    build:
231        run: cargo build
232";
233        let (out, _) = insert_command(input, "flodl-hf", "HF").unwrap();
234        assert!(out.contains("    flodl-hf:"));
235        assert!(out.contains("        description: HF"));
236    }
237
238    #[test]
239    fn empty_description_omits_subfield() {
240        let input = "commands:\n  build:\n    run: cargo build\n";
241        let (out, _) = insert_command(input, "flodl-hf", "").unwrap();
242        assert!(out.contains("  flodl-hf:"));
243        assert!(
244            !out.contains("description: \n"),
245            "no empty description: {out}"
246        );
247    }
248
249    #[test]
250    fn neighbouring_command_name_does_not_false_positive() {
251        // `flodl-hf` and `flodl` are distinct keys; presence of one must
252        // not block adding the other.
253        let input = "commands:\n  flodl-hf:\n    description: existing\n";
254        let (out, outcome) = insert_command(input, "flodl", "new").unwrap();
255        assert_eq!(outcome, AddCommandOutcome::Added);
256        assert!(out.contains("flodl-hf:"));
257        assert!(out.contains("flodl:"));
258    }
259
260    #[test]
261    fn preserves_trailing_content_after_block() {
262        // commands: is followed by another top-level key — new entry
263        // must not bleed into it.
264        let input = "\
265commands:
266  build:
267    run: cargo build
268
269other_top_level: foo
270";
271        let (out, _) = insert_command(input, "flodl-hf", "HF").unwrap();
272        assert!(
273            out.contains("other_top_level: foo"),
274            "trailing key preserved: {out}"
275        );
276        // `flodl-hf:` lands BEFORE `other_top_level:` (still inside commands block).
277        let new = out.find("flodl-hf:").unwrap();
278        let other = out.find("other_top_level:").unwrap();
279        assert!(new < other);
280    }
281
282    #[test]
283    fn empty_name_errors() {
284        let err = insert_command("commands:\n", "", "x").unwrap_err();
285        assert!(err.contains("name cannot be empty"));
286    }
287}