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