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