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";
11const USER_SETTINGS_FILENAME: &str = "settings.json";
12
13pub fn user_settings_path() -> Option<PathBuf> {
14 SettingsStore::new("AETHER_HOME", ".aether").map(|store| store.home().join(USER_SETTINGS_FILENAME))
15}
16
17pub fn user_settings_exist() -> bool {
18 user_settings_path().is_some_and(|p| p.is_file())
19}
20
21pub fn project_settings_path(project_root: &Path) -> PathBuf {
22 project_root.join(PROJECT_SETTINGS_PATH)
23}
24
25pub fn project_settings_exist(project_root: &Path) -> bool {
26 project_settings_path(project_root).is_file()
27}
28
29#[derive(Debug, Clone, Default, PartialEq, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
30#[serde(rename_all = "camelCase", deny_unknown_fields)]
31pub struct AetherSettings {
32 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub agent: Option<String>,
34 #[serde(default, skip_serializing_if = "Vec::is_empty")]
35 pub prompts: Vec<PromptSource>,
36 #[serde(default, skip_serializing_if = "Vec::is_empty")]
37 pub mcps: Vec<McpSourceSpec>,
38 #[serde(default, skip_serializing_if = "ProviderConnectionOverrides::is_empty")]
39 pub providers: ProviderConnectionOverrides,
40 #[schemars(length(min = 1))]
41 pub agents: Vec<AgentConfig>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct SettingsFileSource {
46 pub path: PathBuf,
47 pub root: PathBuf,
48}
49
50#[derive(Debug, Clone)]
51pub enum AetherSettingsSource {
52 File(SettingsFileSource),
53 OptionalFile(SettingsFileSource),
54 Json(String),
55 Value(AetherSettings),
56}
57
58impl SettingsFileSource {
59 pub fn new(path: impl Into<PathBuf>, root: impl Into<PathBuf>) -> Self {
60 Self { path: path.into(), root: root.into() }
61 }
62}
63
64impl AetherSettings {
65 pub fn load_default(project_root: &Path) -> Result<Self, SettingsError> {
66 Self::load(project_root, default_sources(project_root))
67 }
68
69 pub fn load(
70 project_root: &Path,
71 sources: impl IntoIterator<Item = AetherSettingsSource>,
72 ) -> Result<Self, SettingsError> {
73 sources.into_iter().try_fold(Self::default(), |config, source| {
74 let next = Self::load_source(project_root, source)?;
75 Ok(config.merge(next))
76 })
77 }
78
79 pub fn merge(mut self, next: Self) -> Self {
80 if next.agent.is_some() {
81 self.agent = next.agent;
82 }
83
84 if !next.prompts.is_empty() {
85 self.prompts = next.prompts;
86 }
87 if !next.mcps.is_empty() {
88 self.mcps = next.mcps;
89 }
90 self.providers.merge(next.providers);
91
92 for next_agent in next.agents {
93 if let Some(existing) = self.agents.iter_mut().find(|agent| agent.name.trim() == next_agent.name.trim()) {
94 *existing = next_agent;
95 } else {
96 self.agents.push(next_agent);
97 }
98 }
99
100 self
101 }
102
103 fn load_source(project_root: &Path, source: AetherSettingsSource) -> Result<Self, SettingsError> {
104 match source {
105 AetherSettingsSource::File(source) => load_file_source(project_root, source, false),
106 AetherSettingsSource::OptionalFile(source) => load_file_source(project_root, source, true),
107 AetherSettingsSource::Json(json) => Self::try_from(json.as_str()),
108 AetherSettingsSource::Value(settings) => Ok(settings),
109 }
110 }
111}
112
113fn default_sources(project_root: &Path) -> Vec<AetherSettingsSource> {
114 let aether_home = SettingsStore::new("AETHER_HOME", ".aether").map(|store| store.home().to_path_buf());
115 default_sources_for_home(project_root, aether_home.as_deref())
116}
117
118fn default_sources_for_home(project_root: &Path, aether_home: Option<&Path>) -> Vec<AetherSettingsSource> {
119 let mut sources = Vec::new();
120 if let Some(aether_home) = aether_home {
121 sources.push(AetherSettingsSource::OptionalFile(SettingsFileSource::new("settings.json", aether_home)));
122 }
123 sources.push(AetherSettingsSource::OptionalFile(SettingsFileSource::new(PROJECT_SETTINGS_PATH, project_root)));
124 sources
125}
126
127fn load_file_source(
128 project_root: &Path,
129 source: SettingsFileSource,
130 missing_is_empty: bool,
131) -> Result<AetherSettings, SettingsError> {
132 let root = resolve_against(project_root, source.root);
133 let path = resolve_against(&root, source.path);
134 let settings = load_file(&path, missing_is_empty)?;
135 let source_root = (root != project_root).then_some(root.as_path());
136 Ok(normalize_resource_paths(settings, source_root))
137}
138
139fn resolve_against(base: &Path, path: PathBuf) -> PathBuf {
140 if path.is_absolute() { path } else { base.join(path) }
141}
142
143fn load_file(path: &Path, missing_is_empty: bool) -> Result<AetherSettings, SettingsError> {
144 match read_to_string(path) {
145 Ok(content) if content.trim().is_empty() => Ok(AetherSettings::default()),
146 Ok(content) => AetherSettings::try_from(content.as_str()),
147 Err(error) if missing_is_empty && error.kind() == std::io::ErrorKind::NotFound => Ok(AetherSettings::default()),
148 Err(error) => Err(SettingsError::IoError(format!("Failed to read {}: {}", path.display(), error))),
149 }
150}
151
152fn normalize_resource_paths(mut settings: AetherSettings, source_root: Option<&Path>) -> AetherSettings {
153 let Some(root) = source_root else { return settings };
154 promote_prompt_sources(&mut settings.prompts, root);
155 promote_mcp_sources(&mut settings.mcps, root);
156
157 for agent in &mut settings.agents {
158 promote_prompt_sources(&mut agent.prompts, root);
159 promote_mcp_sources(&mut agent.mcps, root);
160 }
161
162 settings
163}
164
165fn promote_prompt_sources(sources: &mut [PromptSource], source_root: &Path) {
166 for source in sources {
167 match source {
168 PromptSource::File { path, .. } | PromptSource::Glob { pattern: path, .. } => {
169 path.promote_relative(source_root);
170 }
171 PromptSource::Text { .. } => {}
172 }
173 }
174}
175
176fn promote_mcp_sources(sources: &mut [McpSourceSpec], source_root: &Path) {
177 for source in sources {
178 if let McpSourceSpec::File(file) = source {
179 file.path.promote_relative(source_root);
180 }
181 }
182}
183
184impl TryFrom<&str> for AetherSettings {
185 type Error = SettingsError;
186
187 fn try_from(content: &str) -> Result<Self, Self::Error> {
188 serde_json::from_str(content).map_err(|e| SettingsError::ParseError(e.to_string()))
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195 use crate::{AgentCatalog, McpFileSpec, McpSourceSpec, PromptSource};
196 use aether_core::agent_spec::McpConfigSource;
197 use aether_core::core::Prompt;
198 use std::collections::BTreeMap;
199 use std::fs::{create_dir_all, write};
200
201 #[test]
202 fn project_settings_path_points_at_project_aether_settings() {
203 assert_eq!(project_settings_path(Path::new("/repo")), PathBuf::from("/repo/.aether/settings.json"));
204 }
205
206 #[test]
207 fn project_settings_exist_checks_project_settings_file() {
208 let dir = tempfile::tempdir().unwrap();
209 assert!(!project_settings_exist(dir.path()));
210 write_file(dir.path(), PROJECT_SETTINGS_PATH, "{}");
211 assert!(project_settings_exist(dir.path()));
212 }
213
214 #[test]
215 fn resolves_selected_agent() {
216 let dir = tempfile::tempdir().unwrap();
217 write_file(dir.path(), "PROMPT.md", "Be helpful");
218 let config = AetherSettings {
219 agent: Some("beta".to_string()),
220 agents: vec![agent_config("alpha"), agent_config("beta")],
221 ..AetherSettings::default()
222 };
223
224 let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
225
226 assert_eq!(catalog.default_agent().map(|spec| spec.name.as_str()), Some("beta"));
227 }
228
229 #[test]
230 fn rejects_selected_agent_that_is_not_user_invocable() {
231 let mut internal = agent_config("internal");
232 internal.user_invocable = false;
233 internal.agent_invocable = true;
234 let config =
235 AetherSettings { agent: Some("internal".to_string()), agents: vec![internal], ..AetherSettings::default() };
236
237 let err = AgentCatalog::from_settings(Path::new("/tmp"), config).unwrap_err();
238
239 assert!(matches!(err, SettingsError::NonUserInvocableAgentSelector { .. }));
240 }
241
242 #[test]
243 fn settings_file_paths_are_project_relative() {
244 let dir = tempfile::tempdir().unwrap();
245 write_file(dir.path(), "PROMPT.md", "Be helpful");
246 write_file(
247 dir.path(),
248 "nested/config.json",
249 r#"{"agents":[{"name":"alpha","description":"Alpha","model":"anthropic:claude-sonnet-4-5","userInvocable":true,"prompts":[{"type":"file","path":"PROMPT.md"}]}]}"#,
250 );
251
252 let config = AetherSettings::load(
253 dir.path(),
254 [AetherSettingsSource::File(SettingsFileSource::new("nested/config.json", dir.path()))],
255 )
256 .unwrap();
257 let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
258
259 assert_eq!(catalog.all()[0].name, "alpha");
260 }
261
262 #[test]
263 fn load_merges_sources_with_rightmost_agent_winning() {
264 let dir = tempfile::tempdir().unwrap();
265 let base = AetherSettings {
266 agent: Some("alpha".to_string()),
267 prompts: vec![PromptSource::file("BASE.md")],
268 agents: vec![AgentConfig { description: "Base alpha".to_string(), ..agent_config("alpha") }],
269 ..AetherSettings::default()
270 };
271 let override_config = AetherSettings {
272 agent: Some("beta".to_string()),
273 prompts: vec![PromptSource::file("OVERRIDE.md")],
274 agents: vec![
275 AgentConfig { description: "Override alpha".to_string(), ..agent_config("alpha") },
276 agent_config("beta"),
277 ],
278 ..AetherSettings::default()
279 };
280
281 let config = AetherSettings::load(
282 dir.path(),
283 [AetherSettingsSource::Value(base), AetherSettingsSource::Value(override_config)],
284 )
285 .unwrap();
286
287 assert_eq!(
288 config,
289 AetherSettings {
290 agent: Some("beta".to_string()),
291 prompts: vec![PromptSource::file("OVERRIDE.md")],
292 agents: vec![
293 AgentConfig { description: "Override alpha".to_string(), ..agent_config("alpha") },
294 agent_config("beta"),
295 ],
296 ..AetherSettings::default()
297 }
298 );
299 }
300
301 #[test]
302 fn load_default_merges_user_and_project_settings_with_project_winning() {
303 let project = tempfile::tempdir().unwrap();
304 let home = tempfile::tempdir().unwrap();
305 let aether_home = home.path().join(".aether");
306 write_file(
307 &aether_home,
308 "settings.json",
309 r#"{
310 "agent":"shared",
311 "prompts":["USER.md"],
312 "agents":[
313 {"name":"shared","description":"User shared","model":"anthropic:claude-sonnet-4-5","userInvocable":true},
314 {"name":"user-only","description":"User only","model":"anthropic:claude-sonnet-4-5","userInvocable":true}
315 ]
316 }"#,
317 );
318 write_file(
319 project.path(),
320 ".aether/settings.json",
321 r#"{
322 "agent":"project-only",
323 "prompts":["PROJECT.md"],
324 "agents":[
325 {"name":"shared","description":"Project shared","model":"anthropic:claude-sonnet-4-5","userInvocable":true},
326 {"name":"project-only","description":"Project only","model":"anthropic:claude-sonnet-4-5","userInvocable":true}
327 ]
328 }"#,
329 );
330
331 let config = load_default_from_home(project.path(), &aether_home).unwrap();
332 assert_eq!(
333 config,
334 AetherSettings {
335 agent: Some("project-only".to_string()),
336 prompts: vec![PromptSource::file("PROJECT.md")],
337 agents: vec![
338 settings_agent("shared", "Project shared"),
339 settings_agent("user-only", "User only"),
340 settings_agent("project-only", "Project only"),
341 ],
342 ..AetherSettings::default()
343 }
344 );
345 }
346
347 #[test]
348 fn load_default_uses_user_settings_when_project_settings_are_missing() {
349 let project = tempfile::tempdir().unwrap();
350 let home = tempfile::tempdir().unwrap();
351 let aether_home = home.path().join(".aether");
352 write_file(
353 &aether_home,
354 "settings.json",
355 r#"{"agents":[{"name":"user-only","description":"User only","model":"anthropic:claude-sonnet-4-5","userInvocable":true}]}"#,
356 );
357
358 let config = load_default_from_home(project.path(), &aether_home).unwrap();
359 assert_eq!(
360 config,
361 AetherSettings { agents: vec![settings_agent("user-only", "User only")], ..AetherSettings::default() }
362 );
363 }
364
365 #[test]
366 fn load_default_resolves_user_agent_paths_from_aether_home() {
367 let project = tempfile::tempdir().unwrap();
368 let home = tempfile::tempdir().unwrap();
369 let aether_home = home.path().join(".aether");
370 write_file(&aether_home, "agents/user.md", "User instructions");
371 write_file(&aether_home, "mcp/user.json", r#"{"servers":{}}"#);
372 write_file(
373 &aether_home,
374 "settings.json",
375 r#"{
376 "agents":[{
377 "name":"user-only",
378 "description":"User only",
379 "model":"anthropic:claude-sonnet-4-5",
380 "userInvocable":true,
381 "prompts":["agents/user.md"],
382 "mcps":["mcp/user.json"]
383 }]
384 }"#,
385 );
386
387 let config = load_default_from_home(project.path(), &aether_home).unwrap();
388 let catalog = AgentCatalog::from_settings(project.path(), config).unwrap();
389 let spec = catalog.resolve("user-only").unwrap();
390
391 let expected_prompt = aether_home.join("agents/user.md");
392 assert!(spec.prompts.iter().any(|prompt| match prompt {
393 Prompt::File { path, .. } => path == &expected_prompt,
394 Prompt::Text(_) | Prompt::McpInstructions(_) => false,
395 }));
396 assert!(matches!(
397 &spec.mcp_config_sources[0],
398 McpConfigSource::File { path, proxy: false } if path == &aether_home.join("mcp/user.json")
399 ));
400 }
401
402 #[test]
403 fn load_default_uses_project_settings_when_user_settings_are_missing() {
404 let project = tempfile::tempdir().unwrap();
405 let home = tempfile::tempdir().unwrap();
406 let aether_home = home.path().join(".aether");
407 write_file(
408 project.path(),
409 ".aether/settings.json",
410 r#"{"agents":[{"name":"project-only","description":"Project only","model":"anthropic:claude-sonnet-4-5","userInvocable":true}]}"#,
411 );
412
413 let config = load_default_from_home(project.path(), &aether_home).unwrap();
414
415 assert_eq!(
416 config,
417 AetherSettings {
418 agents: vec![settings_agent("project-only", "Project only")],
419 ..AetherSettings::default()
420 }
421 );
422 }
423
424 #[test]
425 fn load_default_returns_default_when_user_and_project_settings_are_missing() {
426 let project = tempfile::tempdir().unwrap();
427 let home = tempfile::tempdir().unwrap();
428 let aether_home = home.path().join(".aether");
429 let config = load_default_from_home(project.path(), &aether_home).unwrap();
430 assert_eq!(config, AetherSettings::default());
431 }
432
433 #[test]
434 fn load_default_rejects_malformed_user_settings() {
435 let project = tempfile::tempdir().unwrap();
436 let home = tempfile::tempdir().unwrap();
437 let aether_home = home.path().join(".aether");
438 write_file(&aether_home, "settings.json", "{not-json");
439 let err = load_default_from_home(project.path(), &aether_home).unwrap_err();
440 assert!(matches!(err, SettingsError::ParseError(_)));
441 }
442
443 #[test]
444 fn strict_file_source_errors_when_missing() {
445 let project = tempfile::tempdir().unwrap();
446 let err = AetherSettings::load(
447 project.path(),
448 [AetherSettingsSource::File(SettingsFileSource::new("missing.json", project.path()))],
449 )
450 .unwrap_err();
451
452 assert!(matches!(err, SettingsError::IoError(_)));
453 }
454
455 #[test]
456 fn optional_file_source_returns_default_when_missing() {
457 let project = tempfile::tempdir().unwrap();
458 let config = AetherSettings::load(
459 project.path(),
460 [AetherSettingsSource::OptionalFile(SettingsFileSource::new("missing.json", project.path()))],
461 )
462 .unwrap();
463
464 assert_eq!(config, AetherSettings::default());
465 }
466
467 #[test]
468 fn resolves_inline_mcp_config() {
469 let dir = tempfile::tempdir().unwrap();
470 write_file(dir.path(), "PROMPT.md", "Be helpful");
471 let config = AetherSettings {
472 agent: None,
473 agents: vec![AgentConfig {
474 mcps: vec![McpSourceSpec::Inline { servers: BTreeMap::new() }],
475 ..agent_config("alpha")
476 }],
477 ..AetherSettings::default()
478 };
479
480 let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
481 let spec = catalog.resolve("alpha").unwrap();
482
483 assert_eq!(spec.mcp_config_sources.len(), 1);
484 assert!(matches!(spec.mcp_config_sources[0], McpConfigSource::Inline(_)));
485 }
486
487 #[test]
488 fn parses_top_level_prompt_and_mcp_defaults() {
489 let config = AetherSettings::try_from(
490 r#"{
491 "prompts": [{"type":"file","path":"BASE.md"}],
492 "mcps": [{"type":"file","path":"mcp.json"}],
493 "agents": [{
494 "name":"alpha",
495 "description":"Alpha",
496 "model":"anthropic:claude-sonnet-4-5",
497 "userInvocable":true
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![settings_agent("alpha", "Alpha")],
509 ..AetherSettings::default()
510 }
511 );
512 }
513
514 #[test]
515 fn parses_and_serializes_string_shorthand_for_file_sources() {
516 let config = AetherSettings::try_from(
517 r#"{
518 "prompts": ["BASE.md"],
519 "mcps": ["mcp.json"],
520 "agents": [{
521 "name":"alpha",
522 "description":"Alpha",
523 "model":"anthropic:claude-sonnet-4-5",
524 "userInvocable":true,
525 "prompts":["AGENT.md"],
526 "mcps":["agent-mcp.json"]
527 }]
528 }"#,
529 )
530 .unwrap();
531
532 assert_eq!(
533 config,
534 AetherSettings {
535 prompts: vec![PromptSource::file("BASE.md")],
536 mcps: vec![McpSourceSpec::file("mcp.json")],
537 agents: vec![AgentConfig {
538 prompts: vec![PromptSource::file("AGENT.md")],
539 mcps: vec![McpSourceSpec::file("agent-mcp.json")],
540 ..settings_agent("alpha", "Alpha")
541 }],
542 ..AetherSettings::default()
543 }
544 );
545
546 let value = serde_json::to_value(&config).unwrap();
547 assert_eq!(value["prompts"], serde_json::json!(["BASE.md"]));
548 assert_eq!(value["mcps"], serde_json::json!(["mcp.json"]));
549 assert_eq!(value["agents"][0]["prompts"], serde_json::json!(["AGENT.md"]));
550 assert_eq!(value["agents"][0]["mcps"], serde_json::json!(["agent-mcp.json"]));
551 }
552
553 #[test]
554 fn serializes_proxied_mcp_file_as_typed_object() {
555 let source: McpSourceSpec = McpFileSpec::new("mcp.json").proxy().into();
556
557 let value = serde_json::to_value(source).unwrap();
558
559 assert_eq!(value, serde_json::json!({"type":"file", "path":"mcp.json", "proxy":true}));
560 }
561
562 #[test]
563 fn rejects_old_top_level_mcp_servers_field() {
564 let err = AetherSettings::try_from(
565 r#"{
566 "mcpServers": ["mcp.json"],
567 "agents": [{
568 "name":"alpha",
569 "description":"Alpha",
570 "model":"anthropic:claude-sonnet-4-5",
571 "userInvocable":true,
572 "prompts":[{"type":"file","path":"PROMPT.md"}]
573 }]
574 }"#,
575 )
576 .unwrap_err();
577
578 assert!(matches!(err, SettingsError::ParseError(message) if message.contains("mcpServers")));
579 }
580
581 #[test]
582 fn load_default_resolves_workspace_scoped_user_prompt_and_mcp_paths() {
583 let project = tempfile::tempdir().unwrap();
584 let home = tempfile::tempdir().unwrap();
585 let aether_home = home.path().join(".aether");
586 write_file(&aether_home, "agents/planner/SYSTEM.md", "System instructions");
587 write_file(project.path(), "AGENTS.md", "Agent instructions");
588 write_file(project.path(), ".aether/mcp.json", r#"{"servers":{}}"#);
589 write_file(
590 &aether_home,
591 "settings.json",
592 r#"{
593 "agents":[{
594 "name":"planner",
595 "description":"Plans work",
596 "model":"anthropic:claude-sonnet-4-5",
597 "userInvocable":true,
598 "prompts":[
599 "agents/planner/SYSTEM.md",
600 {"type":"file","path":"${WORKSPACE}/AGENTS.md"}
601 ],
602 "mcps":[
603 {"type":"file","path":"${WORKSPACE}/.aether/mcp.json"}
604 ]
605 }]
606 }"#,
607 );
608
609 let config = load_default_from_home(project.path(), &aether_home).unwrap();
610 let catalog = AgentCatalog::from_settings(project.path(), config).unwrap();
611 let spec = catalog.resolve("planner").unwrap();
612
613 let expected_system = aether_home.join("agents/planner/SYSTEM.md");
614 let expected_agents = project.path().join("AGENTS.md");
615 assert!(spec.prompts.iter().any(|p| match p {
616 Prompt::File { path, .. } => path == &expected_system,
617 _ => false,
618 }));
619 assert!(spec.prompts.iter().any(|p| match p {
620 Prompt::File { path, .. } => path == &expected_agents,
621 _ => false,
622 }));
623 assert!(matches!(
624 &spec.mcp_config_sources[0],
625 McpConfigSource::File { path, proxy: false } if *path == project.path().join(".aether/mcp.json")
626 ));
627 }
628
629 #[test]
630 fn workspace_scoped_paths_expand_in_project_settings_without_absolutizing_normal_relative_paths() {
631 let project = tempfile::tempdir().unwrap();
632 write_file(project.path(), "PROJECT.md", "Project prompt");
633 write_file(project.path(), "AGENTS.md", "Agent prompt");
634 write_file(
635 project.path(),
636 ".aether/settings.json",
637 r#"{
638 "agents":[{
639 "name":"alpha",
640 "description":"Alpha",
641 "model":"anthropic:claude-sonnet-4-5",
642 "userInvocable":true,
643 "prompts":["PROJECT.md", {"type":"file","path":"${WORKSPACE}/AGENTS.md"}]
644 }]
645 }"#,
646 );
647
648 let config = AetherSettings::load(
649 project.path(),
650 [AetherSettingsSource::OptionalFile(SettingsFileSource::new(PROJECT_SETTINGS_PATH, project.path()))],
651 )
652 .unwrap();
653
654 assert_eq!(config.agents[0].prompts[0], PromptSource::file("PROJECT.md"));
655 assert_eq!(config.agents[0].prompts[1], PromptSource::file("${WORKSPACE}/AGENTS.md"));
656 }
657
658 #[test]
659 fn json_and_value_sources_preserve_workspace_scoped_paths_losslessly() {
660 let project = tempfile::tempdir().unwrap();
661
662 let json_config = AetherSettings::load(
663 project.path(),
664 [AetherSettingsSource::Json(
665 r#"{
666 "agents":[{
667 "name":"alpha",
668 "description":"Alpha",
669 "model":"anthropic:claude-sonnet-4-5",
670 "userInvocable":true,
671 "prompts":["${WORKSPACE}/AGENTS.md"]
672 }]
673 }"#
674 .to_string(),
675 )],
676 )
677 .unwrap();
678
679 assert_eq!(json_config.agents[0].prompts[0], PromptSource::file("${WORKSPACE}/AGENTS.md"));
680
681 let value_config = AetherSettings::load(
682 project.path(),
683 [AetherSettingsSource::Value(AetherSettings {
684 agents: vec![AgentConfig {
685 prompts: vec![PromptSource::file("${WORKSPACE}/AGENTS.md")],
686 ..agent_config("alpha")
687 }],
688 ..AetherSettings::default()
689 })],
690 )
691 .unwrap();
692 assert_eq!(value_config.agents[0].prompts[0], PromptSource::file("${WORKSPACE}/AGENTS.md"));
693 }
694
695 #[test]
696 fn optional_workspace_scoped_mcp_source_is_skipped_when_missing() {
697 let project = tempfile::tempdir().unwrap();
698 write_file(project.path(), "BASE.md", "Base instructions");
699 let config = AetherSettings {
700 agents: vec![AgentConfig {
701 prompts: vec![PromptSource::file("BASE.md")],
702 mcps: vec![McpFileSpec::new("${WORKSPACE}/.aether/mcp.json").optional().into()],
703 ..agent_config("alpha")
704 }],
705 ..AetherSettings::default()
706 };
707
708 let config = AetherSettings::load(project.path(), [AetherSettingsSource::Value(config)]).unwrap();
709 let catalog = AgentCatalog::from_settings(project.path(), config).unwrap();
710 let spec = catalog.resolve("alpha").unwrap();
711
712 assert!(spec.mcp_config_sources.is_empty());
713 }
714
715 #[test]
716 fn optional_mcp_source_skips_unresolved_variable() {
717 let project = tempfile::tempdir().unwrap();
718 write_file(project.path(), "BASE.md", "Base instructions");
719 let config = AetherSettings {
720 agents: vec![AgentConfig {
721 prompts: vec![PromptSource::file("BASE.md")],
722 mcps: vec![McpFileSpec::new("${DEFINITELY_NOT_SET_VAR_MCP_OPTIONAL}/mcp.json").optional().into()],
723 ..agent_config("alpha")
724 }],
725 ..AetherSettings::default()
726 };
727
728 let config = AetherSettings::load(project.path(), [AetherSettingsSource::Value(config)]).unwrap();
729 let catalog = AgentCatalog::from_settings(project.path(), config).unwrap();
730 let spec = catalog.resolve("alpha").unwrap();
731
732 assert!(spec.mcp_config_sources.is_empty());
733 }
734
735 #[test]
736 fn required_mcp_source_errors_on_unresolved_variable() {
737 let project = tempfile::tempdir().unwrap();
738 write_file(project.path(), "BASE.md", "Base instructions");
739 let config = AetherSettings {
740 agents: vec![AgentConfig {
741 prompts: vec![PromptSource::file("BASE.md")],
742 mcps: vec![McpSourceSpec::file("${DEFINITELY_NOT_SET_VAR_MCP_REQ}/mcp.json")],
743 ..agent_config("alpha")
744 }],
745 ..AetherSettings::default()
746 };
747
748 let err = AgentCatalog::from_settings(project.path(), config).unwrap_err();
749 assert!(matches!(err, SettingsError::UnresolvedMcpConfigVariable { .. }));
750 }
751
752 #[test]
753 fn required_workspace_scoped_mcp_source_errors_when_missing() {
754 let project = tempfile::tempdir().unwrap();
755 write_file(project.path(), "BASE.md", "Base instructions");
756 let config = AetherSettings {
757 agents: vec![AgentConfig {
758 prompts: vec![PromptSource::file("BASE.md")],
759 mcps: vec![McpSourceSpec::file("nonexistent.json")],
760 ..agent_config("alpha")
761 }],
762 ..AetherSettings::default()
763 };
764
765 let err = AgentCatalog::from_settings(project.path(), config).unwrap_err();
766 assert!(matches!(err, SettingsError::InvalidMcpConfigPath { .. }));
767 }
768
769 #[test]
770 fn optional_existing_mcp_source_preserves_proxy_flag() {
771 let project = tempfile::tempdir().unwrap();
772 write_file(project.path(), "BASE.md", "Base instructions");
773 write_file(project.path(), "mcp.json", r#"{"servers":{}}"#);
774 let config = AetherSettings {
775 agents: vec![AgentConfig {
776 prompts: vec![PromptSource::file("BASE.md")],
777 mcps: vec![McpFileSpec::new("mcp.json").proxy().optional().into()],
778 ..agent_config("alpha")
779 }],
780 ..AetherSettings::default()
781 };
782
783 let catalog = AgentCatalog::from_settings(project.path(), config).unwrap();
784 let spec = catalog.resolve("alpha").unwrap();
785
786 assert!(matches!(&spec.mcp_config_sources[0], McpConfigSource::File { proxy: true, .. }));
787 }
788
789 #[test]
790 fn optional_mcp_source_serializes_as_typed_object() {
791 let source: McpSourceSpec = McpFileSpec::new("${WORKSPACE}/.aether/mcp.json").optional().into();
792 let value = serde_json::to_value(source).unwrap();
793 assert_eq!(value, serde_json::json!({"type":"file", "path":"${WORKSPACE}/.aether/mcp.json", "optional":true}));
794 }
795
796 #[test]
797 fn optional_prompt_source_serializes_as_typed_object() {
798 let source = PromptSource::file("${WORKSPACE}/AGENTS.md").optional();
799 let value = serde_json::to_value(&source).unwrap();
800 assert_eq!(value, serde_json::json!({"type":"file", "path":"${WORKSPACE}/AGENTS.md", "optional":true}));
801 }
802
803 #[test]
804 fn all_optional_prompts_missing_errors_with_no_prompts() {
805 let project = tempfile::tempdir().unwrap();
806 let config = AetherSettings {
807 agents: vec![AgentConfig {
808 prompts: vec![PromptSource::file("MISSING.md").optional()],
809 ..agent_config("alpha")
810 }],
811 ..AetherSettings::default()
812 };
813
814 let err = AgentCatalog::from_settings(project.path(), config).unwrap_err();
815 assert!(matches!(err, SettingsError::AllOptionalPromptsMissing { agent } if agent == "alpha"));
816 }
817
818 #[test]
819 fn settings_round_trip_preserves_workspace_prefix_and_relative_paths() {
820 let original = r#"{"agents":[{
821 "name":"alpha",
822 "description":"Alpha",
823 "model":"anthropic:claude-sonnet-4-5",
824 "userInvocable":true,
825 "prompts":[
826 "AGENTS.md",
827 "${WORKSPACE}/SYSTEM.md",
828 {"type":"file","path":"${WORKSPACE}/.aether/rules.md","optional":true},
829 {"type":"glob","pattern":"${WORKSPACE}/.aether/rules/*.md"}
830 ],
831 "mcps":[
832 "mcp.json",
833 {"type":"file","path":"${WORKSPACE}/.aether/mcp.json","optional":true}
834 ]
835 }]}"#;
836
837 let settings = AetherSettings::try_from(original).unwrap();
838 let reserialized = serde_json::to_string(&settings).unwrap();
839 let reparsed = AetherSettings::try_from(reserialized.as_str()).unwrap();
840
841 assert_eq!(settings, reparsed, "settings should round-trip losslessly through serde");
842 }
843
844 #[test]
845 fn user_settings_relative_paths_absolutize_at_load_but_workspace_token_is_preserved() {
846 let project = tempfile::tempdir().unwrap();
847 let home = tempfile::tempdir().unwrap();
848 let aether_home = home.path().join(".aether");
849 write_file(&aether_home, "agents/planner/SYSTEM.md", "system");
850 write_file(project.path(), "AGENTS.md", "agents");
851 write_file(
852 &aether_home,
853 "settings.json",
854 r#"{"agents":[{
855 "name":"planner",
856 "description":"Plans",
857 "model":"anthropic:claude-sonnet-4-5",
858 "userInvocable":true,
859 "prompts":["agents/planner/SYSTEM.md", "${WORKSPACE}/AGENTS.md"]
860 }]}"#,
861 );
862
863 let settings = load_default_from_home(project.path(), &aether_home).unwrap();
864
865 let expected_user = aether_home.join("agents/planner/SYSTEM.md").to_string_lossy().to_string();
866 assert_eq!(
867 settings.agents[0].prompts,
868 vec![PromptSource::file(expected_user), PromptSource::file("${WORKSPACE}/AGENTS.md")],
869 "user-rooted relative paths must absolutize; ${{WORKSPACE}}/ paths must be preserved",
870 );
871 }
872
873 fn load_default_from_home(project_root: &Path, aether_home: &Path) -> Result<AetherSettings, SettingsError> {
874 AetherSettings::load(project_root, default_sources_for_home(project_root, Some(aether_home)))
875 }
876
877 fn write_file(dir: &Path, path: &str, content: &str) {
878 let full = dir.join(path);
879 if let Some(parent) = full.parent() {
880 create_dir_all(parent).unwrap();
881 }
882
883 write(full, content).unwrap();
884 }
885
886 fn settings_agent(name: &str, description: &str) -> AgentConfig {
887 AgentConfig {
888 name: name.to_string(),
889 description: description.to_string(),
890 model: "anthropic:claude-sonnet-4-5".to_string(),
891 user_invocable: true,
892 ..AgentConfig::default()
893 }
894 }
895
896 fn agent_config(name: &str) -> AgentConfig {
897 AgentConfig {
898 name: name.to_string(),
899 description: format!("{name} agent"),
900 model: "anthropic:claude-sonnet-4-5".to_string(),
901 user_invocable: true,
902 prompts: vec![PromptSource::file("PROMPT.md")],
903 ..AgentConfig::default()
904 }
905 }
906}