1use anyhow::Context;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum ProjectsEdit {
8 Unchanged,
10 Updated(String),
12}
13
14pub fn add_project(content: &str, path: &str) -> anyhow::Result<ProjectsEdit> {
16 match parsed_string_projects(content)? {
17 ParsedProjects::NotAList => anyhow::bail!("projects is not a list"),
18 ParsedProjects::List(items) if items.iter().any(|p| p == path) => {
19 Ok(ProjectsEdit::Unchanged)
20 }
21 ParsedProjects::Missing | ParsedProjects::List(_) => insert_project(content, path),
22 }
23}
24
25pub fn remove_project(content: &str, candidates: &[&str]) -> anyhow::Result<ProjectsEdit> {
27 match parsed_string_projects(content)? {
28 ParsedProjects::Missing | ParsedProjects::NotAList => {
29 anyhow::bail!("no projects list in config")
30 }
31
32 ParsedProjects::List(items) => {
33 if !items.iter().any(|p| candidates.contains(&p.as_str())) {
34 return Ok(ProjectsEdit::Unchanged);
35 }
36 delete_project(content, candidates)
37 }
38 }
39}
40
41enum ParsedProjects {
42 Missing,
43 NotAList,
44 List(Vec<String>),
45}
46
47fn parsed_string_projects(content: &str) -> anyhow::Result<ParsedProjects> {
48 if content.trim().is_empty() {
49 return Ok(ParsedProjects::Missing);
50 }
51 let doc: serde_norway::Value = serde_norway::from_str(content).context("parsing config.yml")?;
52 let Some(root) = doc.as_mapping() else {
53 anyhow::bail!("config is not a YAML mapping");
54 };
55 match root.get("projects") {
56 None | Some(serde_norway::Value::Null) => Ok(ParsedProjects::Missing),
57 Some(serde_norway::Value::Sequence(seq)) => Ok(ParsedProjects::List(
58 seq.iter()
59 .filter_map(|item| item.as_str().map(str::to_string))
60 .collect(),
61 )),
62 Some(_) => Ok(ParsedProjects::NotAList),
63 }
64}
65
66fn insert_project(content: &str, path: &str) -> anyhow::Result<ProjectsEdit> {
67 let (mut lines, newline_at_end) = split_lines(content);
68 match find_root_projects(&lines) {
69 None => {
70 append_projects_block(&mut lines, path);
71 Ok(ProjectsEdit::Updated(join_lines(&lines, newline_at_end)))
72 }
73 Some(key_line) => {
74 let same_line = projects_value(&lines[key_line]).unwrap_or("").to_string();
75 let (value, comment) = split_value_and_comment(&same_line);
76 let value = value.trim();
77 if is_complete_flow(value) && value != "[]" {
78 let updated = add_to_flow_line(&lines[key_line], path)
79 .ok_or_else(|| anyhow::anyhow!("could not edit projects: list as text"))?;
80 lines[key_line] = updated;
81 return Ok(ProjectsEdit::Updated(join_lines(&lines, newline_at_end)));
82 }
83 if is_empty_list_value(value) {
84 if comment.trim().is_empty() {
85 lines[key_line] = "projects:".to_string();
86 } else {
87 lines[key_line] = format!("projects:{comment}");
88 }
89 } else if !value.is_empty() {
90 anyhow::bail!("could not edit projects: list as text");
91 }
92 let indent = detect_indent(&lines, key_line);
93 let insert_at = insert_index(&lines, key_line) + 1;
94 lines.insert(insert_at, format!("{indent}- {}", format_yaml_string(path)));
95 Ok(ProjectsEdit::Updated(join_lines(&lines, newline_at_end)))
96 }
97 }
98}
99
100fn delete_project(content: &str, candidates: &[&str]) -> anyhow::Result<ProjectsEdit> {
101 let (mut lines, newline_at_end) = split_lines(content);
102 let key_line = find_root_projects(&lines)
103 .ok_or_else(|| anyhow::anyhow!("could not edit projects: list as text"))?;
104 let same_line = projects_value(&lines[key_line]).unwrap_or("").to_string();
105 let (value, _) = split_value_and_comment(&same_line);
106 let value = value.trim();
107 if is_complete_flow(value) {
108 let updated = remove_from_flow_line(&lines[key_line], candidates)
109 .ok_or_else(|| anyhow::anyhow!("could not edit projects: list as text"))?;
110 lines[key_line] = updated;
111 return Ok(ProjectsEdit::Updated(join_lines(&lines, newline_at_end)));
112 }
113 if !value.is_empty() && !is_empty_list_value(value) {
114 anyhow::bail!("could not edit projects: list as text");
115 }
116 let mut removed = false;
117 let mut idx = key_line + 1;
118 while idx < lines.len() {
119 if is_section_end(&lines[idx]) {
120 break;
121 }
122 if let Some(item) = block_item_value(&lines[idx])
123 && candidates.contains(&item.as_str())
124 {
125 lines.remove(idx);
126 removed = true;
127 continue;
128 }
129 idx += 1;
130 }
131 if !removed {
132 anyhow::bail!("could not edit projects: list as text");
133 }
134 Ok(ProjectsEdit::Updated(join_lines(&lines, newline_at_end)))
135}
136
137fn append_projects_block(lines: &mut Vec<String>, path: &str) {
138 if lines.last().is_some_and(|line| !line.is_empty()) {
139 lines.push(String::new());
140 }
141 lines.push("projects:".to_string());
142 lines.push(format!(" - {}", format_yaml_string(path)));
143}
144
145fn find_root_projects(lines: &[String]) -> Option<usize> {
146 lines.iter().position(|line| projects_value(line).is_some())
147}
148
149fn projects_value(line: &str) -> Option<&str> {
150 let rest = line.strip_prefix("projects:")?;
151 if rest.is_empty() || rest.starts_with([' ', '\t']) {
152 Some(rest)
153 } else {
154 None
155 }
156}
157
158fn insert_index(lines: &[String], key_line: usize) -> usize {
159 let mut last = key_line;
160 for (idx, line) in lines.iter().enumerate().skip(key_line + 1) {
161 if is_section_end(line) {
162 break;
163 }
164 if block_item_value(line).is_some() || is_commented_list_item(line) || is_continuation(line)
165 {
166 last = idx;
167 }
168 }
169 last
170}
171
172fn detect_indent(lines: &[String], key_line: usize) -> String {
173 for line in lines.iter().skip(key_line + 1) {
174 if is_section_end(line) {
175 break;
176 }
177 if block_item_value(line).is_some() || is_commented_list_item(line) {
178 let trimmed = line.trim_start();
179 return line[..line.len() - trimmed.len()].to_string();
180 }
181 }
182 " ".to_string()
183}
184
185fn is_section_end(line: &str) -> bool {
186 let trimmed = line.trim_start();
187 if trimmed.is_empty() || trimmed.starts_with('#') {
188 return false;
189 }
190 if trimmed == "---" || trimmed == "..." {
191 return true;
192 }
193 if line.starts_with([' ', '\t']) {
194 return false;
195 }
196 if trimmed.starts_with('-') {
197 return false;
198 }
199 trimmed.contains(':')
200}
201
202fn block_item_value(line: &str) -> Option<String> {
203 let trimmed = line.trim_start();
204 if trimmed.starts_with('#') {
205 return None;
206 }
207 let rest = trimmed.strip_prefix('-')?;
208 if !rest.is_empty() && !rest.starts_with([' ', '\t']) {
209 return None;
210 }
211 Some(parse_scalar(rest))
212}
213
214fn is_commented_list_item(line: &str) -> bool {
215 let trimmed = line.trim_start();
216 let Some(rest) = trimmed.strip_prefix('#') else {
217 return false;
218 };
219 rest.trim_start().starts_with('-')
220}
221
222fn is_continuation(line: &str) -> bool {
223 let trimmed = line.trim_start();
224 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('-') {
225 return false;
226 }
227 line.starts_with([' ', '\t'])
228}
229
230fn is_empty_list_value(value: &str) -> bool {
231 matches!(value, "" | "[]" | "~" | "null" | "Null" | "NULL")
232}
233
234fn is_complete_flow(value: &str) -> bool {
235 value.starts_with('[') && value.ends_with(']')
236}
237
238fn add_to_flow_line(line: &str, path: &str) -> Option<String> {
239 let rest = projects_value(line)?;
240 let (value, comment) = split_value_and_comment(rest);
241 let open = value.find('[')?;
242 let close = value.rfind(']')?;
243 let inner = value[open + 1..close].trim();
244 let formatted = format_yaml_string(path);
245 let new_inner = if inner.is_empty() {
246 formatted
247 } else {
248 format!("{inner}, {formatted}")
249 };
250 Some(format!("projects:{}[{new_inner}]{comment}", &value[..open]))
251}
252
253fn remove_from_flow_line(line: &str, candidates: &[&str]) -> Option<String> {
254 let rest = projects_value(line)?;
255 let (value, comment) = split_value_and_comment(rest);
256 let open = value.find('[')?;
257 let close = value.rfind(']')?;
258 let inner = &value[open + 1..close];
259 let items = parse_flow_items(inner)?;
260 let remaining: Vec<String> = items
261 .into_iter()
262 .filter(|item| !candidates.contains(&item.as_str()))
263 .map(|item| format_yaml_string(&item))
264 .collect();
265 Some(format!(
266 "projects:{}[{}]{comment}",
267 &value[..open],
268 remaining.join(", ")
269 ))
270}
271
272fn parse_flow_items(inner: &str) -> Option<Vec<String>> {
273 if inner.trim().is_empty() {
274 return Some(Vec::new());
275 }
276 let mut items = Vec::new();
277 let mut current = String::new();
278 let mut in_single = false;
279 let mut in_double = false;
280 let mut chars = inner.chars();
281 while let Some(c) = chars.next() {
282 match c {
283 '\\' if in_double => {
284 current.push(c);
285 if let Some(next) = chars.next() {
286 current.push(next);
287 }
288 }
289 '\'' if !in_double => {
290 in_single = !in_single;
291 current.push(c);
292 }
293 '"' if !in_single => {
294 in_double = !in_double;
295 current.push(c);
296 }
297 ',' if !in_single && !in_double => {
298 items.push(parse_scalar(¤t));
299 current.clear();
300 }
301 _ => current.push(c),
302 }
303 }
304 if in_single || in_double {
305 return None;
306 }
307 if !current.trim().is_empty() {
308 items.push(parse_scalar(¤t));
309 }
310 Some(items)
311}
312
313fn split_value_and_comment(s: &str) -> (&str, &str) {
314 match inline_comment_index(s) {
315 Some(hash_idx) => {
316 let value = s[..hash_idx].trim_end();
317 let comment = &s[value.len()..];
318 (value, comment)
319 }
320 None => (s, ""),
321 }
322}
323
324fn inline_comment_index(s: &str) -> Option<usize> {
325 let mut in_single = false;
326 let mut in_double = false;
327 let mut chars = s.char_indices();
328 while let Some((i, c)) = chars.next() {
329 match c {
330 '\\' if in_double => {
331 chars.next();
332 }
333 '\'' if !in_double => in_single = !in_single,
334 '"' if !in_single => in_double = !in_double,
335 '#' if !in_single
336 && !in_double
337 && (i == 0 || s.as_bytes().get(i - 1).is_some_and(u8::is_ascii_whitespace)) =>
338 {
339 return Some(i);
340 }
341 _ => {}
342 }
343 }
344 None
345}
346
347fn parse_scalar(s: &str) -> String {
348 let s = if let Some(i) = inline_comment_index(s) {
349 s[..i].trim()
350 } else {
351 s.trim()
352 };
353 if s.len() >= 2 && s.starts_with('"') && s.ends_with('"') {
354 return s[1..s.len() - 1]
355 .replace("\\\\", "\\")
356 .replace("\\\"", "\"");
357 }
358 if s.len() >= 2 && s.starts_with('\'') && s.ends_with('\'') {
359 return s[1..s.len() - 1].replace("''", "'");
360 }
361 s.to_string()
362}
363
364fn format_yaml_string(s: &str) -> String {
365 if needs_yaml_quotes(s) {
366 format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
367 } else {
368 s.to_string()
369 }
370}
371
372fn needs_yaml_quotes(s: &str) -> bool {
373 s.is_empty()
374 || s.starts_with([' ', '\t', '-'])
375 || s.ends_with([' ', '\t'])
376 || matches!(
377 s,
378 "null" | "Null" | "NULL" | "~" | "true" | "false" | "yes" | "no"
379 )
380 || s.bytes().any(|b| {
381 matches!(
382 b,
383 b':' | b'#'
384 | b'{'
385 | b'}'
386 | b'['
387 | b']'
388 | b','
389 | b'&'
390 | b'*'
391 | b'!'
392 | b'|'
393 | b'>'
394 | b'<'
395 | b'='
396 | b'%'
397 | b'@'
398 | b'`'
399 | b'\''
400 | b'"'
401 | b'\\'
402 )
403 })
404}
405
406fn split_lines(content: &str) -> (Vec<String>, bool) {
407 if content.is_empty() {
408 return (Vec::new(), false);
409 }
410 let newline_at_end = content.ends_with('\n');
411 let body = content.strip_suffix('\n').unwrap_or(content);
412 (
413 body.split('\n').map(str::to_string).collect(),
414 newline_at_end,
415 )
416}
417
418fn join_lines(lines: &[String], newline_at_end: bool) -> String {
419 let mut out = lines.join("\n");
420 if (newline_at_end || !out.is_empty()) && !out.ends_with('\n') {
421 out.push('\n');
422 }
423 out
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429
430 const COMMENTED_CONFIG: &str = "\
431# Orch global configuration
432# See: https://github.com/gabrielkoerich/orch
433
434projects:
435 - /Users/me/Projects/orch
436 # - /Users/me/Projects/old-app
437
438# Agents to discover in PATH
439agents:
440 - claude
441 - codex
442 # - glm
443 # - olm # Ollama via Claude-compatible CLI
444
445router:
446 mode: \"round_robin\"
447 # weights:
448 # claude: 0.25
449 # codex: 0.1
450 # pool:
451 # - claude:haiku
452 # - opencode:free
453";
454
455 fn updated(edit: ProjectsEdit) -> String {
456 match edit {
457 ProjectsEdit::Updated(s) => s,
458 ProjectsEdit::Unchanged => panic!("expected Updated"),
459 }
460 }
461
462 fn comment_lines(content: &str) -> Vec<&str> {
463 content
464 .lines()
465 .filter(|line| line.trim_start().starts_with('#'))
466 .collect()
467 }
468
469 fn blank_line_count(content: &str) -> usize {
470 content.lines().filter(|line| line.is_empty()).count()
471 }
472
473 #[test]
474 fn project_add_preserves_comments_and_blank_lines() {
475 let after = updated(add_project(COMMENTED_CONFIG, "/Users/me/Projects/new").unwrap());
476 assert_eq!(comment_lines(COMMENTED_CONFIG), comment_lines(&after));
477 assert_eq!(blank_line_count(COMMENTED_CONFIG), blank_line_count(&after));
478 assert!(after.contains(" - /Users/me/Projects/new\n"));
479 assert!(after.contains(" - /Users/me/Projects/orch\n"));
480 assert_eq!(
481 after,
482 "\
483# Orch global configuration
484# See: https://github.com/gabrielkoerich/orch
485
486projects:
487 - /Users/me/Projects/orch
488 # - /Users/me/Projects/old-app
489 - /Users/me/Projects/new
490
491# Agents to discover in PATH
492agents:
493 - claude
494 - codex
495 # - glm
496 # - olm # Ollama via Claude-compatible CLI
497
498router:
499 mode: \"round_robin\"
500 # weights:
501 # claude: 0.25
502 # codex: 0.1
503 # pool:
504 # - claude:haiku
505 # - opencode:free
506"
507 );
508 }
509
510 #[test]
511 fn project_add_existing_path_changes_nothing() {
512 let edit = add_project(COMMENTED_CONFIG, "/Users/me/Projects/orch").unwrap();
513 assert_eq!(edit, ProjectsEdit::Unchanged);
514 }
515
516 #[test]
517 fn project_add_quoted_duplicate_changes_nothing() {
518 let config = "projects:\n - \"/Users/me/Projects/orch\"\n# keep me\n";
519 let edit = add_project(config, "/Users/me/Projects/orch").unwrap();
520 assert_eq!(edit, ProjectsEdit::Unchanged);
521 }
522
523 #[test]
524 fn project_remove_preserves_comments() {
525 let after =
526 updated(remove_project(COMMENTED_CONFIG, &["/Users/me/Projects/orch"]).unwrap());
527 assert_eq!(comment_lines(COMMENTED_CONFIG), comment_lines(&after));
528 assert!(!after.contains(" - /Users/me/Projects/orch\n"));
529 assert!(after.contains(" # - /Users/me/Projects/old-app\n"));
530 assert!(after.contains(" # - glm\n"));
531 assert_eq!(
532 after,
533 "\
534# Orch global configuration
535# See: https://github.com/gabrielkoerich/orch
536
537projects:
538 # - /Users/me/Projects/old-app
539
540# Agents to discover in PATH
541agents:
542 - claude
543 - codex
544 # - glm
545 # - olm # Ollama via Claude-compatible CLI
546
547router:
548 mode: \"round_robin\"
549 # weights:
550 # claude: 0.25
551 # codex: 0.1
552 # pool:
553 # - claude:haiku
554 # - opencode:free
555"
556 );
557 }
558
559 #[test]
560 fn project_remove_missing_path_changes_nothing() {
561 let edit = remove_project(COMMENTED_CONFIG, &["/nope"]).unwrap();
562 assert_eq!(edit, ProjectsEdit::Unchanged);
563 }
564
565 #[test]
566 fn project_add_empty_flow_list_keeps_surrounding_comments() {
567 let config = "\
568# header
569
570projects: []
571
572# Agents
573agents:
574 # - glm
575 - claude
576";
577 let after = updated(add_project(config, "/tmp/app").unwrap());
578 assert_eq!(comment_lines(config), comment_lines(&after));
579 assert_eq!(
580 after,
581 "\
582# header
583
584projects:
585 - /tmp/app
586
587# Agents
588agents:
589 # - glm
590 - claude
591"
592 );
593 }
594
595 #[test]
596 fn project_add_appends_block_when_projects_key_missing() {
597 let config = "# just a comment\n\nrouter:\n mode: llm\n";
598 let after = updated(add_project(config, "/tmp/app").unwrap());
599 assert!(after.contains("# just a comment\n"));
600 assert!(after.ends_with("projects:\n - /tmp/app\n"));
601 }
602
603 #[test]
604 fn project_add_rejects_non_list_projects() {
605 let err = add_project("projects: nope\n", "/tmp/app").unwrap_err();
606 assert!(err.to_string().contains("projects is not a list"));
607 }
608
609 #[test]
610 fn project_add_appends_to_flow_list() {
611 let config = "# keep\nprojects: [/a, /b]\n# tail\n";
612 let after = updated(add_project(config, "/c").unwrap());
613 assert_eq!(after, "# keep\nprojects: [/a, /b, /c]\n# tail\n");
614 }
615
616 #[test]
617 fn project_remove_from_flow_list() {
618 let config = "# keep\nprojects: [/a, /b]\n# tail\n";
619 let after = updated(remove_project(config, &["/a"]).unwrap());
620 assert_eq!(after, "# keep\nprojects: [/b]\n# tail\n");
621 }
622}