Skip to main content

aether_project/
aether_settings.rs

1use utils::SettingsStore;
2
3use crate::agent_config::AgentConfig;
4use crate::error::SettingsError;
5use crate::{McpSourceSpec, PromptSource};
6use llm::ProviderConnectionOverrides;
7use std::fs::read_to_string;
8use std::path::{Path, PathBuf};
9
10const PROJECT_SETTINGS_PATH: &str = ".aether/settings.json";
11const USER_SETTINGS_FILENAME: &str = "settings.json";
12
13pub fn user_settings_path() -> Option<PathBuf> {
14    SettingsStore::new("AETHER_HOME", ".aether").map(|store| store.home().join(USER_SETTINGS_FILENAME))
15}
16
17pub fn user_settings_exist() -> bool {
18    user_settings_path().is_some_and(|p| p.is_file())
19}
20
21pub fn project_settings_path(project_root: &Path) -> PathBuf {
22    project_root.join(PROJECT_SETTINGS_PATH)
23}
24
25pub fn project_settings_exist(project_root: &Path) -> bool {
26    project_settings_path(project_root).is_file()
27}
28
29#[derive(Debug, Clone, Default, PartialEq, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
30#[serde(rename_all = "camelCase", deny_unknown_fields)]
31pub struct AetherSettings {
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub agent: Option<String>,
34    #[serde(default, skip_serializing_if = "Vec::is_empty")]
35    pub prompts: Vec<PromptSource>,
36    #[serde(default, skip_serializing_if = "Vec::is_empty")]
37    pub mcps: Vec<McpSourceSpec>,
38    #[serde(default, skip_serializing_if = "ProviderConnectionOverrides::is_empty")]
39    pub providers: ProviderConnectionOverrides,
40    #[schemars(length(min = 1))]
41    pub agents: Vec<AgentConfig>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct SettingsFileSource {
46    pub path: PathBuf,
47    pub root: PathBuf,
48}
49
50#[derive(Debug, Clone)]
51pub enum AetherSettingsSource {
52    File(SettingsFileSource),
53    OptionalFile(SettingsFileSource),
54    Json(String),
55    Value(AetherSettings),
56}
57
58impl SettingsFileSource {
59    pub fn new(path: impl Into<PathBuf>, root: impl Into<PathBuf>) -> Self {
60        Self { path: path.into(), root: root.into() }
61    }
62}
63
64impl AetherSettings {
65    pub fn load_default(project_root: &Path) -> Result<Self, SettingsError> {
66        Self::load(project_root, default_sources(project_root))
67    }
68
69    pub fn load(
70        project_root: &Path,
71        sources: impl IntoIterator<Item = AetherSettingsSource>,
72    ) -> Result<Self, SettingsError> {
73        sources.into_iter().try_fold(Self::default(), |config, source| {
74            let next = Self::load_source(project_root, source)?;
75            Ok(config.merge(next))
76        })
77    }
78
79    pub fn merge(mut self, next: Self) -> Self {
80        if next.agent.is_some() {
81            self.agent = next.agent;
82        }
83
84        if !next.prompts.is_empty() {
85            self.prompts = next.prompts;
86        }
87        if !next.mcps.is_empty() {
88            self.mcps = next.mcps;
89        }
90        self.providers.merge(next.providers);
91
92        for next_agent in next.agents {
93            if let Some(existing) = self.agents.iter_mut().find(|agent| agent.name.trim() == next_agent.name.trim()) {
94                *existing = next_agent;
95            } else {
96                self.agents.push(next_agent);
97            }
98        }
99
100        self
101    }
102
103    fn load_source(project_root: &Path, source: AetherSettingsSource) -> Result<Self, SettingsError> {
104        match source {
105            AetherSettingsSource::File(source) => load_file_source(project_root, source, false),
106            AetherSettingsSource::OptionalFile(source) => load_file_source(project_root, source, true),
107            AetherSettingsSource::Json(json) => Self::try_from(json.as_str()),
108            AetherSettingsSource::Value(settings) => Ok(settings),
109        }
110    }
111}
112
113fn default_sources(project_root: &Path) -> Vec<AetherSettingsSource> {
114    let aether_home = SettingsStore::new("AETHER_HOME", ".aether").map(|store| store.home().to_path_buf());
115    default_sources_for_home(project_root, aether_home.as_deref())
116}
117
118fn default_sources_for_home(project_root: &Path, aether_home: Option<&Path>) -> Vec<AetherSettingsSource> {
119    let mut sources = Vec::new();
120    if let Some(aether_home) = aether_home {
121        sources.push(AetherSettingsSource::OptionalFile(SettingsFileSource::new("settings.json", aether_home)));
122    }
123    sources.push(AetherSettingsSource::OptionalFile(SettingsFileSource::new(PROJECT_SETTINGS_PATH, project_root)));
124    sources
125}
126
127fn load_file_source(
128    project_root: &Path,
129    source: SettingsFileSource,
130    missing_is_empty: bool,
131) -> Result<AetherSettings, SettingsError> {
132    let root = resolve_against(project_root, source.root);
133    let path = resolve_against(&root, source.path);
134    let settings = load_file(&path, missing_is_empty)?;
135    let source_root = (root != project_root).then_some(root.as_path());
136    Ok(normalize_resource_paths(settings, source_root))
137}
138
139fn resolve_against(base: &Path, path: PathBuf) -> PathBuf {
140    if path.is_absolute() { path } else { base.join(path) }
141}
142
143fn load_file(path: &Path, missing_is_empty: bool) -> Result<AetherSettings, SettingsError> {
144    match read_to_string(path) {
145        Ok(content) if content.trim().is_empty() => Ok(AetherSettings::default()),
146        Ok(content) => AetherSettings::try_from(content.as_str()),
147        Err(error) if missing_is_empty && error.kind() == std::io::ErrorKind::NotFound => Ok(AetherSettings::default()),
148        Err(error) => Err(SettingsError::IoError(format!("Failed to read {}: {}", path.display(), error))),
149    }
150}
151
152fn normalize_resource_paths(mut settings: AetherSettings, source_root: Option<&Path>) -> AetherSettings {
153    let Some(root) = source_root else { return settings };
154    promote_prompt_sources(&mut settings.prompts, root);
155    promote_mcp_sources(&mut settings.mcps, root);
156
157    for agent in &mut settings.agents {
158        promote_prompt_sources(&mut agent.prompts, root);
159        promote_mcp_sources(&mut agent.mcps, root);
160    }
161
162    settings
163}
164
165fn promote_prompt_sources(sources: &mut [PromptSource], source_root: &Path) {
166    for source in sources {
167        match source {
168            PromptSource::File { path, .. } | PromptSource::Glob { pattern: path, .. } => {
169                path.promote_relative(source_root);
170            }
171            PromptSource::Text { .. } => {}
172        }
173    }
174}
175
176fn promote_mcp_sources(sources: &mut [McpSourceSpec], source_root: &Path) {
177    for source in sources {
178        if let McpSourceSpec::File(file) = source {
179            file.path.promote_relative(source_root);
180        }
181    }
182}
183
184impl TryFrom<&str> for AetherSettings {
185    type Error = SettingsError;
186
187    fn try_from(content: &str) -> Result<Self, Self::Error> {
188        serde_json::from_str(content).map_err(|e| SettingsError::ParseError(e.to_string()))
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::{AgentCatalog, McpFileSpec, McpSourceSpec, PromptSource};
196    use aether_core::agent_spec::McpConfigSource;
197    use aether_core::core::Prompt;
198    use std::collections::BTreeMap;
199    use std::fs::{create_dir_all, write};
200
201    #[test]
202    fn project_settings_path_points_at_project_aether_settings() {
203        assert_eq!(project_settings_path(Path::new("/repo")), PathBuf::from("/repo/.aether/settings.json"));
204    }
205
206    #[test]
207    fn project_settings_exist_checks_project_settings_file() {
208        let dir = tempfile::tempdir().unwrap();
209        assert!(!project_settings_exist(dir.path()));
210        write_file(dir.path(), PROJECT_SETTINGS_PATH, "{}");
211        assert!(project_settings_exist(dir.path()));
212    }
213
214    #[test]
215    fn resolves_selected_agent() {
216        let dir = tempfile::tempdir().unwrap();
217        write_file(dir.path(), "PROMPT.md", "Be helpful");
218        let config = AetherSettings {
219            agent: Some("beta".to_string()),
220            agents: vec![agent_config("alpha"), agent_config("beta")],
221            ..AetherSettings::default()
222        };
223
224        let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
225
226        assert_eq!(catalog.default_agent().map(|spec| spec.name.as_str()), Some("beta"));
227    }
228
229    #[test]
230    fn rejects_selected_agent_that_is_not_user_invocable() {
231        let mut internal = agent_config("internal");
232        internal.user_invocable = false;
233        internal.agent_invocable = true;
234        let config =
235            AetherSettings { agent: Some("internal".to_string()), agents: vec![internal], ..AetherSettings::default() };
236
237        let err = AgentCatalog::from_settings(Path::new("/tmp"), config).unwrap_err();
238
239        assert!(matches!(err, SettingsError::NonUserInvocableAgentSelector { .. }));
240    }
241
242    #[test]
243    fn settings_file_paths_are_project_relative() {
244        let dir = tempfile::tempdir().unwrap();
245        write_file(dir.path(), "PROMPT.md", "Be helpful");
246        write_file(
247            dir.path(),
248            "nested/config.json",
249            r#"{"agents":[{"name":"alpha","description":"Alpha","model":"anthropic:claude-sonnet-4-5","userInvocable":true,"prompts":[{"type":"file","path":"PROMPT.md"}]}]}"#,
250        );
251
252        let config = AetherSettings::load(
253            dir.path(),
254            [AetherSettingsSource::File(SettingsFileSource::new("nested/config.json", dir.path()))],
255        )
256        .unwrap();
257        let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
258
259        assert_eq!(catalog.all()[0].name, "alpha");
260    }
261
262    #[test]
263    fn load_merges_sources_with_rightmost_agent_winning() {
264        let dir = tempfile::tempdir().unwrap();
265        let base = AetherSettings {
266            agent: Some("alpha".to_string()),
267            prompts: vec![PromptSource::file("BASE.md")],
268            agents: vec![AgentConfig { description: "Base alpha".to_string(), ..agent_config("alpha") }],
269            ..AetherSettings::default()
270        };
271        let override_config = AetherSettings {
272            agent: Some("beta".to_string()),
273            prompts: vec![PromptSource::file("OVERRIDE.md")],
274            agents: vec![
275                AgentConfig { description: "Override alpha".to_string(), ..agent_config("alpha") },
276                agent_config("beta"),
277            ],
278            ..AetherSettings::default()
279        };
280
281        let config = AetherSettings::load(
282            dir.path(),
283            [AetherSettingsSource::Value(base), AetherSettingsSource::Value(override_config)],
284        )
285        .unwrap();
286
287        assert_eq!(
288            config,
289            AetherSettings {
290                agent: Some("beta".to_string()),
291                prompts: vec![PromptSource::file("OVERRIDE.md")],
292                agents: vec![
293                    AgentConfig { description: "Override alpha".to_string(), ..agent_config("alpha") },
294                    agent_config("beta"),
295                ],
296                ..AetherSettings::default()
297            }
298        );
299    }
300
301    #[test]
302    fn load_default_merges_user_and_project_settings_with_project_winning() {
303        let project = tempfile::tempdir().unwrap();
304        let home = tempfile::tempdir().unwrap();
305        let aether_home = home.path().join(".aether");
306        write_file(
307            &aether_home,
308            "settings.json",
309            r#"{
310                "agent":"shared",
311                "prompts":["USER.md"],
312                "agents":[
313                    {"name":"shared","description":"User shared","model":"anthropic:claude-sonnet-4-5","userInvocable":true},
314                    {"name":"user-only","description":"User only","model":"anthropic:claude-sonnet-4-5","userInvocable":true}
315                ]
316            }"#,
317        );
318        write_file(
319            project.path(),
320            ".aether/settings.json",
321            r#"{
322                "agent":"project-only",
323                "prompts":["PROJECT.md"],
324                "agents":[
325                    {"name":"shared","description":"Project shared","model":"anthropic:claude-sonnet-4-5","userInvocable":true},
326                    {"name":"project-only","description":"Project only","model":"anthropic:claude-sonnet-4-5","userInvocable":true}
327                ]
328            }"#,
329        );
330
331        let config = load_default_from_home(project.path(), &aether_home).unwrap();
332        assert_eq!(
333            config,
334            AetherSettings {
335                agent: Some("project-only".to_string()),
336                prompts: vec![PromptSource::file("PROJECT.md")],
337                agents: vec![
338                    settings_agent("shared", "Project shared"),
339                    settings_agent("user-only", "User only"),
340                    settings_agent("project-only", "Project only"),
341                ],
342                ..AetherSettings::default()
343            }
344        );
345    }
346
347    #[test]
348    fn load_default_uses_user_settings_when_project_settings_are_missing() {
349        let project = tempfile::tempdir().unwrap();
350        let home = tempfile::tempdir().unwrap();
351        let aether_home = home.path().join(".aether");
352        write_file(
353            &aether_home,
354            "settings.json",
355            r#"{"agents":[{"name":"user-only","description":"User only","model":"anthropic:claude-sonnet-4-5","userInvocable":true}]}"#,
356        );
357
358        let config = load_default_from_home(project.path(), &aether_home).unwrap();
359        assert_eq!(
360            config,
361            AetherSettings { agents: vec![settings_agent("user-only", "User only")], ..AetherSettings::default() }
362        );
363    }
364
365    #[test]
366    fn load_default_resolves_user_agent_paths_from_aether_home() {
367        let project = tempfile::tempdir().unwrap();
368        let home = tempfile::tempdir().unwrap();
369        let aether_home = home.path().join(".aether");
370        write_file(&aether_home, "agents/user.md", "User instructions");
371        write_file(&aether_home, "mcp/user.json", r#"{"servers":{}}"#);
372        write_file(
373            &aether_home,
374            "settings.json",
375            r#"{
376                "agents":[{
377                    "name":"user-only",
378                    "description":"User only",
379                    "model":"anthropic:claude-sonnet-4-5",
380                    "userInvocable":true,
381                    "prompts":["agents/user.md"],
382                    "mcps":["mcp/user.json"]
383                }]
384            }"#,
385        );
386
387        let config = load_default_from_home(project.path(), &aether_home).unwrap();
388        let catalog = AgentCatalog::from_settings(project.path(), config).unwrap();
389        let spec = catalog.resolve("user-only").unwrap();
390
391        let expected_prompt = aether_home.join("agents/user.md");
392        assert!(spec.prompts.iter().any(|prompt| match prompt {
393            Prompt::File { path, .. } => path == &expected_prompt,
394            Prompt::Text(_) | Prompt::McpInstructions(_) => false,
395        }));
396        assert!(matches!(
397            &spec.mcp_config_sources[0],
398            McpConfigSource::File { path, proxy: false } if path == &aether_home.join("mcp/user.json")
399        ));
400    }
401
402    #[test]
403    fn load_default_uses_project_settings_when_user_settings_are_missing() {
404        let project = tempfile::tempdir().unwrap();
405        let home = tempfile::tempdir().unwrap();
406        let aether_home = home.path().join(".aether");
407        write_file(
408            project.path(),
409            ".aether/settings.json",
410            r#"{"agents":[{"name":"project-only","description":"Project only","model":"anthropic:claude-sonnet-4-5","userInvocable":true}]}"#,
411        );
412
413        let config = load_default_from_home(project.path(), &aether_home).unwrap();
414
415        assert_eq!(
416            config,
417            AetherSettings {
418                agents: vec![settings_agent("project-only", "Project only")],
419                ..AetherSettings::default()
420            }
421        );
422    }
423
424    #[test]
425    fn load_default_returns_default_when_user_and_project_settings_are_missing() {
426        let project = tempfile::tempdir().unwrap();
427        let home = tempfile::tempdir().unwrap();
428        let aether_home = home.path().join(".aether");
429        let config = load_default_from_home(project.path(), &aether_home).unwrap();
430        assert_eq!(config, AetherSettings::default());
431    }
432
433    #[test]
434    fn load_default_rejects_malformed_user_settings() {
435        let project = tempfile::tempdir().unwrap();
436        let home = tempfile::tempdir().unwrap();
437        let aether_home = home.path().join(".aether");
438        write_file(&aether_home, "settings.json", "{not-json");
439        let err = load_default_from_home(project.path(), &aether_home).unwrap_err();
440        assert!(matches!(err, SettingsError::ParseError(_)));
441    }
442
443    #[test]
444    fn strict_file_source_errors_when_missing() {
445        let project = tempfile::tempdir().unwrap();
446        let err = AetherSettings::load(
447            project.path(),
448            [AetherSettingsSource::File(SettingsFileSource::new("missing.json", project.path()))],
449        )
450        .unwrap_err();
451
452        assert!(matches!(err, SettingsError::IoError(_)));
453    }
454
455    #[test]
456    fn optional_file_source_returns_default_when_missing() {
457        let project = tempfile::tempdir().unwrap();
458        let config = AetherSettings::load(
459            project.path(),
460            [AetherSettingsSource::OptionalFile(SettingsFileSource::new("missing.json", project.path()))],
461        )
462        .unwrap();
463
464        assert_eq!(config, AetherSettings::default());
465    }
466
467    #[test]
468    fn resolves_inline_mcp_config() {
469        let dir = tempfile::tempdir().unwrap();
470        write_file(dir.path(), "PROMPT.md", "Be helpful");
471        let config = AetherSettings {
472            agent: None,
473            agents: vec![AgentConfig {
474                mcps: vec![McpSourceSpec::Inline { servers: BTreeMap::new() }],
475                ..agent_config("alpha")
476            }],
477            ..AetherSettings::default()
478        };
479
480        let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
481        let spec = catalog.resolve("alpha").unwrap();
482
483        assert_eq!(spec.mcp_config_sources.len(), 1);
484        assert!(matches!(spec.mcp_config_sources[0], McpConfigSource::Inline(_)));
485    }
486
487    #[test]
488    fn parses_top_level_prompt_and_mcp_defaults() {
489        let config = AetherSettings::try_from(
490            r#"{
491                "prompts": [{"type":"file","path":"BASE.md"}],
492                "mcps": [{"type":"file","path":"mcp.json"}],
493                "agents": [{
494                    "name":"alpha",
495                    "description":"Alpha",
496                    "model":"anthropic:claude-sonnet-4-5",
497                    "userInvocable":true
498                }]
499            }"#,
500        )
501        .unwrap();
502
503        assert_eq!(
504            config,
505            AetherSettings {
506                prompts: vec![PromptSource::file("BASE.md")],
507                mcps: vec![McpSourceSpec::file("mcp.json")],
508                agents: vec![settings_agent("alpha", "Alpha")],
509                ..AetherSettings::default()
510            }
511        );
512    }
513
514    #[test]
515    fn parses_and_serializes_string_shorthand_for_file_sources() {
516        let config = AetherSettings::try_from(
517            r#"{
518                "prompts": ["BASE.md"],
519                "mcps": ["mcp.json"],
520                "agents": [{
521                    "name":"alpha",
522                    "description":"Alpha",
523                    "model":"anthropic:claude-sonnet-4-5",
524                    "userInvocable":true,
525                    "prompts":["AGENT.md"],
526                    "mcps":["agent-mcp.json"]
527                }]
528            }"#,
529        )
530        .unwrap();
531
532        assert_eq!(
533            config,
534            AetherSettings {
535                prompts: vec![PromptSource::file("BASE.md")],
536                mcps: vec![McpSourceSpec::file("mcp.json")],
537                agents: vec![AgentConfig {
538                    prompts: vec![PromptSource::file("AGENT.md")],
539                    mcps: vec![McpSourceSpec::file("agent-mcp.json")],
540                    ..settings_agent("alpha", "Alpha")
541                }],
542                ..AetherSettings::default()
543            }
544        );
545
546        let value = serde_json::to_value(&config).unwrap();
547        assert_eq!(value["prompts"], serde_json::json!(["BASE.md"]));
548        assert_eq!(value["mcps"], serde_json::json!(["mcp.json"]));
549        assert_eq!(value["agents"][0]["prompts"], serde_json::json!(["AGENT.md"]));
550        assert_eq!(value["agents"][0]["mcps"], serde_json::json!(["agent-mcp.json"]));
551    }
552
553    #[test]
554    fn serializes_proxied_mcp_file_as_typed_object() {
555        let source: McpSourceSpec = McpFileSpec::new("mcp.json").proxy().into();
556
557        let value = serde_json::to_value(source).unwrap();
558
559        assert_eq!(value, serde_json::json!({"type":"file", "path":"mcp.json", "proxy":true}));
560    }
561
562    #[test]
563    fn rejects_old_top_level_mcp_servers_field() {
564        let err = AetherSettings::try_from(
565            r#"{
566                "mcpServers": ["mcp.json"],
567                "agents": [{
568                    "name":"alpha",
569                    "description":"Alpha",
570                    "model":"anthropic:claude-sonnet-4-5",
571                    "userInvocable":true,
572                    "prompts":[{"type":"file","path":"PROMPT.md"}]
573                }]
574            }"#,
575        )
576        .unwrap_err();
577
578        assert!(matches!(err, SettingsError::ParseError(message) if message.contains("mcpServers")));
579    }
580
581    #[test]
582    fn load_default_resolves_workspace_scoped_user_prompt_and_mcp_paths() {
583        let project = tempfile::tempdir().unwrap();
584        let home = tempfile::tempdir().unwrap();
585        let aether_home = home.path().join(".aether");
586        write_file(&aether_home, "agents/planner/SYSTEM.md", "System instructions");
587        write_file(project.path(), "AGENTS.md", "Agent instructions");
588        write_file(project.path(), ".aether/mcp.json", r#"{"servers":{}}"#);
589        write_file(
590            &aether_home,
591            "settings.json",
592            r#"{
593                "agents":[{
594                    "name":"planner",
595                    "description":"Plans work",
596                    "model":"anthropic:claude-sonnet-4-5",
597                    "userInvocable":true,
598                    "prompts":[
599                        "agents/planner/SYSTEM.md",
600                        {"type":"file","path":"${WORKSPACE}/AGENTS.md"}
601                    ],
602                    "mcps":[
603                        {"type":"file","path":"${WORKSPACE}/.aether/mcp.json"}
604                    ]
605                }]
606            }"#,
607        );
608
609        let config = load_default_from_home(project.path(), &aether_home).unwrap();
610        let catalog = AgentCatalog::from_settings(project.path(), config).unwrap();
611        let spec = catalog.resolve("planner").unwrap();
612
613        let expected_system = aether_home.join("agents/planner/SYSTEM.md");
614        let expected_agents = project.path().join("AGENTS.md");
615        assert!(spec.prompts.iter().any(|p| match p {
616            Prompt::File { path, .. } => path == &expected_system,
617            _ => false,
618        }));
619        assert!(spec.prompts.iter().any(|p| match p {
620            Prompt::File { path, .. } => path == &expected_agents,
621            _ => false,
622        }));
623        assert!(matches!(
624            &spec.mcp_config_sources[0],
625            McpConfigSource::File { path, proxy: false } if *path == project.path().join(".aether/mcp.json")
626        ));
627    }
628
629    #[test]
630    fn workspace_scoped_paths_expand_in_project_settings_without_absolutizing_normal_relative_paths() {
631        let project = tempfile::tempdir().unwrap();
632        write_file(project.path(), "PROJECT.md", "Project prompt");
633        write_file(project.path(), "AGENTS.md", "Agent prompt");
634        write_file(
635            project.path(),
636            ".aether/settings.json",
637            r#"{
638                "agents":[{
639                    "name":"alpha",
640                    "description":"Alpha",
641                    "model":"anthropic:claude-sonnet-4-5",
642                    "userInvocable":true,
643                    "prompts":["PROJECT.md", {"type":"file","path":"${WORKSPACE}/AGENTS.md"}]
644                }]
645            }"#,
646        );
647
648        let config = AetherSettings::load(
649            project.path(),
650            [AetherSettingsSource::OptionalFile(SettingsFileSource::new(PROJECT_SETTINGS_PATH, project.path()))],
651        )
652        .unwrap();
653
654        assert_eq!(config.agents[0].prompts[0], PromptSource::file("PROJECT.md"));
655        assert_eq!(config.agents[0].prompts[1], PromptSource::file("${WORKSPACE}/AGENTS.md"));
656    }
657
658    #[test]
659    fn json_and_value_sources_preserve_workspace_scoped_paths_losslessly() {
660        let project = tempfile::tempdir().unwrap();
661
662        let json_config = AetherSettings::load(
663            project.path(),
664            [AetherSettingsSource::Json(
665                r#"{
666                    "agents":[{
667                        "name":"alpha",
668                        "description":"Alpha",
669                        "model":"anthropic:claude-sonnet-4-5",
670                        "userInvocable":true,
671                        "prompts":["${WORKSPACE}/AGENTS.md"]
672                    }]
673                }"#
674                .to_string(),
675            )],
676        )
677        .unwrap();
678
679        assert_eq!(json_config.agents[0].prompts[0], PromptSource::file("${WORKSPACE}/AGENTS.md"));
680
681        let value_config = AetherSettings::load(
682            project.path(),
683            [AetherSettingsSource::Value(AetherSettings {
684                agents: vec![AgentConfig {
685                    prompts: vec![PromptSource::file("${WORKSPACE}/AGENTS.md")],
686                    ..agent_config("alpha")
687                }],
688                ..AetherSettings::default()
689            })],
690        )
691        .unwrap();
692        assert_eq!(value_config.agents[0].prompts[0], PromptSource::file("${WORKSPACE}/AGENTS.md"));
693    }
694
695    #[test]
696    fn optional_workspace_scoped_mcp_source_is_skipped_when_missing() {
697        let project = tempfile::tempdir().unwrap();
698        write_file(project.path(), "BASE.md", "Base instructions");
699        let config = AetherSettings {
700            agents: vec![AgentConfig {
701                prompts: vec![PromptSource::file("BASE.md")],
702                mcps: vec![McpFileSpec::new("${WORKSPACE}/.aether/mcp.json").optional().into()],
703                ..agent_config("alpha")
704            }],
705            ..AetherSettings::default()
706        };
707
708        let config = AetherSettings::load(project.path(), [AetherSettingsSource::Value(config)]).unwrap();
709        let catalog = AgentCatalog::from_settings(project.path(), config).unwrap();
710        let spec = catalog.resolve("alpha").unwrap();
711
712        assert!(spec.mcp_config_sources.is_empty());
713    }
714
715    #[test]
716    fn optional_mcp_source_skips_unresolved_variable() {
717        let project = tempfile::tempdir().unwrap();
718        write_file(project.path(), "BASE.md", "Base instructions");
719        let config = AetherSettings {
720            agents: vec![AgentConfig {
721                prompts: vec![PromptSource::file("BASE.md")],
722                mcps: vec![McpFileSpec::new("${DEFINITELY_NOT_SET_VAR_MCP_OPTIONAL}/mcp.json").optional().into()],
723                ..agent_config("alpha")
724            }],
725            ..AetherSettings::default()
726        };
727
728        let config = AetherSettings::load(project.path(), [AetherSettingsSource::Value(config)]).unwrap();
729        let catalog = AgentCatalog::from_settings(project.path(), config).unwrap();
730        let spec = catalog.resolve("alpha").unwrap();
731
732        assert!(spec.mcp_config_sources.is_empty());
733    }
734
735    #[test]
736    fn required_mcp_source_errors_on_unresolved_variable() {
737        let project = tempfile::tempdir().unwrap();
738        write_file(project.path(), "BASE.md", "Base instructions");
739        let config = AetherSettings {
740            agents: vec![AgentConfig {
741                prompts: vec![PromptSource::file("BASE.md")],
742                mcps: vec![McpSourceSpec::file("${DEFINITELY_NOT_SET_VAR_MCP_REQ}/mcp.json")],
743                ..agent_config("alpha")
744            }],
745            ..AetherSettings::default()
746        };
747
748        let err = AgentCatalog::from_settings(project.path(), config).unwrap_err();
749        assert!(matches!(err, SettingsError::UnresolvedMcpConfigVariable { .. }));
750    }
751
752    #[test]
753    fn required_workspace_scoped_mcp_source_errors_when_missing() {
754        let project = tempfile::tempdir().unwrap();
755        write_file(project.path(), "BASE.md", "Base instructions");
756        let config = AetherSettings {
757            agents: vec![AgentConfig {
758                prompts: vec![PromptSource::file("BASE.md")],
759                mcps: vec![McpSourceSpec::file("nonexistent.json")],
760                ..agent_config("alpha")
761            }],
762            ..AetherSettings::default()
763        };
764
765        let err = AgentCatalog::from_settings(project.path(), config).unwrap_err();
766        assert!(matches!(err, SettingsError::InvalidMcpConfigPath { .. }));
767    }
768
769    #[test]
770    fn optional_existing_mcp_source_preserves_proxy_flag() {
771        let project = tempfile::tempdir().unwrap();
772        write_file(project.path(), "BASE.md", "Base instructions");
773        write_file(project.path(), "mcp.json", r#"{"servers":{}}"#);
774        let config = AetherSettings {
775            agents: vec![AgentConfig {
776                prompts: vec![PromptSource::file("BASE.md")],
777                mcps: vec![McpFileSpec::new("mcp.json").proxy().optional().into()],
778                ..agent_config("alpha")
779            }],
780            ..AetherSettings::default()
781        };
782
783        let catalog = AgentCatalog::from_settings(project.path(), config).unwrap();
784        let spec = catalog.resolve("alpha").unwrap();
785
786        assert!(matches!(&spec.mcp_config_sources[0], McpConfigSource::File { proxy: true, .. }));
787    }
788
789    #[test]
790    fn optional_mcp_source_serializes_as_typed_object() {
791        let source: McpSourceSpec = McpFileSpec::new("${WORKSPACE}/.aether/mcp.json").optional().into();
792        let value = serde_json::to_value(source).unwrap();
793        assert_eq!(value, serde_json::json!({"type":"file", "path":"${WORKSPACE}/.aether/mcp.json", "optional":true}));
794    }
795
796    #[test]
797    fn optional_prompt_source_serializes_as_typed_object() {
798        let source = PromptSource::file("${WORKSPACE}/AGENTS.md").optional();
799        let value = serde_json::to_value(&source).unwrap();
800        assert_eq!(value, serde_json::json!({"type":"file", "path":"${WORKSPACE}/AGENTS.md", "optional":true}));
801    }
802
803    #[test]
804    fn all_optional_prompts_missing_errors_with_no_prompts() {
805        let project = tempfile::tempdir().unwrap();
806        let config = AetherSettings {
807            agents: vec![AgentConfig {
808                prompts: vec![PromptSource::file("MISSING.md").optional()],
809                ..agent_config("alpha")
810            }],
811            ..AetherSettings::default()
812        };
813
814        let err = AgentCatalog::from_settings(project.path(), config).unwrap_err();
815        assert!(matches!(err, SettingsError::AllOptionalPromptsMissing { agent } if agent == "alpha"));
816    }
817
818    #[test]
819    fn settings_round_trip_preserves_workspace_prefix_and_relative_paths() {
820        let original = r#"{"agents":[{
821            "name":"alpha",
822            "description":"Alpha",
823            "model":"anthropic:claude-sonnet-4-5",
824            "userInvocable":true,
825            "prompts":[
826                "AGENTS.md",
827                "${WORKSPACE}/SYSTEM.md",
828                {"type":"file","path":"${WORKSPACE}/.aether/rules.md","optional":true},
829                {"type":"glob","pattern":"${WORKSPACE}/.aether/rules/*.md"}
830            ],
831            "mcps":[
832                "mcp.json",
833                {"type":"file","path":"${WORKSPACE}/.aether/mcp.json","optional":true}
834            ]
835        }]}"#;
836
837        let settings = AetherSettings::try_from(original).unwrap();
838        let reserialized = serde_json::to_string(&settings).unwrap();
839        let reparsed = AetherSettings::try_from(reserialized.as_str()).unwrap();
840
841        assert_eq!(settings, reparsed, "settings should round-trip losslessly through serde");
842    }
843
844    #[test]
845    fn user_settings_relative_paths_absolutize_at_load_but_workspace_token_is_preserved() {
846        let project = tempfile::tempdir().unwrap();
847        let home = tempfile::tempdir().unwrap();
848        let aether_home = home.path().join(".aether");
849        write_file(&aether_home, "agents/planner/SYSTEM.md", "system");
850        write_file(project.path(), "AGENTS.md", "agents");
851        write_file(
852            &aether_home,
853            "settings.json",
854            r#"{"agents":[{
855                "name":"planner",
856                "description":"Plans",
857                "model":"anthropic:claude-sonnet-4-5",
858                "userInvocable":true,
859                "prompts":["agents/planner/SYSTEM.md", "${WORKSPACE}/AGENTS.md"]
860            }]}"#,
861        );
862
863        let settings = load_default_from_home(project.path(), &aether_home).unwrap();
864
865        let expected_user = aether_home.join("agents/planner/SYSTEM.md").to_string_lossy().to_string();
866        assert_eq!(
867            settings.agents[0].prompts,
868            vec![PromptSource::file(expected_user), PromptSource::file("${WORKSPACE}/AGENTS.md")],
869            "user-rooted relative paths must absolutize; ${{WORKSPACE}}/ paths must be preserved",
870        );
871    }
872
873    fn load_default_from_home(project_root: &Path, aether_home: &Path) -> Result<AetherSettings, SettingsError> {
874        AetherSettings::load(project_root, default_sources_for_home(project_root, Some(aether_home)))
875    }
876
877    fn write_file(dir: &Path, path: &str, content: &str) {
878        let full = dir.join(path);
879        if let Some(parent) = full.parent() {
880            create_dir_all(parent).unwrap();
881        }
882
883        write(full, content).unwrap();
884    }
885
886    fn settings_agent(name: &str, description: &str) -> AgentConfig {
887        AgentConfig {
888            name: name.to_string(),
889            description: description.to_string(),
890            model: "anthropic:claude-sonnet-4-5".to_string(),
891            user_invocable: true,
892            ..AgentConfig::default()
893        }
894    }
895
896    fn agent_config(name: &str) -> AgentConfig {
897        AgentConfig {
898            name: name.to_string(),
899            description: format!("{name} agent"),
900            model: "anthropic:claude-sonnet-4-5".to_string(),
901            user_invocable: true,
902            prompts: vec![PromptSource::file("PROMPT.md")],
903            ..AgentConfig::default()
904        }
905    }
906}