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<HashMap<PathBuf, ()>>> =
30    std::sync::LazyLock::new(|| RwLock::new(HashMap::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                if bad.remove(path) {
131                    tracing::info!(path = %path.display(), "config file parses again, reloaded");
132                }
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                .map(|mut bad| bad.insert(path.clone()))
143                .unwrap_or(false);
144            if first_failure {
145                tracing::error!(
146                    path = %path.display(),
147                    %err,
148                    "config file is invalid, keeping the last good values"
149                );
150            }
151            first_failure
152        }
153    }
154}
155
156/// Start the file watcher if not already running.
157fn ensure_watcher() {
158    let mut watcher_guard = FILE_WATCHER.lock().unwrap_or_else(|e| e.into_inner());
159    if watcher_guard.is_some() {
160        return;
161    }
162
163    // Create the watcher
164    let watcher = RecommendedWatcher::new(
165        move |res: Result<notify::Event, notify::Error>| {
166            if let Ok(event) = res {
167                if !is_content_change(&event.kind) {
168                    return;
169                }
170                for path in event.paths {
171                    // Check if it's a config file we care about
172                    let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
173
174                    if filename == "config.yml" || filename == ".orch.yml" {
175                        let _ = reload_file(&path);
176                    }
177                }
178            }
179        },
180        Config::default().with_poll_interval(Duration::from_secs(2)),
181    );
182
183    if let Ok(w) = watcher {
184        *watcher_guard = Some(w);
185    }
186}
187
188// inotify reports every read as an access event, and reload_file reads the file, so reacting to
189// access events reloads forever on Linux
190fn is_content_change(kind: &notify::EventKind) -> bool {
191    kind.is_create() || kind.is_modify() || kind.is_remove()
192}
193
194/**
195 * Register a config file for change watching. `get` / `get_list` watch the files they read, but a
196 * project's `.orch.yml` is read directly by `get_projects_with_paths`, so the caller that knows the
197 * project list registers it here. A missing path is skipped: notify cannot watch one, and a project
198 * may be configured through the legacy `.orchestrator.yml` name or the global `gh.repo` fallback,
199 * which has no project directory at all
200 **/
201pub fn watch(path: &Path) {
202    if !path.exists() {
203        tracing::debug!(path = %path.display(), "no config file to watch here");
204        return;
205    }
206    watch_file(&path.to_path_buf());
207}
208
209/// Watch a config file for changes.
210fn watch_file(path: &PathBuf) {
211    // First ensure watcher is running
212    ensure_watcher();
213
214    // Track that we want to watch this file
215    if let Ok(mut watched) = WATCHER.write() {
216        if !watched.contains_key(path) {
217            watched.insert(path.clone(), ());
218
219            // Add to notify watcher
220            if let Ok(mut guard) = FILE_WATCHER.lock() {
221                if let Some(ref mut watcher) = *guard {
222                    match watcher.watch(path, RecursiveMode::NonRecursive) {
223                        Ok(()) => tracing::debug!("now watching config file: {}", path.display()),
224                        Err(e) => tracing::warn!(
225                            "failed to register config file watcher for {}: {}",
226                            path.display(),
227                            e
228                        ),
229                    }
230                }
231            }
232        }
233    }
234}
235
236fn read_file_to_string(path: &Path) -> anyhow::Result<String> {
237    match tokio::runtime::Handle::try_current() {
238        Ok(handle) if handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread => {
239            Ok(tokio::task::block_in_place(|| {
240                std::fs::read_to_string(path)
241            })?)
242        }
243        _ => Ok(std::fs::read_to_string(path)?),
244    }
245}
246
247fn load_cached_root(path: &PathBuf) -> anyhow::Result<serde_norway::Value> {
248    if let Ok(cache) = CACHE.read() {
249        if let Some(cached) = cache.get(path) {
250            return Ok(cached.clone());
251        }
252    }
253
254    let content =
255        read_file_to_string(path).with_context(|| format!("reading {}", path.display()))?;
256    let parsed: serde_norway::Value =
257        serde_norway::from_str(&content).with_context(|| format!("parsing {}", path.display()))?;
258
259    if let Ok(mut cache) = CACHE.write() {
260        cache.insert(path.clone(), parsed.clone());
261    }
262
263    Ok(parsed)
264}
265
266/// Resolve the global config path: `~/.orch/config.yml`
267fn global_config_path() -> anyhow::Result<PathBuf> {
268    crate::home::config_path()
269}
270
271/// Get a config value as a list of strings by dot-separated key.
272///
273/// Supports both YAML sequences and comma-separated strings:
274/// ```yaml
275/// workflow:
276///   allowed_tools:
277///     - Edit
278///     - Write
279///     - git
280/// ```
281/// or:
282/// ```yaml
283/// workflow:
284///   allowed_tools: "Edit,Write,git"
285/// ```
286///
287/// Returns empty vec if key not found.
288pub fn get_list(key: &str) -> anyhow::Result<Vec<String>> {
289    // Try project config first
290    // Use an absolute path so the cache key is stable across tests/processes
291    // that change the current working directory.
292    let project_path = std::env::current_dir()
293        .unwrap_or_else(|_| PathBuf::from("."))
294        .join(".orch.yml");
295    if project_path.exists() {
296        if let Ok(val) = resolve_list(&project_path, key) {
297            if !val.is_empty() {
298                return Ok(val);
299            }
300        }
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    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) -> anyhow::Result<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 Ok(vec![]),
328        }
329    }
330
331    match current {
332        serde_norway::Value::Sequence(seq) => Ok(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            Ok(s.split(',')
339                .map(|s| s.trim().to_string())
340                .filter(|s| !s.is_empty())
341                .collect())
342        }
343        _ => Ok(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        if let Ok(val) = resolve_key(&project_path, key) {
364            return Ok(val);
365        }
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        if let Ok(val) = resolve_key(&project_path, key) {
382            return Ok(val);
383        }
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        if let Ok(paths) = resolve_projects_list(&global_path, "projects") {
423            if !paths.is_empty() {
424                return Ok(paths);
425            }
426        }
427    }
428
429    Ok(vec![])
430}
431
432/// Like `get_projects()` but also returns the project directory path.
433///
434/// Returns `(repo_slug, project_dir)` pairs. Used by the engine to resolve
435/// per-project config (e.g., jobs from each project's `.orch.yml`).
436pub fn get_projects_with_paths() -> anyhow::Result<Vec<(String, std::path::PathBuf)>> {
437    let paths = get_project_paths()?;
438
439    if !paths.is_empty() {
440        let mut results = Vec::new();
441        for path_str in &paths {
442            let path = std::path::PathBuf::from(path_str);
443            let orch_yml = path.join(".orch.yml");
444
445            let config_file = if orch_yml.exists() {
446                orch_yml
447            } else {
448                let legacy = path.join(".orchestrator.yml");
449                if legacy.exists() {
450                    legacy
451                } else {
452                    continue;
453                }
454            };
455
456            if let Ok(content) = std::fs::read_to_string(&config_file) {
457                if let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&content) {
458                    if let Some(repo) = doc
459                        .get("gh")
460                        .and_then(|gh| gh.get("repo"))
461                        .and_then(|r| r.as_str())
462                    {
463                        results.push((repo.to_string(), path));
464                    }
465                }
466            }
467        }
468
469        if !results.is_empty() {
470            return Ok(results);
471        }
472    }
473
474    // Fall back to global gh.repo (no project dir available)
475    if let Ok(repo) = get("gh.repo") {
476        if !repo.is_empty() {
477            return Ok(vec![(repo, std::path::PathBuf::from("."))]);
478        }
479    }
480
481    Ok(vec![])
482}
483
484/// Resolve the repo slug for a specific project path.
485///
486/// Reads `gh.repo` from the project's `.orch.yml`.
487pub fn get_repo_for_project(project_path: &std::path::Path) -> anyhow::Result<String> {
488    let orch_yml = project_path.join(".orch.yml");
489    let config_file = if orch_yml.exists() {
490        orch_yml
491    } else {
492        let legacy = project_path.join(".orchestrator.yml");
493        if legacy.exists() {
494            legacy
495        } else {
496            anyhow::bail!("no .orch.yml found in {}", project_path.display());
497        }
498    };
499
500    let content = std::fs::read_to_string(&config_file)?;
501    let doc: serde_norway::Value = serde_norway::from_str(&content)?;
502
503    doc.get("gh")
504        .and_then(|gh| gh.get("repo"))
505        .and_then(|r| r.as_str())
506        .map(String::from)
507        .ok_or_else(|| anyhow::anyhow!("gh.repo not set in {}", config_file.display()))
508}
509
510/// Resolve project path from CWD by finding the nearest `.orch.yml`.
511///
512/// Walks up from the current directory looking for `.orch.yml` or legacy `.orchestrator.yml`.
513pub fn find_project_root() -> anyhow::Result<std::path::PathBuf> {
514    let mut dir = std::env::current_dir()?;
515
516    loop {
517        if dir.join(".orch.yml").exists() || dir.join(".orchestrator.yml").exists() {
518            return Ok(dir);
519        }
520
521        if !dir.pop() {
522            break;
523        }
524    }
525
526    anyhow::bail!("no .orch.yml found in current directory or parents — run `orch init`")
527}
528
529/// Resolve the repo slug for the current project (from CWD).
530///
531/// Walks up from CWD to find `.orch.yml`, reads `gh.repo` from it.
532/// Falls back to global `gh.repo` for backward compatibility.
533pub fn get_current_repo() -> anyhow::Result<String> {
534    // Try to find project root from CWD
535    if let Ok(project_root) = find_project_root() {
536        if let Ok(repo) = get_repo_for_project(&project_root) {
537            return Ok(repo);
538        }
539    }
540
541    // Fall back to per-project config in CWD (.orch.yml)
542    if let Ok(repo) = get("gh.repo") {
543        if !repo.is_empty() {
544            return Ok(repo);
545        }
546    }
547
548    // Legacy fallback
549    if let Ok(repo) = get("repo") {
550        if !repo.is_empty() {
551            return Ok(repo);
552        }
553    }
554
555    anyhow::bail!("could not determine repo — ensure .orch.yml has gh.repo set")
556}
557
558/// Skill repository configuration.
559#[derive(Debug, Clone)]
560pub struct SkillConfig {
561    /// GitHub repository in "owner/repo" format.
562    pub repo: String,
563}
564
565/// Get skills configuration from the global config.
566///
567/// Reads the `skills:` list from `~/.orch/config.yml`:
568/// ```yaml
569/// skills:
570///   - repo: "owner/skill-repo"
571///   - repo: "owner/another-repo"
572/// ```
573///
574/// Returns an empty vector if no skills are configured.
575pub fn get_skills() -> anyhow::Result<Vec<SkillConfig>> {
576    let global_path = global_config_path()?;
577
578    if !global_path.exists() {
579        return Ok(vec![]);
580    }
581
582    let root = load_cached_root(&global_path)?;
583
584    extract_skills_list(&root)
585}
586
587/// Extract skills list from a YAML tree.
588fn extract_skills_list(root: &serde_norway::Value) -> anyhow::Result<Vec<SkillConfig>> {
589    let Some(serde_norway::Value::Sequence(seq)) = root.get("skills") else {
590        return Ok(vec![]);
591    };
592
593    let mut skills = Vec::new();
594    for item in seq {
595        if let serde_norway::Value::Mapping(map) = item {
596            let repo = map
597                .get("repo")
598                .and_then(|v| v.as_str())
599                .map(String::from)
600                .unwrap_or_default();
601
602            if !repo.is_empty() {
603                skills.push(SkillConfig { repo });
604            }
605        }
606    }
607
608    Ok(skills)
609}
610
611/// Resolve a projects list from a config file.
612fn resolve_projects_list(path: &PathBuf, key: &str) -> anyhow::Result<Vec<String>> {
613    // First, ensure we're watching this file for changes
614    watch_file(path);
615
616    let root = load_cached_root(path)?;
617
618    // Try to extract projects list - if key doesn't exist, return empty vector
619    match extract_projects_list(&root, key) {
620        Ok(projects) => Ok(projects),
621        Err(_) => Ok(vec![]),
622    }
623}
624
625/// Extract a projects list from a YAML tree.
626fn extract_projects_list(root: &serde_norway::Value, key: &str) -> anyhow::Result<Vec<String>> {
627    let mut current = root;
628    for part in key.split('.') {
629        // If key doesn't exist, return empty vector
630        let next = match current.get(part) {
631            Some(v) => v,
632            None => return Ok(vec![]),
633        };
634        current = next;
635    }
636
637    match current {
638        serde_norway::Value::Sequence(seq) => {
639            let mut projects = Vec::new();
640            for item in seq {
641                if let serde_norway::Value::String(s) = item {
642                    projects.push(s.clone());
643                } else if let serde_norway::Value::Mapping(map) = item {
644                    // Support map format: { repo: "owner/repo", project_dir: "/path" }
645                    if let Some(serde_norway::Value::String(repo)) = map.get("repo") {
646                        projects.push(repo.clone());
647                    }
648                }
649            }
650            Ok(projects)
651        }
652        _ => Ok(vec![]),
653    }
654}
655
656/// Resolve a dot-separated key from a YAML file.
657///
658/// Caches the parsed YAML so repeated lookups don't re-read disk.
659/// Sets up file watching for hot reload when first loading.
660fn resolve_key(path: &PathBuf, key: &str) -> anyhow::Result<String> {
661    // First, ensure we're watching this file for changes
662    watch_file(path);
663
664    let root = load_cached_root(path)?;
665
666    extract_value(&root, key)
667}
668
669/// Extract a value from a YAML tree by dot-separated key.
670fn extract_value(root: &serde_norway::Value, key: &str) -> anyhow::Result<String> {
671    let mut current = root;
672    for part in key.split('.') {
673        current = current
674            .get(part)
675            .with_context(|| format!("key not found: {key}"))?;
676    }
677
678    match current {
679        serde_norway::Value::String(s) => Ok(s.clone()),
680        serde_norway::Value::Number(n) => Ok(n.to_string()),
681        serde_norway::Value::Bool(b) => Ok(b.to_string()),
682        serde_norway::Value::Null => Ok(String::new()),
683        _ => Ok(serde_norway::to_string(current)?),
684    }
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690    use std::io::Write;
691
692    fn write_yaml(dir: &std::path::Path, name: &str, content: &str) -> PathBuf {
693        let path = dir.join(name);
694        let mut f = std::fs::File::create(&path).unwrap();
695        f.write_all(content.as_bytes()).unwrap();
696        path
697    }
698
699    #[test]
700    fn resolve_simple_key() {
701        let dir = tempfile::tempdir().unwrap();
702        let path = write_yaml(dir.path(), "config.yml", "repo: owner/repo\n");
703        let val = resolve_key(&path, "repo").unwrap();
704        assert_eq!(val, "owner/repo");
705    }
706
707    #[test]
708    fn resolve_nested_key() {
709        let dir = tempfile::tempdir().unwrap();
710        let path = write_yaml(
711            dir.path(),
712            "config.yml",
713            "agents:\n  claude:\n    model: opus\n",
714        );
715        let val = resolve_key(&path, "agents.claude.model").unwrap();
716        assert_eq!(val, "opus");
717    }
718
719    #[test]
720    fn resolve_boolean_value() {
721        let dir = tempfile::tempdir().unwrap();
722        let path = write_yaml(dir.path(), "config.yml", "enabled: true\n");
723        let val = resolve_key(&path, "enabled").unwrap();
724        assert_eq!(val, "true");
725    }
726
727    #[test]
728    fn resolve_number_value() {
729        let dir = tempfile::tempdir().unwrap();
730        let path = write_yaml(dir.path(), "config.yml", "timeout: 300\n");
731        let val = resolve_key(&path, "timeout").unwrap();
732        assert_eq!(val, "300");
733    }
734
735    #[test]
736    fn resolve_missing_key_errors() {
737        let dir = tempfile::tempdir().unwrap();
738        let path = write_yaml(dir.path(), "config.yml", "repo: owner/repo\n");
739        let result = resolve_key(&path, "missing.key");
740        assert!(result.is_err());
741    }
742
743    #[test]
744    fn resolve_missing_file_errors() {
745        let path = PathBuf::from("/nonexistent/config.yml");
746        let result = resolve_key(&path, "repo");
747        assert!(result.is_err());
748    }
749
750    #[test]
751    fn resolve_projects_list_string_array() {
752        let dir = tempfile::tempdir().unwrap();
753        let path = write_yaml(
754            dir.path(),
755            "config.yml",
756            "projects:\n  - owner/repo1\n  - owner/repo2\n",
757        );
758        let projects = resolve_projects_list(&path, "projects").unwrap();
759        assert_eq!(projects, vec!["owner/repo1", "owner/repo2"]);
760    }
761
762    #[test]
763    fn resolve_projects_list_map_array() {
764        let dir = tempfile::tempdir().unwrap();
765        let path = write_yaml(
766            dir.path(),
767            "config.yml",
768            "projects:\n  - repo: owner/repo1\n    project_dir: /path/to/repo1\n  - repo: owner/repo2\n    project_dir: /path/to/repo2\n",
769        );
770        let projects = resolve_projects_list(&path, "projects").unwrap();
771        assert_eq!(projects, vec!["owner/repo1", "owner/repo2"]);
772    }
773
774    #[test]
775    fn resolve_projects_list_fallback_to_repo() {
776        let dir = tempfile::tempdir().unwrap();
777        let path = write_yaml(dir.path(), "config.yml", "repo: owner/single-repo\n");
778        let projects = resolve_projects_list(&path, "projects").unwrap();
779        assert!(projects.is_empty());
780    }
781
782    #[test]
783    fn extract_projects_list_from_sequence() {
784        let yaml =
785            serde_norway::from_str::<serde_norway::Value>("projects:\n  - a\n  - b\n  - c\n")
786                .unwrap();
787        let projects = extract_projects_list(&yaml, "projects").unwrap();
788        assert_eq!(projects, vec!["a", "b", "c"]);
789    }
790
791    #[test]
792    fn extract_projects_list_from_mapping_sequence() {
793        let yaml =
794            serde_norway::from_str::<serde_norway::Value>("projects:\n  - repo: a\n  - repo: b\n")
795                .unwrap();
796        let projects = extract_projects_list(&yaml, "projects").unwrap();
797        assert_eq!(projects, vec!["a", "b"]);
798    }
799
800    #[test]
801    fn extract_projects_list_empty_for_missing() {
802        let yaml = serde_norway::from_str::<serde_norway::Value>("foo: bar").unwrap();
803        let projects = extract_projects_list(&yaml, "projects").unwrap();
804        assert!(projects.is_empty());
805    }
806
807    #[test]
808    fn subscribe_receives_change_notifications() {
809        let mut rx = subscribe();
810        let dir = tempfile::tempdir().unwrap();
811        let path = write_yaml(dir.path(), "config.yml", "repo: owner/repo\n");
812
813        // Simulate the watcher firing on a file change
814        assert!(!reload_file(&path));
815
816        // Drain until we find our path (other tests may send on the shared channel)
817        loop {
818            match rx.try_recv() {
819                Ok(received_path) if received_path == path => break,
820                Ok(_) => continue, // skip notifications from other tests
821                Err(e) => panic!("expected config change notification, got error: {e:?}"),
822            }
823        }
824    }
825
826    #[test]
827    fn subscribe_multiple_receivers() {
828        let mut rx1 = subscribe();
829        let mut rx2 = subscribe();
830        let dir = tempfile::tempdir().unwrap();
831        let path = write_yaml(dir.path(), "config.yml", "repo: owner/repo\n");
832
833        assert!(!reload_file(&path));
834
835        // Drain until we find our path on both receivers
836        let find_path = |rx: &mut tokio::sync::broadcast::Receiver<PathBuf>, expected: &PathBuf| loop {
837            match rx.try_recv() {
838                Ok(p) if &p == expected => return,
839                Ok(_) => continue,
840                Err(e) => panic!("expected config change notification, got error: {e:?}"),
841            }
842        };
843        find_path(&mut rx1, &path);
844        find_path(&mut rx2, &path);
845    }
846
847    #[test]
848    fn resolve_list_from_sequence() {
849        let dir = tempfile::tempdir().unwrap();
850        let path = write_yaml(
851            dir.path(),
852            "config.yml",
853            "workflow:\n  allowed_tools:\n    - Edit\n    - Write\n    - git\n    - npm\n",
854        );
855        let list = resolve_list(&path, "workflow.allowed_tools").unwrap();
856        assert_eq!(list, vec!["Edit", "Write", "git", "npm"]);
857    }
858
859    #[test]
860    fn resolve_list_from_comma_string() {
861        let dir = tempfile::tempdir().unwrap();
862        let path = write_yaml(
863            dir.path(),
864            "config.yml",
865            "workflow:\n  allowed_tools: \"Edit,Write,git,npm\"\n",
866        );
867        let list = resolve_list(&path, "workflow.allowed_tools").unwrap();
868        assert_eq!(list, vec!["Edit", "Write", "git", "npm"]);
869    }
870
871    #[test]
872    fn resolve_list_missing_key_returns_empty() {
873        let dir = tempfile::tempdir().unwrap();
874        let path = write_yaml(dir.path(), "config.yml", "foo: bar\n");
875        let list = resolve_list(&path, "workflow.allowed_tools").unwrap();
876        assert!(list.is_empty());
877    }
878
879    #[test]
880    fn extract_skills_list_from_sequence() {
881        let yaml = serde_norway::from_str::<serde_norway::Value>(
882            "skills:\n  - repo: owner/skills1\n  - repo: owner/skills2\n",
883        )
884        .unwrap();
885        let skills = extract_skills_list(&yaml).unwrap();
886        assert_eq!(skills.len(), 2);
887        assert_eq!(skills[0].repo, "owner/skills1");
888        assert_eq!(skills[1].repo, "owner/skills2");
889    }
890
891    #[test]
892    fn extract_skills_list_single_repo() {
893        let yaml =
894            serde_norway::from_str::<serde_norway::Value>("skills:\n  - repo: owner/skills\n")
895                .unwrap();
896        let skills = extract_skills_list(&yaml).unwrap();
897        assert_eq!(skills.len(), 1);
898        assert_eq!(skills[0].repo, "owner/skills");
899    }
900
901    /**
902     * Read a key straight out of the cache. The reload tests must not call `resolve_key` or
903     * `resolve_projects_list`: those register the file with the real notify watcher, whose thread
904     * then races the test for the first `reload_file` call on a broken edit
905     **/
906    fn cached_value(path: &PathBuf, key: &str) -> String {
907        extract_value(&load_cached_root(path).unwrap(), key).unwrap()
908    }
909
910    fn cached_projects(path: &PathBuf) -> Vec<String> {
911        extract_projects_list(&load_cached_root(path).unwrap(), "projects").unwrap()
912    }
913
914    #[test]
915    fn reading_a_config_file_does_not_trigger_a_reload() {
916        use notify::event::{AccessKind, AccessMode, CreateKind, DataChange, ModifyKind};
917        assert!(!is_content_change(&notify::EventKind::Access(
918            AccessKind::Open(AccessMode::Read)
919        )));
920        assert!(!is_content_change(&notify::EventKind::Access(
921            AccessKind::Close(AccessMode::Read)
922        )));
923        assert!(is_content_change(&notify::EventKind::Modify(
924            ModifyKind::Data(DataChange::Content)
925        )));
926        assert!(is_content_change(&notify::EventKind::Create(
927            CreateKind::File
928        )));
929    }
930
931    #[test]
932    fn invalid_edit_keeps_last_good_values_and_sends_no_notification() {
933        let dir = tempfile::tempdir().unwrap();
934        let path = write_yaml(dir.path(), "config.yml", "engine:\n  max_parallel: 8\n");
935        assert!(!reload_file(&path));
936        assert_eq!(cached_value(&path, "engine.max_parallel"), "8");
937
938        let mut rx = subscribe();
939        std::fs::write(&path, "engine:\n  max_parallel: [unclosed\n").unwrap();
940        assert!(reload_file(&path));
941
942        assert_eq!(cached_value(&path, "engine.max_parallel"), "8");
943        assert!(rx.try_recv().is_err(), "a broken edit must not notify");
944
945        // A later good edit is picked up again
946        std::fs::write(&path, "engine:\n  max_parallel: 12\n").unwrap();
947        assert!(!reload_file(&path));
948        assert_eq!(cached_value(&path, "engine.max_parallel"), "12");
949    }
950
951    #[test]
952    fn adding_a_project_is_visible_after_reload() {
953        let dir = tempfile::tempdir().unwrap();
954        let path = write_yaml(dir.path(), "config.yml", "projects:\n  - /srv/one\n");
955        assert_eq!(cached_projects(&path), vec!["/srv/one"]);
956
957        std::fs::write(&path, "projects:\n  - /srv/one\n  - /srv/two\n").unwrap();
958        assert!(!reload_file(&path));
959
960        assert_eq!(cached_projects(&path), vec!["/srv/one", "/srv/two"]);
961    }
962
963    #[test]
964    fn repeated_watcher_events_for_one_breakage_log_once() {
965        let dir = tempfile::tempdir().unwrap();
966        let path = write_yaml(dir.path(), "config.yml", "engine:\n  max_parallel: 4\n");
967        reload_file(&path);
968
969        std::fs::write(&path, "engine: [broken\n").unwrap();
970        assert!(reload_file(&path), "first bad read logs");
971        assert!(!reload_file(&path), "same breakage does not log again");
972    }
973
974    #[test]
975    #[serial_test::serial(orch_home)]
976    fn a_project_setting_overrides_the_global_one() {
977        let (home, with, without) = (
978            tempfile::tempdir().unwrap(),
979            tempfile::tempdir().unwrap(),
980            tempfile::tempdir().unwrap(),
981        );
982        write_yaml(home.path(), "config.yml", "workflow:\n  sandbox: os\n");
983        write_yaml(with.path(), ".orch.yml", "workflow:\n  sandbox: false\n");
984        std::env::set_var("ORCH_HOME", home.path());
985
986        let got = (
987            get_for_project(with.path(), "workflow.sandbox").unwrap(),
988            get_for_project(without.path(), "workflow.sandbox").unwrap(),
989        );
990        std::env::remove_var("ORCH_HOME");
991        assert_eq!(got, ("false".to_string(), "os".to_string()));
992    }
993
994    #[test]
995    fn extract_skills_list_empty_for_missing() {
996        let yaml = serde_norway::from_str::<serde_norway::Value>("foo: bar").unwrap();
997        let skills = extract_skills_list(&yaml).unwrap();
998        assert!(skills.is_empty());
999    }
1000}