flodl_cli/util/
fdl_yml.rs1use std::fs;
13use std::path::Path;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum AddCommandOutcome {
18 Added,
20 AlreadyPresent,
22}
23
24pub 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 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 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 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 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 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 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 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 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 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 assert!(out.contains(" flodl-hf:"));
222 assert!(out.contains(" description: HF"));
223 }
224
225 #[test]
226 fn detects_existing_indent_and_matches_it() {
227 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 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 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 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}