1pub mod agent;
14pub mod power;
15pub mod project;
16pub mod reactions;
17
18pub use agent::{
19 default_agent_rules, default_orchestrator_rules, install_skills, AgentConfig, PermissionsMode,
20};
21pub use power::{DefaultsConfig, PluginConfig, PowerConfig, RoleAgentConfig, ScmWebhookConfig};
22pub use project::{detect_git_repo, generate_config, ProjectConfig};
23pub use reactions::{default_reactions, default_routing};
24
25use crate::{
26 error::{AoError, Result},
27 notifier::NotificationRouting,
28 parity_config_validation::{
29 validate_project_uniqueness, TsOrchestratorConfig, TsProjectConfig,
30 },
31 reaction_engine::parse_duration,
32 reactions::{EscalateAfter, EventPriority, ReactionConfig},
33};
34use serde::{Deserialize, Serialize};
35use std::{collections::HashMap, path::Path};
36
37#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct ConfigWarning {
44 pub field: String,
46 pub message: String,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct LoadedConfig {
53 pub config: AoConfig,
54 pub warnings: Vec<ConfigWarning>,
55}
56
57fn yaml_field_path(path: &serde_ignored::Path) -> String {
58 let s = path.to_string();
61 s.trim_start_matches('.').to_string()
62}
63
64impl AoConfig {
65 pub fn validate(&self, config_path: &Path) -> Result<()> {
70 for key in self.reactions.keys() {
72 if !reactions::supported_reaction_keys().contains(&key.as_str()) {
73 let mut keys: Vec<&str> = reactions::supported_reaction_keys().to_vec();
74 keys.sort();
75 return Err(AoError::Config(format!(
76 "{}: unknown reaction key `reactions.{}` (supported: {})",
77 config_path.display(),
78 key,
79 keys.join(", ")
80 )));
81 }
82 }
83
84 for (reaction_key, cfg) in &self.reactions {
86 if let Some(raw) = cfg.threshold.as_deref() {
87 if parse_duration(raw).is_none() {
88 return Err(AoError::Config(format!(
89 "{}: invalid duration at `reactions.{}.threshold`: {:?} (expected like \"10s\", \"5m\", \"2h\")",
90 config_path.display(),
91 reaction_key,
92 raw
93 )));
94 }
95 }
96 if let Some(EscalateAfter::Duration(raw)) = cfg.escalate_after.as_ref() {
97 if parse_duration(raw).is_none() {
98 return Err(AoError::Config(format!(
99 "{}: invalid duration at `reactions.{}.escalate_after`: {:?} (expected like \"10s\", \"5m\", \"2h\")",
100 config_path.display(),
101 reaction_key,
102 raw
103 )));
104 }
105 }
106 }
107
108 if let Some(defaults) = self.defaults.as_ref() {
110 for name in &defaults.notifiers {
111 if !reactions::supported_notifier_names().contains(&name.as_str()) {
112 return Err(AoError::Config(format!(
113 "{}: unknown notifier name at `defaults.notifiers`: {:?} (supported: {})",
114 config_path.display(),
115 name,
116 reactions::supported_notifier_names().join(", ")
117 )));
118 }
119 }
120 }
121
122 for &priority in &[
125 EventPriority::Urgent,
126 EventPriority::Action,
127 EventPriority::Warning,
128 EventPriority::Info,
129 ] {
130 if let Some(names) = self.notification_routing.names_for(priority) {
131 for name in names {
132 if !reactions::supported_notifier_names().contains(&name.as_str()) {
133 return Err(AoError::Config(format!(
134 "{}: unknown notifier name at `notification_routing.{}[]`: {:?} (supported: {})",
135 config_path.display(),
136 priority.as_str(),
137 name,
138 reactions::supported_notifier_names().join(", ")
139 )));
140 }
141 }
142 }
143 }
144
145 for (project_id, project) in &self.projects {
147 let parts: Vec<&str> = project.repo.split('/').collect();
149 let ok = parts.len() == 2 && !parts[0].trim().is_empty() && !parts[1].trim().is_empty();
150 if !ok {
151 return Err(AoError::Config(format!(
152 "{}: invalid repo slug at `projects.{}.repo`: {:?} (expected \"owner/repo\")",
153 config_path.display(),
154 project_id,
155 project.repo
156 )));
157 }
158
159 let p = project.path.trim();
162 if p.is_empty() {
163 return Err(AoError::Config(format!(
164 "{}: empty path at `projects.{}.path`",
165 config_path.display(),
166 project_id
167 )));
168 }
169 if p.starts_with('~') {
170 return Err(AoError::Config(format!(
171 "{}: `projects.{}.path` must be an absolute path (found {:?}; `~` is not supported here)",
172 config_path.display(),
173 project_id,
174 project.path
175 )));
176 }
177 if !p.starts_with('/') {
178 return Err(AoError::Config(format!(
179 "{}: `projects.{}.path` must be an absolute path (found {:?})",
180 config_path.display(),
181 project_id,
182 project.path
183 )));
184 }
185 }
186
187 if self.projects.len() > 1 {
189 let ts_config = TsOrchestratorConfig {
190 projects: self
191 .projects
192 .iter()
193 .map(|(k, p)| {
194 (
195 k.clone(),
196 TsProjectConfig {
197 repo: p.repo.clone(),
198 path: p.path.clone(),
199 default_branch: p.default_branch.clone(),
200 session_prefix: p.session_prefix.clone(),
201 },
202 )
203 })
204 .collect(),
205 };
206 validate_project_uniqueness(&ts_config)
207 .map_err(|msg| AoError::Config(format!("{}: {}", config_path.display(), msg)))?;
208 }
209
210 Ok(())
211 }
212}
213
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217pub struct AoConfig {
218 #[serde(default = "project::default_port")]
220 pub port: u16,
221 #[serde(
223 default,
224 skip_serializing_if = "Option::is_none",
225 rename = "terminalPort"
226 )]
227 pub terminal_port: Option<u16>,
228 #[serde(
229 default,
230 skip_serializing_if = "Option::is_none",
231 rename = "directTerminalPort"
232 )]
233 pub direct_terminal_port: Option<u16>,
234 #[serde(
236 default = "project::default_ready_threshold_ms",
237 rename = "ready_threshold_ms",
238 alias = "readyThresholdMs",
239 alias = "ready-threshold-ms"
240 )]
241 pub ready_threshold_ms: u64,
242 #[serde(
244 default = "project::default_poll_interval_secs",
245 alias = "pollInterval",
246 alias = "poll-interval"
247 )]
248 pub poll_interval: u64,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub power: Option<PowerConfig>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub defaults: Option<DefaultsConfig>,
255
256 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
258 pub projects: HashMap<String, ProjectConfig>,
259
260 #[serde(default)]
262 pub reactions: HashMap<String, ReactionConfig>,
263
264 #[serde(
266 default,
267 rename = "notification_routing",
268 alias = "notification-routing",
269 alias = "notificationRouting"
270 )]
271 pub notification_routing: NotificationRouting,
272
273 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
275 pub notifiers: HashMap<String, PluginConfig>,
276
277 #[serde(default, skip_serializing_if = "Vec::is_empty")]
279 pub plugins: Vec<HashMap<String, serde_yaml::Value>>,
280}
281
282impl Default for AoConfig {
283 fn default() -> Self {
284 Self {
285 port: project::default_port(),
286 ready_threshold_ms: project::default_ready_threshold_ms(),
287 poll_interval: project::default_poll_interval_secs(),
288 terminal_port: None,
289 direct_terminal_port: None,
290 power: None,
291 defaults: None,
292 projects: HashMap::new(),
293 reactions: HashMap::new(),
294 notification_routing: Default::default(),
295 notifiers: HashMap::new(),
296 plugins: vec![],
297 }
298 }
299}
300
301impl AoConfig {
302 pub fn load_from_with_warnings(path: &Path) -> Result<LoadedConfig> {
305 let text = std::fs::read_to_string(path)?;
306
307 let mut warnings: Vec<ConfigWarning> = Vec::new();
308 let deserializer = serde_yaml::Deserializer::from_str(&text);
309 let cfg: AoConfig = serde_ignored::deserialize(deserializer, |p| {
310 warnings.push(ConfigWarning {
311 field: yaml_field_path(&p),
312 message: "unknown field; this key is not supported and will be ignored".into(),
313 });
314 })
315 .map_err(|e| AoError::Yaml(e.to_string()))?;
316
317 cfg.validate(path)?;
318 Ok(LoadedConfig {
319 config: cfg,
320 warnings,
321 })
322 }
323
324 pub fn load_from_or_default_with_warnings(path: &Path) -> Result<LoadedConfig> {
327 match std::fs::read_to_string(path) {
328 Ok(_) => Self::load_from_with_warnings(path),
329 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(LoadedConfig {
330 config: Self::default(),
331 warnings: Vec::new(),
332 }),
333 Err(e) => Err(AoError::Io(e)),
334 }
335 }
336
337 pub fn load_from(path: &Path) -> Result<Self> {
342 let text = std::fs::read_to_string(path)?;
343 let cfg: AoConfig =
344 serde_yaml::from_str(&text).map_err(|e| AoError::Yaml(e.to_string()))?;
345 Ok(cfg)
346 }
347
348 pub fn load_from_or_default(path: &Path) -> Result<Self> {
358 match std::fs::read_to_string(path) {
359 Ok(text) => serde_yaml::from_str(&text).map_err(|e| AoError::Yaml(e.to_string())),
360 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
361 Err(e) => Err(AoError::Io(e)),
362 }
363 }
364
365 pub fn load_default() -> Result<Self> {
368 Self::load_from_or_default(&Self::local_path())
369 }
370
371 pub const CONFIG_FILENAME: &str = "ao-rs.yaml";
373
374 fn discover_path_from(start: &Path) -> std::path::PathBuf {
379 let mut dir = start;
380 loop {
381 let candidate = dir.join(Self::CONFIG_FILENAME);
382 if candidate.is_file() {
383 return candidate;
384 }
385 match dir.parent() {
386 Some(parent) => dir = parent,
387 None => return start.join(Self::CONFIG_FILENAME),
388 }
389 }
390 }
391
392 pub fn local_path() -> std::path::PathBuf {
394 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
395 Self::discover_path_from(&cwd)
396 }
397
398 pub fn path_in(dir: &Path) -> std::path::PathBuf {
400 dir.join(Self::CONFIG_FILENAME)
401 }
402
403 pub fn save_to(&self, path: &Path) -> Result<()> {
405 if let Some(parent) = path.parent() {
406 std::fs::create_dir_all(parent)?;
407 }
408 let yaml = serde_yaml::to_string(self).map_err(|e| AoError::Yaml(e.to_string()))?;
409 std::fs::write(path, yaml)?;
410 Ok(())
411 }
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417 use std::sync::atomic::{AtomicUsize, Ordering};
418 use std::time::{SystemTime, UNIX_EPOCH};
419
420 fn unique_temp_file(label: &str) -> std::path::PathBuf {
421 static COUNTER: AtomicUsize = AtomicUsize::new(0);
422 let nanos = SystemTime::now()
423 .duration_since(UNIX_EPOCH)
424 .unwrap()
425 .as_nanos();
426 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
427 std::env::temp_dir().join(format!("ao-rs-config-{label}-{nanos}-{n}.yaml"))
428 }
429
430 #[test]
431 fn load_from_parses_minimal_config() {
432 let path = unique_temp_file("minimal");
433 std::fs::write(
434 &path,
435 r#"
436reactions:
437 ci-failed:
438 action: send-to-agent
439 message: "CI broke — please fix."
440"#,
441 )
442 .unwrap();
443
444 let cfg = AoConfig::load_from(&path).unwrap();
445 let ci = cfg.reactions.get("ci-failed").unwrap();
446 assert_eq!(ci.action, crate::reactions::ReactionAction::SendToAgent);
447 assert_eq!(ci.message.as_deref(), Some("CI broke — please fix."));
448
449 let _ = std::fs::remove_file(&path);
450 }
451
452 #[test]
453 fn load_from_parses_all_three_reactions() {
454 let path = unique_temp_file("all-three");
455 std::fs::write(
456 &path,
457 r#"
458reactions:
459 ci-failed:
460 action: send-to-agent
461 message: "fix ci"
462 retries: 3
463 changes-requested:
464 action: send-to-agent
465 message: "address review"
466 approved-and-green:
467 action: auto-merge
468"#,
469 )
470 .unwrap();
471
472 let cfg = AoConfig::load_from(&path).unwrap();
473 assert_eq!(cfg.reactions.len(), 3);
474 assert_eq!(
475 cfg.reactions["ci-failed"].action,
476 crate::reactions::ReactionAction::SendToAgent
477 );
478 assert_eq!(cfg.reactions["ci-failed"].retries, Some(3));
479 assert_eq!(
480 cfg.reactions["changes-requested"].action,
481 crate::reactions::ReactionAction::SendToAgent
482 );
483 assert_eq!(
484 cfg.reactions["approved-and-green"].action,
485 crate::reactions::ReactionAction::AutoMerge
486 );
487
488 let _ = std::fs::remove_file(&path);
489 }
490
491 #[test]
492 fn load_from_empty_file_produces_default_config() {
493 let path = unique_temp_file("empty");
500 std::fs::write(&path, "").unwrap();
501 let cfg = AoConfig::load_from(&path).unwrap();
502 assert!(cfg.reactions.is_empty());
503 let _ = std::fs::remove_file(&path);
504 }
505
506 #[test]
507 fn load_from_config_with_no_reactions_key_is_ok() {
508 let path = unique_temp_file("empty-reactions");
511 std::fs::write(&path, "reactions: {}\n").unwrap();
512 let cfg = AoConfig::load_from(&path).unwrap();
513 assert!(cfg.reactions.is_empty());
514 let _ = std::fs::remove_file(&path);
515 }
516
517 #[test]
518 fn load_from_invalid_yaml_errors() {
519 let path = unique_temp_file("invalid");
520 std::fs::write(&path, "reactions: [not-a-map]\n").unwrap();
521 assert!(AoConfig::load_from(&path).is_err());
522 let _ = std::fs::remove_file(&path);
523 }
524
525 #[test]
526 fn load_from_with_warnings_reports_unknown_fields() {
527 let path = unique_temp_file("unknown-fields");
528 std::fs::write(
529 &path,
530 r#"
531port: 3000
532unknownTopLevel: 123
533defaults:
534 runtime: tmux
535 unknownDefaultsKey: true
536"#,
537 )
538 .unwrap();
539 let loaded = AoConfig::load_from_with_warnings(&path).unwrap();
540 assert_eq!(loaded.config.port, 3000);
541 assert!(
542 loaded
543 .warnings
544 .iter()
545 .any(|w| w.field.contains("unknownTopLevel")),
546 "expected unknownTopLevel warning, got {:?}",
547 loaded.warnings
548 );
549 assert!(
550 loaded
551 .warnings
552 .iter()
553 .any(|w| w.field.contains("defaults") && w.field.contains("unknownDefaultsKey")),
554 "expected defaults.unknownDefaultsKey warning, got {:?}",
555 loaded.warnings
556 );
557 let _ = std::fs::remove_file(&path);
558 }
559
560 #[test]
561 fn validate_rejects_unknown_reaction_key() {
562 let path = unique_temp_file("bad-reaction-key");
563 std::fs::write(
564 &path,
565 r#"
566reactions:
567 ci-failed:
568 action: notify
569 ci-broke:
570 action: notify
571"#,
572 )
573 .unwrap();
574 let err = AoConfig::load_from_with_warnings(&path).unwrap_err();
575 let msg = err.to_string();
576 assert!(msg.contains("unknown reaction key"), "got: {msg}");
577 assert!(msg.contains("reactions.ci-broke"), "got: {msg}");
578 let _ = std::fs::remove_file(&path);
579 }
580
581 #[test]
582 fn validate_rejects_bad_duration() {
583 let path = unique_temp_file("bad-duration");
584 std::fs::write(
585 &path,
586 r#"
587reactions:
588 agent-stuck:
589 action: notify
590 threshold: "1m30s"
591"#,
592 )
593 .unwrap();
594 let err = AoConfig::load_from_with_warnings(&path).unwrap_err();
595 let msg = err.to_string();
596 assert!(msg.contains("invalid duration"), "got: {msg}");
597 assert!(
598 msg.contains("reactions.agent-stuck.threshold"),
599 "got: {msg}"
600 );
601 let _ = std::fs::remove_file(&path);
602 }
603
604 #[test]
605 fn validate_rejects_unknown_notifier_name_in_routing() {
606 let path = unique_temp_file("bad-notifier");
607 std::fs::write(
608 &path,
609 r#"
610notification-routing:
611 urgent: [stdout, slackk]
612"#,
613 )
614 .unwrap();
615 let err = AoConfig::load_from_with_warnings(&path).unwrap_err();
616 let msg = err.to_string();
617 assert!(msg.contains("unknown notifier name"), "got: {msg}");
618 assert!(msg.contains("slackk"), "got: {msg}");
619 let _ = std::fs::remove_file(&path);
620 }
621
622 #[test]
623 fn load_from_or_default_missing_file_returns_empty() {
624 let missing = std::env::temp_dir().join("ao-rs-nonexistent-config-nonexistent-config.yaml");
628 let _ = std::fs::remove_file(&missing);
630
631 let cfg = AoConfig::load_from_or_default(&missing).unwrap();
632 assert!(cfg.reactions.is_empty());
633 }
634
635 #[test]
636 fn load_from_or_default_parses_existing_file() {
637 let path = unique_temp_file("or-default-exists");
640 std::fs::write(&path, "reactions:\n ci-failed:\n action: notify\n").unwrap();
641 let cfg = AoConfig::load_from_or_default(&path).unwrap();
642 assert_eq!(cfg.reactions.len(), 1);
643 let _ = std::fs::remove_file(&path);
644 }
645
646 #[test]
647 fn load_from_config_without_notification_routing_defaults_empty() {
648 let path = unique_temp_file("no-routing");
652 std::fs::write(&path, "reactions:\n ci-failed:\n action: notify\n").unwrap();
653 let cfg = AoConfig::load_from(&path).unwrap();
654 assert_eq!(cfg.reactions.len(), 1);
655 assert!(cfg.notification_routing.is_empty());
656 let _ = std::fs::remove_file(&path);
657 }
658
659 #[test]
660 fn load_from_parses_notification_routing_only() {
661 let path = unique_temp_file("routing-only");
665 std::fs::write(
666 &path,
667 r#"
668notification-routing:
669 urgent: [stdout, ntfy]
670 warning: [stdout]
671"#,
672 )
673 .unwrap();
674 let cfg = AoConfig::load_from(&path).unwrap();
675 assert!(cfg.reactions.is_empty());
676 assert_eq!(cfg.notification_routing.len(), 2);
677 assert_eq!(
678 cfg.notification_routing
679 .names_for(EventPriority::Urgent)
680 .unwrap(),
681 &["stdout".to_string(), "ntfy".to_string()]
682 );
683 let _ = std::fs::remove_file(&path);
684 }
685
686 #[test]
687 fn load_from_parses_reactions_and_routing_together() {
688 let path = unique_temp_file("full-config");
692 std::fs::write(
693 &path,
694 r#"
695reactions:
696 ci-failed:
697 action: send-to-agent
698 message: "CI broke"
699 retries: 3
700 approved-and-green:
701 action: auto-merge
702
703notification-routing:
704 urgent: [stdout]
705 action: [stdout]
706 warning: [stdout]
707 info: [stdout]
708"#,
709 )
710 .unwrap();
711 let cfg = AoConfig::load_from(&path).unwrap();
712 assert_eq!(cfg.reactions.len(), 2);
713 assert_eq!(cfg.notification_routing.len(), 4);
714 assert_eq!(
715 cfg.reactions["ci-failed"].action,
716 crate::reactions::ReactionAction::SendToAgent
717 );
718 assert_eq!(
719 cfg.notification_routing
720 .names_for(EventPriority::Info)
721 .unwrap(),
722 &["stdout".to_string()]
723 );
724 let _ = std::fs::remove_file(&path);
725 }
726
727 #[test]
728 fn notification_routing_canonicalizes_on_write() {
729 let path = unique_temp_file("canonical-routing");
734 std::fs::write(&path, "notification-routing:\n info: [stdout]\n").unwrap();
735 let cfg = AoConfig::load_from(&path).unwrap();
736 let yaml_out = serde_yaml::to_string(&cfg).unwrap();
737 assert!(
738 yaml_out.contains("notification_routing:"),
739 "expected canonical snake_case key in output, got:\n{yaml_out}"
740 );
741 assert!(
742 !yaml_out.contains("notification-routing:"),
743 "expected no kebab-case key in output, got:\n{yaml_out}"
744 );
745 let _ = std::fs::remove_file(&path);
746 }
747
748 #[test]
749 fn full_config_with_all_sections_roundtrips() {
750 let mut projects = HashMap::new();
751 projects.insert(
752 "my-app".into(),
753 ProjectConfig {
754 name: None,
755 repo: "org/my-app".into(),
756 path: "/home/user/my-app".into(),
757 default_branch: "main".into(),
758 session_prefix: None,
759 branch_namespace: None,
760 runtime: None,
761 agent: None,
762 workspace: None,
763 tracker: None,
764 scm: None,
765 symlinks: vec![],
766 post_create: vec![],
767 agent_config: Some(AgentConfig {
768 permissions: PermissionsMode::Default,
769 rules: None,
770 rules_file: None,
771 model: None,
772 orchestrator_model: None,
773 opencode_session_id: None,
774 }),
775 orchestrator: None,
776 worker: None,
777 reactions: HashMap::new(),
778 agent_rules: None,
779 agent_rules_file: None,
780 orchestrator_rules: None,
781 orchestrator_session_strategy: None,
782 opencode_issue_session_strategy: None,
783 },
784 );
785
786 let config = AoConfig {
787 port: project::default_port(),
788 ready_threshold_ms: project::default_ready_threshold_ms(),
789 poll_interval: project::default_poll_interval_secs(),
790 terminal_port: None,
791 direct_terminal_port: None,
792 power: None,
793 defaults: Some(DefaultsConfig::default()),
794 projects,
795 reactions: default_reactions(),
796 notification_routing: default_routing(),
797 notifiers: HashMap::new(),
798 plugins: vec![],
799 };
800
801 let yaml = serde_yaml::to_string(&config).unwrap();
802 let config2: AoConfig = serde_yaml::from_str(&yaml).unwrap();
803 assert_eq!(config, config2);
804 }
805
806 #[test]
807 fn existing_config_without_new_fields_still_parses() {
808 let path = unique_temp_file("compat");
809 std::fs::write(&path, "reactions:\n ci-failed:\n action: notify\n").unwrap();
810 let cfg = AoConfig::load_from(&path).unwrap();
811 assert_eq!(cfg.reactions.len(), 1);
812 assert!(cfg.defaults.is_none());
813 assert!(cfg.projects.is_empty());
814 let _ = std::fs::remove_file(&path);
815 }
816
817 #[test]
818 fn save_to_writes_valid_yaml() {
819 let path = unique_temp_file("save-to");
820 let config = AoConfig {
821 port: project::default_port(),
822 ready_threshold_ms: project::default_ready_threshold_ms(),
823 poll_interval: project::default_poll_interval_secs(),
824 terminal_port: None,
825 direct_terminal_port: None,
826 power: None,
827 defaults: Some(DefaultsConfig::default()),
828 projects: HashMap::new(),
829 reactions: default_reactions(),
830 notification_routing: default_routing(),
831 notifiers: HashMap::new(),
832 plugins: vec![],
833 };
834 config.save_to(&path).unwrap();
835
836 let loaded = AoConfig::load_from(&path).unwrap();
837 assert_eq!(config, loaded);
838 let _ = std::fs::remove_file(&path);
839 }
840
841 #[test]
842 fn validate_rejects_duplicate_project_basename() {
843 let path = unique_temp_file("dup-basename");
844 std::fs::write(
845 &path,
846 r#"
847projects:
848 proj-a:
849 repo: org/app
850 path: /home/user/app
851 proj-b:
852 repo: org/app2
853 path: /home/other/app
854"#,
855 )
856 .unwrap();
857 let err = AoConfig::load_from_with_warnings(&path).unwrap_err();
858 let msg = err.to_string();
859 assert!(
860 msg.contains("Duplicate project ID"),
861 "expected duplicate basename error, got: {msg}"
862 );
863 let _ = std::fs::remove_file(&path);
864 }
865
866 #[test]
867 fn validate_rejects_duplicate_session_prefix() {
868 let path = unique_temp_file("dup-prefix");
869 std::fs::write(
870 &path,
871 r#"
872projects:
873 proj-a:
874 repo: org/app
875 path: /home/user/my-app
876 sessionPrefix: myapp
877 proj-b:
878 repo: org/other
879 path: /home/user/other-app
880 sessionPrefix: myapp
881"#,
882 )
883 .unwrap();
884 let err = AoConfig::load_from_with_warnings(&path).unwrap_err();
885 let msg = err.to_string();
886 assert!(
887 msg.contains("Duplicate session prefix"),
888 "expected duplicate session prefix error, got: {msg}"
889 );
890 let _ = std::fs::remove_file(&path);
891 }
892
893 #[test]
894 fn permissions_mode_typo_fails_to_load() {
895 let path = unique_temp_file("bad-permissions");
896 std::fs::write(
897 &path,
898 r#"
899projects:
900 my-app:
901 repo: org/my-app
902 path: /tmp/my-app
903 agent_config:
904 permissions: permisionless
905"#,
906 )
907 .unwrap();
908 let err = AoConfig::load_from(&path).unwrap_err();
909 let msg = err.to_string();
910 assert!(
911 msg.contains("permisionless") || msg.contains("unknown variant"),
912 "expected deserialization error for typo, got: {msg}"
913 );
914 let _ = std::fs::remove_file(&path);
915 }
916}