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