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<HashSet<PathBuf>>> =
30 std::sync::LazyLock::new(|| RwLock::new(HashSet::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 && bad.remove(path)
131 {
132 tracing::info!(path = %path.display(), "config file parses again, reloaded");
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 .is_ok_and(|mut bad| bad.insert(path.clone()));
143 if first_failure {
144 tracing::error!(
145 path = %path.display(),
146 %err,
147 "config file is invalid, keeping the last good values"
148 );
149 }
150 first_failure
151 }
152 }
153}
154
155fn ensure_watcher() {
157 let mut watcher_guard = FILE_WATCHER
158 .lock()
159 .unwrap_or_else(std::sync::PoisonError::into_inner);
160 if watcher_guard.is_some() {
161 return;
162 }
163
164 let watcher = RecommendedWatcher::new(
166 move |res: Result<notify::Event, notify::Error>| {
167 if let Ok(event) = res {
168 if !is_content_change(event.kind) {
169 return;
170 }
171 for path in event.paths {
172 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
174
175 if filename == "config.yml" || filename == ".orch.yml" {
176 let _ = reload_file(&path);
177 }
178 }
179 }
180 },
181 Config::default().with_poll_interval(Duration::from_secs(2)),
182 );
183
184 if let Ok(w) = watcher {
185 *watcher_guard = Some(w);
186 }
187}
188
189fn is_content_change(kind: notify::EventKind) -> bool {
192 kind.is_create() || kind.is_modify() || kind.is_remove()
193}
194
195pub fn watch(path: &Path) {
203 if !path.exists() {
204 tracing::debug!(path = %path.display(), "no config file to watch here");
205 return;
206 }
207 watch_file(&path.to_path_buf());
208}
209
210fn watch_file(path: &PathBuf) {
212 ensure_watcher();
214
215 if let Ok(mut watched) = WATCHER.write()
217 && !watched.contains(path)
218 {
219 watched.insert(path.clone());
220
221 if let Ok(mut guard) = FILE_WATCHER.lock()
223 && let Some(ref mut watcher) = *guard
224 {
225 match watcher.watch(path, RecursiveMode::NonRecursive) {
226 Ok(()) => tracing::debug!("now watching config file: {}", path.display()),
227 Err(e) => tracing::warn!(
228 "failed to register config file watcher for {}: {}",
229 path.display(),
230 e
231 ),
232 }
233 }
234 }
235}
236
237fn read_file_to_string(path: &Path) -> anyhow::Result<String> {
238 match tokio::runtime::Handle::try_current() {
239 Ok(handle) if handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread => {
240 Ok(tokio::task::block_in_place(|| {
241 std::fs::read_to_string(path)
242 })?)
243 }
244 _ => Ok(std::fs::read_to_string(path)?),
245 }
246}
247
248fn load_cached_root(path: &PathBuf) -> anyhow::Result<serde_norway::Value> {
249 if let Ok(cache) = CACHE.read()
250 && let Some(cached) = cache.get(path)
251 {
252 return Ok(cached.clone());
253 }
254
255 let content =
256 read_file_to_string(path).with_context(|| format!("reading {}", path.display()))?;
257 let parsed: serde_norway::Value =
258 serde_norway::from_str(&content).with_context(|| format!("parsing {}", path.display()))?;
259
260 if let Ok(mut cache) = CACHE.write() {
261 cache.insert(path.clone(), parsed.clone());
262 }
263
264 Ok(parsed)
265}
266
267fn global_config_path() -> anyhow::Result<PathBuf> {
269 crate::home::config_path()
270}
271
272pub fn get_list(key: &str) -> anyhow::Result<Vec<String>> {
290 let project_path = std::env::current_dir()
294 .unwrap_or_else(|_| PathBuf::from("."))
295 .join(".orch.yml");
296 if project_path.exists()
297 && let Ok(val) = resolve_list(&project_path, key)
298 && !val.is_empty()
299 {
300 return Ok(val);
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 Ok(extract_list(&root, key))
319}
320
321fn extract_list(root: &serde_norway::Value, key: &str) -> 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 vec![],
328 }
329 }
330
331 match current {
332 serde_norway::Value::Sequence(seq) => seq
333 .iter()
334 .filter_map(|item| item.as_str().map(String::from))
335 .collect(),
336 serde_norway::Value::String(s) => {
337 s.split(',')
339 .map(|s| s.trim().to_string())
340 .filter(|s| !s.is_empty())
341 .collect()
342 }
343 _ => 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 && let Ok(val) = resolve_key(&project_path, key)
364 {
365 return Ok(val);
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 && let Ok(val) = resolve_key(&project_path, key)
382 {
383 return Ok(val);
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_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
404pub 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
414pub fn get_project_paths() -> anyhow::Result<Vec<String>> {
419 let global_path = global_config_path()?;
420
421 if global_path.exists()
422 && let Ok(paths) = resolve_projects_list(&global_path, "projects")
423 && !paths.is_empty()
424 {
425 return Ok(paths);
426 }
427
428 Ok(vec![])
429}
430
431pub fn get_projects_with_paths() -> anyhow::Result<Vec<(String, std::path::PathBuf)>> {
436 let paths = get_project_paths()?;
437
438 if !paths.is_empty() {
439 let mut results = Vec::new();
440 for path_str in &paths {
441 let path = std::path::PathBuf::from(path_str);
442 let orch_yml = path.join(".orch.yml");
443
444 let config_file = if orch_yml.exists() {
445 orch_yml
446 } else {
447 let legacy = path.join(".orchestrator.yml");
448 if legacy.exists() {
449 legacy
450 } else {
451 continue;
452 }
453 };
454
455 if let Ok(content) = std::fs::read_to_string(&config_file)
456 && let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&content)
457 && let Some(repo) = doc
458 .get("gh")
459 .and_then(|gh| gh.get("repo"))
460 .and_then(|r| r.as_str())
461 {
462 results.push((repo.to_string(), path));
463 }
464 }
465
466 if !results.is_empty() {
467 return Ok(results);
468 }
469 }
470
471 if let Ok(repo) = get("gh.repo")
473 && !repo.is_empty()
474 {
475 return Ok(vec![(repo, std::path::PathBuf::from("."))]);
476 }
477
478 Ok(vec![])
479}
480
481pub fn get_repo_for_project(project_path: &std::path::Path) -> anyhow::Result<String> {
485 let orch_yml = project_path.join(".orch.yml");
486 let config_file = if orch_yml.exists() {
487 orch_yml
488 } else {
489 let legacy = project_path.join(".orchestrator.yml");
490 if legacy.exists() {
491 legacy
492 } else {
493 anyhow::bail!("no .orch.yml found in {}", project_path.display());
494 }
495 };
496
497 let content = std::fs::read_to_string(&config_file)?;
498 let doc: serde_norway::Value = serde_norway::from_str(&content)?;
499
500 doc.get("gh")
501 .and_then(|gh| gh.get("repo"))
502 .and_then(|r| r.as_str())
503 .map(String::from)
504 .ok_or_else(|| anyhow::anyhow!("gh.repo not set in {}", config_file.display()))
505}
506
507pub fn find_project_root() -> anyhow::Result<std::path::PathBuf> {
511 let mut dir = std::env::current_dir()?;
512
513 loop {
514 if dir.join(".orch.yml").exists() || dir.join(".orchestrator.yml").exists() {
515 return Ok(dir);
516 }
517
518 if !dir.pop() {
519 break;
520 }
521 }
522
523 anyhow::bail!("no .orch.yml found in current directory or parents — run `orch init`")
524}
525
526pub fn get_current_repo() -> anyhow::Result<String> {
531 if let Ok(project_root) = find_project_root()
533 && let Ok(repo) = get_repo_for_project(&project_root)
534 {
535 return Ok(repo);
536 }
537
538 if let Ok(repo) = get("gh.repo")
540 && !repo.is_empty()
541 {
542 return Ok(repo);
543 }
544
545 if let Ok(repo) = get("repo")
547 && !repo.is_empty()
548 {
549 return Ok(repo);
550 }
551
552 anyhow::bail!("could not determine repo — ensure .orch.yml has gh.repo set")
553}
554
555#[derive(Debug, Clone)]
557pub struct SkillConfig {
558 pub repo: String,
560}
561
562pub fn get_skills() -> anyhow::Result<Vec<SkillConfig>> {
573 let global_path = global_config_path()?;
574
575 if !global_path.exists() {
576 return Ok(vec![]);
577 }
578
579 let root = load_cached_root(&global_path)?;
580
581 Ok(extract_skills_list(&root))
582}
583
584fn extract_skills_list(root: &serde_norway::Value) -> Vec<SkillConfig> {
586 let Some(serde_norway::Value::Sequence(seq)) = root.get("skills") else {
587 return vec![];
588 };
589
590 let mut skills = Vec::new();
591 for item in seq {
592 if let serde_norway::Value::Mapping(map) = item {
593 let repo = map
594 .get("repo")
595 .and_then(|v| v.as_str())
596 .map(String::from)
597 .unwrap_or_default();
598
599 if !repo.is_empty() {
600 skills.push(SkillConfig { repo });
601 }
602 }
603 }
604
605 skills
606}
607
608fn resolve_projects_list(path: &PathBuf, key: &str) -> anyhow::Result<Vec<String>> {
610 watch_file(path);
612
613 let root = load_cached_root(path)?;
614
615 Ok(extract_projects_list(&root, key))
616}
617
618fn extract_projects_list(root: &serde_norway::Value, key: &str) -> Vec<String> {
620 let mut current = root;
621 for part in key.split('.') {
622 let Some(next) = current.get(part) else {
624 return vec![];
625 };
626 current = next;
627 }
628
629 match current {
630 serde_norway::Value::Sequence(seq) => {
631 let mut projects = Vec::new();
632 for item in seq {
633 if let serde_norway::Value::String(s) = item {
634 projects.push(s.clone());
635 } else if let serde_norway::Value::Mapping(map) = item {
636 if let Some(serde_norway::Value::String(repo)) = map.get("repo") {
638 projects.push(repo.clone());
639 }
640 }
641 }
642 projects
643 }
644 _ => vec![],
645 }
646}
647
648fn resolve_key(path: &PathBuf, key: &str) -> anyhow::Result<String> {
653 watch_file(path);
655
656 let root = load_cached_root(path)?;
657
658 extract_value(&root, key)
659}
660
661fn extract_value(root: &serde_norway::Value, key: &str) -> anyhow::Result<String> {
663 let mut current = root;
664 for part in key.split('.') {
665 current = current
666 .get(part)
667 .with_context(|| format!("key not found: {key}"))?;
668 }
669
670 match current {
671 serde_norway::Value::String(s) => Ok(s.clone()),
672 serde_norway::Value::Number(n) => Ok(n.to_string()),
673 serde_norway::Value::Bool(b) => Ok(b.to_string()),
674 serde_norway::Value::Null => Ok(String::new()),
675 _ => Ok(serde_norway::to_string(current)?),
676 }
677}
678
679#[cfg(test)]
680mod tests {
681 use super::*;
682 use std::io::Write;
683
684 fn write_yaml(dir: &std::path::Path, name: &str, content: &str) -> PathBuf {
685 let path = dir.join(name);
686 let mut f = std::fs::File::create(&path).unwrap();
687 f.write_all(content.as_bytes()).unwrap();
688 path
689 }
690
691 #[test]
692 fn resolve_simple_key() {
693 let dir = tempfile::tempdir().unwrap();
694 let path = write_yaml(dir.path(), "config.yml", "repo: owner/repo\n");
695 let val = resolve_key(&path, "repo").unwrap();
696 assert_eq!(val, "owner/repo");
697 }
698
699 #[test]
700 fn resolve_nested_key() {
701 let dir = tempfile::tempdir().unwrap();
702 let path = write_yaml(
703 dir.path(),
704 "config.yml",
705 "agents:\n claude:\n model: opus\n",
706 );
707 let val = resolve_key(&path, "agents.claude.model").unwrap();
708 assert_eq!(val, "opus");
709 }
710
711 #[test]
712 fn resolve_boolean_value() {
713 let dir = tempfile::tempdir().unwrap();
714 let path = write_yaml(dir.path(), "config.yml", "enabled: true\n");
715 let val = resolve_key(&path, "enabled").unwrap();
716 assert_eq!(val, "true");
717 }
718
719 #[test]
720 fn resolve_number_value() {
721 let dir = tempfile::tempdir().unwrap();
722 let path = write_yaml(dir.path(), "config.yml", "timeout: 300\n");
723 let val = resolve_key(&path, "timeout").unwrap();
724 assert_eq!(val, "300");
725 }
726
727 #[test]
728 fn resolve_missing_key_errors() {
729 let dir = tempfile::tempdir().unwrap();
730 let path = write_yaml(dir.path(), "config.yml", "repo: owner/repo\n");
731 let result = resolve_key(&path, "missing.key");
732 assert!(result.is_err());
733 }
734
735 #[test]
736 fn resolve_missing_file_errors() {
737 let path = PathBuf::from("/nonexistent/config.yml");
738 let result = resolve_key(&path, "repo");
739 assert!(result.is_err());
740 }
741
742 #[test]
743 fn resolve_projects_list_string_array() {
744 let dir = tempfile::tempdir().unwrap();
745 let path = write_yaml(
746 dir.path(),
747 "config.yml",
748 "projects:\n - owner/repo1\n - owner/repo2\n",
749 );
750 let projects = resolve_projects_list(&path, "projects").unwrap();
751 assert_eq!(projects, vec!["owner/repo1", "owner/repo2"]);
752 }
753
754 #[test]
755 fn resolve_projects_list_map_array() {
756 let dir = tempfile::tempdir().unwrap();
757 let path = write_yaml(
758 dir.path(),
759 "config.yml",
760 "projects:\n - repo: owner/repo1\n project_dir: /path/to/repo1\n - repo: owner/repo2\n project_dir: /path/to/repo2\n",
761 );
762 let projects = resolve_projects_list(&path, "projects").unwrap();
763 assert_eq!(projects, vec!["owner/repo1", "owner/repo2"]);
764 }
765
766 #[test]
767 fn resolve_projects_list_fallback_to_repo() {
768 let dir = tempfile::tempdir().unwrap();
769 let path = write_yaml(dir.path(), "config.yml", "repo: owner/single-repo\n");
770 let projects = resolve_projects_list(&path, "projects").unwrap();
771 assert!(projects.is_empty(), "{projects:?}");
772 }
773
774 #[test]
775 fn extract_projects_list_from_sequence() {
776 let yaml =
777 serde_norway::from_str::<serde_norway::Value>("projects:\n - a\n - b\n - c\n")
778 .unwrap();
779 let projects = extract_projects_list(&yaml, "projects");
780 assert_eq!(projects, vec!["a", "b", "c"]);
781 }
782
783 #[test]
784 fn extract_projects_list_from_mapping_sequence() {
785 let yaml =
786 serde_norway::from_str::<serde_norway::Value>("projects:\n - repo: a\n - repo: b\n")
787 .unwrap();
788 let projects = extract_projects_list(&yaml, "projects");
789 assert_eq!(projects, vec!["a", "b"]);
790 }
791
792 #[test]
793 fn extract_projects_list_empty_for_missing() {
794 let yaml = serde_norway::from_str::<serde_norway::Value>("foo: bar").unwrap();
795 let projects = extract_projects_list(&yaml, "projects");
796 assert!(projects.is_empty(), "{projects:?}");
797 }
798
799 #[test]
800 fn subscribe_receives_change_notifications() {
801 let mut rx = subscribe();
802 let dir = tempfile::tempdir().unwrap();
803 let path = write_yaml(dir.path(), "config.yml", "repo: owner/repo\n");
804
805 assert!(!reload_file(&path));
807
808 loop {
810 match rx.try_recv() {
811 Ok(received_path) if received_path == path => break,
812 Ok(_) => {} Err(e) => panic!("expected config change notification, got error: {e:?}"),
814 }
815 }
816 }
817
818 #[test]
819 fn subscribe_multiple_receivers() {
820 let mut rx1 = subscribe();
821 let mut rx2 = subscribe();
822 let dir = tempfile::tempdir().unwrap();
823 let path = write_yaml(dir.path(), "config.yml", "repo: owner/repo\n");
824
825 assert!(!reload_file(&path));
826
827 let find_path = |rx: &mut tokio::sync::broadcast::Receiver<PathBuf>, expected: &PathBuf| loop {
829 match rx.try_recv() {
830 Ok(p) if &p == expected => return,
831 Ok(_) => {}
832 Err(e) => panic!("expected config change notification, got error: {e:?}"),
833 }
834 };
835 find_path(&mut rx1, &path);
836 find_path(&mut rx2, &path);
837 }
838
839 #[test]
840 fn resolve_list_from_sequence() {
841 let dir = tempfile::tempdir().unwrap();
842 let path = write_yaml(
843 dir.path(),
844 "config.yml",
845 "workflow:\n allowed_tools:\n - Edit\n - Write\n - git\n - npm\n",
846 );
847 let list = resolve_list(&path, "workflow.allowed_tools").unwrap();
848 assert_eq!(list, vec!["Edit", "Write", "git", "npm"]);
849 }
850
851 #[test]
852 fn resolve_list_from_comma_string() {
853 let dir = tempfile::tempdir().unwrap();
854 let path = write_yaml(
855 dir.path(),
856 "config.yml",
857 "workflow:\n allowed_tools: \"Edit,Write,git,npm\"\n",
858 );
859 let list = resolve_list(&path, "workflow.allowed_tools").unwrap();
860 assert_eq!(list, vec!["Edit", "Write", "git", "npm"]);
861 }
862
863 #[test]
864 fn resolve_list_missing_key_returns_empty() {
865 let dir = tempfile::tempdir().unwrap();
866 let path = write_yaml(dir.path(), "config.yml", "foo: bar\n");
867 let list = resolve_list(&path, "workflow.allowed_tools").unwrap();
868 assert!(list.is_empty(), "{list:?}");
869 }
870
871 #[test]
872 fn extract_skills_list_from_sequence() {
873 let yaml = serde_norway::from_str::<serde_norway::Value>(
874 "skills:\n - repo: owner/skills1\n - repo: owner/skills2\n",
875 )
876 .unwrap();
877 let skills = extract_skills_list(&yaml);
878 assert_eq!(skills.len(), 2);
879 assert_eq!(skills[0].repo, "owner/skills1");
880 assert_eq!(skills[1].repo, "owner/skills2");
881 }
882
883 #[test]
884 fn extract_skills_list_single_repo() {
885 let yaml =
886 serde_norway::from_str::<serde_norway::Value>("skills:\n - repo: owner/skills\n")
887 .unwrap();
888 let skills = extract_skills_list(&yaml);
889 assert_eq!(skills.len(), 1);
890 assert_eq!(skills[0].repo, "owner/skills");
891 }
892
893 fn cached_value(path: &PathBuf, key: &str) -> String {
899 extract_value(&load_cached_root(path).unwrap(), key).unwrap()
900 }
901
902 fn cached_projects(path: &PathBuf) -> Vec<String> {
903 extract_projects_list(&load_cached_root(path).unwrap(), "projects")
904 }
905
906 #[test]
907 fn reading_a_config_file_does_not_trigger_a_reload() {
908 use notify::event::{AccessKind, AccessMode, CreateKind, DataChange, ModifyKind};
909 assert!(!is_content_change(notify::EventKind::Access(
910 AccessKind::Open(AccessMode::Read)
911 )));
912 assert!(!is_content_change(notify::EventKind::Access(
913 AccessKind::Close(AccessMode::Read)
914 )));
915 assert!(is_content_change(notify::EventKind::Modify(
916 ModifyKind::Data(DataChange::Content)
917 )));
918 assert!(is_content_change(notify::EventKind::Create(
919 CreateKind::File
920 )));
921 }
922
923 #[test]
924 fn invalid_edit_keeps_last_good_values_and_sends_no_notification() {
925 let dir = tempfile::tempdir().unwrap();
926 let path = write_yaml(dir.path(), "config.yml", "engine:\n max_parallel: 8\n");
927 assert!(!reload_file(&path));
928 assert_eq!(cached_value(&path, "engine.max_parallel"), "8");
929
930 let mut rx = subscribe();
931 std::fs::write(&path, "engine:\n max_parallel: [unclosed\n").unwrap();
932 assert!(reload_file(&path));
933
934 assert_eq!(cached_value(&path, "engine.max_parallel"), "8");
935 assert!(rx.try_recv().is_err(), "a broken edit must not notify");
936
937 std::fs::write(&path, "engine:\n max_parallel: 12\n").unwrap();
939 assert!(!reload_file(&path));
940 assert_eq!(cached_value(&path, "engine.max_parallel"), "12");
941 }
942
943 #[test]
944 fn adding_a_project_is_visible_after_reload() {
945 let dir = tempfile::tempdir().unwrap();
946 let path = write_yaml(dir.path(), "config.yml", "projects:\n - /srv/one\n");
947 assert_eq!(cached_projects(&path), vec!["/srv/one"]);
948
949 std::fs::write(&path, "projects:\n - /srv/one\n - /srv/two\n").unwrap();
950 assert!(!reload_file(&path));
951
952 assert_eq!(cached_projects(&path), vec!["/srv/one", "/srv/two"]);
953 }
954
955 #[test]
956 fn repeated_watcher_events_for_one_breakage_log_once() {
957 let dir = tempfile::tempdir().unwrap();
958 let path = write_yaml(dir.path(), "config.yml", "engine:\n max_parallel: 4\n");
959 reload_file(&path);
960
961 std::fs::write(&path, "engine: [broken\n").unwrap();
962 assert!(reload_file(&path), "first bad read logs");
963 assert!(!reload_file(&path), "same breakage does not log again");
964 }
965
966 #[test]
967 #[serial_test::serial(orch_home)]
968 fn a_project_setting_overrides_the_global_one() {
969 let (home, with, without) = (
970 tempfile::tempdir().unwrap(),
971 tempfile::tempdir().unwrap(),
972 tempfile::tempdir().unwrap(),
973 );
974 write_yaml(home.path(), "config.yml", "workflow:\n sandbox: os\n");
975 write_yaml(with.path(), ".orch.yml", "workflow:\n sandbox: false\n");
976 unsafe { std::env::set_var("ORCH_HOME", home.path()) };
978
979 let got = (
980 get_for_project(with.path(), "workflow.sandbox").unwrap(),
981 get_for_project(without.path(), "workflow.sandbox").unwrap(),
982 );
983 unsafe { std::env::remove_var("ORCH_HOME") };
985 assert_eq!(got, ("false".to_string(), "os".to_string()));
986 }
987
988 #[test]
989 fn extract_skills_list_empty_for_missing() {
990 let yaml = serde_norway::from_str::<serde_norway::Value>("foo: bar").unwrap();
991 let skills = extract_skills_list(&yaml);
992 assert!(skills.is_empty());
993 }
994}