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::collections::BTreeMap;
12use std::fs::read_to_string;
13use std::path::{Path, PathBuf};
14use utils::variables::VarError;
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
17#[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)]
18pub enum CredentialsStoreConfig {
19 Keyring,
21
22 Memory,
26
27 EncryptedFile {
29 #[serde(default, skip_serializing_if = "Option::is_none")]
32 path: Option<PathBuf>,
33 #[serde(default, skip_serializing_if = "Option::is_none", rename = "passwordEnv")]
36 password_env: Option<String>,
37 },
38}
39
40const PROJECT_SETTINGS_PATH: &str = ".aether/settings.json";
41const USER_SETTINGS_FILENAME: &str = "settings.json";
42
43pub fn user_settings_path() -> Option<PathBuf> {
44 SettingsStore::new("AETHER_HOME", ".aether").map(|store| store.home().join(USER_SETTINGS_FILENAME))
45}
46
47pub fn user_settings_exist() -> bool {
48 user_settings_path().is_some_and(|p| p.is_file())
49}
50
51pub fn project_settings_path(project_root: &Path) -> PathBuf {
52 project_root.join(PROJECT_SETTINGS_PATH)
53}
54
55pub fn project_settings_exist(project_root: &Path) -> bool {
56 project_settings_path(project_root).is_file()
57}
58
59pub fn settings_resource_root(settings_path: &Path) -> PathBuf {
63 let Some(settings_dir) = settings_path.parent() else {
64 return PathBuf::from(".");
65 };
66
67 if settings_dir.file_name().and_then(|name| name.to_str()) == Some(".aether") {
68 return settings_dir.parent().unwrap_or(settings_dir).to_path_buf();
69 }
70
71 settings_dir.to_path_buf()
72}
73
74#[doc = include_str!("docs/aether_settings.md")]
75#[derive(Debug, Clone, Default, PartialEq, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
76#[serde(rename_all = "camelCase", deny_unknown_fields)]
77pub struct AetherSettings {
78 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub agent: Option<String>,
82 #[serde(default, skip_serializing_if = "Vec::is_empty")]
85 pub prompts: Vec<PromptSource>,
86 #[serde(default, skip_serializing_if = "Vec::is_empty")]
89 pub mcps: Vec<McpSourceSpec>,
90 #[serde(default, skip_serializing_if = "ProviderConnectionOverrides::is_empty")]
93 pub providers: ProviderConnectionOverrides,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub credentials_store: Option<CredentialsStoreConfig>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub telemetry: Option<TelemetrySettings>,
101 #[schemars(length(min = 1))]
103 pub agents: Vec<AgentConfig>,
104}
105
106#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
111#[serde(rename_all = "camelCase", deny_unknown_fields)]
112pub struct TelemetrySettings {
113 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub service_name: Option<String>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub sample_ratio: Option<f64>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub capture_content: Option<bool>,
123 #[serde(default, skip_serializing_if = "TelemetrySignalSettings::is_unset")]
125 pub traces: TelemetrySignalSettings,
126 #[serde(default, skip_serializing_if = "TelemetrySignalSettings::is_unset")]
128 pub metrics: TelemetrySignalSettings,
129 #[serde(default, skip_serializing_if = "OtlpTelemetrySettings::is_unset")]
130 pub otlp: OtlpTelemetrySettings,
131}
132
133#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
134#[serde(rename_all = "camelCase", deny_unknown_fields)]
135pub struct TelemetrySignalSettings {
136 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub enabled: Option<bool>,
138}
139
140#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
141#[serde(rename_all = "camelCase", deny_unknown_fields)]
142pub struct OtlpTelemetrySettings {
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub endpoint: Option<String>,
145 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
146 pub headers: BTreeMap<String, String>,
147}
148
149impl TelemetrySettings {
150 pub fn effective_enabled(&self) -> bool {
151 self.traces_enabled() || self.metrics_enabled()
152 }
153
154 pub fn service_name(&self) -> &str {
155 self.service_name.as_deref().unwrap_or("aether")
156 }
157
158 pub fn sample_ratio(&self) -> f64 {
159 self.sample_ratio.unwrap_or(1.0)
160 }
161
162 pub fn capture_content(&self) -> bool {
163 self.capture_content.unwrap_or(false)
164 }
165
166 pub fn traces_enabled(&self) -> bool {
167 self.traces.enabled.unwrap_or(true)
168 }
169
170 pub fn metrics_enabled(&self) -> bool {
171 self.metrics.enabled.unwrap_or(true)
172 }
173
174 fn merge(&mut self, next: Self) {
175 merge_field(&mut self.service_name, next.service_name);
176 merge_field(&mut self.sample_ratio, next.sample_ratio);
177 merge_field(&mut self.capture_content, next.capture_content);
178 merge_field(&mut self.traces.enabled, next.traces.enabled);
179 merge_field(&mut self.metrics.enabled, next.metrics.enabled);
180 merge_field(&mut self.otlp.endpoint, next.otlp.endpoint);
181 self.otlp.headers.extend(next.otlp.headers);
182 }
183}
184
185impl TelemetrySignalSettings {
186 fn is_unset(&self) -> bool {
187 self.enabled.is_none()
188 }
189}
190
191impl OtlpTelemetrySettings {
192 fn is_unset(&self) -> bool {
193 self.endpoint.is_none() && self.headers.is_empty()
194 }
195}
196
197fn merge_field<T>(current: &mut Option<T>, next: Option<T>) {
198 if next.is_some() {
199 *current = next;
200 }
201}
202
203#[derive(Debug, Clone, PartialEq, Eq)]
204pub struct SettingsFileSource {
205 pub path: PathBuf,
206 pub root: PathBuf,
207}
208
209#[derive(Debug, Clone)]
210pub enum AetherSettingsSource {
211 File(SettingsFileSource),
212 OptionalFile(SettingsFileSource),
213 Json(String),
214 Value(Box<AetherSettings>),
215}
216
217impl SettingsFileSource {
218 pub fn new(path: impl Into<PathBuf>, root: impl Into<PathBuf>) -> Self {
219 Self { path: path.into(), root: root.into() }
220 }
221}
222
223impl AetherSettings {
224 pub fn load_default(project_root: &Path) -> Result<Self, SettingsError> {
225 Self::load(project_root, default_sources(project_root))
226 }
227
228 pub fn load(
229 project_root: &Path,
230 sources: impl IntoIterator<Item = AetherSettingsSource>,
231 ) -> Result<Self, SettingsError> {
232 sources.into_iter().try_fold(Self::default(), |config, source| {
233 let next = Self::load_source(project_root, source)?;
234 Ok(config.merge(next))
235 })
236 }
237
238 pub fn load_file_for_export(path: &Path) -> Result<Self, SettingsError> {
239 let content = read_to_string(path).map_err(|source| {
240 SettingsError::IoError(format!("failed to read settings file '{}': {source}", path.display()))
241 })?;
242 let mut settings = Self::try_from(content.as_str())?;
243 settings.inline_resources(&settings_resource_root(path))?;
244 Ok(settings)
245 }
246
247 pub fn merge(mut self, next: Self) -> Self {
248 if next.agent.is_some() {
249 self.agent = next.agent;
250 }
251
252 if !next.prompts.is_empty() {
253 self.prompts = next.prompts;
254 }
255 if !next.mcps.is_empty() {
256 self.mcps = next.mcps;
257 }
258 self.providers.merge(next.providers);
259
260 if next.credentials_store.is_some() {
261 self.credentials_store = next.credentials_store;
262 }
263
264 if let Some(next_telemetry) = next.telemetry {
265 self.telemetry.get_or_insert_default().merge(next_telemetry);
266 }
267
268 for next_agent in next.agents {
269 if let Some(existing) = self.agents.iter_mut().find(|agent| agent.name.trim() == next_agent.name.trim()) {
270 *existing = next_agent;
271 } else {
272 self.agents.push(next_agent);
273 }
274 }
275
276 self
277 }
278
279 pub fn inline_resources(&mut self, root: &Path) -> Result<(), SettingsError> {
286 self.prompts = inline_prompt_sources(&self.prompts, root)?;
287 self.mcps = inline_mcp_sources(&self.mcps, root)?;
288 for agent in &mut self.agents {
289 agent.prompts = inline_prompt_sources(&agent.prompts, root)?;
290 agent.mcps = inline_mcp_sources(&agent.mcps, root)?;
291 }
292 Ok(())
293 }
294
295 fn load_source(project_root: &Path, source: AetherSettingsSource) -> Result<Self, SettingsError> {
296 match source {
297 AetherSettingsSource::File(source) => load_file_source(project_root, source, false),
298 AetherSettingsSource::OptionalFile(source) => load_file_source(project_root, source, true),
299 AetherSettingsSource::Json(json) => Self::try_from(json.as_str()),
300 AetherSettingsSource::Value(settings) => Ok(*settings),
301 }
302 }
303}
304
305fn default_sources(project_root: &Path) -> Vec<AetherSettingsSource> {
306 let aether_home = SettingsStore::new("AETHER_HOME", ".aether").map(|store| store.home().to_path_buf());
307 default_sources_for_home(project_root, aether_home.as_deref())
308}
309
310fn default_sources_for_home(project_root: &Path, aether_home: Option<&Path>) -> Vec<AetherSettingsSource> {
311 let mut sources = Vec::new();
312 if let Some(aether_home) = aether_home {
313 sources.push(AetherSettingsSource::OptionalFile(SettingsFileSource::new("settings.json", aether_home)));
314 }
315 sources.push(AetherSettingsSource::OptionalFile(SettingsFileSource::new(PROJECT_SETTINGS_PATH, project_root)));
316 sources
317}
318
319fn load_file_source(
320 project_root: &Path,
321 source: SettingsFileSource,
322 missing_is_empty: bool,
323) -> Result<AetherSettings, SettingsError> {
324 let root = resolve_against(project_root, source.root);
325 let path = resolve_against(&root, source.path);
326 let settings = load_file(&path, missing_is_empty)?;
327 let source_root = (root != project_root).then_some(root.as_path());
328 Ok(normalize_resource_paths(settings, source_root))
329}
330
331fn resolve_against(base: &Path, path: PathBuf) -> PathBuf {
332 if path.is_absolute() { path } else { base.join(path) }
333}
334
335fn load_file(path: &Path, missing_is_empty: bool) -> Result<AetherSettings, SettingsError> {
336 match read_to_string(path) {
337 Ok(content) if content.trim().is_empty() => Ok(AetherSettings::default()),
338 Ok(content) => AetherSettings::try_from(content.as_str()),
339 Err(error) if missing_is_empty && error.kind() == std::io::ErrorKind::NotFound => Ok(AetherSettings::default()),
340 Err(error) => Err(SettingsError::IoError(format!("Failed to read {}: {}", path.display(), error))),
341 }
342}
343
344fn normalize_resource_paths(mut settings: AetherSettings, source_root: Option<&Path>) -> AetherSettings {
345 let Some(root) = source_root else { return settings };
346 promote_prompt_sources(&mut settings.prompts, root);
347 promote_mcp_sources(&mut settings.mcps, root);
348
349 for agent in &mut settings.agents {
350 promote_prompt_sources(&mut agent.prompts, root);
351 promote_mcp_sources(&mut agent.mcps, root);
352 }
353
354 settings
355}
356
357fn inline_prompt_sources(sources: &[PromptSource], root: &Path) -> Result<Vec<PromptSource>, SettingsError> {
358 let mut inlined = Vec::new();
359 for prompt in Prompt::from_sources(root, sources)? {
360 let text = match prompt {
361 Prompt::Text(text) => text,
362 Prompt::File { path, .. } => read_to_string(&path)
363 .map_err(|e| SettingsError::IoError(format!("Failed to read prompt '{}': {e}", path.display())))?,
364 Prompt::McpInstructions(_) => continue,
365 };
366 inlined.push(PromptSource::Text { text });
367 }
368 Ok(inlined)
369}
370
371fn inline_mcp_sources(sources: &[McpSourceSpec], root: &Path) -> Result<Vec<McpSourceSpec>, SettingsError> {
372 let mut inlined = Vec::new();
373 for source in sources {
374 let McpSourceSpec::File(McpFileSpec { path, proxy, optional }) = source else {
375 inlined.push(source.clone());
376 continue;
377 };
378
379 let full_path = match path.resolve(root) {
380 Ok(full_path) => full_path,
381 Err(VarError::NotFound(variable)) => {
382 if *optional {
383 tracing::warn!(
384 "Skipping optional MCP config '{}': variable '{variable}' is not defined",
385 path.as_authored()
386 );
387 continue;
388 }
389 return Err(SettingsError::UnresolvedMcpConfigVariable {
390 path: path.as_authored().to_string(),
391 variable,
392 });
393 }
394 };
395
396 if !full_path.is_file() {
397 if *optional {
398 continue;
399 }
400 return Err(SettingsError::InvalidMcpConfigPath { path: path.as_authored().to_string() });
401 }
402
403 let mut config = McpConfig::from_json_file(&full_path)
404 .map_err(|e| SettingsError::IoError(format!("Failed to read MCP config '{}': {e}", full_path.display())))?;
405 if *proxy {
406 config.mark_all_proxy();
407 }
408 inlined.push(McpSourceSpec::Inline { servers: config.servers });
409 }
410 Ok(inlined)
411}
412
413fn promote_prompt_sources(sources: &mut [PromptSource], source_root: &Path) {
414 for source in sources {
415 match source {
416 PromptSource::File { path, .. } | PromptSource::Glob { pattern: path, .. } => {
417 path.promote_relative(source_root);
418 }
419 PromptSource::Text { .. } => {}
420 }
421 }
422}
423
424fn promote_mcp_sources(sources: &mut [McpSourceSpec], source_root: &Path) {
425 for source in sources {
426 if let McpSourceSpec::File(file) = source {
427 file.path.promote_relative(source_root);
428 }
429 }
430}
431
432impl TryFrom<&str> for AetherSettings {
433 type Error = SettingsError;
434
435 fn try_from(content: &str) -> Result<Self, Self::Error> {
436 serde_json::from_str(content).map_err(|e| SettingsError::ParseError(e.to_string()))
437 }
438}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443 use crate::{AgentCatalog, McpFileSpec, McpSourceSpec, PromptSource};
444 use aether_core::agent_spec::McpConfigSource;
445 use aether_core::core::Prompt;
446 use std::collections::BTreeMap;
447 use std::fs::{create_dir_all, write};
448
449 #[test]
450 fn telemetry_is_disabled_when_absent() {
451 assert!(AetherSettings::default().telemetry.is_none());
452
453 let settings = AetherSettings::try_from(r#"{ "telemetry": {}, "agents": [] }"#).unwrap();
454 assert!(settings.telemetry.unwrap().effective_enabled());
455 }
456
457 #[test]
458 #[allow(clippy::float_cmp)]
459 fn parses_telemetry_camel_case_and_http_protobuf() {
460 let config = AetherSettings::try_from(
461 r#"{
462 "telemetry": {
463 "serviceName": "aether-test",
464 "sampleRatio": 0.5,
465 "captureContent": true,
466 "traces": { "enabled": true },
467 "metrics": { "enabled": false },
468 "otlp": {
469 "endpoint": "http://localhost:4318",
470 "headers": { "authorization": "Bearer token" }
471 }
472 },
473 "agents": [{"name":"alpha","description":"Alpha","model":"anthropic:claude-sonnet-4-5","userInvocable":true}]
474 }"#,
475 )
476 .unwrap();
477
478 let telemetry = config.telemetry.as_ref().unwrap();
479 assert_eq!(telemetry.service_name(), "aether-test");
480 assert_eq!(telemetry.sample_ratio(), 0.5);
481 assert!(telemetry.capture_content());
482 assert!(telemetry.traces_enabled());
483 assert!(!telemetry.metrics_enabled());
484 assert_eq!(telemetry.otlp.headers.get("authorization").map(String::as_str), Some("Bearer token"));
485 }
486
487 #[test]
488 fn telemetry_with_no_enabled_signals_does_not_require_endpoint() {
489 let config = AetherSettings::try_from(
490 r#"{
491 "telemetry": { "traces": { "enabled": false }, "metrics": { "enabled": false } },
492 "agents": [{"name":"alpha","description":"Alpha","model":"anthropic:claude-sonnet-4-5","userInvocable":true}]
493 }"#,
494 )
495 .unwrap();
496
497 assert!(!config.telemetry.unwrap().effective_enabled());
498 }
499
500 #[test]
501 fn telemetry_overlays_merge_fields_and_allow_explicit_defaults() {
502 let config = AetherSettings::load(
503 Path::new("/project"),
504 [
505 AetherSettingsSource::Json(
506 r#"{
507 "telemetry": {
508 "captureContent": true,
509 "traces": { "enabled": true },
510 "metrics": { "enabled": true },
511 "otlp": { "endpoint": "http://localhost:4318" }
512 },
513 "agents": []
514 }"#
515 .to_string(),
516 ),
517 AetherSettingsSource::Json(
518 r#"{
519 "telemetry": {
520 "captureContent": false,
521 "metrics": { "enabled": false }
522 },
523 "agents": []
524 }"#
525 .to_string(),
526 ),
527 ],
528 )
529 .unwrap();
530
531 let telemetry = config.telemetry.unwrap();
532 assert!(!telemetry.capture_content(), "explicit default false remains distinguishable from omission");
533 assert!(telemetry.traces_enabled(), "omitted nested fields remain inherited");
534 assert!(!telemetry.metrics_enabled(), "nested overrides merge independently");
535 assert_eq!(telemetry.otlp.endpoint.as_deref(), Some("http://localhost:4318"));
536 }
537
538 #[test]
539 fn telemetry_overlay_merges_otlp_headers_per_key() {
540 let config = AetherSettings::load(
541 Path::new("/project"),
542 [
543 AetherSettingsSource::Json(
544 r#"{
545 "telemetry": {
546 "otlp": {
547 "endpoint": "http://localhost:4318",
548 "headers": { "authorization": "Bearer base", "x-base": "1" }
549 }
550 },
551 "agents": []
552 }"#
553 .to_string(),
554 ),
555 AetherSettingsSource::Json(
556 r#"{
557 "telemetry": {
558 "otlp": {
559 "headers": { "authorization": "Bearer overlay", "x-overlay": "2" }
560 }
561 },
562 "agents": []
563 }"#
564 .to_string(),
565 ),
566 ],
567 )
568 .unwrap();
569
570 let headers = config.telemetry.unwrap().otlp.headers;
571 assert_eq!(headers.get("authorization").map(String::as_str), Some("Bearer overlay"));
572 assert_eq!(headers.get("x-base").map(String::as_str), Some("1"), "base-layer headers survive an overlay");
573 assert_eq!(headers.get("x-overlay").map(String::as_str), Some("2"));
574 }
575
576 #[test]
577 fn telemetry_overlay_inherits_unspecified_parent_fields() {
578 let config = AetherSettings::load(
579 Path::new("/project"),
580 [
581 AetherSettingsSource::Json(
582 r#"{
583 "telemetry": {
584 "sampleRatio": 0.25,
585 "otlp": { "endpoint": "http://localhost:4318" }
586 },
587 "agents": []
588 }"#
589 .to_string(),
590 ),
591 AetherSettingsSource::Json(r#"{ "telemetry": { "captureContent": true }, "agents": [] }"#.to_string()),
592 ],
593 )
594 .unwrap();
595
596 let telemetry = config.telemetry.unwrap();
597 assert!(telemetry.capture_content());
598 assert!((telemetry.sample_ratio() - 0.25).abs() < f64::EPSILON);
599 assert_eq!(telemetry.otlp.endpoint.as_deref(), Some("http://localhost:4318"));
600 }
601
602 #[test]
603 fn project_settings_path_points_at_project_aether_settings() {
604 assert_eq!(project_settings_path(Path::new("/repo")), PathBuf::from("/repo/.aether/settings.json"));
605 }
606
607 #[test]
608 fn settings_resource_root_uses_project_root_for_aether_dir_settings() {
609 assert_eq!(settings_resource_root(Path::new("/repo/.aether/settings.json")), PathBuf::from("/repo"));
610 assert_eq!(settings_resource_root(Path::new("/repo/config/settings.json")), PathBuf::from("/repo/config"));
611 }
612
613 #[test]
614 fn project_settings_exist_checks_project_settings_file() {
615 let dir = tempfile::tempdir().unwrap();
616 assert!(!project_settings_exist(dir.path()));
617 write_file(dir.path(), PROJECT_SETTINGS_PATH, "{}");
618 assert!(project_settings_exist(dir.path()));
619 }
620
621 #[test]
622 fn resolves_selected_agent() {
623 let dir = tempfile::tempdir().unwrap();
624 write_file(dir.path(), "PROMPT.md", "Be helpful");
625 let config = AetherSettings {
626 agent: Some("beta".to_string()),
627 agents: vec![agent_config("alpha"), agent_config("beta")],
628 ..AetherSettings::default()
629 };
630
631 let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
632
633 assert_eq!(catalog.default_agent().map(|spec| spec.name.as_str()), Some("beta"));
634 }
635
636 #[test]
637 fn rejects_selected_agent_that_is_not_user_invocable() {
638 let mut internal = agent_config("internal");
639 internal.user_invocable = false;
640 internal.agent_invocable = true;
641 let config =
642 AetherSettings { agent: Some("internal".to_string()), agents: vec![internal], ..AetherSettings::default() };
643
644 let err = AgentCatalog::from_settings(Path::new("/tmp"), config).unwrap_err();
645
646 assert!(matches!(err, SettingsError::NonUserInvocableAgentSelector { .. }));
647 }
648
649 #[test]
650 fn settings_file_paths_are_project_relative() {
651 let dir = tempfile::tempdir().unwrap();
652 write_file(dir.path(), "PROMPT.md", "Be helpful");
653 write_file(
654 dir.path(),
655 "nested/config.json",
656 r#"{"agents":[{"name":"alpha","description":"Alpha","model":"anthropic:claude-sonnet-4-5","userInvocable":true,"prompts":[{"type":"file","path":"PROMPT.md"}]}]}"#,
657 );
658
659 let config = AetherSettings::load(
660 dir.path(),
661 [AetherSettingsSource::File(SettingsFileSource::new("nested/config.json", dir.path()))],
662 )
663 .unwrap();
664 let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
665
666 assert_eq!(catalog.all()[0].name, "alpha");
667 }
668
669 #[test]
670 fn load_merges_sources_with_rightmost_agent_winning() {
671 let dir = tempfile::tempdir().unwrap();
672 let base = AetherSettings {
673 agent: Some("alpha".to_string()),
674 prompts: vec![PromptSource::file("BASE.md")],
675 agents: vec![AgentConfig { description: "Base alpha".to_string(), ..agent_config("alpha") }],
676 ..AetherSettings::default()
677 };
678 let override_config = AetherSettings {
679 agent: Some("beta".to_string()),
680 prompts: vec![PromptSource::file("OVERRIDE.md")],
681 agents: vec![
682 AgentConfig { description: "Override alpha".to_string(), ..agent_config("alpha") },
683 agent_config("beta"),
684 ],
685 ..AetherSettings::default()
686 };
687
688 let config = AetherSettings::load(
689 dir.path(),
690 [AetherSettingsSource::Value(Box::new(base)), AetherSettingsSource::Value(Box::new(override_config))],
691 )
692 .unwrap();
693
694 assert_eq!(
695 config,
696 AetherSettings {
697 agent: Some("beta".to_string()),
698 prompts: vec![PromptSource::file("OVERRIDE.md")],
699 agents: vec![
700 AgentConfig { description: "Override alpha".to_string(), ..agent_config("alpha") },
701 agent_config("beta"),
702 ],
703 ..AetherSettings::default()
704 }
705 );
706 }
707
708 #[test]
709 fn load_default_merges_user_and_project_settings_with_project_winning() {
710 let project = tempfile::tempdir().unwrap();
711 let home = tempfile::tempdir().unwrap();
712 let aether_home = home.path().join(".aether");
713 write_file(
714 &aether_home,
715 "settings.json",
716 r#"{
717 "agent":"shared",
718 "prompts":["USER.md"],
719 "agents":[
720 {"name":"shared","description":"User shared","model":"anthropic:claude-sonnet-4-5","userInvocable":true},
721 {"name":"user-only","description":"User only","model":"anthropic:claude-sonnet-4-5","userInvocable":true}
722 ]
723 }"#,
724 );
725 write_file(
726 project.path(),
727 ".aether/settings.json",
728 r#"{
729 "agent":"project-only",
730 "prompts":["PROJECT.md"],
731 "agents":[
732 {"name":"shared","description":"Project shared","model":"anthropic:claude-sonnet-4-5","userInvocable":true},
733 {"name":"project-only","description":"Project only","model":"anthropic:claude-sonnet-4-5","userInvocable":true}
734 ]
735 }"#,
736 );
737
738 let config = load_default_from_home(project.path(), &aether_home).unwrap();
739 assert_eq!(
740 config,
741 AetherSettings {
742 agent: Some("project-only".to_string()),
743 prompts: vec![PromptSource::file("PROJECT.md")],
744 agents: vec![
745 settings_agent("shared", "Project shared"),
746 settings_agent("user-only", "User only"),
747 settings_agent("project-only", "Project only"),
748 ],
749 ..AetherSettings::default()
750 }
751 );
752 }
753
754 #[test]
755 fn load_default_uses_user_settings_when_project_settings_are_missing() {
756 let project = tempfile::tempdir().unwrap();
757 let home = tempfile::tempdir().unwrap();
758 let aether_home = home.path().join(".aether");
759 write_file(
760 &aether_home,
761 "settings.json",
762 r#"{"agents":[{"name":"user-only","description":"User only","model":"anthropic:claude-sonnet-4-5","userInvocable":true}]}"#,
763 );
764
765 let config = load_default_from_home(project.path(), &aether_home).unwrap();
766 assert_eq!(
767 config,
768 AetherSettings { agents: vec![settings_agent("user-only", "User only")], ..AetherSettings::default() }
769 );
770 }
771
772 #[test]
773 fn load_default_resolves_user_agent_paths_from_aether_home() {
774 let project = tempfile::tempdir().unwrap();
775 let home = tempfile::tempdir().unwrap();
776 let aether_home = home.path().join(".aether");
777 write_file(&aether_home, "agents/user.md", "User instructions");
778 write_file(&aether_home, "mcp/user.json", r#"{"servers":{}}"#);
779 write_file(
780 &aether_home,
781 "settings.json",
782 r#"{
783 "agents":[{
784 "name":"user-only",
785 "description":"User only",
786 "model":"anthropic:claude-sonnet-4-5",
787 "userInvocable":true,
788 "prompts":["agents/user.md"],
789 "mcps":["mcp/user.json"]
790 }]
791 }"#,
792 );
793
794 let config = load_default_from_home(project.path(), &aether_home).unwrap();
795 let catalog = AgentCatalog::from_settings(project.path(), config).unwrap();
796 let spec = catalog.resolve("user-only").unwrap();
797
798 let expected_prompt = aether_home.join("agents/user.md");
799 assert!(spec.prompts.iter().any(|prompt| match prompt {
800 Prompt::File { path, .. } => path == &expected_prompt,
801 Prompt::Text(_) | Prompt::McpInstructions(_) => false,
802 }));
803 assert!(matches!(
804 &spec.mcp_config_sources[0],
805 McpConfigSource::File { path, proxy: false } if path == &aether_home.join("mcp/user.json")
806 ));
807 }
808
809 #[test]
810 fn load_default_uses_project_settings_when_user_settings_are_missing() {
811 let project = tempfile::tempdir().unwrap();
812 let home = tempfile::tempdir().unwrap();
813 let aether_home = home.path().join(".aether");
814 write_file(
815 project.path(),
816 ".aether/settings.json",
817 r#"{"agents":[{"name":"project-only","description":"Project only","model":"anthropic:claude-sonnet-4-5","userInvocable":true}]}"#,
818 );
819
820 let config = load_default_from_home(project.path(), &aether_home).unwrap();
821
822 assert_eq!(
823 config,
824 AetherSettings {
825 agents: vec![settings_agent("project-only", "Project only")],
826 ..AetherSettings::default()
827 }
828 );
829 }
830
831 #[test]
832 fn load_default_returns_default_when_user_and_project_settings_are_missing() {
833 let project = tempfile::tempdir().unwrap();
834 let home = tempfile::tempdir().unwrap();
835 let aether_home = home.path().join(".aether");
836 let config = load_default_from_home(project.path(), &aether_home).unwrap();
837 assert_eq!(config, AetherSettings::default());
838 }
839
840 #[test]
841 fn load_default_rejects_malformed_user_settings() {
842 let project = tempfile::tempdir().unwrap();
843 let home = tempfile::tempdir().unwrap();
844 let aether_home = home.path().join(".aether");
845 write_file(&aether_home, "settings.json", "{not-json");
846 let err = load_default_from_home(project.path(), &aether_home).unwrap_err();
847 assert!(matches!(err, SettingsError::ParseError(_)));
848 }
849
850 #[test]
851 fn strict_file_source_errors_when_missing() {
852 let project = tempfile::tempdir().unwrap();
853 let err = AetherSettings::load(
854 project.path(),
855 [AetherSettingsSource::File(SettingsFileSource::new("missing.json", project.path()))],
856 )
857 .unwrap_err();
858
859 assert!(matches!(err, SettingsError::IoError(_)));
860 }
861
862 #[test]
863 fn optional_file_source_returns_default_when_missing() {
864 let project = tempfile::tempdir().unwrap();
865 let config = AetherSettings::load(
866 project.path(),
867 [AetherSettingsSource::OptionalFile(SettingsFileSource::new("missing.json", project.path()))],
868 )
869 .unwrap();
870
871 assert_eq!(config, AetherSettings::default());
872 }
873
874 #[test]
875 fn inline_resources_replaces_file_sources_with_their_contents() {
876 let root = tempfile::tempdir().unwrap();
877 write_file(root.path(), "BASE.md", "Be helpful");
878 write_file(root.path(), "AGENT.md", "Edit carefully");
879 write_file(root.path(), "mcp.json", r#"{"servers":{"coding":{"type":"stdio","command":"run"}}}"#);
880
881 let mut settings = AetherSettings {
882 prompts: vec![PromptSource::file("BASE.md")],
883 mcps: vec![McpSourceSpec::file("mcp.json")],
884 agents: vec![AgentConfig {
885 prompts: vec![PromptSource::file("AGENT.md")],
886 mcps: vec![McpSourceSpec::file("mcp.json")],
887 ..agent_config("alpha")
888 }],
889 ..AetherSettings::default()
890 };
891
892 settings.inline_resources(root.path()).unwrap();
893
894 assert_eq!(settings.prompts, vec![PromptSource::Text { text: "Be helpful".to_string() }]);
895 assert_eq!(settings.agents[0].prompts, vec![PromptSource::Text { text: "Edit carefully".to_string() }]);
896 assert!(matches!(&settings.mcps[0], McpSourceSpec::Inline { servers } if servers.contains_key("coding")));
897 assert!(
898 matches!(&settings.agents[0].mcps[0], McpSourceSpec::Inline { servers } if servers.contains_key("coding"))
899 );
900
901 let serialized = serde_json::to_string(&settings).unwrap();
902 assert!(!serialized.contains("BASE.md") && !serialized.contains("mcp.json"), "{serialized}");
903 }
904
905 #[test]
906 fn inline_resources_drops_optional_missing_sources() {
907 let root = tempfile::tempdir().unwrap();
908 write_file(root.path(), "PROMPT.md", "Agent prompt");
909 let mut settings = AetherSettings {
910 prompts: vec![PromptSource::file("absent.md").optional()],
911 mcps: vec![McpSourceSpec::File(McpFileSpec::new("absent.json").optional())],
912 agents: vec![agent_config("alpha")],
913 ..AetherSettings::default()
914 };
915
916 settings.inline_resources(root.path()).unwrap();
917
918 assert!(settings.prompts.is_empty());
919 assert!(settings.mcps.is_empty());
920 }
921
922 #[test]
923 fn inline_resources_errors_on_required_missing_mcp() {
924 let root = tempfile::tempdir().unwrap();
925 let mut settings = AetherSettings {
926 mcps: vec![McpSourceSpec::file("absent.json")],
927 agents: vec![agent_config("alpha")],
928 ..AetherSettings::default()
929 };
930
931 let err = settings.inline_resources(root.path()).unwrap_err();
932 assert!(matches!(err, SettingsError::InvalidMcpConfigPath { .. }));
933 }
934
935 #[test]
936 fn resolves_inline_mcp_config() {
937 let dir = tempfile::tempdir().unwrap();
938 write_file(dir.path(), "PROMPT.md", "Be helpful");
939 let config = AetherSettings {
940 agent: None,
941 agents: vec![AgentConfig {
942 mcps: vec![McpSourceSpec::Inline { servers: BTreeMap::new() }],
943 ..agent_config("alpha")
944 }],
945 ..AetherSettings::default()
946 };
947
948 let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
949 let spec = catalog.resolve("alpha").unwrap();
950
951 assert_eq!(spec.mcp_config_sources.len(), 1);
952 assert!(matches!(spec.mcp_config_sources[0], McpConfigSource::Inline(_)));
953 }
954
955 #[test]
956 fn parses_top_level_prompt_and_mcp_defaults() {
957 let config = AetherSettings::try_from(
958 r#"{
959 "prompts": [{"type":"file","path":"BASE.md"}],
960 "mcps": [{"type":"file","path":"mcp.json"}],
961 "agents": [{
962 "name":"alpha",
963 "description":"Alpha",
964 "model":"anthropic:claude-sonnet-4-5",
965 "userInvocable":true
966 }]
967 }"#,
968 )
969 .unwrap();
970
971 assert_eq!(
972 config,
973 AetherSettings {
974 prompts: vec![PromptSource::file("BASE.md")],
975 mcps: vec![McpSourceSpec::file("mcp.json")],
976 agents: vec![settings_agent("alpha", "Alpha")],
977 ..AetherSettings::default()
978 }
979 );
980 }
981
982 #[test]
983 fn parses_and_serializes_string_shorthand_for_file_sources() {
984 let config = AetherSettings::try_from(
985 r#"{
986 "prompts": ["BASE.md"],
987 "mcps": ["mcp.json"],
988 "agents": [{
989 "name":"alpha",
990 "description":"Alpha",
991 "model":"anthropic:claude-sonnet-4-5",
992 "userInvocable":true,
993 "prompts":["AGENT.md"],
994 "mcps":["agent-mcp.json"]
995 }]
996 }"#,
997 )
998 .unwrap();
999
1000 assert_eq!(
1001 config,
1002 AetherSettings {
1003 prompts: vec![PromptSource::file("BASE.md")],
1004 mcps: vec![McpSourceSpec::file("mcp.json")],
1005 agents: vec![AgentConfig {
1006 prompts: vec![PromptSource::file("AGENT.md")],
1007 mcps: vec![McpSourceSpec::file("agent-mcp.json")],
1008 ..settings_agent("alpha", "Alpha")
1009 }],
1010 ..AetherSettings::default()
1011 }
1012 );
1013
1014 let value = serde_json::to_value(&config).unwrap();
1015 assert_eq!(value["prompts"], serde_json::json!(["BASE.md"]));
1016 assert_eq!(value["mcps"], serde_json::json!(["mcp.json"]));
1017 assert_eq!(value["agents"][0]["prompts"], serde_json::json!(["AGENT.md"]));
1018 assert_eq!(value["agents"][0]["mcps"], serde_json::json!(["agent-mcp.json"]));
1019 }
1020
1021 #[test]
1022 fn serializes_proxied_mcp_file_as_typed_object() {
1023 let source: McpSourceSpec = McpFileSpec::new("mcp.json").proxy().into();
1024
1025 let value = serde_json::to_value(source).unwrap();
1026
1027 assert_eq!(value, serde_json::json!({"type":"file", "path":"mcp.json", "proxy":true}));
1028 }
1029
1030 #[test]
1031 fn rejects_old_top_level_mcp_servers_field() {
1032 let err = AetherSettings::try_from(
1033 r#"{
1034 "mcpServers": ["mcp.json"],
1035 "agents": [{
1036 "name":"alpha",
1037 "description":"Alpha",
1038 "model":"anthropic:claude-sonnet-4-5",
1039 "userInvocable":true,
1040 "prompts":[{"type":"file","path":"PROMPT.md"}]
1041 }]
1042 }"#,
1043 )
1044 .unwrap_err();
1045
1046 assert!(matches!(err, SettingsError::ParseError(message) if message.contains("mcpServers")));
1047 }
1048
1049 #[test]
1050 fn load_default_resolves_workspace_scoped_user_prompt_and_mcp_paths() {
1051 let project = tempfile::tempdir().unwrap();
1052 let home = tempfile::tempdir().unwrap();
1053 let aether_home = home.path().join(".aether");
1054 write_file(&aether_home, "agents/planner/SYSTEM.md", "System instructions");
1055 write_file(project.path(), "AGENTS.md", "Agent instructions");
1056 write_file(project.path(), ".aether/mcp.json", r#"{"servers":{}}"#);
1057 write_file(
1058 &aether_home,
1059 "settings.json",
1060 r#"{
1061 "agents":[{
1062 "name":"planner",
1063 "description":"Plans work",
1064 "model":"anthropic:claude-sonnet-4-5",
1065 "userInvocable":true,
1066 "prompts":[
1067 "agents/planner/SYSTEM.md",
1068 {"type":"file","path":"${WORKSPACE}/AGENTS.md"}
1069 ],
1070 "mcps":[
1071 {"type":"file","path":"${WORKSPACE}/.aether/mcp.json"}
1072 ]
1073 }]
1074 }"#,
1075 );
1076
1077 let config = load_default_from_home(project.path(), &aether_home).unwrap();
1078 let catalog = AgentCatalog::from_settings(project.path(), config).unwrap();
1079 let spec = catalog.resolve("planner").unwrap();
1080
1081 let expected_system = aether_home.join("agents/planner/SYSTEM.md");
1082 let expected_agents = project.path().join("AGENTS.md");
1083 assert!(spec.prompts.iter().any(|p| match p {
1084 Prompt::File { path, .. } => path == &expected_system,
1085 _ => false,
1086 }));
1087 assert!(spec.prompts.iter().any(|p| match p {
1088 Prompt::File { path, .. } => path == &expected_agents,
1089 _ => false,
1090 }));
1091 assert!(matches!(
1092 &spec.mcp_config_sources[0],
1093 McpConfigSource::File { path, proxy: false } if *path == project.path().join(".aether/mcp.json")
1094 ));
1095 }
1096
1097 #[test]
1098 fn workspace_scoped_paths_expand_in_project_settings_without_absolutizing_normal_relative_paths() {
1099 let project = tempfile::tempdir().unwrap();
1100 write_file(project.path(), "PROJECT.md", "Project prompt");
1101 write_file(project.path(), "AGENTS.md", "Agent prompt");
1102 write_file(
1103 project.path(),
1104 ".aether/settings.json",
1105 r#"{
1106 "agents":[{
1107 "name":"alpha",
1108 "description":"Alpha",
1109 "model":"anthropic:claude-sonnet-4-5",
1110 "userInvocable":true,
1111 "prompts":["PROJECT.md", {"type":"file","path":"${WORKSPACE}/AGENTS.md"}]
1112 }]
1113 }"#,
1114 );
1115
1116 let config = AetherSettings::load(
1117 project.path(),
1118 [AetherSettingsSource::OptionalFile(SettingsFileSource::new(PROJECT_SETTINGS_PATH, project.path()))],
1119 )
1120 .unwrap();
1121
1122 assert_eq!(config.agents[0].prompts[0], PromptSource::file("PROJECT.md"));
1123 assert_eq!(config.agents[0].prompts[1], PromptSource::file("${WORKSPACE}/AGENTS.md"));
1124 }
1125
1126 #[test]
1127 fn json_and_value_sources_preserve_workspace_scoped_paths_losslessly() {
1128 let project = tempfile::tempdir().unwrap();
1129
1130 let json_config = AetherSettings::load(
1131 project.path(),
1132 [AetherSettingsSource::Json(
1133 r#"{
1134 "agents":[{
1135 "name":"alpha",
1136 "description":"Alpha",
1137 "model":"anthropic:claude-sonnet-4-5",
1138 "userInvocable":true,
1139 "prompts":["${WORKSPACE}/AGENTS.md"]
1140 }]
1141 }"#
1142 .to_string(),
1143 )],
1144 )
1145 .unwrap();
1146
1147 assert_eq!(json_config.agents[0].prompts[0], PromptSource::file("${WORKSPACE}/AGENTS.md"));
1148
1149 let value_config = AetherSettings::load(
1150 project.path(),
1151 [AetherSettingsSource::Value(Box::new(AetherSettings {
1152 agents: vec![AgentConfig {
1153 prompts: vec![PromptSource::file("${WORKSPACE}/AGENTS.md")],
1154 ..agent_config("alpha")
1155 }],
1156 ..AetherSettings::default()
1157 }))],
1158 )
1159 .unwrap();
1160 assert_eq!(value_config.agents[0].prompts[0], PromptSource::file("${WORKSPACE}/AGENTS.md"));
1161 }
1162
1163 #[test]
1164 fn optional_workspace_scoped_mcp_source_is_skipped_when_missing() {
1165 let project = tempfile::tempdir().unwrap();
1166 write_file(project.path(), "BASE.md", "Base instructions");
1167 let config = AetherSettings {
1168 agents: vec![AgentConfig {
1169 prompts: vec![PromptSource::file("BASE.md")],
1170 mcps: vec![McpFileSpec::new("${WORKSPACE}/.aether/mcp.json").optional().into()],
1171 ..agent_config("alpha")
1172 }],
1173 ..AetherSettings::default()
1174 };
1175
1176 let config = AetherSettings::load(project.path(), [AetherSettingsSource::Value(Box::new(config))]).unwrap();
1177 let catalog = AgentCatalog::from_settings(project.path(), config).unwrap();
1178 let spec = catalog.resolve("alpha").unwrap();
1179
1180 assert!(spec.mcp_config_sources.is_empty());
1181 }
1182
1183 #[test]
1184 fn optional_mcp_source_skips_unresolved_variable() {
1185 let project = tempfile::tempdir().unwrap();
1186 write_file(project.path(), "BASE.md", "Base instructions");
1187 let config = AetherSettings {
1188 agents: vec![AgentConfig {
1189 prompts: vec![PromptSource::file("BASE.md")],
1190 mcps: vec![McpFileSpec::new("${DEFINITELY_NOT_SET_VAR_MCP_OPTIONAL}/mcp.json").optional().into()],
1191 ..agent_config("alpha")
1192 }],
1193 ..AetherSettings::default()
1194 };
1195
1196 let config = AetherSettings::load(project.path(), [AetherSettingsSource::Value(Box::new(config))]).unwrap();
1197 let catalog = AgentCatalog::from_settings(project.path(), config).unwrap();
1198 let spec = catalog.resolve("alpha").unwrap();
1199
1200 assert!(spec.mcp_config_sources.is_empty());
1201 }
1202
1203 #[test]
1204 fn required_mcp_source_errors_on_unresolved_variable() {
1205 let project = tempfile::tempdir().unwrap();
1206 write_file(project.path(), "BASE.md", "Base instructions");
1207 let config = AetherSettings {
1208 agents: vec![AgentConfig {
1209 prompts: vec![PromptSource::file("BASE.md")],
1210 mcps: vec![McpSourceSpec::file("${DEFINITELY_NOT_SET_VAR_MCP_REQ}/mcp.json")],
1211 ..agent_config("alpha")
1212 }],
1213 ..AetherSettings::default()
1214 };
1215
1216 let err = AgentCatalog::from_settings(project.path(), config).unwrap_err();
1217 assert!(matches!(err, SettingsError::UnresolvedMcpConfigVariable { .. }));
1218 }
1219
1220 #[test]
1221 fn required_workspace_scoped_mcp_source_errors_when_missing() {
1222 let project = tempfile::tempdir().unwrap();
1223 write_file(project.path(), "BASE.md", "Base instructions");
1224 let config = AetherSettings {
1225 agents: vec![AgentConfig {
1226 prompts: vec![PromptSource::file("BASE.md")],
1227 mcps: vec![McpSourceSpec::file("nonexistent.json")],
1228 ..agent_config("alpha")
1229 }],
1230 ..AetherSettings::default()
1231 };
1232
1233 let err = AgentCatalog::from_settings(project.path(), config).unwrap_err();
1234 assert!(matches!(err, SettingsError::InvalidMcpConfigPath { .. }));
1235 }
1236
1237 #[test]
1238 fn optional_existing_mcp_source_preserves_proxy_flag() {
1239 let project = tempfile::tempdir().unwrap();
1240 write_file(project.path(), "BASE.md", "Base instructions");
1241 write_file(project.path(), "mcp.json", r#"{"servers":{}}"#);
1242 let config = AetherSettings {
1243 agents: vec![AgentConfig {
1244 prompts: vec![PromptSource::file("BASE.md")],
1245 mcps: vec![McpFileSpec::new("mcp.json").proxy().optional().into()],
1246 ..agent_config("alpha")
1247 }],
1248 ..AetherSettings::default()
1249 };
1250
1251 let catalog = AgentCatalog::from_settings(project.path(), config).unwrap();
1252 let spec = catalog.resolve("alpha").unwrap();
1253
1254 assert!(matches!(&spec.mcp_config_sources[0], McpConfigSource::File { proxy: true, .. }));
1255 }
1256
1257 #[test]
1258 fn optional_mcp_source_serializes_as_typed_object() {
1259 let source: McpSourceSpec = McpFileSpec::new("${WORKSPACE}/.aether/mcp.json").optional().into();
1260 let value = serde_json::to_value(source).unwrap();
1261 assert_eq!(value, serde_json::json!({"type":"file", "path":"${WORKSPACE}/.aether/mcp.json", "optional":true}));
1262 }
1263
1264 #[test]
1265 fn optional_prompt_source_serializes_as_typed_object() {
1266 let source = PromptSource::file("${WORKSPACE}/AGENTS.md").optional();
1267 let value = serde_json::to_value(&source).unwrap();
1268 assert_eq!(value, serde_json::json!({"type":"file", "path":"${WORKSPACE}/AGENTS.md", "optional":true}));
1269 }
1270
1271 #[test]
1272 fn all_optional_prompts_missing_errors_with_no_prompts() {
1273 let project = tempfile::tempdir().unwrap();
1274 let config = AetherSettings {
1275 agents: vec![AgentConfig {
1276 prompts: vec![PromptSource::file("MISSING.md").optional()],
1277 ..agent_config("alpha")
1278 }],
1279 ..AetherSettings::default()
1280 };
1281
1282 let err = AgentCatalog::from_settings(project.path(), config).unwrap_err();
1283 assert!(matches!(err, SettingsError::AllOptionalPromptsMissing { agent } if agent == "alpha"));
1284 }
1285
1286 #[test]
1287 fn settings_round_trip_preserves_workspace_prefix_and_relative_paths() {
1288 let original = r#"{"agents":[{
1289 "name":"alpha",
1290 "description":"Alpha",
1291 "model":"anthropic:claude-sonnet-4-5",
1292 "userInvocable":true,
1293 "prompts":[
1294 "AGENTS.md",
1295 "${WORKSPACE}/SYSTEM.md",
1296 {"type":"file","path":"${WORKSPACE}/.aether/rules.md","optional":true},
1297 {"type":"glob","pattern":"${WORKSPACE}/.aether/rules/*.md"}
1298 ],
1299 "mcps":[
1300 "mcp.json",
1301 {"type":"file","path":"${WORKSPACE}/.aether/mcp.json","optional":true}
1302 ]
1303 }]}"#;
1304
1305 let settings = AetherSettings::try_from(original).unwrap();
1306 let reserialized = serde_json::to_string(&settings).unwrap();
1307 let reparsed = AetherSettings::try_from(reserialized.as_str()).unwrap();
1308
1309 assert_eq!(settings, reparsed, "settings should round-trip losslessly through serde");
1310 }
1311
1312 #[test]
1313 fn user_settings_relative_paths_absolutize_at_load_but_workspace_token_is_preserved() {
1314 let project = tempfile::tempdir().unwrap();
1315 let home = tempfile::tempdir().unwrap();
1316 let aether_home = home.path().join(".aether");
1317 write_file(&aether_home, "agents/planner/SYSTEM.md", "system");
1318 write_file(project.path(), "AGENTS.md", "agents");
1319 write_file(
1320 &aether_home,
1321 "settings.json",
1322 r#"{"agents":[{
1323 "name":"planner",
1324 "description":"Plans",
1325 "model":"anthropic:claude-sonnet-4-5",
1326 "userInvocable":true,
1327 "prompts":["agents/planner/SYSTEM.md", "${WORKSPACE}/AGENTS.md"]
1328 }]}"#,
1329 );
1330
1331 let settings = load_default_from_home(project.path(), &aether_home).unwrap();
1332
1333 let expected_user = aether_home.join("agents/planner/SYSTEM.md").to_string_lossy().to_string();
1334 assert_eq!(
1335 settings.agents[0].prompts,
1336 vec![PromptSource::file(expected_user), PromptSource::file("${WORKSPACE}/AGENTS.md")],
1337 "user-rooted relative paths must absolutize; ${{WORKSPACE}}/ paths must be preserved",
1338 );
1339 }
1340
1341 fn load_default_from_home(project_root: &Path, aether_home: &Path) -> Result<AetherSettings, SettingsError> {
1342 AetherSettings::load(project_root, default_sources_for_home(project_root, Some(aether_home)))
1343 }
1344
1345 fn write_file(dir: &Path, path: &str, content: &str) {
1346 let full = dir.join(path);
1347 if let Some(parent) = full.parent() {
1348 create_dir_all(parent).unwrap();
1349 }
1350
1351 write(full, content).unwrap();
1352 }
1353
1354 fn settings_agent(name: &str, description: &str) -> AgentConfig {
1355 AgentConfig {
1356 name: name.to_string(),
1357 description: description.to_string(),
1358 model: "anthropic:claude-sonnet-4-5".to_string(),
1359 user_invocable: true,
1360 ..AgentConfig::default()
1361 }
1362 }
1363
1364 fn agent_config(name: &str) -> AgentConfig {
1365 AgentConfig {
1366 name: name.to_string(),
1367 description: format!("{name} agent"),
1368 model: "anthropic:claude-sonnet-4-5".to_string(),
1369 user_invocable: true,
1370 prompts: vec![PromptSource::file("PROMPT.md")],
1371 ..AgentConfig::default()
1372 }
1373 }
1374
1375 #[test]
1376 fn parses_credentials_store_keyring() {
1377 let config = AetherSettings::try_from(
1378 r#"{
1379 "credentialsStore": { "type": "keyring" },
1380 "agents": [{"name":"alpha","description":"Alpha","model":"anthropic:claude-sonnet-4-5","userInvocable":true}]
1381 }"#,
1382 )
1383 .unwrap();
1384
1385 assert_eq!(config.credentials_store, Some(CredentialsStoreConfig::Keyring));
1386 }
1387
1388 #[test]
1389 fn parses_credentials_store_memory() {
1390 let config = AetherSettings::try_from(
1391 r#"{
1392 "credentialsStore": { "type": "memory" },
1393 "agents": [{"name":"alpha","description":"Alpha","model":"anthropic:claude-sonnet-4-5","userInvocable":true}]
1394 }"#,
1395 )
1396 .unwrap();
1397
1398 assert_eq!(config.credentials_store, Some(CredentialsStoreConfig::Memory));
1399 }
1400
1401 #[test]
1402 fn parses_credentials_store_encrypted_file_with_options() {
1403 let config = AetherSettings::try_from(
1404 r#"{
1405 "credentialsStore": {
1406 "type": "encryptedFile",
1407 "path": "/custom/creds.enc",
1408 "passwordEnv": "MY_SECRET"
1409 },
1410 "agents": [{"name":"alpha","description":"Alpha","model":"anthropic:claude-sonnet-4-5","userInvocable":true}]
1411 }"#,
1412 )
1413 .unwrap();
1414
1415 assert!(matches!(
1416 &config.credentials_store,
1417 Some(CredentialsStoreConfig::EncryptedFile { path, password_env })
1418 if path == &Some(PathBuf::from("/custom/creds.enc"))
1419 && password_env == &Some("MY_SECRET".to_string())
1420 ));
1421 }
1422}