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