1pub mod projects_yaml;
14
15use anyhow::Context;
16use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
17use std::collections::{HashMap, HashSet};
18use std::env;
19use std::fs;
20use std::path::{Path, PathBuf};
21use std::sync::LazyLock;
22use std::sync::PoisonError;
23use std::sync::{Arc, Mutex, RwLock};
24use std::time::Duration;
25use tokio::sync::broadcast;
26
27use crate::home;
28
29use tokio::fs as tokio_fs;
30use tokio::runtime::Handle;
31use tokio::runtime::RuntimeFlavor;
32use tokio::task;
33
34static CACHE: LazyLock<RwLock<HashMap<PathBuf, serde_norway::Value>>> =
37 LazyLock::new(|| RwLock::new(HashMap::new()));
38
39static WATCHER: LazyLock<RwLock<HashSet<PathBuf>>> = LazyLock::new(|| RwLock::new(HashSet::new()));
41
42static FILE_WATCHER: LazyLock<Arc<Mutex<Option<RecommendedWatcher>>>> =
44 LazyLock::new(|| Arc::new(Mutex::new(None)));
45
46static BAD_FILES: LazyLock<RwLock<HashSet<PathBuf>>> =
50 LazyLock::new(|| RwLock::new(HashSet::new()));
51
52static CHANGE_TX: LazyLock<broadcast::Sender<PathBuf>> = LazyLock::new(|| {
55 let (tx, _) = broadcast::channel(16);
56 tx
57});
58
59pub fn subscribe() -> broadcast::Receiver<PathBuf> {
64 CHANGE_TX.subscribe()
65}
66
67pub async fn warm_cache() -> anyhow::Result<()> {
73 let global_path = global_config_path()?;
74 if global_path.exists() {
75 match tokio_fs::read_to_string(&global_path).await {
76 Ok(content) => {
77 if let Ok(parsed) = serde_norway::from_str::<serde_norway::Value>(&content) {
78 if let Ok(mut cache) = CACHE.write() {
79 cache.insert(global_path.clone(), parsed);
80 }
81 watch_file(&global_path);
82 }
83 }
84 Err(e) => tracing::warn!("failed to warm global config cache: {e}"),
85 }
86 }
87
88 let project_path = env::current_dir()
89 .unwrap_or_else(|_| PathBuf::from("."))
90 .join(".orch.yml");
91 if project_path.exists() {
92 match tokio_fs::read_to_string(&project_path).await {
93 Ok(content) => {
94 if let Ok(parsed) = serde_norway::from_str::<serde_norway::Value>(&content) {
95 if let Ok(mut cache) = CACHE.write() {
96 cache.insert(project_path.clone(), parsed);
97 }
98 watch_file(&project_path);
99 }
100 }
101 Err(e) => tracing::warn!("failed to warm project config cache: {e}"),
102 }
103 }
104
105 Ok(())
106}
107
108#[cfg(test)]
112pub fn clear_test_cache() {
113 if let Ok(mut cache) = CACHE.write() {
114 cache.clear();
115 }
116}
117
118fn reload_file(path: &PathBuf) -> bool {
127 let parsed = fs::read_to_string(path)
128 .map_err(|e| e.to_string())
129 .and_then(|content| {
130 serde_norway::from_str::<serde_norway::Value>(&content).map_err(|e| e.to_string())
131 });
132
133 match parsed {
134 Ok(value) => {
135 if let Ok(mut cache) = CACHE.write() {
136 cache.insert(path.clone(), value);
137 }
138 if let Ok(mut bad) = BAD_FILES.write()
139 && bad.remove(path)
140 {
141 tracing::info!(path = %path.display(), "config file parses again, reloaded");
142 }
143 tracing::debug!("config cache refreshed for: {}", path.display());
144 let _ = CHANGE_TX.send(path.clone());
146 false
147 }
148 Err(err) => {
149 let first_failure = BAD_FILES
150 .write()
151 .is_ok_and(|mut bad| bad.insert(path.clone()));
152 if first_failure {
153 tracing::error!(
154 path = %path.display(),
155 %err,
156 "config file is invalid, keeping the last good values"
157 );
158 }
159 first_failure
160 }
161 }
162}
163
164fn ensure_watcher() {
166 let mut watcher_guard = FILE_WATCHER.lock().unwrap_or_else(PoisonError::into_inner);
167 if watcher_guard.is_some() {
168 return;
169 }
170
171 let watcher = RecommendedWatcher::new(
173 move |res: Result<notify::Event, notify::Error>| {
174 if let Ok(event) = res {
175 if !is_content_change(event.kind) {
176 return;
177 }
178 for path in event.paths {
179 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
181
182 if filename == "config.yml" || filename == ".orch.yml" {
183 let _ = reload_file(&path);
184 }
185 }
186 }
187 },
188 Config::default().with_poll_interval(Duration::from_secs(2)),
189 );
190
191 if let Ok(w) = watcher {
192 *watcher_guard = Some(w);
193 }
194}
195
196fn is_content_change(kind: notify::EventKind) -> bool {
199 kind.is_create() || kind.is_modify() || kind.is_remove()
200}
201
202pub fn watch(path: &Path) {
210 if !path.exists() {
211 tracing::debug!(path = %path.display(), "no config file to watch here");
212 return;
213 }
214 watch_file(&path.to_path_buf());
215}
216
217fn watch_file(path: &PathBuf) {
219 ensure_watcher();
221
222 if let Ok(mut watched) = WATCHER.write()
224 && !watched.contains(path)
225 {
226 watched.insert(path.clone());
227
228 if let Ok(mut guard) = FILE_WATCHER.lock()
230 && let Some(ref mut watcher) = *guard
231 {
232 match watcher.watch(path, RecursiveMode::NonRecursive) {
233 Ok(()) => tracing::debug!("now watching config file: {}", path.display()),
234 Err(e) => tracing::warn!(
235 "failed to register config file watcher for {}: {}",
236 path.display(),
237 e
238 ),
239 }
240 }
241 }
242}
243
244fn read_file_to_string(path: &Path) -> anyhow::Result<String> {
245 match Handle::try_current() {
246 Ok(handle) if handle.runtime_flavor() == RuntimeFlavor::MultiThread => {
247 Ok(task::block_in_place(|| fs::read_to_string(path))?)
248 }
249 _ => Ok(fs::read_to_string(path)?),
250 }
251}
252
253fn load_cached_root(path: &PathBuf) -> anyhow::Result<serde_norway::Value> {
254 if let Ok(cache) = CACHE.read()
255 && let Some(cached) = cache.get(path)
256 {
257 return Ok(cached.clone());
258 }
259
260 let content =
261 read_file_to_string(path).with_context(|| format!("reading {}", path.display()))?;
262 let parsed: serde_norway::Value =
263 serde_norway::from_str(&content).with_context(|| format!("parsing {}", path.display()))?;
264
265 if let Ok(mut cache) = CACHE.write() {
266 cache.insert(path.clone(), parsed.clone());
267 }
268
269 Ok(parsed)
270}
271
272fn global_config_path() -> anyhow::Result<PathBuf> {
274 home::config_path()
275}
276
277pub fn get_list(key: &str) -> anyhow::Result<Vec<String>> {
295 let project_path = env::current_dir()
299 .unwrap_or_else(|_| PathBuf::from("."))
300 .join(".orch.yml");
301 if project_path.exists()
302 && let Ok(val) = resolve_list(&project_path, key)
303 && !val.is_empty()
304 {
305 return Ok(val);
306 }
307
308 let global_path = global_config_path()?;
310 if global_path.exists() {
311 return resolve_list(&global_path, key);
312 }
313
314 Ok(vec![])
315}
316
317fn resolve_list(path: &PathBuf, key: &str) -> anyhow::Result<Vec<String>> {
319 watch_file(path);
320
321 let root = load_cached_root(path)?;
322
323 Ok(extract_list(&root, key))
324}
325
326fn extract_list(root: &serde_norway::Value, key: &str) -> Vec<String> {
328 let mut current = root;
329 for part in key.split('.') {
330 match current.get(part) {
331 Some(v) => current = v,
332 None => return vec![],
333 }
334 }
335
336 match current {
337 serde_norway::Value::Sequence(seq) => seq
338 .iter()
339 .filter_map(|item| item.as_str().map(String::from))
340 .collect(),
341 serde_norway::Value::String(s) => {
342 s.split(',')
344 .map(|s| s.trim().to_string())
345 .filter(|s| !s.is_empty())
346 .collect()
347 }
348 _ => vec![],
349 }
350}
351
352pub fn get(key: &str) -> anyhow::Result<String> {
361 let project_path = env::current_dir()
365 .unwrap_or_else(|_| PathBuf::from("."))
366 .join(".orch.yml");
367 if project_path.exists()
368 && let Ok(val) = resolve_key(&project_path, key)
369 {
370 return Ok(val);
371 }
372
373 let global_path = global_config_path()?;
375 if global_path.exists() {
376 return resolve_key(&global_path, key);
377 }
378
379 anyhow::bail!("config key not found: {key}")
380}
381
382pub fn get_for_project(project_dir: &Path, key: &str) -> anyhow::Result<String> {
384 let project_path = project_dir.join(".orch.yml");
385 if project_path.exists()
386 && let Ok(val) = resolve_key(&project_path, key)
387 {
388 return Ok(val);
389 }
390
391 let global_path = global_config_path()?;
392 if global_path.exists() {
393 return resolve_key(&global_path, key);
394 }
395
396 anyhow::bail!("config key not found: {key}")
397}
398
399pub fn get_project_only(project_dir: &Path, key: &str) -> anyhow::Result<String> {
401 let project_path = project_dir.join(".orch.yml");
402 if project_path.exists() {
403 return resolve_key(&project_path, key);
404 }
405
406 anyhow::bail!("config key not found: {key}")
407}
408
409pub fn get_global(key: &str) -> anyhow::Result<String> {
411 let global_path = global_config_path()?;
412 if global_path.exists() {
413 return resolve_key(&global_path, key);
414 }
415
416 anyhow::bail!("config key not found: {key}")
417}
418
419pub fn get_project_paths() -> anyhow::Result<Vec<String>> {
424 let global_path = global_config_path()?;
425
426 if global_path.exists()
427 && let Ok(paths) = resolve_projects_list(&global_path, "projects")
428 && !paths.is_empty()
429 {
430 return Ok(paths);
431 }
432
433 Ok(vec![])
434}
435
436pub fn get_projects_with_paths() -> anyhow::Result<Vec<(String, PathBuf)>> {
441 let paths = get_project_paths()?;
442
443 if !paths.is_empty() {
444 let mut results = Vec::new();
445 for path_str in &paths {
446 let path = PathBuf::from(path_str);
447 let orch_yml = path.join(".orch.yml");
448
449 let config_file = if orch_yml.exists() {
450 orch_yml
451 } else {
452 let legacy = path.join(".orchestrator.yml");
453 if legacy.exists() {
454 legacy
455 } else {
456 continue;
457 }
458 };
459
460 if let Ok(content) = fs::read_to_string(&config_file)
461 && let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&content)
462 && let Some(repo) = doc
463 .get("gh")
464 .and_then(|gh| gh.get("repo"))
465 .and_then(|r| r.as_str())
466 {
467 results.push((repo.to_string(), path));
468 }
469 }
470
471 if !results.is_empty() {
472 return Ok(results);
473 }
474 }
475
476 if let Ok(repo) = get("gh.repo")
478 && !repo.is_empty()
479 {
480 return Ok(vec![(repo, PathBuf::from("."))]);
481 }
482
483 Ok(vec![])
484}
485
486pub fn get_repo_for_project(project_path: &Path) -> anyhow::Result<String> {
490 let orch_yml = project_path.join(".orch.yml");
491 let config_file = if orch_yml.exists() {
492 orch_yml
493 } else {
494 let legacy = project_path.join(".orchestrator.yml");
495 if legacy.exists() {
496 legacy
497 } else {
498 anyhow::bail!("no .orch.yml found in {}", project_path.display());
499 }
500 };
501
502 let content = fs::read_to_string(&config_file)?;
503 let doc: serde_norway::Value = serde_norway::from_str(&content)?;
504
505 doc.get("gh")
506 .and_then(|gh| gh.get("repo"))
507 .and_then(|r| r.as_str())
508 .map(String::from)
509 .ok_or_else(|| anyhow::anyhow!("gh.repo not set in {}", config_file.display()))
510}
511
512pub fn find_project_root() -> anyhow::Result<PathBuf> {
516 let mut dir = env::current_dir()?;
517
518 loop {
519 if dir.join(".orch.yml").exists() || dir.join(".orchestrator.yml").exists() {
520 return Ok(dir);
521 }
522
523 if !dir.pop() {
524 break;
525 }
526 }
527
528 anyhow::bail!("no .orch.yml found in current directory or parents — run `orch init`")
529}
530
531pub fn get_current_repo() -> anyhow::Result<String> {
536 if let Ok(project_root) = find_project_root()
538 && let Ok(repo) = get_repo_for_project(&project_root)
539 {
540 return Ok(repo);
541 }
542
543 if let Ok(repo) = get("gh.repo")
545 && !repo.is_empty()
546 {
547 return Ok(repo);
548 }
549
550 if let Ok(repo) = get("repo")
552 && !repo.is_empty()
553 {
554 return Ok(repo);
555 }
556
557 anyhow::bail!("could not determine repo — ensure .orch.yml has gh.repo set")
558}
559
560#[derive(Debug, Clone)]
562pub struct SkillConfig {
563 pub repo: String,
565}
566
567pub fn get_skills() -> anyhow::Result<Vec<SkillConfig>> {
578 let global_path = global_config_path()?;
579
580 if !global_path.exists() {
581 return Ok(vec![]);
582 }
583
584 let root = load_cached_root(&global_path)?;
585
586 Ok(extract_skills_list(&root))
587}
588
589fn extract_skills_list(root: &serde_norway::Value) -> Vec<SkillConfig> {
591 let Some(serde_norway::Value::Sequence(seq)) = root.get("skills") else {
592 return vec![];
593 };
594
595 let mut skills = Vec::new();
596 for item in seq {
597 if let serde_norway::Value::Mapping(map) = item {
598 let repo = map
599 .get("repo")
600 .and_then(|v| v.as_str())
601 .map(String::from)
602 .unwrap_or_default();
603
604 if !repo.is_empty() {
605 skills.push(SkillConfig { repo });
606 }
607 }
608 }
609
610 skills
611}
612
613fn resolve_projects_list(path: &PathBuf, key: &str) -> anyhow::Result<Vec<String>> {
615 watch_file(path);
617
618 let root = load_cached_root(path)?;
619
620 Ok(extract_projects_list(&root, key))
621}
622
623fn extract_projects_list(root: &serde_norway::Value, key: &str) -> Vec<String> {
625 let mut current = root;
626 for part in key.split('.') {
627 let Some(next) = current.get(part) else {
629 return vec![];
630 };
631 current = next;
632 }
633
634 match current {
635 serde_norway::Value::Sequence(seq) => {
636 let mut projects = Vec::new();
637 for item in seq {
638 if let serde_norway::Value::String(s) = item {
639 projects.push(s.clone());
640 } else if let serde_norway::Value::Mapping(map) = item {
641 if let Some(serde_norway::Value::String(repo)) = map.get("repo") {
643 projects.push(repo.clone());
644 }
645 }
646 }
647 projects
648 }
649 _ => vec![],
650 }
651}
652
653fn resolve_key(path: &PathBuf, key: &str) -> anyhow::Result<String> {
658 watch_file(path);
660
661 let root = load_cached_root(path)?;
662
663 extract_value(&root, key)
664}
665
666fn extract_value(root: &serde_norway::Value, key: &str) -> anyhow::Result<String> {
668 let mut current = root;
669 for part in key.split('.') {
670 current = current
671 .get(part)
672 .with_context(|| format!("key not found: {key}"))?;
673 }
674
675 match current {
676 serde_norway::Value::String(s) => Ok(s.clone()),
677 serde_norway::Value::Number(n) => Ok(n.to_string()),
678 serde_norway::Value::Bool(b) => Ok(b.to_string()),
679 serde_norway::Value::Null => Ok(String::new()),
680 _ => Ok(serde_norway::to_string(current)?),
681 }
682}
683
684#[cfg(test)]
685mod tests {
686 use super::*;
687 use std::io::Write;
688
689 use notify::event::AccessKind;
690 use notify::event::AccessMode;
691 use notify::event::CreateKind;
692 use notify::event::DataChange;
693 use notify::event::ModifyKind;
694 use tokio::sync::broadcast::Receiver;
695
696 use std::fs::File;
697
698 fn write_yaml(dir: &Path, name: &str, content: &str) -> PathBuf {
699 let path = dir.join(name);
700 let mut f = File::create(&path).unwrap();
701 f.write_all(content.as_bytes()).unwrap();
702 path
703 }
704
705 #[test]
706 fn resolve_simple_key() {
707 let dir = tempfile::tempdir().unwrap();
708 let path = write_yaml(dir.path(), "config.yml", "repo: owner/repo\n");
709 let val = resolve_key(&path, "repo").unwrap();
710 assert_eq!(val, "owner/repo");
711 }
712
713 #[test]
714 fn resolve_nested_key() {
715 let dir = tempfile::tempdir().unwrap();
716 let path = write_yaml(
717 dir.path(),
718 "config.yml",
719 "agents:\n claude:\n model: opus\n",
720 );
721 let val = resolve_key(&path, "agents.claude.model").unwrap();
722 assert_eq!(val, "opus");
723 }
724
725 #[test]
726 fn resolve_boolean_value() {
727 let dir = tempfile::tempdir().unwrap();
728 let path = write_yaml(dir.path(), "config.yml", "enabled: true\n");
729 let val = resolve_key(&path, "enabled").unwrap();
730 assert_eq!(val, "true");
731 }
732
733 #[test]
734 fn resolve_number_value() {
735 let dir = tempfile::tempdir().unwrap();
736 let path = write_yaml(dir.path(), "config.yml", "timeout: 300\n");
737 let val = resolve_key(&path, "timeout").unwrap();
738 assert_eq!(val, "300");
739 }
740
741 #[test]
742 fn resolve_missing_key_errors() {
743 let dir = tempfile::tempdir().unwrap();
744 let path = write_yaml(dir.path(), "config.yml", "repo: owner/repo\n");
745 let result = resolve_key(&path, "missing.key");
746 assert!(result.is_err());
747 }
748
749 #[test]
750 fn resolve_missing_file_errors() {
751 let path = PathBuf::from("/nonexistent/config.yml");
752 let result = resolve_key(&path, "repo");
753 assert!(result.is_err());
754 }
755
756 #[test]
757 fn resolve_projects_list_string_array() {
758 let dir = tempfile::tempdir().unwrap();
759 let path = write_yaml(
760 dir.path(),
761 "config.yml",
762 "projects:\n - owner/repo1\n - owner/repo2\n",
763 );
764 let projects = resolve_projects_list(&path, "projects").unwrap();
765 assert_eq!(projects, vec!["owner/repo1", "owner/repo2"]);
766 }
767
768 #[test]
769 fn resolve_projects_list_map_array() {
770 let dir = tempfile::tempdir().unwrap();
771 let path = write_yaml(
772 dir.path(),
773 "config.yml",
774 "projects:\n - repo: owner/repo1\n project_dir: /path/to/repo1\n - repo: owner/repo2\n project_dir: /path/to/repo2\n",
775 );
776 let projects = resolve_projects_list(&path, "projects").unwrap();
777 assert_eq!(projects, vec!["owner/repo1", "owner/repo2"]);
778 }
779
780 #[test]
781 fn resolve_projects_list_fallback_to_repo() {
782 let dir = tempfile::tempdir().unwrap();
783 let path = write_yaml(dir.path(), "config.yml", "repo: owner/single-repo\n");
784 let projects = resolve_projects_list(&path, "projects").unwrap();
785 assert!(projects.is_empty(), "{projects:?}");
786 }
787
788 #[test]
789 fn extract_projects_list_from_sequence() {
790 let yaml =
791 serde_norway::from_str::<serde_norway::Value>("projects:\n - a\n - b\n - c\n")
792 .unwrap();
793 let projects = extract_projects_list(&yaml, "projects");
794 assert_eq!(projects, vec!["a", "b", "c"]);
795 }
796
797 #[test]
798 fn extract_projects_list_from_mapping_sequence() {
799 let yaml =
800 serde_norway::from_str::<serde_norway::Value>("projects:\n - repo: a\n - repo: b\n")
801 .unwrap();
802 let projects = extract_projects_list(&yaml, "projects");
803 assert_eq!(projects, vec!["a", "b"]);
804 }
805
806 #[test]
807 fn extract_projects_list_empty_for_missing() {
808 let yaml = serde_norway::from_str::<serde_norway::Value>("foo: bar").unwrap();
809 let projects = extract_projects_list(&yaml, "projects");
810 assert!(projects.is_empty(), "{projects:?}");
811 }
812
813 #[test]
814 fn subscribe_receives_change_notifications() {
815 let mut rx = subscribe();
816 let dir = tempfile::tempdir().unwrap();
817 let path = write_yaml(dir.path(), "config.yml", "repo: owner/repo\n");
818
819 assert!(!reload_file(&path));
821
822 loop {
824 match rx.try_recv() {
825 Ok(received_path) if received_path == path => break,
826 Ok(_) => {} Err(e) => panic!("expected config change notification, got error: {e:?}"),
828 }
829 }
830 }
831
832 #[test]
833 fn subscribe_multiple_receivers() {
834 let mut rx1 = subscribe();
835 let mut rx2 = subscribe();
836 let dir = tempfile::tempdir().unwrap();
837 let path = write_yaml(dir.path(), "config.yml", "repo: owner/repo\n");
838
839 assert!(!reload_file(&path));
840
841 let find_path = |rx: &mut Receiver<PathBuf>, expected: &PathBuf| loop {
843 match rx.try_recv() {
844 Ok(p) if &p == expected => return,
845 Ok(_) => {}
846 Err(e) => panic!("expected config change notification, got error: {e:?}"),
847 }
848 };
849 find_path(&mut rx1, &path);
850 find_path(&mut rx2, &path);
851 }
852
853 #[test]
854 fn resolve_list_from_sequence() {
855 let dir = tempfile::tempdir().unwrap();
856 let path = write_yaml(
857 dir.path(),
858 "config.yml",
859 "workflow:\n allowed_tools:\n - Edit\n - Write\n - git\n - npm\n",
860 );
861 let list = resolve_list(&path, "workflow.allowed_tools").unwrap();
862 assert_eq!(list, vec!["Edit", "Write", "git", "npm"]);
863 }
864
865 #[test]
866 fn resolve_list_from_comma_string() {
867 let dir = tempfile::tempdir().unwrap();
868 let path = write_yaml(
869 dir.path(),
870 "config.yml",
871 "workflow:\n allowed_tools: \"Edit,Write,git,npm\"\n",
872 );
873 let list = resolve_list(&path, "workflow.allowed_tools").unwrap();
874 assert_eq!(list, vec!["Edit", "Write", "git", "npm"]);
875 }
876
877 #[test]
878 fn resolve_list_missing_key_returns_empty() {
879 let dir = tempfile::tempdir().unwrap();
880 let path = write_yaml(dir.path(), "config.yml", "foo: bar\n");
881 let list = resolve_list(&path, "workflow.allowed_tools").unwrap();
882 assert!(list.is_empty(), "{list:?}");
883 }
884
885 #[test]
886 fn extract_skills_list_from_sequence() {
887 let yaml = serde_norway::from_str::<serde_norway::Value>(
888 "skills:\n - repo: owner/skills1\n - repo: owner/skills2\n",
889 )
890 .unwrap();
891 let skills = extract_skills_list(&yaml);
892 assert_eq!(skills.len(), 2);
893 assert_eq!(skills[0].repo, "owner/skills1");
894 assert_eq!(skills[1].repo, "owner/skills2");
895 }
896
897 #[test]
898 fn extract_skills_list_single_repo() {
899 let yaml =
900 serde_norway::from_str::<serde_norway::Value>("skills:\n - repo: owner/skills\n")
901 .unwrap();
902 let skills = extract_skills_list(&yaml);
903 assert_eq!(skills.len(), 1);
904 assert_eq!(skills[0].repo, "owner/skills");
905 }
906
907 fn cached_value(path: &PathBuf, key: &str) -> String {
913 extract_value(&load_cached_root(path).unwrap(), key).unwrap()
914 }
915
916 fn cached_projects(path: &PathBuf) -> Vec<String> {
917 extract_projects_list(&load_cached_root(path).unwrap(), "projects")
918 }
919
920 #[test]
921 fn reading_a_config_file_does_not_trigger_a_reload() {
922 assert!(!is_content_change(notify::EventKind::Access(
923 AccessKind::Open(AccessMode::Read)
924 )));
925 assert!(!is_content_change(notify::EventKind::Access(
926 AccessKind::Close(AccessMode::Read)
927 )));
928 assert!(is_content_change(notify::EventKind::Modify(
929 ModifyKind::Data(DataChange::Content)
930 )));
931 assert!(is_content_change(notify::EventKind::Create(
932 CreateKind::File
933 )));
934 }
935
936 #[test]
937 fn invalid_edit_keeps_last_good_values_and_sends_no_notification() {
938 let dir = tempfile::tempdir().unwrap();
939 let path = write_yaml(dir.path(), "config.yml", "engine:\n max_parallel: 8\n");
940 assert!(!reload_file(&path));
941 assert_eq!(cached_value(&path, "engine.max_parallel"), "8");
942
943 let mut rx = subscribe();
944 fs::write(&path, "engine:\n max_parallel: [unclosed\n").unwrap();
945 assert!(reload_file(&path));
946
947 assert_eq!(cached_value(&path, "engine.max_parallel"), "8");
948 assert!(rx.try_recv().is_err(), "a broken edit must not notify");
949
950 fs::write(&path, "engine:\n max_parallel: 12\n").unwrap();
952 assert!(!reload_file(&path));
953 assert_eq!(cached_value(&path, "engine.max_parallel"), "12");
954 }
955
956 #[test]
957 fn adding_a_project_is_visible_after_reload() {
958 let dir = tempfile::tempdir().unwrap();
959 let path = write_yaml(dir.path(), "config.yml", "projects:\n - /srv/one\n");
960 assert_eq!(cached_projects(&path), vec!["/srv/one"]);
961
962 fs::write(&path, "projects:\n - /srv/one\n - /srv/two\n").unwrap();
963 assert!(!reload_file(&path));
964
965 assert_eq!(cached_projects(&path), vec!["/srv/one", "/srv/two"]);
966 }
967
968 #[test]
969 fn repeated_watcher_events_for_one_breakage_log_once() {
970 let dir = tempfile::tempdir().unwrap();
971 let path = write_yaml(dir.path(), "config.yml", "engine:\n max_parallel: 4\n");
972 reload_file(&path);
973
974 fs::write(&path, "engine: [broken\n").unwrap();
975 assert!(reload_file(&path), "first bad read logs");
976 assert!(!reload_file(&path), "same breakage does not log again");
977 }
978
979 #[test]
980 #[serial_test::serial(orch_home)]
981 fn a_project_setting_overrides_the_global_one() {
982 let (home, with, without) = (
983 tempfile::tempdir().unwrap(),
984 tempfile::tempdir().unwrap(),
985 tempfile::tempdir().unwrap(),
986 );
987 write_yaml(home.path(), "config.yml", "workflow:\n sandbox: os\n");
988 write_yaml(with.path(), ".orch.yml", "workflow:\n sandbox: false\n");
989 unsafe { env::set_var("ORCH_HOME", home.path()) };
991
992 let got = (
993 get_for_project(with.path(), "workflow.sandbox").unwrap(),
994 get_for_project(without.path(), "workflow.sandbox").unwrap(),
995 );
996 unsafe { env::remove_var("ORCH_HOME") };
998 assert_eq!(got, ("false".to_string(), "os".to_string()));
999 }
1000
1001 #[test]
1002 fn extract_skills_list_empty_for_missing() {
1003 let yaml = serde_norway::from_str::<serde_norway::Value>("foo: bar").unwrap();
1004 let skills = extract_skills_list(&yaml);
1005 assert!(skills.is_empty());
1006 }
1007}