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