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: a changed file is re-parsed into the cache; one that does not
10//!   parse leaves the last good values in place
11//! - Change notifications: subscribers receive events when config files change
12
13pub mod projects_yaml;
14
15use anyhow::Context;
16use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
17use std::collections::{HashMap, HashSet};
18use std::path::{Path, PathBuf};
19use std::sync::{Arc, Mutex, RwLock};
20use std::time::Duration;
21use tokio::sync::broadcast;
22
23/// Cached YAML values — parsed once per file, reused for all key lookups.
24/// Protected by RwLock for concurrent read access.
25static CACHE: std::sync::LazyLock<RwLock<HashMap<PathBuf, serde_norway::Value>>> =
26    std::sync::LazyLock::new(|| RwLock::new(HashMap::new()));
27
28/// Files currently being watched for changes.
29static WATCHER: std::sync::LazyLock<RwLock<HashSet<PathBuf>>> =
30    std::sync::LazyLock::new(|| RwLock::new(HashSet::new()));
31
32/// Global file watcher instance (started on first use).
33static FILE_WATCHER: std::sync::LazyLock<Arc<Mutex<Option<RecommendedWatcher>>>> =
34    std::sync::LazyLock::new(|| Arc::new(Mutex::new(None)));
35
36/// Config files whose content on disk does not read or parse right now.
37/// Tracked so the parse error is logged once per breakage, not once per
38/// watcher event.
39static BAD_FILES: std::sync::LazyLock<RwLock<HashSet<PathBuf>>> =
40    std::sync::LazyLock::new(|| RwLock::new(HashSet::new()));
41
42/// Broadcast sender for config change notifications.
43/// Sends the path of the changed config file.
44static CHANGE_TX: std::sync::LazyLock<broadcast::Sender<PathBuf>> =
45    std::sync::LazyLock::new(|| {
46        let (tx, _) = broadcast::channel(16);
47        tx
48    });
49
50/// Subscribe to config change notifications.
51///
52/// Returns a receiver that fires whenever a watched config file changes.
53/// The receiver yields the path of the changed file.
54pub fn subscribe() -> broadcast::Receiver<PathBuf> {
55    CHANGE_TX.subscribe()
56}
57
58/// Pre-populate the config cache using async I/O.
59///
60/// Call once at engine startup so subsequent synchronous [`get`] / [`get_list`]
61/// calls always hit the in-memory cache and never block a Tokio thread with
62/// `std::fs::read_to_string`.
63pub async fn warm_cache() -> anyhow::Result<()> {
64    let global_path = global_config_path()?;
65    if global_path.exists() {
66        match tokio::fs::read_to_string(&global_path).await {
67            Ok(content) => {
68                if let Ok(parsed) = serde_norway::from_str::<serde_norway::Value>(&content) {
69                    if let Ok(mut cache) = CACHE.write() {
70                        cache.insert(global_path.clone(), parsed);
71                    }
72                    watch_file(&global_path);
73                }
74            }
75            Err(e) => tracing::warn!("failed to warm global config cache: {e}"),
76        }
77    }
78
79    let project_path = std::env::current_dir()
80        .unwrap_or_else(|_| PathBuf::from("."))
81        .join(".orch.yml");
82    if project_path.exists() {
83        match tokio::fs::read_to_string(&project_path).await {
84            Ok(content) => {
85                if let Ok(parsed) = serde_norway::from_str::<serde_norway::Value>(&content) {
86                    if let Ok(mut cache) = CACHE.write() {
87                        cache.insert(project_path.clone(), parsed);
88                    }
89                    watch_file(&project_path);
90                }
91            }
92            Err(e) => tracing::warn!("failed to warm project config cache: {e}"),
93        }
94    }
95
96    Ok(())
97}
98
99/// Clear the entire config cache. Exposed for use by integration tests that
100/// need to run in an isolated config environment (e.g. a temp dir with no
101/// project `.orch.yml`).
102#[cfg(test)]
103pub fn clear_test_cache() {
104    if let Ok(mut cache) = CACHE.write() {
105        cache.clear();
106    }
107}
108
109/// Re-read a changed config file and notify subscribers.
110///
111/// A file that cannot be read or parsed leaves the last good value in the
112/// cache and sends no notification, so a broken or half-written edit cannot
113/// reset the running config to defaults.
114///
115/// Returns true when this call logged a newly invalid file, so repeated
116/// watcher events for one breakage produce one error line
117fn reload_file(path: &PathBuf) -> bool {
118    let parsed = std::fs::read_to_string(path)
119        .map_err(|e| e.to_string())
120        .and_then(|content| {
121            serde_norway::from_str::<serde_norway::Value>(&content).map_err(|e| e.to_string())
122        });
123
124    match parsed {
125        Ok(value) => {
126            if let Ok(mut cache) = CACHE.write() {
127                cache.insert(path.clone(), value);
128            }
129            if let Ok(mut bad) = BAD_FILES.write()
130                && bad.remove(path)
131            {
132                tracing::info!(path = %path.display(), "config file parses again, reloaded");
133            }
134            tracing::debug!("config cache refreshed for: {}", path.display());
135            // Notify subscribers (ignore error if no active receivers)
136            let _ = CHANGE_TX.send(path.clone());
137            false
138        }
139        Err(err) => {
140            let first_failure = BAD_FILES
141                .write()
142                .is_ok_and(|mut bad| bad.insert(path.clone()));
143            if first_failure {
144                tracing::error!(
145                    path = %path.display(),
146                    %err,
147                    "config file is invalid, keeping the last good values"
148                );
149            }
150            first_failure
151        }
152    }
153}
154
155/// Start the file watcher if not already running.
156fn ensure_watcher() {
157    let mut watcher_guard = FILE_WATCHER
158        .lock()
159        .unwrap_or_else(std::sync::PoisonError::into_inner);
160    if watcher_guard.is_some() {
161        return;
162    }
163
164    // Create the watcher
165    let watcher = RecommendedWatcher::new(
166        move |res: Result<notify::Event, notify::Error>| {
167            if let Ok(event) = res {
168                if !is_content_change(event.kind) {
169                    return;
170                }
171                for path in event.paths {
172                    // Check if it's a config file we care about
173                    let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
174
175                    if filename == "config.yml" || filename == ".orch.yml" {
176                        let _ = reload_file(&path);
177                    }
178                }
179            }
180        },
181        Config::default().with_poll_interval(Duration::from_secs(2)),
182    );
183
184    if let Ok(w) = watcher {
185        *watcher_guard = Some(w);
186    }
187}
188
189// inotify reports every read as an access event, and reload_file reads the file, so reacting to
190// access events reloads forever on Linux
191fn is_content_change(kind: notify::EventKind) -> bool {
192    kind.is_create() || kind.is_modify() || kind.is_remove()
193}
194
195/**
196 * Register a config file for change watching. `get` / `get_list` watch the files they read, but a
197 * project's `.orch.yml` is read directly by `get_projects_with_paths`, so the caller that knows the
198 * project list registers it here. A missing path is skipped: notify cannot watch one, and a project
199 * may be configured through the legacy `.orchestrator.yml` name or the global `gh.repo` fallback,
200 * which has no project directory at all
201 **/
202pub fn watch(path: &Path) {
203    if !path.exists() {
204        tracing::debug!(path = %path.display(), "no config file to watch here");
205        return;
206    }
207    watch_file(&path.to_path_buf());
208}
209
210/// Watch a config file for changes.
211fn watch_file(path: &PathBuf) {
212    // First ensure watcher is running
213    ensure_watcher();
214
215    // Track that we want to watch this file
216    if let Ok(mut watched) = WATCHER.write()
217        && !watched.contains(path)
218    {
219        watched.insert(path.clone());
220
221        // Add to notify watcher
222        if let Ok(mut guard) = FILE_WATCHER.lock()
223            && let Some(ref mut watcher) = *guard
224        {
225            match watcher.watch(path, RecursiveMode::NonRecursive) {
226                Ok(()) => tracing::debug!("now watching config file: {}", path.display()),
227                Err(e) => tracing::warn!(
228                    "failed to register config file watcher for {}: {}",
229                    path.display(),
230                    e
231                ),
232            }
233        }
234    }
235}
236
237fn read_file_to_string(path: &Path) -> anyhow::Result<String> {
238    match tokio::runtime::Handle::try_current() {
239        Ok(handle) if handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread => {
240            Ok(tokio::task::block_in_place(|| {
241                std::fs::read_to_string(path)
242            })?)
243        }
244        _ => Ok(std::fs::read_to_string(path)?),
245    }
246}
247
248fn load_cached_root(path: &PathBuf) -> anyhow::Result<serde_norway::Value> {
249    if let Ok(cache) = CACHE.read()
250        && let Some(cached) = cache.get(path)
251    {
252        return Ok(cached.clone());
253    }
254
255    let content =
256        read_file_to_string(path).with_context(|| format!("reading {}", path.display()))?;
257    let parsed: serde_norway::Value =
258        serde_norway::from_str(&content).with_context(|| format!("parsing {}", path.display()))?;
259
260    if let Ok(mut cache) = CACHE.write() {
261        cache.insert(path.clone(), parsed.clone());
262    }
263
264    Ok(parsed)
265}
266
267/// Resolve the global config path: `~/.orch/config.yml`
268fn global_config_path() -> anyhow::Result<PathBuf> {
269    crate::home::config_path()
270}
271
272/// Get a config value as a list of strings by dot-separated key.
273///
274/// Supports both YAML sequences and comma-separated strings:
275/// ```yaml
276/// workflow:
277///   allowed_tools:
278///     - Edit
279///     - Write
280///     - git
281/// ```
282/// or:
283/// ```yaml
284/// workflow:
285///   allowed_tools: "Edit,Write,git"
286/// ```
287///
288/// Returns empty vec if key not found.
289pub fn get_list(key: &str) -> anyhow::Result<Vec<String>> {
290    // Try project config first
291    // Use an absolute path so the cache key is stable across tests/processes
292    // that change the current working directory.
293    let project_path = std::env::current_dir()
294        .unwrap_or_else(|_| PathBuf::from("."))
295        .join(".orch.yml");
296    if project_path.exists()
297        && let Ok(val) = resolve_list(&project_path, key)
298        && !val.is_empty()
299    {
300        return Ok(val);
301    }
302
303    // Fall back to global config
304    let global_path = global_config_path()?;
305    if global_path.exists() {
306        return resolve_list(&global_path, key);
307    }
308
309    Ok(vec![])
310}
311
312/// Resolve a dot-separated key as a list from a YAML file.
313fn resolve_list(path: &PathBuf, key: &str) -> anyhow::Result<Vec<String>> {
314    watch_file(path);
315
316    let root = load_cached_root(path)?;
317
318    Ok(extract_list(&root, key))
319}
320
321/// Extract a list of strings from a YAML tree by dot-separated key.
322fn extract_list(root: &serde_norway::Value, key: &str) -> Vec<String> {
323    let mut current = root;
324    for part in key.split('.') {
325        match current.get(part) {
326            Some(v) => current = v,
327            None => return vec![],
328        }
329    }
330
331    match current {
332        serde_norway::Value::Sequence(seq) => seq
333            .iter()
334            .filter_map(|item| item.as_str().map(String::from))
335            .collect(),
336        serde_norway::Value::String(s) => {
337            // Support comma-separated string format
338            s.split(',')
339                .map(|s| s.trim().to_string())
340                .filter(|s| !s.is_empty())
341                .collect()
342        }
343        _ => vec![],
344    }
345}
346
347/// Get a config value by dot-separated key (e.g. "agents.claude.model").
348///
349/// Lookup order:
350/// 1. `.orch.yml` in the current directory (project config)
351/// 2. `~/.orch/config.yml` (global config)
352///
353/// Returns the first match as a string.
354/// Files are parsed once and cached for the process lifetime.
355pub fn get(key: &str) -> anyhow::Result<String> {
356    // Try project config first
357    // Use an absolute path so the cache key is stable across tests/processes
358    // that change the current working directory.
359    let project_path = std::env::current_dir()
360        .unwrap_or_else(|_| PathBuf::from("."))
361        .join(".orch.yml");
362    if project_path.exists()
363        && let Ok(val) = resolve_key(&project_path, key)
364    {
365        return Ok(val);
366    }
367
368    // Fall back to global config
369    let global_path = global_config_path()?;
370    if global_path.exists() {
371        return resolve_key(&global_path, key);
372    }
373
374    anyhow::bail!("config key not found: {key}")
375}
376
377/// Like [`get`], but reads the `.orch.yml` in `project_dir`, since the service runs from `/`
378pub fn get_for_project(project_dir: &std::path::Path, key: &str) -> anyhow::Result<String> {
379    let project_path = project_dir.join(".orch.yml");
380    if project_path.exists()
381        && let Ok(val) = resolve_key(&project_path, key)
382    {
383        return Ok(val);
384    }
385
386    let global_path = global_config_path()?;
387    if global_path.exists() {
388        return resolve_key(&global_path, key);
389    }
390
391    anyhow::bail!("config key not found: {key}")
392}
393
394/// The project's `.orch.yml` alone, without the global fallback of [`get_for_project`]
395pub fn get_project_only(project_dir: &std::path::Path, key: &str) -> anyhow::Result<String> {
396    let project_path = project_dir.join(".orch.yml");
397    if project_path.exists() {
398        return resolve_key(&project_path, key);
399    }
400
401    anyhow::bail!("config key not found: {key}")
402}
403
404/// The global config alone, for a setting no project file may override
405pub fn get_global(key: &str) -> anyhow::Result<String> {
406    let global_path = global_config_path()?;
407    if global_path.exists() {
408        return resolve_key(&global_path, key);
409    }
410
411    anyhow::bail!("config key not found: {key}")
412}
413
414/// Get all project paths from the global config `projects:` list.
415///
416/// Returns a list of absolute paths to project directories.
417/// Each directory should contain a `.orch.yml` with `gh.repo`.
418pub fn get_project_paths() -> anyhow::Result<Vec<String>> {
419    let global_path = global_config_path()?;
420
421    if global_path.exists()
422        && let Ok(paths) = resolve_projects_list(&global_path, "projects")
423        && !paths.is_empty()
424    {
425        return Ok(paths);
426    }
427
428    Ok(vec![])
429}
430
431/// Like `get_projects()` but also returns the project directory path.
432///
433/// Returns `(repo_slug, project_dir)` pairs. Used by the engine to resolve
434/// per-project config (e.g., jobs from each project's `.orch.yml`).
435pub fn get_projects_with_paths() -> anyhow::Result<Vec<(String, std::path::PathBuf)>> {
436    let paths = get_project_paths()?;
437
438    if !paths.is_empty() {
439        let mut results = Vec::new();
440        for path_str in &paths {
441            let path = std::path::PathBuf::from(path_str);
442            let orch_yml = path.join(".orch.yml");
443
444            let config_file = if orch_yml.exists() {
445                orch_yml
446            } else {
447                let legacy = path.join(".orchestrator.yml");
448                if legacy.exists() {
449                    legacy
450                } else {
451                    continue;
452                }
453            };
454
455            if let Ok(content) = std::fs::read_to_string(&config_file)
456                && let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&content)
457                && let Some(repo) = doc
458                    .get("gh")
459                    .and_then(|gh| gh.get("repo"))
460                    .and_then(|r| r.as_str())
461            {
462                results.push((repo.to_string(), path));
463            }
464        }
465
466        if !results.is_empty() {
467            return Ok(results);
468        }
469    }
470
471    // Fall back to global gh.repo (no project dir available)
472    if let Ok(repo) = get("gh.repo")
473        && !repo.is_empty()
474    {
475        return Ok(vec![(repo, std::path::PathBuf::from("."))]);
476    }
477
478    Ok(vec![])
479}
480
481/// Resolve the repo slug for a specific project path.
482///
483/// Reads `gh.repo` from the project's `.orch.yml`.
484pub fn get_repo_for_project(project_path: &std::path::Path) -> anyhow::Result<String> {
485    let orch_yml = project_path.join(".orch.yml");
486    let config_file = if orch_yml.exists() {
487        orch_yml
488    } else {
489        let legacy = project_path.join(".orchestrator.yml");
490        if legacy.exists() {
491            legacy
492        } else {
493            anyhow::bail!("no .orch.yml found in {}", project_path.display());
494        }
495    };
496
497    let content = std::fs::read_to_string(&config_file)?;
498    let doc: serde_norway::Value = serde_norway::from_str(&content)?;
499
500    doc.get("gh")
501        .and_then(|gh| gh.get("repo"))
502        .and_then(|r| r.as_str())
503        .map(String::from)
504        .ok_or_else(|| anyhow::anyhow!("gh.repo not set in {}", config_file.display()))
505}
506
507/// Resolve project path from CWD by finding the nearest `.orch.yml`.
508///
509/// Walks up from the current directory looking for `.orch.yml` or legacy `.orchestrator.yml`.
510pub fn find_project_root() -> anyhow::Result<std::path::PathBuf> {
511    let mut dir = std::env::current_dir()?;
512
513    loop {
514        if dir.join(".orch.yml").exists() || dir.join(".orchestrator.yml").exists() {
515            return Ok(dir);
516        }
517
518        if !dir.pop() {
519            break;
520        }
521    }
522
523    anyhow::bail!("no .orch.yml found in current directory or parents — run `orch init`")
524}
525
526/// Resolve the repo slug for the current project (from CWD).
527///
528/// Walks up from CWD to find `.orch.yml`, reads `gh.repo` from it.
529/// Falls back to global `gh.repo` for backward compatibility.
530pub fn get_current_repo() -> anyhow::Result<String> {
531    // Try to find project root from CWD
532    if let Ok(project_root) = find_project_root()
533        && let Ok(repo) = get_repo_for_project(&project_root)
534    {
535        return Ok(repo);
536    }
537
538    // Fall back to per-project config in CWD (.orch.yml)
539    if let Ok(repo) = get("gh.repo")
540        && !repo.is_empty()
541    {
542        return Ok(repo);
543    }
544
545    // Legacy fallback
546    if let Ok(repo) = get("repo")
547        && !repo.is_empty()
548    {
549        return Ok(repo);
550    }
551
552    anyhow::bail!("could not determine repo — ensure .orch.yml has gh.repo set")
553}
554
555/// Skill repository configuration.
556#[derive(Debug, Clone)]
557pub struct SkillConfig {
558    /// GitHub repository in "owner/repo" format.
559    pub repo: String,
560}
561
562/// Get skills configuration from the global config.
563///
564/// Reads the `skills:` list from `~/.orch/config.yml`:
565/// ```yaml
566/// skills:
567///   - repo: "owner/skill-repo"
568///   - repo: "owner/another-repo"
569/// ```
570///
571/// Returns an empty vector if no skills are configured.
572pub fn get_skills() -> anyhow::Result<Vec<SkillConfig>> {
573    let global_path = global_config_path()?;
574
575    if !global_path.exists() {
576        return Ok(vec![]);
577    }
578
579    let root = load_cached_root(&global_path)?;
580
581    Ok(extract_skills_list(&root))
582}
583
584/// Extract skills list from a YAML tree.
585fn extract_skills_list(root: &serde_norway::Value) -> Vec<SkillConfig> {
586    let Some(serde_norway::Value::Sequence(seq)) = root.get("skills") else {
587        return vec![];
588    };
589
590    let mut skills = Vec::new();
591    for item in seq {
592        if let serde_norway::Value::Mapping(map) = item {
593            let repo = map
594                .get("repo")
595                .and_then(|v| v.as_str())
596                .map(String::from)
597                .unwrap_or_default();
598
599            if !repo.is_empty() {
600                skills.push(SkillConfig { repo });
601            }
602        }
603    }
604
605    skills
606}
607
608/// Resolve a projects list from a config file.
609fn resolve_projects_list(path: &PathBuf, key: &str) -> anyhow::Result<Vec<String>> {
610    // First, ensure we're watching this file for changes
611    watch_file(path);
612
613    let root = load_cached_root(path)?;
614
615    Ok(extract_projects_list(&root, key))
616}
617
618/// Extract a projects list from a YAML tree.
619fn extract_projects_list(root: &serde_norway::Value, key: &str) -> Vec<String> {
620    let mut current = root;
621    for part in key.split('.') {
622        // If key doesn't exist, return empty vector
623        let Some(next) = current.get(part) else {
624            return vec![];
625        };
626        current = next;
627    }
628
629    match current {
630        serde_norway::Value::Sequence(seq) => {
631            let mut projects = Vec::new();
632            for item in seq {
633                if let serde_norway::Value::String(s) = item {
634                    projects.push(s.clone());
635                } else if let serde_norway::Value::Mapping(map) = item {
636                    // Support map format: { repo: "owner/repo", project_dir: "/path" }
637                    if let Some(serde_norway::Value::String(repo)) = map.get("repo") {
638                        projects.push(repo.clone());
639                    }
640                }
641            }
642            projects
643        }
644        _ => vec![],
645    }
646}
647
648/// Resolve a dot-separated key from a YAML file.
649///
650/// Caches the parsed YAML so repeated lookups don't re-read disk.
651/// Sets up file watching for hot reload when first loading.
652fn resolve_key(path: &PathBuf, key: &str) -> anyhow::Result<String> {
653    // First, ensure we're watching this file for changes
654    watch_file(path);
655
656    let root = load_cached_root(path)?;
657
658    extract_value(&root, key)
659}
660
661/// Extract a value from a YAML tree by dot-separated key.
662fn extract_value(root: &serde_norway::Value, key: &str) -> anyhow::Result<String> {
663    let mut current = root;
664    for part in key.split('.') {
665        current = current
666            .get(part)
667            .with_context(|| format!("key not found: {key}"))?;
668    }
669
670    match current {
671        serde_norway::Value::String(s) => Ok(s.clone()),
672        serde_norway::Value::Number(n) => Ok(n.to_string()),
673        serde_norway::Value::Bool(b) => Ok(b.to_string()),
674        serde_norway::Value::Null => Ok(String::new()),
675        _ => Ok(serde_norway::to_string(current)?),
676    }
677}
678
679#[cfg(test)]
680mod tests {
681    use super::*;
682    use std::io::Write;
683
684    fn write_yaml(dir: &std::path::Path, name: &str, content: &str) -> PathBuf {
685        let path = dir.join(name);
686        let mut f = std::fs::File::create(&path).unwrap();
687        f.write_all(content.as_bytes()).unwrap();
688        path
689    }
690
691    #[test]
692    fn resolve_simple_key() {
693        let dir = tempfile::tempdir().unwrap();
694        let path = write_yaml(dir.path(), "config.yml", "repo: owner/repo\n");
695        let val = resolve_key(&path, "repo").unwrap();
696        assert_eq!(val, "owner/repo");
697    }
698
699    #[test]
700    fn resolve_nested_key() {
701        let dir = tempfile::tempdir().unwrap();
702        let path = write_yaml(
703            dir.path(),
704            "config.yml",
705            "agents:\n  claude:\n    model: opus\n",
706        );
707        let val = resolve_key(&path, "agents.claude.model").unwrap();
708        assert_eq!(val, "opus");
709    }
710
711    #[test]
712    fn resolve_boolean_value() {
713        let dir = tempfile::tempdir().unwrap();
714        let path = write_yaml(dir.path(), "config.yml", "enabled: true\n");
715        let val = resolve_key(&path, "enabled").unwrap();
716        assert_eq!(val, "true");
717    }
718
719    #[test]
720    fn resolve_number_value() {
721        let dir = tempfile::tempdir().unwrap();
722        let path = write_yaml(dir.path(), "config.yml", "timeout: 300\n");
723        let val = resolve_key(&path, "timeout").unwrap();
724        assert_eq!(val, "300");
725    }
726
727    #[test]
728    fn resolve_missing_key_errors() {
729        let dir = tempfile::tempdir().unwrap();
730        let path = write_yaml(dir.path(), "config.yml", "repo: owner/repo\n");
731        let result = resolve_key(&path, "missing.key");
732        assert!(result.is_err());
733    }
734
735    #[test]
736    fn resolve_missing_file_errors() {
737        let path = PathBuf::from("/nonexistent/config.yml");
738        let result = resolve_key(&path, "repo");
739        assert!(result.is_err());
740    }
741
742    #[test]
743    fn resolve_projects_list_string_array() {
744        let dir = tempfile::tempdir().unwrap();
745        let path = write_yaml(
746            dir.path(),
747            "config.yml",
748            "projects:\n  - owner/repo1\n  - owner/repo2\n",
749        );
750        let projects = resolve_projects_list(&path, "projects").unwrap();
751        assert_eq!(projects, vec!["owner/repo1", "owner/repo2"]);
752    }
753
754    #[test]
755    fn resolve_projects_list_map_array() {
756        let dir = tempfile::tempdir().unwrap();
757        let path = write_yaml(
758            dir.path(),
759            "config.yml",
760            "projects:\n  - repo: owner/repo1\n    project_dir: /path/to/repo1\n  - repo: owner/repo2\n    project_dir: /path/to/repo2\n",
761        );
762        let projects = resolve_projects_list(&path, "projects").unwrap();
763        assert_eq!(projects, vec!["owner/repo1", "owner/repo2"]);
764    }
765
766    #[test]
767    fn resolve_projects_list_fallback_to_repo() {
768        let dir = tempfile::tempdir().unwrap();
769        let path = write_yaml(dir.path(), "config.yml", "repo: owner/single-repo\n");
770        let projects = resolve_projects_list(&path, "projects").unwrap();
771        assert!(projects.is_empty(), "{projects:?}");
772    }
773
774    #[test]
775    fn extract_projects_list_from_sequence() {
776        let yaml =
777            serde_norway::from_str::<serde_norway::Value>("projects:\n  - a\n  - b\n  - c\n")
778                .unwrap();
779        let projects = extract_projects_list(&yaml, "projects");
780        assert_eq!(projects, vec!["a", "b", "c"]);
781    }
782
783    #[test]
784    fn extract_projects_list_from_mapping_sequence() {
785        let yaml =
786            serde_norway::from_str::<serde_norway::Value>("projects:\n  - repo: a\n  - repo: b\n")
787                .unwrap();
788        let projects = extract_projects_list(&yaml, "projects");
789        assert_eq!(projects, vec!["a", "b"]);
790    }
791
792    #[test]
793    fn extract_projects_list_empty_for_missing() {
794        let yaml = serde_norway::from_str::<serde_norway::Value>("foo: bar").unwrap();
795        let projects = extract_projects_list(&yaml, "projects");
796        assert!(projects.is_empty(), "{projects:?}");
797    }
798
799    #[test]
800    fn subscribe_receives_change_notifications() {
801        let mut rx = subscribe();
802        let dir = tempfile::tempdir().unwrap();
803        let path = write_yaml(dir.path(), "config.yml", "repo: owner/repo\n");
804
805        // Simulate the watcher firing on a file change
806        assert!(!reload_file(&path));
807
808        // Drain until we find our path (other tests may send on the shared channel)
809        loop {
810            match rx.try_recv() {
811                Ok(received_path) if received_path == path => break,
812                Ok(_) => {} // skip notifications from other tests
813                Err(e) => panic!("expected config change notification, got error: {e:?}"),
814            }
815        }
816    }
817
818    #[test]
819    fn subscribe_multiple_receivers() {
820        let mut rx1 = subscribe();
821        let mut rx2 = subscribe();
822        let dir = tempfile::tempdir().unwrap();
823        let path = write_yaml(dir.path(), "config.yml", "repo: owner/repo\n");
824
825        assert!(!reload_file(&path));
826
827        // Drain until we find our path on both receivers
828        let find_path = |rx: &mut tokio::sync::broadcast::Receiver<PathBuf>, expected: &PathBuf| loop {
829            match rx.try_recv() {
830                Ok(p) if &p == expected => return,
831                Ok(_) => {}
832                Err(e) => panic!("expected config change notification, got error: {e:?}"),
833            }
834        };
835        find_path(&mut rx1, &path);
836        find_path(&mut rx2, &path);
837    }
838
839    #[test]
840    fn resolve_list_from_sequence() {
841        let dir = tempfile::tempdir().unwrap();
842        let path = write_yaml(
843            dir.path(),
844            "config.yml",
845            "workflow:\n  allowed_tools:\n    - Edit\n    - Write\n    - git\n    - npm\n",
846        );
847        let list = resolve_list(&path, "workflow.allowed_tools").unwrap();
848        assert_eq!(list, vec!["Edit", "Write", "git", "npm"]);
849    }
850
851    #[test]
852    fn resolve_list_from_comma_string() {
853        let dir = tempfile::tempdir().unwrap();
854        let path = write_yaml(
855            dir.path(),
856            "config.yml",
857            "workflow:\n  allowed_tools: \"Edit,Write,git,npm\"\n",
858        );
859        let list = resolve_list(&path, "workflow.allowed_tools").unwrap();
860        assert_eq!(list, vec!["Edit", "Write", "git", "npm"]);
861    }
862
863    #[test]
864    fn resolve_list_missing_key_returns_empty() {
865        let dir = tempfile::tempdir().unwrap();
866        let path = write_yaml(dir.path(), "config.yml", "foo: bar\n");
867        let list = resolve_list(&path, "workflow.allowed_tools").unwrap();
868        assert!(list.is_empty(), "{list:?}");
869    }
870
871    #[test]
872    fn extract_skills_list_from_sequence() {
873        let yaml = serde_norway::from_str::<serde_norway::Value>(
874            "skills:\n  - repo: owner/skills1\n  - repo: owner/skills2\n",
875        )
876        .unwrap();
877        let skills = extract_skills_list(&yaml);
878        assert_eq!(skills.len(), 2);
879        assert_eq!(skills[0].repo, "owner/skills1");
880        assert_eq!(skills[1].repo, "owner/skills2");
881    }
882
883    #[test]
884    fn extract_skills_list_single_repo() {
885        let yaml =
886            serde_norway::from_str::<serde_norway::Value>("skills:\n  - repo: owner/skills\n")
887                .unwrap();
888        let skills = extract_skills_list(&yaml);
889        assert_eq!(skills.len(), 1);
890        assert_eq!(skills[0].repo, "owner/skills");
891    }
892
893    /**
894     * Read a key straight out of the cache. The reload tests must not call `resolve_key` or
895     * `resolve_projects_list`: those register the file with the real notify watcher, whose thread
896     * then races the test for the first `reload_file` call on a broken edit
897     **/
898    fn cached_value(path: &PathBuf, key: &str) -> String {
899        extract_value(&load_cached_root(path).unwrap(), key).unwrap()
900    }
901
902    fn cached_projects(path: &PathBuf) -> Vec<String> {
903        extract_projects_list(&load_cached_root(path).unwrap(), "projects")
904    }
905
906    #[test]
907    fn reading_a_config_file_does_not_trigger_a_reload() {
908        use notify::event::{AccessKind, AccessMode, CreateKind, DataChange, ModifyKind};
909        assert!(!is_content_change(notify::EventKind::Access(
910            AccessKind::Open(AccessMode::Read)
911        )));
912        assert!(!is_content_change(notify::EventKind::Access(
913            AccessKind::Close(AccessMode::Read)
914        )));
915        assert!(is_content_change(notify::EventKind::Modify(
916            ModifyKind::Data(DataChange::Content)
917        )));
918        assert!(is_content_change(notify::EventKind::Create(
919            CreateKind::File
920        )));
921    }
922
923    #[test]
924    fn invalid_edit_keeps_last_good_values_and_sends_no_notification() {
925        let dir = tempfile::tempdir().unwrap();
926        let path = write_yaml(dir.path(), "config.yml", "engine:\n  max_parallel: 8\n");
927        assert!(!reload_file(&path));
928        assert_eq!(cached_value(&path, "engine.max_parallel"), "8");
929
930        let mut rx = subscribe();
931        std::fs::write(&path, "engine:\n  max_parallel: [unclosed\n").unwrap();
932        assert!(reload_file(&path));
933
934        assert_eq!(cached_value(&path, "engine.max_parallel"), "8");
935        assert!(rx.try_recv().is_err(), "a broken edit must not notify");
936
937        // A later good edit is picked up again
938        std::fs::write(&path, "engine:\n  max_parallel: 12\n").unwrap();
939        assert!(!reload_file(&path));
940        assert_eq!(cached_value(&path, "engine.max_parallel"), "12");
941    }
942
943    #[test]
944    fn adding_a_project_is_visible_after_reload() {
945        let dir = tempfile::tempdir().unwrap();
946        let path = write_yaml(dir.path(), "config.yml", "projects:\n  - /srv/one\n");
947        assert_eq!(cached_projects(&path), vec!["/srv/one"]);
948
949        std::fs::write(&path, "projects:\n  - /srv/one\n  - /srv/two\n").unwrap();
950        assert!(!reload_file(&path));
951
952        assert_eq!(cached_projects(&path), vec!["/srv/one", "/srv/two"]);
953    }
954
955    #[test]
956    fn repeated_watcher_events_for_one_breakage_log_once() {
957        let dir = tempfile::tempdir().unwrap();
958        let path = write_yaml(dir.path(), "config.yml", "engine:\n  max_parallel: 4\n");
959        reload_file(&path);
960
961        std::fs::write(&path, "engine: [broken\n").unwrap();
962        assert!(reload_file(&path), "first bad read logs");
963        assert!(!reload_file(&path), "same breakage does not log again");
964    }
965
966    #[test]
967    #[serial_test::serial(orch_home)]
968    fn a_project_setting_overrides_the_global_one() {
969        let (home, with, without) = (
970            tempfile::tempdir().unwrap(),
971            tempfile::tempdir().unwrap(),
972            tempfile::tempdir().unwrap(),
973        );
974        write_yaml(home.path(), "config.yml", "workflow:\n  sandbox: os\n");
975        write_yaml(with.path(), ".orch.yml", "workflow:\n  sandbox: false\n");
976        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
977        unsafe { std::env::set_var("ORCH_HOME", home.path()) };
978
979        let got = (
980            get_for_project(with.path(), "workflow.sandbox").unwrap(),
981            get_for_project(without.path(), "workflow.sandbox").unwrap(),
982        );
983        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
984        unsafe { std::env::remove_var("ORCH_HOME") };
985        assert_eq!(got, ("false".to_string(), "os".to_string()));
986    }
987
988    #[test]
989    fn extract_skills_list_empty_for_missing() {
990        let yaml = serde_norway::from_str::<serde_norway::Value>("foo: bar").unwrap();
991        let skills = extract_skills_list(&yaml);
992        assert!(skills.is_empty());
993    }
994}