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