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 std::fs::read_to_string;
7use std::path::{Path, PathBuf};
8
9const PROJECT_SETTINGS_PATH: &str = ".aether/settings.json";
10
11#[derive(Debug, Clone, Default, PartialEq, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
12#[serde(rename_all = "camelCase", deny_unknown_fields)]
13pub struct AetherSettings {
14    #[serde(default, skip_serializing_if = "Option::is_none")]
15    pub agent: Option<String>,
16    #[serde(default, skip_serializing_if = "Vec::is_empty")]
17    pub prompts: Vec<PromptSource>,
18    #[serde(default, skip_serializing_if = "Vec::is_empty")]
19    pub mcps: Vec<McpSourceSpec>,
20    #[schemars(length(min = 1))]
21    pub agents: Vec<AgentConfig>,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct SettingsFileSource {
26    pub path: PathBuf,
27    pub root: PathBuf,
28}
29
30#[derive(Debug, Clone)]
31pub enum AetherSettingsSource {
32    File(SettingsFileSource),
33    OptionalFile(SettingsFileSource),
34    Json(String),
35    Value(AetherSettings),
36}
37
38impl SettingsFileSource {
39    pub fn new(path: impl Into<PathBuf>, root: impl Into<PathBuf>) -> Self {
40        Self { path: path.into(), root: root.into() }
41    }
42}
43
44impl AetherSettings {
45    pub fn load_default(project_root: &Path) -> Result<Self, SettingsError> {
46        Self::load(project_root, default_sources(project_root))
47    }
48
49    pub fn load(
50        project_root: &Path,
51        sources: impl IntoIterator<Item = AetherSettingsSource>,
52    ) -> Result<Self, SettingsError> {
53        sources.into_iter().try_fold(Self::default(), |config, source| {
54            let next = Self::load_source(project_root, source)?;
55            Ok(config.merge(next))
56        })
57    }
58
59    pub fn merge(mut self, next: Self) -> Self {
60        if next.agent.is_some() {
61            self.agent = next.agent;
62        }
63
64        if !next.prompts.is_empty() {
65            self.prompts = next.prompts;
66        }
67        if !next.mcps.is_empty() {
68            self.mcps = next.mcps;
69        }
70
71        for next_agent in next.agents {
72            if let Some(existing) = self.agents.iter_mut().find(|agent| agent.name.trim() == next_agent.name.trim()) {
73                *existing = next_agent;
74            } else {
75                self.agents.push(next_agent);
76            }
77        }
78
79        self
80    }
81
82    fn load_source(project_root: &Path, source: AetherSettingsSource) -> Result<Self, SettingsError> {
83        match source {
84            AetherSettingsSource::File(source) => load_file_source(project_root, source, false),
85            AetherSettingsSource::OptionalFile(source) => load_file_source(project_root, source, true),
86            AetherSettingsSource::Json(json) => Self::try_from(json.as_str()),
87            AetherSettingsSource::Value(config) => Ok(config),
88        }
89    }
90}
91
92fn default_sources(project_root: &Path) -> Vec<AetherSettingsSource> {
93    let aether_home = SettingsStore::new("AETHER_HOME", ".aether").map(|store| store.home().to_path_buf());
94    default_sources_for_home(project_root, aether_home.as_deref())
95}
96
97fn default_sources_for_home(project_root: &Path, aether_home: Option<&Path>) -> Vec<AetherSettingsSource> {
98    let mut sources = Vec::new();
99    if let Some(aether_home) = aether_home {
100        sources.push(AetherSettingsSource::OptionalFile(SettingsFileSource::new("settings.json", aether_home)));
101    }
102    sources.push(AetherSettingsSource::OptionalFile(SettingsFileSource::new(PROJECT_SETTINGS_PATH, project_root)));
103    sources
104}
105
106fn load_file_source(
107    project_root: &Path,
108    source: SettingsFileSource,
109    missing_is_empty: bool,
110) -> Result<AetherSettings, SettingsError> {
111    let root = source_path(project_root, source.root);
112    let path = source_path(&root, source.path);
113    let settings = load_file(&path, missing_is_empty)?;
114    Ok(if root == project_root { settings } else { normalize_resource_paths(settings, &root) })
115}
116
117fn source_path(project_root: &Path, path: PathBuf) -> PathBuf {
118    if path.is_absolute() { path } else { project_root.join(path) }
119}
120
121fn load_file(path: &Path, missing_is_empty: bool) -> Result<AetherSettings, SettingsError> {
122    match read_to_string(path) {
123        Ok(content) if content.trim().is_empty() => Ok(AetherSettings::default()),
124        Ok(content) => AetherSettings::try_from(content.as_str()),
125        Err(error) if missing_is_empty && error.kind() == std::io::ErrorKind::NotFound => Ok(AetherSettings::default()),
126        Err(error) => Err(SettingsError::IoError(format!("Failed to read {}: {}", path.display(), error))),
127    }
128}
129
130fn normalize_resource_paths(mut settings: AetherSettings, resource_root: &Path) -> AetherSettings {
131    normalize_prompt_sources(&mut settings.prompts, resource_root);
132    normalize_mcp_sources(&mut settings.mcps, resource_root);
133
134    for agent in &mut settings.agents {
135        normalize_prompt_sources(&mut agent.prompts, resource_root);
136        normalize_mcp_sources(&mut agent.mcps, resource_root);
137    }
138
139    settings
140}
141
142fn normalize_prompt_sources(sources: &mut [PromptSource], resource_root: &Path) {
143    for source in sources {
144        match source {
145            PromptSource::File { path } | PromptSource::Glob { pattern: path } => {
146                normalize_path_string(path, resource_root);
147            }
148            PromptSource::Text { .. } => {}
149        }
150    }
151}
152
153fn normalize_mcp_sources(sources: &mut [McpSourceSpec], resource_root: &Path) {
154    for source in sources {
155        match source {
156            McpSourceSpec::File { path, .. } => normalize_path_string(path, resource_root),
157            McpSourceSpec::Inline { .. } => {}
158        }
159    }
160}
161
162fn normalize_path_string(path: &mut String, resource_root: &Path) {
163    if !Path::new(path).is_absolute() {
164        *path = resource_root.join(&path).to_string_lossy().to_string();
165    }
166}
167
168impl TryFrom<&str> for AetherSettings {
169    type Error = SettingsError;
170
171    fn try_from(content: &str) -> Result<Self, Self::Error> {
172        serde_json::from_str(content).map_err(|e| SettingsError::ParseError(e.to_string()))
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use crate::{AgentCatalog, McpSourceSpec, PromptSource};
180    use aether_core::agent_spec::McpConfigSource;
181    use aether_core::core::Prompt;
182    use std::collections::BTreeMap;
183    use std::fs::{create_dir_all, write};
184
185    #[test]
186    fn resolves_selected_agent() {
187        let dir = tempfile::tempdir().unwrap();
188        write_file(dir.path(), "PROMPT.md", "Be helpful");
189        let config = AetherSettings {
190            agent: Some("beta".to_string()),
191            agents: vec![agent_config("alpha"), agent_config("beta")],
192            ..AetherSettings::default()
193        };
194
195        let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
196
197        assert_eq!(catalog.default_agent().map(|spec| spec.name.as_str()), Some("beta"));
198    }
199
200    #[test]
201    fn rejects_selected_agent_that_is_not_user_invocable() {
202        let mut internal = agent_config("internal");
203        internal.user_invocable = false;
204        internal.agent_invocable = true;
205        let config =
206            AetherSettings { agent: Some("internal".to_string()), agents: vec![internal], ..AetherSettings::default() };
207
208        let err = AgentCatalog::from_settings(Path::new("/tmp"), config).unwrap_err();
209
210        assert!(matches!(err, SettingsError::NonUserInvocableAgentSelector { .. }));
211    }
212
213    #[test]
214    fn settings_file_paths_are_project_relative() {
215        let dir = tempfile::tempdir().unwrap();
216        write_file(dir.path(), "PROMPT.md", "Be helpful");
217        write_file(
218            dir.path(),
219            "nested/config.json",
220            r#"{"agents":[{"name":"alpha","description":"Alpha","model":"anthropic:claude-sonnet-4-5","userInvocable":true,"prompts":[{"type":"file","path":"PROMPT.md"}]}]}"#,
221        );
222
223        let config = AetherSettings::load(
224            dir.path(),
225            [AetherSettingsSource::File(SettingsFileSource::new("nested/config.json", dir.path()))],
226        )
227        .unwrap();
228        let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
229
230        assert_eq!(catalog.all()[0].name, "alpha");
231    }
232
233    #[test]
234    fn load_merges_sources_with_rightmost_agent_winning() {
235        let dir = tempfile::tempdir().unwrap();
236        let base = AetherSettings {
237            agent: Some("alpha".to_string()),
238            prompts: vec![PromptSource::file("BASE.md")],
239            agents: vec![AgentConfig { description: "Base alpha".to_string(), ..agent_config("alpha") }],
240            ..AetherSettings::default()
241        };
242        let override_config = AetherSettings {
243            agent: Some("beta".to_string()),
244            prompts: vec![PromptSource::file("OVERRIDE.md")],
245            agents: vec![
246                AgentConfig { description: "Override alpha".to_string(), ..agent_config("alpha") },
247                agent_config("beta"),
248            ],
249            ..AetherSettings::default()
250        };
251
252        let config = AetherSettings::load(
253            dir.path(),
254            [AetherSettingsSource::Value(base), AetherSettingsSource::Value(override_config)],
255        )
256        .unwrap();
257
258        assert_eq!(
259            config,
260            AetherSettings {
261                agent: Some("beta".to_string()),
262                prompts: vec![PromptSource::file("OVERRIDE.md")],
263                agents: vec![
264                    AgentConfig { description: "Override alpha".to_string(), ..agent_config("alpha") },
265                    agent_config("beta"),
266                ],
267                ..AetherSettings::default()
268            }
269        );
270    }
271
272    #[test]
273    fn load_default_merges_user_and_project_settings_with_project_winning() {
274        let project = tempfile::tempdir().unwrap();
275        let home = tempfile::tempdir().unwrap();
276        let aether_home = home.path().join(".aether");
277        write_file(
278            &aether_home,
279            "settings.json",
280            r#"{
281                "agent":"shared",
282                "prompts":["USER.md"],
283                "agents":[
284                    {"name":"shared","description":"User shared","model":"anthropic:claude-sonnet-4-5","userInvocable":true},
285                    {"name":"user-only","description":"User only","model":"anthropic:claude-sonnet-4-5","userInvocable":true}
286                ]
287            }"#,
288        );
289        write_file(
290            project.path(),
291            ".aether/settings.json",
292            r#"{
293                "agent":"project-only",
294                "prompts":["PROJECT.md"],
295                "agents":[
296                    {"name":"shared","description":"Project shared","model":"anthropic:claude-sonnet-4-5","userInvocable":true},
297                    {"name":"project-only","description":"Project only","model":"anthropic:claude-sonnet-4-5","userInvocable":true}
298                ]
299            }"#,
300        );
301
302        let config = load_default_from_home(project.path(), &aether_home).unwrap();
303        assert_eq!(
304            config,
305            AetherSettings {
306                agent: Some("project-only".to_string()),
307                prompts: vec![PromptSource::file("PROJECT.md")],
308                agents: vec![
309                    settings_agent("shared", "Project shared"),
310                    settings_agent("user-only", "User only"),
311                    settings_agent("project-only", "Project only"),
312                ],
313                ..AetherSettings::default()
314            }
315        );
316    }
317
318    #[test]
319    fn load_default_uses_user_settings_when_project_settings_are_missing() {
320        let project = tempfile::tempdir().unwrap();
321        let home = tempfile::tempdir().unwrap();
322        let aether_home = home.path().join(".aether");
323        write_file(
324            &aether_home,
325            "settings.json",
326            r#"{"agents":[{"name":"user-only","description":"User only","model":"anthropic:claude-sonnet-4-5","userInvocable":true}]}"#,
327        );
328
329        let config = load_default_from_home(project.path(), &aether_home).unwrap();
330        assert_eq!(
331            config,
332            AetherSettings { agents: vec![settings_agent("user-only", "User only")], ..AetherSettings::default() }
333        );
334    }
335
336    #[test]
337    fn load_default_resolves_user_agent_paths_from_aether_home() {
338        let project = tempfile::tempdir().unwrap();
339        let home = tempfile::tempdir().unwrap();
340        let aether_home = home.path().join(".aether");
341        write_file(&aether_home, "agents/user.md", "User instructions");
342        write_file(&aether_home, "mcp/user.json", r#"{"servers":{}}"#);
343        write_file(
344            &aether_home,
345            "settings.json",
346            r#"{
347                "agents":[{
348                    "name":"user-only",
349                    "description":"User only",
350                    "model":"anthropic:claude-sonnet-4-5",
351                    "userInvocable":true,
352                    "prompts":["agents/user.md"],
353                    "mcps":["mcp/user.json"]
354                }]
355            }"#,
356        );
357
358        let config = load_default_from_home(project.path(), &aether_home).unwrap();
359        let catalog = AgentCatalog::from_settings(project.path(), config).unwrap();
360        let spec = catalog.resolve("user-only").unwrap();
361
362        let expected_prompt = aether_home.join("agents/user.md").to_string_lossy().to_string();
363        assert!(spec.prompts.iter().any(|prompt| match prompt {
364            Prompt::File { path, .. } => path == &expected_prompt,
365            Prompt::Text(_) | Prompt::PromptGlobs { .. } | Prompt::McpInstructions(_) => false,
366        }));
367        assert!(matches!(
368            &spec.mcp_config_sources[0],
369            McpConfigSource::File { path, proxy: false } if path == &aether_home.join("mcp/user.json")
370        ));
371    }
372
373    #[test]
374    fn load_default_uses_project_settings_when_user_settings_are_missing() {
375        let project = tempfile::tempdir().unwrap();
376        let home = tempfile::tempdir().unwrap();
377        let aether_home = home.path().join(".aether");
378        write_file(
379            project.path(),
380            ".aether/settings.json",
381            r#"{"agents":[{"name":"project-only","description":"Project only","model":"anthropic:claude-sonnet-4-5","userInvocable":true}]}"#,
382        );
383
384        let config = load_default_from_home(project.path(), &aether_home).unwrap();
385
386        assert_eq!(
387            config,
388            AetherSettings {
389                agents: vec![settings_agent("project-only", "Project only")],
390                ..AetherSettings::default()
391            }
392        );
393    }
394
395    #[test]
396    fn load_default_returns_default_when_user_and_project_settings_are_missing() {
397        let project = tempfile::tempdir().unwrap();
398        let home = tempfile::tempdir().unwrap();
399        let aether_home = home.path().join(".aether");
400        let config = load_default_from_home(project.path(), &aether_home).unwrap();
401        assert_eq!(config, AetherSettings::default());
402    }
403
404    #[test]
405    fn load_default_rejects_malformed_user_settings() {
406        let project = tempfile::tempdir().unwrap();
407        let home = tempfile::tempdir().unwrap();
408        let aether_home = home.path().join(".aether");
409        write_file(&aether_home, "settings.json", "{not-json");
410        let err = load_default_from_home(project.path(), &aether_home).unwrap_err();
411        assert!(matches!(err, SettingsError::ParseError(_)));
412    }
413
414    #[test]
415    fn strict_file_source_errors_when_missing() {
416        let project = tempfile::tempdir().unwrap();
417        let err = AetherSettings::load(
418            project.path(),
419            [AetherSettingsSource::File(SettingsFileSource::new("missing.json", project.path()))],
420        )
421        .unwrap_err();
422
423        assert!(matches!(err, SettingsError::IoError(_)));
424    }
425
426    #[test]
427    fn optional_file_source_returns_default_when_missing() {
428        let project = tempfile::tempdir().unwrap();
429        let config = AetherSettings::load(
430            project.path(),
431            [AetherSettingsSource::OptionalFile(SettingsFileSource::new("missing.json", project.path()))],
432        )
433        .unwrap();
434
435        assert_eq!(config, AetherSettings::default());
436    }
437
438    #[test]
439    fn resolves_inline_mcp_config() {
440        let dir = tempfile::tempdir().unwrap();
441        write_file(dir.path(), "PROMPT.md", "Be helpful");
442        let config = AetherSettings {
443            agent: None,
444            agents: vec![AgentConfig {
445                mcps: vec![McpSourceSpec::Inline { servers: BTreeMap::new() }],
446                ..agent_config("alpha")
447            }],
448            ..AetherSettings::default()
449        };
450
451        let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
452        let spec = catalog.resolve("alpha").unwrap();
453
454        assert_eq!(spec.mcp_config_sources.len(), 1);
455        assert!(matches!(spec.mcp_config_sources[0], McpConfigSource::Inline(_)));
456    }
457
458    #[test]
459    fn parses_top_level_prompt_and_mcp_defaults() {
460        let config = AetherSettings::try_from(
461            r#"{
462                "prompts": [{"type":"file","path":"BASE.md"}],
463                "mcps": [{"type":"file","path":"mcp.json"}],
464                "agents": [{
465                    "name":"alpha",
466                    "description":"Alpha",
467                    "model":"anthropic:claude-sonnet-4-5",
468                    "userInvocable":true
469                }]
470            }"#,
471        )
472        .unwrap();
473
474        assert_eq!(
475            config,
476            AetherSettings {
477                prompts: vec![PromptSource::file("BASE.md")],
478                mcps: vec![McpSourceSpec::file("mcp.json")],
479                agents: vec![settings_agent("alpha", "Alpha")],
480                ..AetherSettings::default()
481            }
482        );
483    }
484
485    #[test]
486    fn parses_and_serializes_string_shorthand_for_file_sources() {
487        let config = AetherSettings::try_from(
488            r#"{
489                "prompts": ["BASE.md"],
490                "mcps": ["mcp.json"],
491                "agents": [{
492                    "name":"alpha",
493                    "description":"Alpha",
494                    "model":"anthropic:claude-sonnet-4-5",
495                    "userInvocable":true,
496                    "prompts":["AGENT.md"],
497                    "mcps":["agent-mcp.json"]
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![AgentConfig {
509                    prompts: vec![PromptSource::file("AGENT.md")],
510                    mcps: vec![McpSourceSpec::file("agent-mcp.json")],
511                    ..settings_agent("alpha", "Alpha")
512                }],
513                ..AetherSettings::default()
514            }
515        );
516
517        let value = serde_json::to_value(&config).unwrap();
518        assert_eq!(value["prompts"], serde_json::json!(["BASE.md"]));
519        assert_eq!(value["mcps"], serde_json::json!(["mcp.json"]));
520        assert_eq!(value["agents"][0]["prompts"], serde_json::json!(["AGENT.md"]));
521        assert_eq!(value["agents"][0]["mcps"], serde_json::json!(["agent-mcp.json"]));
522    }
523
524    #[test]
525    fn serializes_proxied_mcp_file_as_typed_object() {
526        let source = McpSourceSpec::File { path: "mcp.json".to_string(), proxy: true };
527
528        let value = serde_json::to_value(source).unwrap();
529
530        assert_eq!(value, serde_json::json!({"type":"file", "path":"mcp.json", "proxy":true}));
531    }
532
533    #[test]
534    fn rejects_old_top_level_mcp_servers_field() {
535        let err = AetherSettings::try_from(
536            r#"{
537                "mcpServers": ["mcp.json"],
538                "agents": [{
539                    "name":"alpha",
540                    "description":"Alpha",
541                    "model":"anthropic:claude-sonnet-4-5",
542                    "userInvocable":true,
543                    "prompts":[{"type":"file","path":"PROMPT.md"}]
544                }]
545            }"#,
546        )
547        .unwrap_err();
548
549        assert!(matches!(err, SettingsError::ParseError(message) if message.contains("mcpServers")));
550    }
551
552    fn load_default_from_home(project_root: &Path, aether_home: &Path) -> Result<AetherSettings, SettingsError> {
553        AetherSettings::load(project_root, default_sources_for_home(project_root, Some(aether_home)))
554    }
555
556    fn write_file(dir: &Path, path: &str, content: &str) {
557        let full = dir.join(path);
558        if let Some(parent) = full.parent() {
559            create_dir_all(parent).unwrap();
560        }
561
562        write(full, content).unwrap();
563    }
564
565    fn settings_agent(name: &str, description: &str) -> AgentConfig {
566        AgentConfig {
567            name: name.to_string(),
568            description: description.to_string(),
569            model: "anthropic:claude-sonnet-4-5".to_string(),
570            user_invocable: true,
571            ..AgentConfig::default()
572        }
573    }
574
575    fn agent_config(name: &str) -> AgentConfig {
576        AgentConfig {
577            name: name.to_string(),
578            description: format!("{name} agent"),
579            model: "anthropic:claude-sonnet-4-5".to_string(),
580            user_invocable: true,
581            prompts: vec![PromptSource::file("PROMPT.md")],
582            ..AgentConfig::default()
583        }
584    }
585}