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