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