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}