Skip to main content

aether_project/
aether_settings.rs

1use crate::agent_config::AgentConfig;
2use crate::error::SettingsError;
3use crate::{McpSourceSpec, PromptSource};
4use std::fs::read_to_string;
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
8#[serde(rename_all = "camelCase", deny_unknown_fields)]
9pub struct AetherSettings {
10    #[serde(default, skip_serializing_if = "Option::is_none")]
11    pub agent: Option<String>,
12    #[serde(default, skip_serializing_if = "Vec::is_empty")]
13    pub prompts: Vec<PromptSource>,
14    #[serde(default, skip_serializing_if = "Vec::is_empty")]
15    pub mcps: Vec<McpSourceSpec>,
16    #[schemars(length(min = 1))]
17    pub agents: Vec<AgentConfig>,
18}
19
20#[derive(Debug, Clone)]
21pub enum AetherSettingsSource {
22    File(PathBuf),
23    Json(String),
24    Value(AetherSettings),
25}
26
27impl AetherSettings {
28    pub fn load_default(project_root: &Path) -> Result<Self, SettingsError> {
29        let settings_path = project_root.join(".aether/settings.json");
30        match read_to_string(&settings_path) {
31            Ok(content) if content.trim().is_empty() => Ok(Self::default()),
32            Ok(content) => Self::try_from(content.as_str()),
33            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
34            Err(e) => Err(SettingsError::IoError(format!("Failed to read {}: {}", settings_path.display(), e))),
35        }
36    }
37
38    pub fn load(
39        project_root: &Path,
40        sources: impl IntoIterator<Item = AetherSettingsSource>,
41    ) -> Result<Self, SettingsError> {
42        sources.into_iter().try_fold(Self::default(), |config, source| {
43            let next = Self::load_source(project_root, source)?;
44            Ok(config.merge(next))
45        })
46    }
47
48    pub fn merge(mut self, next: Self) -> Self {
49        if next.agent.is_some() {
50            self.agent = next.agent;
51        }
52
53        if !next.prompts.is_empty() {
54            self.prompts = next.prompts;
55        }
56        if !next.mcps.is_empty() {
57            self.mcps = next.mcps;
58        }
59
60        for next_agent in next.agents {
61            if let Some(existing) = self.agents.iter_mut().find(|agent| agent.name.trim() == next_agent.name.trim()) {
62                *existing = next_agent;
63            } else {
64                self.agents.push(next_agent);
65            }
66        }
67
68        self
69    }
70
71    fn load_source(project_root: &Path, source: AetherSettingsSource) -> Result<Self, SettingsError> {
72        match source {
73            AetherSettingsSource::File(path) => {
74                let path = project_root.join(path);
75                let content = read_to_string(&path)
76                    .map_err(|e| SettingsError::IoError(format!("Failed to read {}: {}", path.display(), e)))?;
77                Self::try_from(content.as_str())
78            }
79            AetherSettingsSource::Json(json) => Self::try_from(json.as_str()),
80            AetherSettingsSource::Value(config) => Ok(config),
81        }
82    }
83}
84
85impl TryFrom<&str> for AetherSettings {
86    type Error = SettingsError;
87
88    fn try_from(content: &str) -> Result<Self, Self::Error> {
89        serde_json::from_str(content).map_err(|e| SettingsError::ParseError(e.to_string()))
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use crate::{AgentCatalog, McpSourceSpec, PromptSource};
97    use aether_core::agent_spec::McpConfigSource;
98    use std::collections::BTreeMap;
99    use std::fs::{create_dir_all, write};
100    use std::path::PathBuf;
101
102    #[test]
103    fn resolves_selected_agent() {
104        let dir = tempfile::tempdir().unwrap();
105        write_file(dir.path(), "PROMPT.md", "Be helpful");
106        let config = AetherSettings {
107            agent: Some("beta".to_string()),
108            agents: vec![agent_config("alpha"), agent_config("beta")],
109            ..AetherSettings::default()
110        };
111
112        let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
113
114        assert_eq!(catalog.default_agent().map(|spec| spec.name.as_str()), Some("beta"));
115    }
116
117    #[test]
118    fn rejects_selected_agent_that_is_not_user_invocable() {
119        let mut internal = agent_config("internal");
120        internal.user_invocable = false;
121        internal.agent_invocable = true;
122        let config =
123            AetherSettings { agent: Some("internal".to_string()), agents: vec![internal], ..AetherSettings::default() };
124
125        let err = AgentCatalog::from_settings(Path::new("/tmp"), config).unwrap_err();
126
127        assert!(matches!(err, SettingsError::NonUserInvocableAgentSelector { .. }));
128    }
129
130    #[test]
131    fn settings_file_paths_are_project_relative() {
132        let dir = tempfile::tempdir().unwrap();
133        write_file(dir.path(), "PROMPT.md", "Be helpful");
134        write_file(
135            dir.path(),
136            "nested/config.json",
137            r#"{"agents":[{"name":"alpha","description":"Alpha","model":"anthropic:claude-sonnet-4-5","userInvocable":true,"prompts":[{"type":"file","path":"PROMPT.md"}]}]}"#,
138        );
139
140        let config =
141            AetherSettings::load(dir.path(), [AetherSettingsSource::File(PathBuf::from("nested/config.json"))])
142                .unwrap();
143        let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
144
145        assert_eq!(catalog.all()[0].name, "alpha");
146    }
147
148    #[test]
149    fn load_merges_sources_with_rightmost_agent_winning() {
150        let dir = tempfile::tempdir().unwrap();
151        let base = AetherSettings {
152            agent: Some("alpha".to_string()),
153            prompts: vec![PromptSource::file("BASE.md")],
154            agents: vec![AgentConfig { description: "Base alpha".to_string(), ..agent_config("alpha") }],
155            ..AetherSettings::default()
156        };
157        let override_config = AetherSettings {
158            agent: Some("beta".to_string()),
159            prompts: vec![PromptSource::file("OVERRIDE.md")],
160            agents: vec![
161                AgentConfig { description: "Override alpha".to_string(), ..agent_config("alpha") },
162                agent_config("beta"),
163            ],
164            ..AetherSettings::default()
165        };
166
167        let config = AetherSettings::load(
168            dir.path(),
169            [AetherSettingsSource::Value(base), AetherSettingsSource::Value(override_config)],
170        )
171        .unwrap();
172
173        assert_eq!(config.agent.as_deref(), Some("beta"));
174        assert_eq!(config.agents.len(), 2);
175        assert_eq!(config.agents[0].name, "alpha");
176        assert_eq!(config.agents[0].description, "Override alpha");
177        assert_eq!(config.agents[1].name, "beta");
178        assert_eq!(config.prompts, vec![PromptSource::file("OVERRIDE.md")]);
179    }
180
181    #[test]
182    fn resolves_inline_mcp_config() {
183        let dir = tempfile::tempdir().unwrap();
184        write_file(dir.path(), "PROMPT.md", "Be helpful");
185        let config = AetherSettings {
186            agent: None,
187            agents: vec![AgentConfig {
188                mcps: vec![McpSourceSpec::Inline { servers: BTreeMap::new() }],
189                ..agent_config("alpha")
190            }],
191            ..AetherSettings::default()
192        };
193
194        let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
195        let spec = catalog.resolve("alpha").unwrap();
196
197        assert_eq!(spec.mcp_config_sources.len(), 1);
198        assert!(matches!(spec.mcp_config_sources[0], McpConfigSource::Inline(_)));
199    }
200
201    #[test]
202    fn parses_top_level_prompt_and_mcp_defaults() {
203        let config = AetherSettings::try_from(
204            r#"{
205                "prompts": [{"type":"file","path":"BASE.md"}],
206                "mcps": [{"type":"file","path":"mcp.json"}],
207                "agents": [{
208                    "name":"alpha",
209                    "description":"Alpha",
210                    "model":"anthropic:claude-sonnet-4-5",
211                    "userInvocable":true
212                }]
213            }"#,
214        )
215        .unwrap();
216
217        assert_eq!(config.prompts, vec![PromptSource::file("BASE.md")]);
218        assert_eq!(config.mcps[0].path(), Some("mcp.json"));
219    }
220
221    #[test]
222    fn parses_and_serializes_string_shorthand_for_file_sources() {
223        let config = AetherSettings::try_from(
224            r#"{
225                "prompts": ["BASE.md"],
226                "mcps": ["mcp.json"],
227                "agents": [{
228                    "name":"alpha",
229                    "description":"Alpha",
230                    "model":"anthropic:claude-sonnet-4-5",
231                    "userInvocable":true,
232                    "prompts":["AGENT.md"],
233                    "mcps":["agent-mcp.json"]
234                }]
235            }"#,
236        )
237        .unwrap();
238
239        assert_eq!(config.prompts, vec![PromptSource::file("BASE.md")]);
240        assert_eq!(config.mcps[0].path(), Some("mcp.json"));
241        assert_eq!(config.agents[0].prompts, vec![PromptSource::file("AGENT.md")]);
242        assert_eq!(config.agents[0].mcps[0].path(), Some("agent-mcp.json"));
243
244        let value = serde_json::to_value(&config).unwrap();
245        assert_eq!(value["prompts"], serde_json::json!(["BASE.md"]));
246        assert_eq!(value["mcps"], serde_json::json!(["mcp.json"]));
247        assert_eq!(value["agents"][0]["prompts"], serde_json::json!(["AGENT.md"]));
248        assert_eq!(value["agents"][0]["mcps"], serde_json::json!(["agent-mcp.json"]));
249    }
250
251    #[test]
252    fn serializes_proxied_mcp_file_as_typed_object() {
253        let source = McpSourceSpec::File { path: "mcp.json".to_string(), proxy: true };
254
255        let value = serde_json::to_value(source).unwrap();
256
257        assert_eq!(value, serde_json::json!({"type":"file", "path":"mcp.json", "proxy":true}));
258    }
259
260    #[test]
261    fn rejects_old_top_level_mcp_servers_field() {
262        let err = AetherSettings::try_from(
263            r#"{
264                "mcpServers": ["mcp.json"],
265                "agents": [{
266                    "name":"alpha",
267                    "description":"Alpha",
268                    "model":"anthropic:claude-sonnet-4-5",
269                    "userInvocable":true,
270                    "prompts":[{"type":"file","path":"PROMPT.md"}]
271                }]
272            }"#,
273        )
274        .unwrap_err();
275
276        assert!(matches!(err, SettingsError::ParseError(message) if message.contains("mcpServers")));
277    }
278
279    fn write_file(dir: &Path, path: &str, content: &str) {
280        let full = dir.join(path);
281        if let Some(parent) = full.parent() {
282            create_dir_all(parent).unwrap();
283        }
284
285        write(full, content).unwrap();
286    }
287
288    fn agent_config(name: &str) -> AgentConfig {
289        AgentConfig {
290            name: name.to_string(),
291            description: format!("{name} agent"),
292            model: "anthropic:claude-sonnet-4-5".to_string(),
293            user_invocable: true,
294            prompts: vec![PromptSource::file("PROMPT.md")],
295            ..AgentConfig::default()
296        }
297    }
298}