1pub 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
23static CACHE: std::sync::LazyLock<RwLock<HashMap<PathBuf, serde_norway::Value>>> =
26 std::sync::LazyLock::new(|| RwLock::new(HashMap::new()));
27
28static WATCHER: std::sync::LazyLock<RwLock<HashMap<PathBuf, ()>>> =
30 std::sync::LazyLock::new(|| RwLock::new(HashMap::new()));
31
32static FILE_WATCHER: std::sync::LazyLock<Arc<Mutex<Option<RecommendedWatcher>>>> =
34 std::sync::LazyLock::new(|| Arc::new(Mutex::new(None)));
35
36static BAD_FILES: std::sync::LazyLock<RwLock<HashSet<PathBuf>>> =
40 std::sync::LazyLock::new(|| RwLock::new(HashSet::new()));
41
42static CHANGE_TX: std::sync::LazyLock<broadcast::Sender<PathBuf>> =
45 std::sync::LazyLock::new(|| {
46 let (tx, _) = broadcast::channel(16);
47 tx
48 });
49
50pub fn subscribe() -> broadcast::Receiver<PathBuf> {
55 CHANGE_TX.subscribe()
56}
57
58pub 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#[cfg(test)]
103pub fn clear_test_cache() {
104 if let Ok(mut cache) = CACHE.write() {
105 cache.clear();
106 }
107}
108
109fn 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 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
156fn 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 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 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
188fn is_content_change(kind: ¬ify::EventKind) -> bool {
191 kind.is_create() || kind.is_modify() || kind.is_remove()
192}
193
194pub 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
209fn watch_file(path: &PathBuf) {
211 ensure_watcher();
213
214 if let Ok(mut watched) = WATCHER.write() {
216 if !watched.contains_key(path) {
217 watched.insert(path.clone(), ());
218
219 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
266fn global_config_path() -> anyhow::Result<PathBuf> {
268 crate::home::config_path()
269}
270
271pub fn get_list(key: &str) -> anyhow::Result<Vec<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_list(&project_path, key) {
297 if !val.is_empty() {
298 return Ok(val);
299 }
300 }
301 }
302
303 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
312fn 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
321fn 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 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
347pub fn get(key: &str) -> anyhow::Result<String> {
356 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 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
377pub 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
394pub fn get_global(key: &str) -> anyhow::Result<String> {
396 let global_path = global_config_path()?;
397 if global_path.exists() {
398 return resolve_key(&global_path, key);
399 }
400
401 anyhow::bail!("config key not found: {key}")
402}
403
404pub fn get_project_paths() -> anyhow::Result<Vec<String>> {
409 let global_path = global_config_path()?;
410
411 if global_path.exists() {
412 if let Ok(paths) = resolve_projects_list(&global_path, "projects") {
413 if !paths.is_empty() {
414 return Ok(paths);
415 }
416 }
417 }
418
419 Ok(vec![])
420}
421
422pub fn get_projects_with_paths() -> anyhow::Result<Vec<(String, std::path::PathBuf)>> {
427 let paths = get_project_paths()?;
428
429 if !paths.is_empty() {
430 let mut results = Vec::new();
431 for path_str in &paths {
432 let path = std::path::PathBuf::from(path_str);
433 let orch_yml = path.join(".orch.yml");
434
435 let config_file = if orch_yml.exists() {
436 orch_yml
437 } else {
438 let legacy = path.join(".orchestrator.yml");
439 if legacy.exists() {
440 legacy
441 } else {
442 continue;
443 }
444 };
445
446 if let Ok(content) = std::fs::read_to_string(&config_file) {
447 if let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&content) {
448 if let Some(repo) = doc
449 .get("gh")
450 .and_then(|gh| gh.get("repo"))
451 .and_then(|r| r.as_str())
452 {
453 results.push((repo.to_string(), path));
454 }
455 }
456 }
457 }
458
459 if !results.is_empty() {
460 return Ok(results);
461 }
462 }
463
464 if let Ok(repo) = get("gh.repo") {
466 if !repo.is_empty() {
467 return Ok(vec![(repo, std::path::PathBuf::from("."))]);
468 }
469 }
470
471 Ok(vec![])
472}
473
474pub fn get_repo_for_project(project_path: &std::path::Path) -> anyhow::Result<String> {
478 let orch_yml = project_path.join(".orch.yml");
479 let config_file = if orch_yml.exists() {
480 orch_yml
481 } else {
482 let legacy = project_path.join(".orchestrator.yml");
483 if legacy.exists() {
484 legacy
485 } else {
486 anyhow::bail!("no .orch.yml found in {}", project_path.display());
487 }
488 };
489
490 let content = std::fs::read_to_string(&config_file)?;
491 let doc: serde_norway::Value = serde_norway::from_str(&content)?;
492
493 doc.get("gh")
494 .and_then(|gh| gh.get("repo"))
495 .and_then(|r| r.as_str())
496 .map(String::from)
497 .ok_or_else(|| anyhow::anyhow!("gh.repo not set in {}", config_file.display()))
498}
499
500pub fn find_project_root() -> anyhow::Result<std::path::PathBuf> {
504 let mut dir = std::env::current_dir()?;
505
506 loop {
507 if dir.join(".orch.yml").exists() || dir.join(".orchestrator.yml").exists() {
508 return Ok(dir);
509 }
510
511 if !dir.pop() {
512 break;
513 }
514 }
515
516 anyhow::bail!("no .orch.yml found in current directory or parents — run `orch init`")
517}
518
519pub fn get_current_repo() -> anyhow::Result<String> {
524 if let Ok(project_root) = find_project_root() {
526 if let Ok(repo) = get_repo_for_project(&project_root) {
527 return Ok(repo);
528 }
529 }
530
531 if let Ok(repo) = get("gh.repo") {
533 if !repo.is_empty() {
534 return Ok(repo);
535 }
536 }
537
538 if let Ok(repo) = get("repo") {
540 if !repo.is_empty() {
541 return Ok(repo);
542 }
543 }
544
545 anyhow::bail!("could not determine repo — ensure .orch.yml has gh.repo set")
546}
547
548#[derive(Debug, Clone)]
550pub struct SkillConfig {
551 pub repo: String,
553}
554
555pub fn get_skills() -> anyhow::Result<Vec<SkillConfig>> {
566 let global_path = global_config_path()?;
567
568 if !global_path.exists() {
569 return Ok(vec![]);
570 }
571
572 let root = load_cached_root(&global_path)?;
573
574 extract_skills_list(&root)
575}
576
577fn extract_skills_list(root: &serde_norway::Value) -> anyhow::Result<Vec<SkillConfig>> {
579 let Some(serde_norway::Value::Sequence(seq)) = root.get("skills") else {
580 return Ok(vec![]);
581 };
582
583 let mut skills = Vec::new();
584 for item in seq {
585 if let serde_norway::Value::Mapping(map) = item {
586 let repo = map
587 .get("repo")
588 .and_then(|v| v.as_str())
589 .map(String::from)
590 .unwrap_or_default();
591
592 if !repo.is_empty() {
593 skills.push(SkillConfig { repo });
594 }
595 }
596 }
597
598 Ok(skills)
599}
600
601fn resolve_projects_list(path: &PathBuf, key: &str) -> anyhow::Result<Vec<String>> {
603 watch_file(path);
605
606 let root = load_cached_root(path)?;
607
608 match extract_projects_list(&root, key) {
610 Ok(projects) => Ok(projects),
611 Err(_) => Ok(vec![]),
612 }
613}
614
615fn extract_projects_list(root: &serde_norway::Value, key: &str) -> anyhow::Result<Vec<String>> {
617 let mut current = root;
618 for part in key.split('.') {
619 let next = match current.get(part) {
621 Some(v) => v,
622 None => return Ok(vec![]),
623 };
624 current = next;
625 }
626
627 match current {
628 serde_norway::Value::Sequence(seq) => {
629 let mut projects = Vec::new();
630 for item in seq {
631 if let serde_norway::Value::String(s) = item {
632 projects.push(s.clone());
633 } else if let serde_norway::Value::Mapping(map) = item {
634 if let Some(serde_norway::Value::String(repo)) = map.get("repo") {
636 projects.push(repo.clone());
637 }
638 }
639 }
640 Ok(projects)
641 }
642 _ => Ok(vec![]),
643 }
644}
645
646fn resolve_key(path: &PathBuf, key: &str) -> anyhow::Result<String> {
651 watch_file(path);
653
654 let root = load_cached_root(path)?;
655
656 extract_value(&root, key)
657}
658
659fn extract_value(root: &serde_norway::Value, key: &str) -> anyhow::Result<String> {
661 let mut current = root;
662 for part in key.split('.') {
663 current = current
664 .get(part)
665 .with_context(|| format!("key not found: {key}"))?;
666 }
667
668 match current {
669 serde_norway::Value::String(s) => Ok(s.clone()),
670 serde_norway::Value::Number(n) => Ok(n.to_string()),
671 serde_norway::Value::Bool(b) => Ok(b.to_string()),
672 serde_norway::Value::Null => Ok(String::new()),
673 _ => Ok(serde_norway::to_string(current)?),
674 }
675}
676
677#[cfg(test)]
678mod tests {
679 use super::*;
680 use std::io::Write;
681
682 fn write_yaml(dir: &std::path::Path, name: &str, content: &str) -> PathBuf {
683 let path = dir.join(name);
684 let mut f = std::fs::File::create(&path).unwrap();
685 f.write_all(content.as_bytes()).unwrap();
686 path
687 }
688
689 #[test]
690 fn resolve_simple_key() {
691 let dir = tempfile::tempdir().unwrap();
692 let path = write_yaml(dir.path(), "config.yml", "repo: owner/repo\n");
693 let val = resolve_key(&path, "repo").unwrap();
694 assert_eq!(val, "owner/repo");
695 }
696
697 #[test]
698 fn resolve_nested_key() {
699 let dir = tempfile::tempdir().unwrap();
700 let path = write_yaml(
701 dir.path(),
702 "config.yml",
703 "agents:\n claude:\n model: opus\n",
704 );
705 let val = resolve_key(&path, "agents.claude.model").unwrap();
706 assert_eq!(val, "opus");
707 }
708
709 #[test]
710 fn resolve_boolean_value() {
711 let dir = tempfile::tempdir().unwrap();
712 let path = write_yaml(dir.path(), "config.yml", "enabled: true\n");
713 let val = resolve_key(&path, "enabled").unwrap();
714 assert_eq!(val, "true");
715 }
716
717 #[test]
718 fn resolve_number_value() {
719 let dir = tempfile::tempdir().unwrap();
720 let path = write_yaml(dir.path(), "config.yml", "timeout: 300\n");
721 let val = resolve_key(&path, "timeout").unwrap();
722 assert_eq!(val, "300");
723 }
724
725 #[test]
726 fn resolve_missing_key_errors() {
727 let dir = tempfile::tempdir().unwrap();
728 let path = write_yaml(dir.path(), "config.yml", "repo: owner/repo\n");
729 let result = resolve_key(&path, "missing.key");
730 assert!(result.is_err());
731 }
732
733 #[test]
734 fn resolve_missing_file_errors() {
735 let path = PathBuf::from("/nonexistent/config.yml");
736 let result = resolve_key(&path, "repo");
737 assert!(result.is_err());
738 }
739
740 #[test]
741 fn resolve_projects_list_string_array() {
742 let dir = tempfile::tempdir().unwrap();
743 let path = write_yaml(
744 dir.path(),
745 "config.yml",
746 "projects:\n - owner/repo1\n - owner/repo2\n",
747 );
748 let projects = resolve_projects_list(&path, "projects").unwrap();
749 assert_eq!(projects, vec!["owner/repo1", "owner/repo2"]);
750 }
751
752 #[test]
753 fn resolve_projects_list_map_array() {
754 let dir = tempfile::tempdir().unwrap();
755 let path = write_yaml(
756 dir.path(),
757 "config.yml",
758 "projects:\n - repo: owner/repo1\n project_dir: /path/to/repo1\n - repo: owner/repo2\n project_dir: /path/to/repo2\n",
759 );
760 let projects = resolve_projects_list(&path, "projects").unwrap();
761 assert_eq!(projects, vec!["owner/repo1", "owner/repo2"]);
762 }
763
764 #[test]
765 fn resolve_projects_list_fallback_to_repo() {
766 let dir = tempfile::tempdir().unwrap();
767 let path = write_yaml(dir.path(), "config.yml", "repo: owner/single-repo\n");
768 let projects = resolve_projects_list(&path, "projects").unwrap();
769 assert!(projects.is_empty());
770 }
771
772 #[test]
773 fn extract_projects_list_from_sequence() {
774 let yaml =
775 serde_norway::from_str::<serde_norway::Value>("projects:\n - a\n - b\n - c\n")
776 .unwrap();
777 let projects = extract_projects_list(&yaml, "projects").unwrap();
778 assert_eq!(projects, vec!["a", "b", "c"]);
779 }
780
781 #[test]
782 fn extract_projects_list_from_mapping_sequence() {
783 let yaml =
784 serde_norway::from_str::<serde_norway::Value>("projects:\n - repo: a\n - repo: b\n")
785 .unwrap();
786 let projects = extract_projects_list(&yaml, "projects").unwrap();
787 assert_eq!(projects, vec!["a", "b"]);
788 }
789
790 #[test]
791 fn extract_projects_list_empty_for_missing() {
792 let yaml = serde_norway::from_str::<serde_norway::Value>("foo: bar").unwrap();
793 let projects = extract_projects_list(&yaml, "projects").unwrap();
794 assert!(projects.is_empty());
795 }
796
797 #[test]
798 fn subscribe_receives_change_notifications() {
799 let mut rx = subscribe();
800 let dir = tempfile::tempdir().unwrap();
801 let path = write_yaml(dir.path(), "config.yml", "repo: owner/repo\n");
802
803 assert!(!reload_file(&path));
805
806 loop {
808 match rx.try_recv() {
809 Ok(received_path) if received_path == path => break,
810 Ok(_) => continue, Err(e) => panic!("expected config change notification, got error: {e:?}"),
812 }
813 }
814 }
815
816 #[test]
817 fn subscribe_multiple_receivers() {
818 let mut rx1 = subscribe();
819 let mut rx2 = subscribe();
820 let dir = tempfile::tempdir().unwrap();
821 let path = write_yaml(dir.path(), "config.yml", "repo: owner/repo\n");
822
823 assert!(!reload_file(&path));
824
825 let find_path = |rx: &mut tokio::sync::broadcast::Receiver<PathBuf>, expected: &PathBuf| loop {
827 match rx.try_recv() {
828 Ok(p) if &p == expected => return,
829 Ok(_) => continue,
830 Err(e) => panic!("expected config change notification, got error: {e:?}"),
831 }
832 };
833 find_path(&mut rx1, &path);
834 find_path(&mut rx2, &path);
835 }
836
837 #[test]
838 fn resolve_list_from_sequence() {
839 let dir = tempfile::tempdir().unwrap();
840 let path = write_yaml(
841 dir.path(),
842 "config.yml",
843 "workflow:\n allowed_tools:\n - Edit\n - Write\n - git\n - npm\n",
844 );
845 let list = resolve_list(&path, "workflow.allowed_tools").unwrap();
846 assert_eq!(list, vec!["Edit", "Write", "git", "npm"]);
847 }
848
849 #[test]
850 fn resolve_list_from_comma_string() {
851 let dir = tempfile::tempdir().unwrap();
852 let path = write_yaml(
853 dir.path(),
854 "config.yml",
855 "workflow:\n allowed_tools: \"Edit,Write,git,npm\"\n",
856 );
857 let list = resolve_list(&path, "workflow.allowed_tools").unwrap();
858 assert_eq!(list, vec!["Edit", "Write", "git", "npm"]);
859 }
860
861 #[test]
862 fn resolve_list_missing_key_returns_empty() {
863 let dir = tempfile::tempdir().unwrap();
864 let path = write_yaml(dir.path(), "config.yml", "foo: bar\n");
865 let list = resolve_list(&path, "workflow.allowed_tools").unwrap();
866 assert!(list.is_empty());
867 }
868
869 #[test]
870 fn extract_skills_list_from_sequence() {
871 let yaml = serde_norway::from_str::<serde_norway::Value>(
872 "skills:\n - repo: owner/skills1\n - repo: owner/skills2\n",
873 )
874 .unwrap();
875 let skills = extract_skills_list(&yaml).unwrap();
876 assert_eq!(skills.len(), 2);
877 assert_eq!(skills[0].repo, "owner/skills1");
878 assert_eq!(skills[1].repo, "owner/skills2");
879 }
880
881 #[test]
882 fn extract_skills_list_single_repo() {
883 let yaml =
884 serde_norway::from_str::<serde_norway::Value>("skills:\n - repo: owner/skills\n")
885 .unwrap();
886 let skills = extract_skills_list(&yaml).unwrap();
887 assert_eq!(skills.len(), 1);
888 assert_eq!(skills[0].repo, "owner/skills");
889 }
890
891 fn cached_value(path: &PathBuf, key: &str) -> String {
897 extract_value(&load_cached_root(path).unwrap(), key).unwrap()
898 }
899
900 fn cached_projects(path: &PathBuf) -> Vec<String> {
901 extract_projects_list(&load_cached_root(path).unwrap(), "projects").unwrap()
902 }
903
904 #[test]
905 fn reading_a_config_file_does_not_trigger_a_reload() {
906 use notify::event::{AccessKind, AccessMode, CreateKind, DataChange, ModifyKind};
907 assert!(!is_content_change(¬ify::EventKind::Access(
908 AccessKind::Open(AccessMode::Read)
909 )));
910 assert!(!is_content_change(¬ify::EventKind::Access(
911 AccessKind::Close(AccessMode::Read)
912 )));
913 assert!(is_content_change(¬ify::EventKind::Modify(
914 ModifyKind::Data(DataChange::Content)
915 )));
916 assert!(is_content_change(¬ify::EventKind::Create(
917 CreateKind::File
918 )));
919 }
920
921 #[test]
922 fn invalid_edit_keeps_last_good_values_and_sends_no_notification() {
923 let dir = tempfile::tempdir().unwrap();
924 let path = write_yaml(dir.path(), "config.yml", "engine:\n max_parallel: 8\n");
925 assert!(!reload_file(&path));
926 assert_eq!(cached_value(&path, "engine.max_parallel"), "8");
927
928 let mut rx = subscribe();
929 std::fs::write(&path, "engine:\n max_parallel: [unclosed\n").unwrap();
930 assert!(reload_file(&path));
931
932 assert_eq!(cached_value(&path, "engine.max_parallel"), "8");
933 assert!(rx.try_recv().is_err(), "a broken edit must not notify");
934
935 std::fs::write(&path, "engine:\n max_parallel: 12\n").unwrap();
937 assert!(!reload_file(&path));
938 assert_eq!(cached_value(&path, "engine.max_parallel"), "12");
939 }
940
941 #[test]
942 fn adding_a_project_is_visible_after_reload() {
943 let dir = tempfile::tempdir().unwrap();
944 let path = write_yaml(dir.path(), "config.yml", "projects:\n - /srv/one\n");
945 assert_eq!(cached_projects(&path), vec!["/srv/one"]);
946
947 std::fs::write(&path, "projects:\n - /srv/one\n - /srv/two\n").unwrap();
948 assert!(!reload_file(&path));
949
950 assert_eq!(cached_projects(&path), vec!["/srv/one", "/srv/two"]);
951 }
952
953 #[test]
954 fn repeated_watcher_events_for_one_breakage_log_once() {
955 let dir = tempfile::tempdir().unwrap();
956 let path = write_yaml(dir.path(), "config.yml", "engine:\n max_parallel: 4\n");
957 reload_file(&path);
958
959 std::fs::write(&path, "engine: [broken\n").unwrap();
960 assert!(reload_file(&path), "first bad read logs");
961 assert!(!reload_file(&path), "same breakage does not log again");
962 }
963
964 #[test]
965 #[serial_test::serial(orch_home)]
966 fn a_project_setting_overrides_the_global_one() {
967 let (home, with, without) = (
968 tempfile::tempdir().unwrap(),
969 tempfile::tempdir().unwrap(),
970 tempfile::tempdir().unwrap(),
971 );
972 write_yaml(home.path(), "config.yml", "workflow:\n sandbox: os\n");
973 write_yaml(with.path(), ".orch.yml", "workflow:\n sandbox: false\n");
974 std::env::set_var("ORCH_HOME", home.path());
975
976 let got = (
977 get_for_project(with.path(), "workflow.sandbox").unwrap(),
978 get_for_project(without.path(), "workflow.sandbox").unwrap(),
979 );
980 std::env::remove_var("ORCH_HOME");
981 assert_eq!(got, ("false".to_string(), "os".to_string()));
982 }
983
984 #[test]
985 fn extract_skills_list_empty_for_missing() {
986 let yaml = serde_norway::from_str::<serde_norway::Value>("foo: bar").unwrap();
987 let skills = extract_skills_list(&yaml).unwrap();
988 assert!(skills.is_empty());
989 }
990}