Skip to main content

orch/config/
projects_yaml.rs

1//! Edit the `projects:` list in `config.yml` as text so comments stay
2
3use anyhow::Context;
4
5/// Result of a comment-preserving `projects:` edit
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum ProjectsEdit {
8    /// Path already present on add, or missing on remove
9    Unchanged,
10    /// New document text
11    Updated(String),
12}
13
14/// Insert `path` into the root `projects:` list
15pub 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
25/// Remove list entries whose value equals any of `candidates`
26pub 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(&current));
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(&current));
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}