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}