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 | 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(&current));
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(&current));
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}