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}