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