Skip to main content

orch/config/
mod.rs

1//! Config reader — loads YAML config files and resolves dot-separated keys.
2//!
3//! Reads `~/.orch/config.yml` (global) and `.orch.yml` (project).
4//! Project config overrides global config for the same key.
5//!
6//! Features:
7//! - In-memory cache: parsed YAML is cached for the process lifetime
8//! - File watching: uses notify crate to watch for config file changes
9//! - Hot reload: cache is invalidated when config files change
10//! - Change notifications: subscribers receive events when config files change
11
12pub mod projects_yaml;
13
14use anyhow::Context;
15use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
16use std::collections::HashMap;
17use std::path::{Path, PathBuf};
18use std::sync::{Arc, Mutex, RwLock};
19use std::time::Duration;
20use tokio::sync::broadcast;
21
22/// Cached YAML values — parsed once per file, reused for all key lookups.
23/// Protected by RwLock for concurrent read access.
24static CACHE: std::sync::LazyLock<RwLock<HashMap<PathBuf, serde_norway::Value>>> =
25    std::sync::LazyLock::new(|| RwLock::new(HashMap::new()));
26
27/// Files currently being watched for changes.
28static WATCHER: std::sync::LazyLock<RwLock<HashMap<PathBuf, ()>>> =
29    std::sync::LazyLock::new(|| RwLock::new(HashMap::new()));
30
31/// Global file watcher instance (started on first use).
32static FILE_WATCHER: std::sync::LazyLock<Arc<Mutex<Option<RecommendedWatcher>>>> =
33    std::sync::LazyLock::new(|| Arc::new(Mutex::new(None)));
34
35/// Broadcast sender for config change notifications.
36/// Sends the path of the changed config file.
37static CHANGE_TX: std::sync::LazyLock<broadcast::Sender<PathBuf>> =
38    std::sync::LazyLock::new(|| {
39        let (tx, _) = broadcast::channel(16);
40        tx
41    });
42
43/// Subscribe to config change notifications.
44///
45/// Returns a receiver that fires whenever a watched config file changes.
46/// The receiver yields the path of the changed file.
47pub fn subscribe() -> broadcast::Receiver<PathBuf> {
48    CHANGE_TX.subscribe()
49}
50
51/// Pre-populate the config cache using async I/O.
52///
53/// Call once at engine startup so subsequent synchronous [`get`] / [`get_list`]
54/// calls always hit the in-memory cache and never block a Tokio thread with
55/// `std::fs::read_to_string`.
56pub async fn warm_cache() -> anyhow::Result<()> {
57    let global_path = global_config_path()?;
58    if global_path.exists() {
59        match tokio::fs::read_to_string(&global_path).await {
60            Ok(content) => {
61                if let Ok(parsed) = serde_norway::from_str::<serde_norway::Value>(&content) {
62                    if let Ok(mut cache) = CACHE.write() {
63                        cache.insert(global_path.clone(), parsed);
64                    }
65                    watch_file(&global_path);
66                }
67            }
68            Err(e) => tracing::warn!("failed to warm global config cache: {e}"),
69        }
70    }
71
72    let project_path = std::env::current_dir()
73        .unwrap_or_else(|_| PathBuf::from("."))
74        .join(".orch.yml");
75    if project_path.exists() {
76        match tokio::fs::read_to_string(&project_path).await {
77            Ok(content) => {
78                if let Ok(parsed) = serde_norway::from_str::<serde_norway::Value>(&content) {
79                    if let Ok(mut cache) = CACHE.write() {
80                        cache.insert(project_path.clone(), parsed);
81                    }
82                    watch_file(&project_path);
83                }
84            }
85            Err(e) => tracing::warn!("failed to warm project config cache: {e}"),
86        }
87    }
88
89    Ok(())
90}
91
92/// Clear the entire config cache. Exposed for use by integration tests that
93/// need to run in an isolated config environment (e.g. a temp dir with no
94/// project `.orch.yml`).
95#[cfg(test)]
96pub fn clear_test_cache() {
97    if let Ok(mut cache) = CACHE.write() {
98        cache.clear();
99    }
100}
101
102/// Invalidate the cache entry for a specific config file
103/// and notify subscribers of the change.
104fn invalidate_cache(path: &PathBuf) {
105    if let Ok(mut cache) = CACHE.write() {
106        cache.remove(path);
107        tracing::debug!("config cache invalidated for: {}", path.display());
108    }
109    // Notify subscribers (ignore error if no active receivers)
110    let _ = CHANGE_TX.send(path.clone());
111}
112
113/// Start the file watcher if not already running.
114fn ensure_watcher() {
115    let mut watcher_guard = FILE_WATCHER.lock().unwrap_or_else(|e| e.into_inner());
116    if watcher_guard.is_some() {
117        return;
118    }
119
120    // Create the watcher
121    let watcher = RecommendedWatcher::new(
122        move |res: Result<notify::Event, notify::Error>| {
123            if let Ok(event) = res {
124                for path in event.paths {
125                    // Check if it's a config file we care about
126                    let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
127
128                    if filename == "config.yml" || filename == ".orch.yml" {
129                        invalidate_cache(&path);
130                    }
131                }
132            }
133        },
134        Config::default().with_poll_interval(Duration::from_secs(2)),
135    );
136
137    if let Ok(w) = watcher {
138        *watcher_guard = Some(w);
139    }
140}
141
142/// Watch a config file for changes.
143fn watch_file(path: &PathBuf) {
144    // First ensure watcher is running
145    ensure_watcher();
146
147    // Track that we want to watch this file
148    if let Ok(mut watched) = WATCHER.write() {
149        if !watched.contains_key(path) {
150            watched.insert(path.clone(), ());
151
152            // Add to notify watcher
153            if let Ok(mut guard) = FILE_WATCHER.lock() {
154                if let Some(ref mut watcher) = *guard {
155                    match watcher.watch(path, RecursiveMode::NonRecursive) {
156                        Ok(()) => tracing::debug!("now watching config file: {}", path.display()),
157                        Err(e) => tracing::warn!(
158                            "failed to register config file watcher for {}: {}",
159                            path.display(),
160                            e
161                        ),
162                    }
163                }
164            }
165        }
166    }
167}
168
169fn read_file_to_string(path: &Path) -> anyhow::Result<String> {
170    match tokio::runtime::Handle::try_current() {
171        Ok(handle) if handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread => {
172            Ok(tokio::task::block_in_place(|| {
173                std::fs::read_to_string(path)
174            })?)
175        }
176        _ => Ok(std::fs::read_to_string(path)?),
177    }
178}
179
180fn load_cached_root(path: &PathBuf) -> anyhow::Result<serde_norway::Value> {
181    if let Ok(cache) = CACHE.read() {
182        if let Some(cached) = cache.get(path) {
183            return Ok(cached.clone());
184        }
185    }
186
187    let content =
188        read_file_to_string(path).with_context(|| format!("reading {}", path.display()))?;
189    let parsed: serde_norway::Value =
190        serde_norway::from_str(&content).with_context(|| format!("parsing {}", path.display()))?;
191
192    if let Ok(mut cache) = CACHE.write() {
193        cache.insert(path.clone(), parsed.clone());
194    }
195
196    Ok(parsed)
197}
198
199/// Resolve the global config path: `~/.orch/config.yml`
200fn global_config_path() -> anyhow::Result<PathBuf> {
201    crate::home::config_path()
202}
203
204/// Get a config value as a list of strings by dot-separated key.
205///
206/// Supports both YAML sequences and comma-separated strings:
207/// ```yaml
208/// workflow:
209///   allowed_tools:
210///     - Edit
211///     - Write
212///     - git
213/// ```
214/// or:
215/// ```yaml
216/// workflow:
217///   allowed_tools: "Edit,Write,git"
218/// ```
219///
220/// Returns empty vec if key not found.
221pub fn get_list(key: &str) -> anyhow::Result<Vec<String>> {
222    // Try project config first
223    // Use an absolute path so the cache key is stable across tests/processes
224    // that change the current working directory.
225    let project_path = std::env::current_dir()
226        .unwrap_or_else(|_| PathBuf::from("."))
227        .join(".orch.yml");
228    if project_path.exists() {
229        if let Ok(val) = resolve_list(&project_path, key) {
230            if !val.is_empty() {
231                return Ok(val);
232            }
233        }
234    }
235
236    // Fall back to global config
237    let global_path = global_config_path()?;
238    if global_path.exists() {
239        return resolve_list(&global_path, key);
240    }
241
242    Ok(vec![])
243}
244
245/// Resolve a dot-separated key as a list from a YAML file.
246fn resolve_list(path: &PathBuf, key: &str) -> anyhow::Result<Vec<String>> {
247    watch_file(path);
248
249    let root = load_cached_root(path)?;
250
251    extract_list(&root, key)
252}
253
254/// Extract a list of strings from a YAML tree by dot-separated key.
255fn extract_list(root: &serde_norway::Value, key: &str) -> anyhow::Result<Vec<String>> {
256    let mut current = root;
257    for part in key.split('.') {
258        match current.get(part) {
259            Some(v) => current = v,
260            None => return Ok(vec![]),
261        }
262    }
263
264    match current {
265        serde_norway::Value::Sequence(seq) => Ok(seq
266            .iter()
267            .filter_map(|item| item.as_str().map(String::from))
268            .collect()),
269        serde_norway::Value::String(s) => {
270            // Support comma-separated string format
271            Ok(s.split(',')
272                .map(|s| s.trim().to_string())
273                .filter(|s| !s.is_empty())
274                .collect())
275        }
276        _ => Ok(vec![]),
277    }
278}
279
280/// Get a config value by dot-separated key (e.g. "agents.claude.model").
281///
282/// Lookup order:
283/// 1. `.orch.yml` in the current directory (project config)
284/// 2. `~/.orch/config.yml` (global config)
285///
286/// Returns the first match as a string.
287/// Files are parsed once and cached for the process lifetime.
288pub fn get(key: &str) -> anyhow::Result<String> {
289    // Try project config first
290    // Use an absolute path so the cache key is stable across tests/processes
291    // that change the current working directory.
292    let project_path = std::env::current_dir()
293        .unwrap_or_else(|_| PathBuf::from("."))
294        .join(".orch.yml");
295    if project_path.exists() {
296        if let Ok(val) = resolve_key(&project_path, key) {
297            return Ok(val);
298        }
299    }
300
301    // Fall back to global config
302    let global_path = global_config_path()?;
303    if global_path.exists() {
304        return resolve_key(&global_path, key);
305    }
306
307    anyhow::bail!("config key not found: {key}")
308}
309
310/// Get all project paths from the global config `projects:` list.
311///
312/// Returns a list of absolute paths to project directories.
313/// Each directory should contain a `.orch.yml` with `gh.repo`.
314pub fn get_project_paths() -> anyhow::Result<Vec<String>> {
315    let global_path = global_config_path()?;
316
317    if global_path.exists() {
318        if let Ok(paths) = resolve_projects_list(&global_path, "projects") {
319            if !paths.is_empty() {
320                return Ok(paths);
321            }
322        }
323    }
324
325    Ok(vec![])
326}
327
328/// Like `get_projects()` but also returns the project directory path.
329///
330/// Returns `(repo_slug, project_dir)` pairs. Used by the engine to resolve
331/// per-project config (e.g., jobs from each project's `.orch.yml`).
332pub fn get_projects_with_paths() -> anyhow::Result<Vec<(String, std::path::PathBuf)>> {
333    let paths = get_project_paths()?;
334
335    if !paths.is_empty() {
336        let mut results = Vec::new();
337        for path_str in &paths {
338            let path = std::path::PathBuf::from(path_str);
339            let orch_yml = path.join(".orch.yml");
340
341            let config_file = if orch_yml.exists() {
342                orch_yml
343            } else {
344                let legacy = path.join(".orchestrator.yml");
345                if legacy.exists() {
346                    legacy
347                } else {
348                    continue;
349                }
350            };
351
352            if let Ok(content) = std::fs::read_to_string(&config_file) {
353                if let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&content) {
354                    if let Some(repo) = doc
355                        .get("gh")
356                        .and_then(|gh| gh.get("repo"))
357                        .and_then(|r| r.as_str())
358                    {
359                        results.push((repo.to_string(), path));
360                    }
361                }
362            }
363        }
364
365        if !results.is_empty() {
366            return Ok(results);
367        }
368    }
369
370    // Fall back to global gh.repo (no project dir available)
371    if let Ok(repo) = get("gh.repo") {
372        if !repo.is_empty() {
373            return Ok(vec![(repo, std::path::PathBuf::from("."))]);
374        }
375    }
376
377    Ok(vec![])
378}
379
380/// Resolve the repo slug for a specific project path.
381///
382/// Reads `gh.repo` from the project's `.orch.yml`.
383pub fn get_repo_for_project(project_path: &std::path::Path) -> anyhow::Result<String> {
384    let orch_yml = project_path.join(".orch.yml");
385    let config_file = if orch_yml.exists() {
386        orch_yml
387    } else {
388        let legacy = project_path.join(".orchestrator.yml");
389        if legacy.exists() {
390            legacy
391        } else {
392            anyhow::bail!("no .orch.yml found in {}", project_path.display());
393        }
394    };
395
396    let content = std::fs::read_to_string(&config_file)?;
397    let doc: serde_norway::Value = serde_norway::from_str(&content)?;
398
399    doc.get("gh")
400        .and_then(|gh| gh.get("repo"))
401        .and_then(|r| r.as_str())
402        .map(String::from)
403        .ok_or_else(|| anyhow::anyhow!("gh.repo not set in {}", config_file.display()))
404}
405
406/// Resolve project path from CWD by finding the nearest `.orch.yml`.
407///
408/// Walks up from the current directory looking for `.orch.yml` or legacy `.orchestrator.yml`.
409pub fn find_project_root() -> anyhow::Result<std::path::PathBuf> {
410    let mut dir = std::env::current_dir()?;
411
412    loop {
413        if dir.join(".orch.yml").exists() || dir.join(".orchestrator.yml").exists() {
414            return Ok(dir);
415        }
416
417        if !dir.pop() {
418            break;
419        }
420    }
421
422    anyhow::bail!("no .orch.yml found in current directory or parents — run `orch init`")
423}
424
425/// Resolve the repo slug for the current project (from CWD).
426///
427/// Walks up from CWD to find `.orch.yml`, reads `gh.repo` from it.
428/// Falls back to global `gh.repo` for backward compatibility.
429pub fn get_current_repo() -> anyhow::Result<String> {
430    // Try to find project root from CWD
431    if let Ok(project_root) = find_project_root() {
432        if let Ok(repo) = get_repo_for_project(&project_root) {
433            return Ok(repo);
434        }
435    }
436
437    // Fall back to per-project config in CWD (.orch.yml)
438    if let Ok(repo) = get("gh.repo") {
439        if !repo.is_empty() {
440            return Ok(repo);
441        }
442    }
443
444    // Legacy fallback
445    if let Ok(repo) = get("repo") {
446        if !repo.is_empty() {
447            return Ok(repo);
448        }
449    }
450
451    anyhow::bail!("could not determine repo — ensure .orch.yml has gh.repo set")
452}
453
454/// Skill repository configuration.
455#[derive(Debug, Clone)]
456pub struct SkillConfig {
457    /// GitHub repository in "owner/repo" format.
458    pub repo: String,
459}
460
461/// Get skills configuration from the global config.
462///
463/// Reads the `skills:` list from `~/.orch/config.yml`:
464/// ```yaml
465/// skills:
466///   - repo: "owner/skill-repo"
467///   - repo: "owner/another-repo"
468/// ```
469///
470/// Returns an empty vector if no skills are configured.
471pub fn get_skills() -> anyhow::Result<Vec<SkillConfig>> {
472    let global_path = global_config_path()?;
473
474    if !global_path.exists() {
475        return Ok(vec![]);
476    }
477
478    let root = load_cached_root(&global_path)?;
479
480    extract_skills_list(&root)
481}
482
483/// Extract skills list from a YAML tree.
484fn extract_skills_list(root: &serde_norway::Value) -> anyhow::Result<Vec<SkillConfig>> {
485    let Some(serde_norway::Value::Sequence(seq)) = root.get("skills") else {
486        return Ok(vec![]);
487    };
488
489    let mut skills = Vec::new();
490    for item in seq {
491        if let serde_norway::Value::Mapping(map) = item {
492            let repo = map
493                .get("repo")
494                .and_then(|v| v.as_str())
495                .map(String::from)
496                .unwrap_or_default();
497
498            if !repo.is_empty() {
499                skills.push(SkillConfig { repo });
500            }
501        }
502    }
503
504    Ok(skills)
505}
506
507/// Resolve a projects list from a config file.
508fn resolve_projects_list(path: &PathBuf, key: &str) -> anyhow::Result<Vec<String>> {
509    // First, ensure we're watching this file for changes
510    watch_file(path);
511
512    let root = load_cached_root(path)?;
513
514    // Try to extract projects list - if key doesn't exist, return empty vector
515    match extract_projects_list(&root, key) {
516        Ok(projects) => Ok(projects),
517        Err(_) => Ok(vec![]),
518    }
519}
520
521/// Extract a projects list from a YAML tree.
522fn extract_projects_list(root: &serde_norway::Value, key: &str) -> anyhow::Result<Vec<String>> {
523    let mut current = root;
524    for part in key.split('.') {
525        // If key doesn't exist, return empty vector
526        let next = match current.get(part) {
527            Some(v) => v,
528            None => return Ok(vec![]),
529        };
530        current = next;
531    }
532
533    match current {
534        serde_norway::Value::Sequence(seq) => {
535            let mut projects = Vec::new();
536            for item in seq {
537                if let serde_norway::Value::String(s) = item {
538                    projects.push(s.clone());
539                } else if let serde_norway::Value::Mapping(map) = item {
540                    // Support map format: { repo: "owner/repo", project_dir: "/path" }
541                    if let Some(serde_norway::Value::String(repo)) = map.get("repo") {
542                        projects.push(repo.clone());
543                    }
544                }
545            }
546            Ok(projects)
547        }
548        _ => Ok(vec![]),
549    }
550}
551
552/// Resolve a dot-separated key from a YAML file.
553///
554/// Caches the parsed YAML so repeated lookups don't re-read disk.
555/// Sets up file watching for hot reload when first loading.
556fn resolve_key(path: &PathBuf, key: &str) -> anyhow::Result<String> {
557    // First, ensure we're watching this file for changes
558    watch_file(path);
559
560    let root = load_cached_root(path)?;
561
562    extract_value(&root, key)
563}
564
565/// Extract a value from a YAML tree by dot-separated key.
566fn extract_value(root: &serde_norway::Value, key: &str) -> anyhow::Result<String> {
567    let mut current = root;
568    for part in key.split('.') {
569        current = current
570            .get(part)
571            .with_context(|| format!("key not found: {key}"))?;
572    }
573
574    match current {
575        serde_norway::Value::String(s) => Ok(s.clone()),
576        serde_norway::Value::Number(n) => Ok(n.to_string()),
577        serde_norway::Value::Bool(b) => Ok(b.to_string()),
578        serde_norway::Value::Null => Ok(String::new()),
579        _ => Ok(serde_norway::to_string(current)?),
580    }
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586    use std::io::Write;
587
588    fn write_yaml(dir: &std::path::Path, name: &str, content: &str) -> PathBuf {
589        let path = dir.join(name);
590        let mut f = std::fs::File::create(&path).unwrap();
591        f.write_all(content.as_bytes()).unwrap();
592        path
593    }
594
595    #[test]
596    fn resolve_simple_key() {
597        let dir = tempfile::tempdir().unwrap();
598        let path = write_yaml(dir.path(), "config.yml", "repo: owner/repo\n");
599        let val = resolve_key(&path, "repo").unwrap();
600        assert_eq!(val, "owner/repo");
601    }
602
603    #[test]
604    fn resolve_nested_key() {
605        let dir = tempfile::tempdir().unwrap();
606        let path = write_yaml(
607            dir.path(),
608            "config.yml",
609            "agents:\n  claude:\n    model: opus\n",
610        );
611        let val = resolve_key(&path, "agents.claude.model").unwrap();
612        assert_eq!(val, "opus");
613    }
614
615    #[test]
616    fn resolve_boolean_value() {
617        let dir = tempfile::tempdir().unwrap();
618        let path = write_yaml(dir.path(), "config.yml", "enabled: true\n");
619        let val = resolve_key(&path, "enabled").unwrap();
620        assert_eq!(val, "true");
621    }
622
623    #[test]
624    fn resolve_number_value() {
625        let dir = tempfile::tempdir().unwrap();
626        let path = write_yaml(dir.path(), "config.yml", "timeout: 300\n");
627        let val = resolve_key(&path, "timeout").unwrap();
628        assert_eq!(val, "300");
629    }
630
631    #[test]
632    fn resolve_missing_key_errors() {
633        let dir = tempfile::tempdir().unwrap();
634        let path = write_yaml(dir.path(), "config.yml", "repo: owner/repo\n");
635        let result = resolve_key(&path, "missing.key");
636        assert!(result.is_err());
637    }
638
639    #[test]
640    fn resolve_missing_file_errors() {
641        let path = PathBuf::from("/nonexistent/config.yml");
642        let result = resolve_key(&path, "repo");
643        assert!(result.is_err());
644    }
645
646    #[test]
647    fn resolve_projects_list_string_array() {
648        let dir = tempfile::tempdir().unwrap();
649        let path = write_yaml(
650            dir.path(),
651            "config.yml",
652            "projects:\n  - owner/repo1\n  - owner/repo2\n",
653        );
654        let projects = resolve_projects_list(&path, "projects").unwrap();
655        assert_eq!(projects, vec!["owner/repo1", "owner/repo2"]);
656    }
657
658    #[test]
659    fn resolve_projects_list_map_array() {
660        let dir = tempfile::tempdir().unwrap();
661        let path = write_yaml(
662            dir.path(),
663            "config.yml",
664            "projects:\n  - repo: owner/repo1\n    project_dir: /path/to/repo1\n  - repo: owner/repo2\n    project_dir: /path/to/repo2\n",
665        );
666        let projects = resolve_projects_list(&path, "projects").unwrap();
667        assert_eq!(projects, vec!["owner/repo1", "owner/repo2"]);
668    }
669
670    #[test]
671    fn resolve_projects_list_fallback_to_repo() {
672        let dir = tempfile::tempdir().unwrap();
673        let path = write_yaml(dir.path(), "config.yml", "repo: owner/single-repo\n");
674        let projects = resolve_projects_list(&path, "projects").unwrap();
675        assert!(projects.is_empty());
676    }
677
678    #[test]
679    fn extract_projects_list_from_sequence() {
680        let yaml =
681            serde_norway::from_str::<serde_norway::Value>("projects:\n  - a\n  - b\n  - c\n")
682                .unwrap();
683        let projects = extract_projects_list(&yaml, "projects").unwrap();
684        assert_eq!(projects, vec!["a", "b", "c"]);
685    }
686
687    #[test]
688    fn extract_projects_list_from_mapping_sequence() {
689        let yaml =
690            serde_norway::from_str::<serde_norway::Value>("projects:\n  - repo: a\n  - repo: b\n")
691                .unwrap();
692        let projects = extract_projects_list(&yaml, "projects").unwrap();
693        assert_eq!(projects, vec!["a", "b"]);
694    }
695
696    #[test]
697    fn extract_projects_list_empty_for_missing() {
698        let yaml = serde_norway::from_str::<serde_norway::Value>("foo: bar").unwrap();
699        let projects = extract_projects_list(&yaml, "projects").unwrap();
700        assert!(projects.is_empty());
701    }
702
703    #[test]
704    fn subscribe_receives_change_notifications() {
705        let mut rx = subscribe();
706        // Use a unique path to avoid interference from parallel tests
707        let path = PathBuf::from(format!("/tmp/test-config-{}.yml", std::process::id()));
708
709        // Manually call invalidate_cache to simulate a file change
710        invalidate_cache(&path);
711
712        // Drain until we find our path (other tests may send on the shared channel)
713        loop {
714            match rx.try_recv() {
715                Ok(received_path) if received_path == path => break,
716                Ok(_) => continue, // skip notifications from other tests
717                Err(e) => panic!("expected config change notification, got error: {e:?}"),
718            }
719        }
720    }
721
722    #[test]
723    fn subscribe_multiple_receivers() {
724        let mut rx1 = subscribe();
725        let mut rx2 = subscribe();
726        // Use a unique path to avoid interference from parallel tests
727        let path = PathBuf::from(format!("/tmp/test-config-multi-{}.yml", std::process::id()));
728
729        invalidate_cache(&path);
730
731        // Drain until we find our path on both receivers
732        let find_path = |rx: &mut tokio::sync::broadcast::Receiver<PathBuf>, expected: &PathBuf| loop {
733            match rx.try_recv() {
734                Ok(p) if &p == expected => return,
735                Ok(_) => continue,
736                Err(e) => panic!("expected config change notification, got error: {e:?}"),
737            }
738        };
739        find_path(&mut rx1, &path);
740        find_path(&mut rx2, &path);
741    }
742
743    #[test]
744    fn resolve_list_from_sequence() {
745        let dir = tempfile::tempdir().unwrap();
746        let path = write_yaml(
747            dir.path(),
748            "config.yml",
749            "workflow:\n  allowed_tools:\n    - Edit\n    - Write\n    - git\n    - npm\n",
750        );
751        let list = resolve_list(&path, "workflow.allowed_tools").unwrap();
752        assert_eq!(list, vec!["Edit", "Write", "git", "npm"]);
753    }
754
755    #[test]
756    fn resolve_list_from_comma_string() {
757        let dir = tempfile::tempdir().unwrap();
758        let path = write_yaml(
759            dir.path(),
760            "config.yml",
761            "workflow:\n  allowed_tools: \"Edit,Write,git,npm\"\n",
762        );
763        let list = resolve_list(&path, "workflow.allowed_tools").unwrap();
764        assert_eq!(list, vec!["Edit", "Write", "git", "npm"]);
765    }
766
767    #[test]
768    fn resolve_list_missing_key_returns_empty() {
769        let dir = tempfile::tempdir().unwrap();
770        let path = write_yaml(dir.path(), "config.yml", "foo: bar\n");
771        let list = resolve_list(&path, "workflow.allowed_tools").unwrap();
772        assert!(list.is_empty());
773    }
774
775    #[test]
776    fn extract_skills_list_from_sequence() {
777        let yaml = serde_norway::from_str::<serde_norway::Value>(
778            "skills:\n  - repo: owner/skills1\n  - repo: owner/skills2\n",
779        )
780        .unwrap();
781        let skills = extract_skills_list(&yaml).unwrap();
782        assert_eq!(skills.len(), 2);
783        assert_eq!(skills[0].repo, "owner/skills1");
784        assert_eq!(skills[1].repo, "owner/skills2");
785    }
786
787    #[test]
788    fn extract_skills_list_single_repo() {
789        let yaml =
790            serde_norway::from_str::<serde_norway::Value>("skills:\n  - repo: owner/skills\n")
791                .unwrap();
792        let skills = extract_skills_list(&yaml).unwrap();
793        assert_eq!(skills.len(), 1);
794        assert_eq!(skills[0].repo, "owner/skills");
795    }
796
797    #[test]
798    fn extract_skills_list_empty_for_missing() {
799        let yaml = serde_norway::from_str::<serde_norway::Value>("foo: bar").unwrap();
800        let skills = extract_skills_list(&yaml).unwrap();
801        assert!(skills.is_empty());
802    }
803}