Skip to main content

ao_core/config/
mod.rs

1//! Project-level config file: `ao-rs.yaml` (discovered by walking up from cwd).
2//!
3//! Mirrors the `OrchestratorConfig` shape from the TypeScript
4//! agent-orchestrator. `ao-rs start` generates this file with sensible
5//! defaults; subsequent runs load the existing file without overwriting.
6//!
7//! ## Missing-file handling
8//!
9//! `load_default()` returns an empty `AoConfig` if the file doesn't exist.
10//! A fresh install runs without the user being forced to create a config
11//! first. Parse errors propagate — a broken config needs to be fixed.
12
13pub 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// ---------------------------------------------------------------------------
38// Diagnostics + validation
39// ---------------------------------------------------------------------------
40
41/// Non-fatal config issues (unknown fields, questionable values).
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct ConfigWarning {
44    /// Human-readable field path (e.g. `"projects.my-app.defaultBranch"`).
45    pub field: String,
46    /// Actionable message.
47    pub message: String,
48}
49
50/// Result of loading a config file: parsed config + any warnings.
51#[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    // serde_ignored uses segments like `.field`, `[0]`, etc.
59    // We prefer a dot-separated path for CLI output.
60    let s = path.to_string();
61    s.trim_start_matches('.').to_string()
62}
63
64impl AoConfig {
65    /// Validate config semantics (beyond YAML parsing).
66    ///
67    /// Returns `Ok(())` when valid, otherwise a `AoError::Config` with an
68    /// actionable, field-scoped message including the config file path.
69    pub fn validate(&self, config_path: &Path) -> Result<()> {
70        // ---- reactions.* keys ----
71        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        // ---- duration parsing (reactions.*.threshold, reactions.*.escalate_after) ----
85        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        // ---- notifier names (defaults.notifiers, notification_routing) ----
109        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        // NotificationRouting parsing is already strict for priority keys
123        // (serde rejects unknown priorities). Here we validate notifier names.
124        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        // ---- projects.* repo/path constraints ----
146        for (project_id, project) in &self.projects {
147            // repo must be owner/repo (one slash, neither side empty).
148            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            // path must be absolute; we intentionally reject `~` because it
160            // won't canonicalize reliably in non-shell contexts.
161            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        // ---- duplicate project basenames / session-prefix (H4) ----
188        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/// Top-level ao-rs config file shape. All fields use `#[serde(default)]`
215/// so partial config files parse without error.
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217pub struct AoConfig {
218    /// Dashboard port (TS: `port`).
219    #[serde(default = "project::default_port")]
220    pub port: u16,
221    /// Terminal server ports (TS: `terminalPort`, `directTerminalPort`).
222    #[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    /// Milliseconds before a "ready" session becomes "idle" (TS: `readyThresholdMs`, default 300000).
235    #[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    /// Lifecycle polling interval in seconds (default 10).
243    #[serde(
244        default = "project::default_poll_interval_secs",
245        alias = "pollInterval",
246        alias = "poll-interval"
247    )]
248    pub poll_interval: u64,
249    /// Power management settings.
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub power: Option<PowerConfig>,
252    /// Orchestrator-wide plugin defaults.
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub defaults: Option<DefaultsConfig>,
255
256    /// Per-project configs keyed by project id.
257    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
258    pub projects: HashMap<String, ProjectConfig>,
259
260    /// Map from reaction key (e.g. `"ci-failed"`) to its config.
261    #[serde(default)]
262    pub reactions: HashMap<String, ReactionConfig>,
263
264    /// Priority-based notification routing table.
265    #[serde(
266        default,
267        rename = "notification_routing",
268        alias = "notification-routing",
269        alias = "notificationRouting"
270    )]
271    pub notification_routing: NotificationRouting,
272
273    /// Notifier plugin configurations (TS: `notifiers`).
274    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
275    pub notifiers: HashMap<String, PluginConfig>,
276
277    /// External plugins list (installer-managed). Currently stored for parity only.
278    #[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    /// Read and parse a config file at an explicit path, collecting warnings
303    /// for unknown fields and validating the supported subset.
304    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    /// Read a config file at an explicit path, or return an empty config
325    /// if the file doesn't exist, collecting warnings and validating.
326    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    /// Read and parse a config file at an explicit path.
338    ///
339    /// Distinct from `load_default` because tests should never touch
340    /// `~/.ao-rs/config.yaml` — they pass a tempfile instead.
341    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    /// Read a config file at an explicit path, or return an empty config
349    /// if the file doesn't exist. Any other I/O or parse error propagates.
350    ///
351    /// Only `NotFound` short-circuits to `Default::default()` — a permission
352    /// denied or unreadable file should still error, since silently pretending
353    /// there's no config would mask a real misconfiguration.
354    ///
355    /// Takes an explicit path (rather than always using `default_path()`)
356    /// so tests can exercise both branches without touching `$HOME`.
357    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    /// Load config from the current directory's `ao-rs.yaml`, or return
366    /// an empty config if the file doesn't exist.
367    pub fn load_default() -> Result<Self> {
368        Self::load_from_or_default(&Self::local_path())
369    }
370
371    /// Config file name in the project directory (like TS's `agent-orchestrator.yaml`).
372    pub const CONFIG_FILENAME: &str = "ao-rs.yaml";
373
374    /// Discover a config path by walking up parent directories.
375    ///
376    /// If a `ao-rs.yaml` exists in any ancestor (including `start`), returns
377    /// the nearest one. Otherwise returns `start/ao-rs.yaml`.
378    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    /// Config file path discovered from the current working directory.
393    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    /// Config file path in a specific directory.
399    pub fn path_in(dir: &Path) -> std::path::PathBuf {
400        dir.join(Self::CONFIG_FILENAME)
401    }
402
403    /// Write this config to disk as YAML, creating parent dirs if needed.
404    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        // serde(default) on every AoConfig field means an empty YAML file
494        // is equivalent to "no reactions configured" — the same outcome
495        // as `load_default()` on a missing file. This is mildly surprising
496        // (a typo'd blank config won't error) but keeps the two entry
497        // points consistent. Test locks it in so a future `deny_unknown_fields`
498        // change doesn't silently flip behaviour.
499        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        // `reactions: {}` or no reactions key at all should parse fine and
509        // produce an empty map — distinct from an entirely empty file.
510        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        // Covers the NotFound short-circuit without touching `$HOME`, so
625        // the test is safe under parallel `cargo test`. `load_default()`
626        // is a thin wrapper around this and inherits the behaviour.
627        let missing = std::env::temp_dir().join("ao-rs-nonexistent-config-nonexistent-config.yaml");
628        // Defensively delete in case a previous run left a stray file.
629        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        // And the happy path: same helper returns the parsed config when
638        // the file does exist, so load_default's dispatch is sound.
639        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        // Backwards compat: a pre-Slice-3 config with only `reactions:`
649        // must keep parsing. `notification_routing` falls back to its
650        // `Default` (empty table) via `#[serde(default)]`.
651        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        // Config with `notification-routing:` but no `reactions:`
662        // still parses. The kebab-case alias on the field name is
663        // what lets the YAML write `notification-routing:`.
664        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        // Full config with both sections — the common case once Phase C
689        // ships. Also verifies the kebab-case `notification-routing:`
690        // alias works alongside the kebab-case reaction keys.
691        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        // The alias → rename contract: we accept `notification-routing:`
730        // on read but always emit `notification_routing:` on write.
731        // Matches the `escalate_after` canonicalization locked in by
732        // Phase A of Slice 2.
733        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}