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