1use anyhow::Context;
13use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
14use std::collections::HashMap;
15use std::path::{Path, PathBuf};
16use std::sync::{Arc, Mutex, RwLock};
17use std::time::Duration;
18use tokio::sync::broadcast;
19
20static CACHE: std::sync::LazyLock<RwLock<HashMap<PathBuf, serde_norway::Value>>> =
23 std::sync::LazyLock::new(|| RwLock::new(HashMap::new()));
24
25static WATCHER: std::sync::LazyLock<RwLock<HashMap<PathBuf, ()>>> =
27 std::sync::LazyLock::new(|| RwLock::new(HashMap::new()));
28
29static FILE_WATCHER: std::sync::LazyLock<Arc<Mutex<Option<RecommendedWatcher>>>> =
31 std::sync::LazyLock::new(|| Arc::new(Mutex::new(None)));
32
33static CHANGE_TX: std::sync::LazyLock<broadcast::Sender<PathBuf>> =
36 std::sync::LazyLock::new(|| {
37 let (tx, _) = broadcast::channel(16);
38 tx
39 });
40
41pub fn subscribe() -> broadcast::Receiver<PathBuf> {
46 CHANGE_TX.subscribe()
47}
48
49pub async fn warm_cache() -> anyhow::Result<()> {
55 let global_path = global_config_path()?;
56 if global_path.exists() {
57 match tokio::fs::read_to_string(&global_path).await {
58 Ok(content) => {
59 if let Ok(parsed) = serde_norway::from_str::<serde_norway::Value>(&content) {
60 if let Ok(mut cache) = CACHE.write() {
61 cache.insert(global_path.clone(), parsed);
62 }
63 watch_file(&global_path);
64 }
65 }
66 Err(e) => tracing::warn!("failed to warm global config cache: {e}"),
67 }
68 }
69
70 let project_path = std::env::current_dir()
71 .unwrap_or_else(|_| PathBuf::from("."))
72 .join(".orch.yml");
73 if project_path.exists() {
74 match tokio::fs::read_to_string(&project_path).await {
75 Ok(content) => {
76 if let Ok(parsed) = serde_norway::from_str::<serde_norway::Value>(&content) {
77 if let Ok(mut cache) = CACHE.write() {
78 cache.insert(project_path.clone(), parsed);
79 }
80 watch_file(&project_path);
81 }
82 }
83 Err(e) => tracing::warn!("failed to warm project config cache: {e}"),
84 }
85 }
86
87 Ok(())
88}
89
90#[cfg(test)]
94pub fn clear_test_cache() {
95 if let Ok(mut cache) = CACHE.write() {
96 cache.clear();
97 }
98}
99
100fn invalidate_cache(path: &PathBuf) {
103 if let Ok(mut cache) = CACHE.write() {
104 cache.remove(path);
105 tracing::debug!("config cache invalidated for: {}", path.display());
106 }
107 let _ = CHANGE_TX.send(path.clone());
109}
110
111fn ensure_watcher() {
113 let mut watcher_guard = FILE_WATCHER.lock().unwrap_or_else(|e| e.into_inner());
114 if watcher_guard.is_some() {
115 return;
116 }
117
118 let watcher = RecommendedWatcher::new(
120 move |res: Result<notify::Event, notify::Error>| {
121 if let Ok(event) = res {
122 for path in event.paths {
123 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
125
126 if filename == "config.yml" || filename == ".orch.yml" {
127 invalidate_cache(&path);
128 }
129 }
130 }
131 },
132 Config::default().with_poll_interval(Duration::from_secs(2)),
133 );
134
135 if let Ok(w) = watcher {
136 *watcher_guard = Some(w);
137 }
138}
139
140fn watch_file(path: &PathBuf) {
142 ensure_watcher();
144
145 if let Ok(mut watched) = WATCHER.write() {
147 if !watched.contains_key(path) {
148 watched.insert(path.clone(), ());
149
150 if let Ok(mut guard) = FILE_WATCHER.lock() {
152 if let Some(ref mut watcher) = *guard {
153 match watcher.watch(path, RecursiveMode::NonRecursive) {
154 Ok(()) => tracing::debug!("now watching config file: {}", path.display()),
155 Err(e) => tracing::warn!(
156 "failed to register config file watcher for {}: {}",
157 path.display(),
158 e
159 ),
160 }
161 }
162 }
163 }
164 }
165}
166
167fn read_file_to_string(path: &Path) -> anyhow::Result<String> {
168 match tokio::runtime::Handle::try_current() {
169 Ok(handle) if handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread => {
170 Ok(tokio::task::block_in_place(|| {
171 std::fs::read_to_string(path)
172 })?)
173 }
174 _ => Ok(std::fs::read_to_string(path)?),
175 }
176}
177
178fn load_cached_root(path: &PathBuf) -> anyhow::Result<serde_norway::Value> {
179 if let Ok(cache) = CACHE.read() {
180 if let Some(cached) = cache.get(path) {
181 return Ok(cached.clone());
182 }
183 }
184
185 let content =
186 read_file_to_string(path).with_context(|| format!("reading {}", path.display()))?;
187 let parsed: serde_norway::Value =
188 serde_norway::from_str(&content).with_context(|| format!("parsing {}", path.display()))?;
189
190 if let Ok(mut cache) = CACHE.write() {
191 cache.insert(path.clone(), parsed.clone());
192 }
193
194 Ok(parsed)
195}
196
197fn global_config_path() -> anyhow::Result<PathBuf> {
199 crate::home::config_path()
200}
201
202pub fn get_list(key: &str) -> anyhow::Result<Vec<String>> {
220 let project_path = std::env::current_dir()
224 .unwrap_or_else(|_| PathBuf::from("."))
225 .join(".orch.yml");
226 if project_path.exists() {
227 if let Ok(val) = resolve_list(&project_path, key) {
228 if !val.is_empty() {
229 return Ok(val);
230 }
231 }
232 }
233
234 let global_path = global_config_path()?;
236 if global_path.exists() {
237 return resolve_list(&global_path, key);
238 }
239
240 Ok(vec![])
241}
242
243fn resolve_list(path: &PathBuf, key: &str) -> anyhow::Result<Vec<String>> {
245 watch_file(path);
246
247 let root = load_cached_root(path)?;
248
249 extract_list(&root, key)
250}
251
252fn extract_list(root: &serde_norway::Value, key: &str) -> anyhow::Result<Vec<String>> {
254 let mut current = root;
255 for part in key.split('.') {
256 match current.get(part) {
257 Some(v) => current = v,
258 None => return Ok(vec![]),
259 }
260 }
261
262 match current {
263 serde_norway::Value::Sequence(seq) => Ok(seq
264 .iter()
265 .filter_map(|item| item.as_str().map(String::from))
266 .collect()),
267 serde_norway::Value::String(s) => {
268 Ok(s.split(',')
270 .map(|s| s.trim().to_string())
271 .filter(|s| !s.is_empty())
272 .collect())
273 }
274 _ => Ok(vec![]),
275 }
276}
277
278pub fn get(key: &str) -> anyhow::Result<String> {
287 let project_path = std::env::current_dir()
291 .unwrap_or_else(|_| PathBuf::from("."))
292 .join(".orch.yml");
293 if project_path.exists() {
294 if let Ok(val) = resolve_key(&project_path, key) {
295 return Ok(val);
296 }
297 }
298
299 let global_path = global_config_path()?;
301 if global_path.exists() {
302 return resolve_key(&global_path, key);
303 }
304
305 anyhow::bail!("config key not found: {key}")
306}
307
308pub fn get_project_paths() -> anyhow::Result<Vec<String>> {
313 let global_path = global_config_path()?;
314
315 if global_path.exists() {
316 if let Ok(paths) = resolve_projects_list(&global_path, "projects") {
317 if !paths.is_empty() {
318 return Ok(paths);
319 }
320 }
321 }
322
323 Ok(vec![])
324}
325
326pub fn get_projects_with_paths() -> anyhow::Result<Vec<(String, std::path::PathBuf)>> {
331 let paths = get_project_paths()?;
332
333 if !paths.is_empty() {
334 let mut results = Vec::new();
335 for path_str in &paths {
336 let path = std::path::PathBuf::from(path_str);
337 let orch_yml = path.join(".orch.yml");
338
339 let config_file = if orch_yml.exists() {
340 orch_yml
341 } else {
342 let legacy = path.join(".orchestrator.yml");
343 if legacy.exists() {
344 legacy
345 } else {
346 continue;
347 }
348 };
349
350 if let Ok(content) = std::fs::read_to_string(&config_file) {
351 if let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&content) {
352 if let Some(repo) = doc
353 .get("gh")
354 .and_then(|gh| gh.get("repo"))
355 .and_then(|r| r.as_str())
356 {
357 results.push((repo.to_string(), path));
358 }
359 }
360 }
361 }
362
363 if !results.is_empty() {
364 return Ok(results);
365 }
366 }
367
368 if let Ok(repo) = get("gh.repo") {
370 if !repo.is_empty() {
371 return Ok(vec![(repo, std::path::PathBuf::from("."))]);
372 }
373 }
374
375 Ok(vec![])
376}
377
378pub fn get_repo_for_project(project_path: &std::path::Path) -> anyhow::Result<String> {
382 let orch_yml = project_path.join(".orch.yml");
383 let config_file = if orch_yml.exists() {
384 orch_yml
385 } else {
386 let legacy = project_path.join(".orchestrator.yml");
387 if legacy.exists() {
388 legacy
389 } else {
390 anyhow::bail!("no .orch.yml found in {}", project_path.display());
391 }
392 };
393
394 let content = std::fs::read_to_string(&config_file)?;
395 let doc: serde_norway::Value = serde_norway::from_str(&content)?;
396
397 doc.get("gh")
398 .and_then(|gh| gh.get("repo"))
399 .and_then(|r| r.as_str())
400 .map(String::from)
401 .ok_or_else(|| anyhow::anyhow!("gh.repo not set in {}", config_file.display()))
402}
403
404pub fn find_project_root() -> anyhow::Result<std::path::PathBuf> {
408 let mut dir = std::env::current_dir()?;
409
410 loop {
411 if dir.join(".orch.yml").exists() || dir.join(".orchestrator.yml").exists() {
412 return Ok(dir);
413 }
414
415 if !dir.pop() {
416 break;
417 }
418 }
419
420 anyhow::bail!("no .orch.yml found in current directory or parents — run `orch init`")
421}
422
423pub fn get_current_repo() -> anyhow::Result<String> {
428 if let Ok(project_root) = find_project_root() {
430 if let Ok(repo) = get_repo_for_project(&project_root) {
431 return Ok(repo);
432 }
433 }
434
435 if let Ok(repo) = get("gh.repo") {
437 if !repo.is_empty() {
438 return Ok(repo);
439 }
440 }
441
442 if let Ok(repo) = get("repo") {
444 if !repo.is_empty() {
445 return Ok(repo);
446 }
447 }
448
449 anyhow::bail!("could not determine repo — ensure .orch.yml has gh.repo set")
450}
451
452#[derive(Debug, Clone)]
454pub struct SkillConfig {
455 pub repo: String,
457}
458
459pub fn get_skills() -> anyhow::Result<Vec<SkillConfig>> {
470 let global_path = global_config_path()?;
471
472 if !global_path.exists() {
473 return Ok(vec![]);
474 }
475
476 let root = load_cached_root(&global_path)?;
477
478 extract_skills_list(&root)
479}
480
481fn extract_skills_list(root: &serde_norway::Value) -> anyhow::Result<Vec<SkillConfig>> {
483 let Some(serde_norway::Value::Sequence(seq)) = root.get("skills") else {
484 return Ok(vec![]);
485 };
486
487 let mut skills = Vec::new();
488 for item in seq {
489 if let serde_norway::Value::Mapping(map) = item {
490 let repo = map
491 .get("repo")
492 .and_then(|v| v.as_str())
493 .map(String::from)
494 .unwrap_or_default();
495
496 if !repo.is_empty() {
497 skills.push(SkillConfig { repo });
498 }
499 }
500 }
501
502 Ok(skills)
503}
504
505fn resolve_projects_list(path: &PathBuf, key: &str) -> anyhow::Result<Vec<String>> {
507 watch_file(path);
509
510 let root = load_cached_root(path)?;
511
512 match extract_projects_list(&root, key) {
514 Ok(projects) => Ok(projects),
515 Err(_) => Ok(vec![]),
516 }
517}
518
519fn extract_projects_list(root: &serde_norway::Value, key: &str) -> anyhow::Result<Vec<String>> {
521 let mut current = root;
522 for part in key.split('.') {
523 let next = match current.get(part) {
525 Some(v) => v,
526 None => return Ok(vec![]),
527 };
528 current = next;
529 }
530
531 match current {
532 serde_norway::Value::Sequence(seq) => {
533 let mut projects = Vec::new();
534 for item in seq {
535 if let serde_norway::Value::String(s) = item {
536 projects.push(s.clone());
537 } else if let serde_norway::Value::Mapping(map) = item {
538 if let Some(serde_norway::Value::String(repo)) = map.get("repo") {
540 projects.push(repo.clone());
541 }
542 }
543 }
544 Ok(projects)
545 }
546 _ => Ok(vec![]),
547 }
548}
549
550fn resolve_key(path: &PathBuf, key: &str) -> anyhow::Result<String> {
555 watch_file(path);
557
558 let root = load_cached_root(path)?;
559
560 extract_value(&root, key)
561}
562
563fn extract_value(root: &serde_norway::Value, key: &str) -> anyhow::Result<String> {
565 let mut current = root;
566 for part in key.split('.') {
567 current = current
568 .get(part)
569 .with_context(|| format!("key not found: {key}"))?;
570 }
571
572 match current {
573 serde_norway::Value::String(s) => Ok(s.clone()),
574 serde_norway::Value::Number(n) => Ok(n.to_string()),
575 serde_norway::Value::Bool(b) => Ok(b.to_string()),
576 serde_norway::Value::Null => Ok(String::new()),
577 _ => Ok(serde_norway::to_string(current)?),
578 }
579}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584 use std::io::Write;
585
586 fn write_yaml(dir: &std::path::Path, name: &str, content: &str) -> PathBuf {
587 let path = dir.join(name);
588 let mut f = std::fs::File::create(&path).unwrap();
589 f.write_all(content.as_bytes()).unwrap();
590 path
591 }
592
593 #[test]
594 fn resolve_simple_key() {
595 let dir = tempfile::tempdir().unwrap();
596 let path = write_yaml(dir.path(), "config.yml", "repo: owner/repo\n");
597 let val = resolve_key(&path, "repo").unwrap();
598 assert_eq!(val, "owner/repo");
599 }
600
601 #[test]
602 fn resolve_nested_key() {
603 let dir = tempfile::tempdir().unwrap();
604 let path = write_yaml(
605 dir.path(),
606 "config.yml",
607 "agents:\n claude:\n model: opus\n",
608 );
609 let val = resolve_key(&path, "agents.claude.model").unwrap();
610 assert_eq!(val, "opus");
611 }
612
613 #[test]
614 fn resolve_boolean_value() {
615 let dir = tempfile::tempdir().unwrap();
616 let path = write_yaml(dir.path(), "config.yml", "enabled: true\n");
617 let val = resolve_key(&path, "enabled").unwrap();
618 assert_eq!(val, "true");
619 }
620
621 #[test]
622 fn resolve_number_value() {
623 let dir = tempfile::tempdir().unwrap();
624 let path = write_yaml(dir.path(), "config.yml", "timeout: 300\n");
625 let val = resolve_key(&path, "timeout").unwrap();
626 assert_eq!(val, "300");
627 }
628
629 #[test]
630 fn resolve_missing_key_errors() {
631 let dir = tempfile::tempdir().unwrap();
632 let path = write_yaml(dir.path(), "config.yml", "repo: owner/repo\n");
633 let result = resolve_key(&path, "missing.key");
634 assert!(result.is_err());
635 }
636
637 #[test]
638 fn resolve_missing_file_errors() {
639 let path = PathBuf::from("/nonexistent/config.yml");
640 let result = resolve_key(&path, "repo");
641 assert!(result.is_err());
642 }
643
644 #[test]
645 fn resolve_projects_list_string_array() {
646 let dir = tempfile::tempdir().unwrap();
647 let path = write_yaml(
648 dir.path(),
649 "config.yml",
650 "projects:\n - owner/repo1\n - owner/repo2\n",
651 );
652 let projects = resolve_projects_list(&path, "projects").unwrap();
653 assert_eq!(projects, vec!["owner/repo1", "owner/repo2"]);
654 }
655
656 #[test]
657 fn resolve_projects_list_map_array() {
658 let dir = tempfile::tempdir().unwrap();
659 let path = write_yaml(
660 dir.path(),
661 "config.yml",
662 "projects:\n - repo: owner/repo1\n project_dir: /path/to/repo1\n - repo: owner/repo2\n project_dir: /path/to/repo2\n",
663 );
664 let projects = resolve_projects_list(&path, "projects").unwrap();
665 assert_eq!(projects, vec!["owner/repo1", "owner/repo2"]);
666 }
667
668 #[test]
669 fn resolve_projects_list_fallback_to_repo() {
670 let dir = tempfile::tempdir().unwrap();
671 let path = write_yaml(dir.path(), "config.yml", "repo: owner/single-repo\n");
672 let projects = resolve_projects_list(&path, "projects").unwrap();
673 assert!(projects.is_empty());
674 }
675
676 #[test]
677 fn extract_projects_list_from_sequence() {
678 let yaml =
679 serde_norway::from_str::<serde_norway::Value>("projects:\n - a\n - b\n - c\n")
680 .unwrap();
681 let projects = extract_projects_list(&yaml, "projects").unwrap();
682 assert_eq!(projects, vec!["a", "b", "c"]);
683 }
684
685 #[test]
686 fn extract_projects_list_from_mapping_sequence() {
687 let yaml =
688 serde_norway::from_str::<serde_norway::Value>("projects:\n - repo: a\n - repo: b\n")
689 .unwrap();
690 let projects = extract_projects_list(&yaml, "projects").unwrap();
691 assert_eq!(projects, vec!["a", "b"]);
692 }
693
694 #[test]
695 fn extract_projects_list_empty_for_missing() {
696 let yaml = serde_norway::from_str::<serde_norway::Value>("foo: bar").unwrap();
697 let projects = extract_projects_list(&yaml, "projects").unwrap();
698 assert!(projects.is_empty());
699 }
700
701 #[test]
702 fn subscribe_receives_change_notifications() {
703 let mut rx = subscribe();
704 let path = PathBuf::from(format!("/tmp/test-config-{}.yml", std::process::id()));
706
707 invalidate_cache(&path);
709
710 loop {
712 match rx.try_recv() {
713 Ok(received_path) if received_path == path => break,
714 Ok(_) => continue, Err(e) => panic!("expected config change notification, got error: {e:?}"),
716 }
717 }
718 }
719
720 #[test]
721 fn subscribe_multiple_receivers() {
722 let mut rx1 = subscribe();
723 let mut rx2 = subscribe();
724 let path = PathBuf::from(format!("/tmp/test-config-multi-{}.yml", std::process::id()));
726
727 invalidate_cache(&path);
728
729 let find_path = |rx: &mut tokio::sync::broadcast::Receiver<PathBuf>, expected: &PathBuf| loop {
731 match rx.try_recv() {
732 Ok(p) if &p == expected => return,
733 Ok(_) => continue,
734 Err(e) => panic!("expected config change notification, got error: {e:?}"),
735 }
736 };
737 find_path(&mut rx1, &path);
738 find_path(&mut rx2, &path);
739 }
740
741 #[test]
742 fn resolve_list_from_sequence() {
743 let dir = tempfile::tempdir().unwrap();
744 let path = write_yaml(
745 dir.path(),
746 "config.yml",
747 "workflow:\n allowed_tools:\n - Edit\n - Write\n - git\n - npm\n",
748 );
749 let list = resolve_list(&path, "workflow.allowed_tools").unwrap();
750 assert_eq!(list, vec!["Edit", "Write", "git", "npm"]);
751 }
752
753 #[test]
754 fn resolve_list_from_comma_string() {
755 let dir = tempfile::tempdir().unwrap();
756 let path = write_yaml(
757 dir.path(),
758 "config.yml",
759 "workflow:\n allowed_tools: \"Edit,Write,git,npm\"\n",
760 );
761 let list = resolve_list(&path, "workflow.allowed_tools").unwrap();
762 assert_eq!(list, vec!["Edit", "Write", "git", "npm"]);
763 }
764
765 #[test]
766 fn resolve_list_missing_key_returns_empty() {
767 let dir = tempfile::tempdir().unwrap();
768 let path = write_yaml(dir.path(), "config.yml", "foo: bar\n");
769 let list = resolve_list(&path, "workflow.allowed_tools").unwrap();
770 assert!(list.is_empty());
771 }
772
773 #[test]
774 fn extract_skills_list_from_sequence() {
775 let yaml = serde_norway::from_str::<serde_norway::Value>(
776 "skills:\n - repo: owner/skills1\n - repo: owner/skills2\n",
777 )
778 .unwrap();
779 let skills = extract_skills_list(&yaml).unwrap();
780 assert_eq!(skills.len(), 2);
781 assert_eq!(skills[0].repo, "owner/skills1");
782 assert_eq!(skills[1].repo, "owner/skills2");
783 }
784
785 #[test]
786 fn extract_skills_list_single_repo() {
787 let yaml =
788 serde_norway::from_str::<serde_norway::Value>("skills:\n - repo: owner/skills\n")
789 .unwrap();
790 let skills = extract_skills_list(&yaml).unwrap();
791 assert_eq!(skills.len(), 1);
792 assert_eq!(skills[0].repo, "owner/skills");
793 }
794
795 #[test]
796 fn extract_skills_list_empty_for_missing() {
797 let yaml = serde_norway::from_str::<serde_norway::Value>("foo: bar").unwrap();
798 let skills = extract_skills_list(&yaml).unwrap();
799 assert!(skills.is_empty());
800 }
801}