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