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, 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 std::collections::BTreeMap;
504
505 #[test]
506 fn telemetry_is_disabled_when_absent() {
507 assert!(AetherSettings::default().telemetry.is_none());
508
509 let settings = AetherSettings::try_from(r#"{ "telemetry": {}, "agents": [] }"#).unwrap();
510 assert!(settings.telemetry.unwrap().effective_enabled());
511 }
512
513 #[test]
514 #[allow(clippy::float_cmp)]
515 fn parses_telemetry_camel_case_and_http_protobuf() {
516 let config = AetherSettings::try_from(
517 r#"{
518 "telemetry": {
519 "serviceName": "aether-test",
520 "sampleRatio": 0.5,
521 "content": {
522 "systemInstructions": true,
523 "inputMessages": true,
524 "outputMessages": false,
525 "toolDefinitions": true,
526 "toolCalls": true
527 },
528 "traces": { "enabled": true },
529 "metrics": { "enabled": false },
530 "otlp": {
531 "endpoint": "http://localhost:4318",
532 "headers": { "authorization": "Bearer token" }
533 }
534 },
535 "agents": [{"name":"alpha","description":"Alpha","model":"anthropic:claude-sonnet-4-5","userInvocable":true}]
536 }"#,
537 )
538 .unwrap();
539
540 let telemetry = config.telemetry.as_ref().unwrap();
541 assert_eq!(telemetry.service_name(), "aether-test");
542 assert_eq!(telemetry.sample_ratio(), 0.5);
543 let content = &telemetry.content;
544 assert_eq!(content.system_instructions, Some(true));
545 assert_eq!(content.input_messages, Some(true));
546 assert_eq!(content.output_messages, Some(false));
547 assert_eq!(content.tool_definitions, Some(true));
548 assert_eq!(content.tool_calls, Some(true));
549 assert!(telemetry.traces_enabled());
550 assert!(!telemetry.metrics_enabled());
551 assert_eq!(telemetry.otlp.headers.get("authorization").map(String::as_str), Some("Bearer token"));
552 }
553
554 #[test]
555 fn telemetry_with_no_enabled_signals_does_not_require_endpoint() {
556 let config = AetherSettings::try_from(
557 r#"{
558 "telemetry": { "traces": { "enabled": false }, "metrics": { "enabled": false } },
559 "agents": [{"name":"alpha","description":"Alpha","model":"anthropic:claude-sonnet-4-5","userInvocable":true}]
560 }"#,
561 )
562 .unwrap();
563
564 assert!(!config.telemetry.unwrap().effective_enabled());
565 }
566
567 #[test]
568 fn telemetry_overlays_merge_fields_and_allow_explicit_defaults() {
569 let config = AetherSettings::load(
570 Path::new("/project"),
571 [
572 AetherSettingsSource::Json(
573 r#"{
574 "telemetry": {
575 "content": { "systemInstructions": true, "inputMessages": true },
576 "traces": { "enabled": true },
577 "metrics": { "enabled": true },
578 "otlp": {
579 "endpoint": "http://localhost:4318",
580 "tracesEndpoint": "https://traces.example.com/export",
581 "metricsEndpoint": "https://metrics.example.com/export"
582 }
583 },
584 "agents": []
585 }"#
586 .to_string(),
587 ),
588 AetherSettingsSource::Json(
589 r#"{
590 "telemetry": {
591 "content": { "systemInstructions": false },
592 "metrics": { "enabled": false }
593 },
594 "agents": []
595 }"#
596 .to_string(),
597 ),
598 ],
599 )
600 .unwrap();
601
602 let telemetry = config.telemetry.unwrap();
603 let content = &telemetry.content;
604 assert_eq!(content.system_instructions, Some(false), "explicit false remains distinguishable from omission");
605 assert_eq!(content.input_messages, Some(true), "omitted nested fields remain inherited");
606 assert!(!content.tool_calls.unwrap_or_default());
607 assert!(telemetry.traces_enabled(), "omitted nested fields remain inherited");
608 assert!(!telemetry.metrics_enabled(), "nested overrides merge independently");
609 assert_eq!(telemetry.otlp.endpoint.as_deref(), Some("http://localhost:4318"));
610 assert_eq!(telemetry.otlp.traces_endpoint.as_deref(), Some("https://traces.example.com/export"));
611 assert_eq!(telemetry.otlp.metrics_endpoint.as_deref(), Some("https://metrics.example.com/export"));
612 }
613
614 #[test]
615 fn telemetry_overlay_merges_otlp_headers_per_key() {
616 let config = AetherSettings::load(
617 Path::new("/project"),
618 [
619 AetherSettingsSource::Json(
620 r#"{
621 "telemetry": {
622 "otlp": {
623 "endpoint": "http://localhost:4318",
624 "headers": { "authorization": "Bearer base", "x-base": "1" }
625 }
626 },
627 "agents": []
628 }"#
629 .to_string(),
630 ),
631 AetherSettingsSource::Json(
632 r#"{
633 "telemetry": {
634 "otlp": {
635 "headers": { "authorization": "Bearer overlay", "x-overlay": "2" }
636 }
637 },
638 "agents": []
639 }"#
640 .to_string(),
641 ),
642 ],
643 )
644 .unwrap();
645
646 let headers = config.telemetry.unwrap().otlp.headers;
647 assert_eq!(headers.get("authorization").map(String::as_str), Some("Bearer overlay"));
648 assert_eq!(headers.get("x-base").map(String::as_str), Some("1"), "base-layer headers survive an overlay");
649 assert_eq!(headers.get("x-overlay").map(String::as_str), Some("2"));
650 }
651
652 #[test]
653 fn telemetry_overlay_inherits_unspecified_parent_fields() {
654 let config = AetherSettings::load(
655 Path::new("/project"),
656 [
657 AetherSettingsSource::Json(
658 r#"{
659 "telemetry": {
660 "sampleRatio": 0.25,
661 "otlp": { "endpoint": "http://localhost:4318" }
662 },
663 "agents": []
664 }"#
665 .to_string(),
666 ),
667 AetherSettingsSource::Json(
668 r#"{ "telemetry": { "content": { "toolCalls": true } }, "agents": [] }"#.to_string(),
669 ),
670 ],
671 )
672 .unwrap();
673
674 let telemetry = config.telemetry.unwrap();
675 assert_eq!(telemetry.content.tool_calls, Some(true));
676 assert!((telemetry.sample_ratio() - 0.25).abs() < f64::EPSILON);
677 assert_eq!(telemetry.otlp.endpoint.as_deref(), Some("http://localhost:4318"));
678 }
679
680 #[test]
681 fn project_settings_path_points_at_project_aether_settings() {
682 assert_eq!(project_settings_path(Path::new("/repo")), PathBuf::from("/repo/.aether/settings.json"));
683 }
684
685 #[test]
686 fn settings_resource_root_uses_project_root_for_aether_dir_settings() {
687 assert_eq!(settings_resource_root(Path::new("/repo/.aether/settings.json")), PathBuf::from("/repo"));
688 assert_eq!(settings_resource_root(Path::new("/repo/config/settings.json")), PathBuf::from("/repo/config"));
689 }
690
691 #[test]
692 fn project_settings_exist_checks_project_settings_file() {
693 let project = project();
694 assert!(!project_settings_exist(project.root()));
695 project.write(PROJECT_SETTINGS_PATH, "{}");
696 assert!(project_settings_exist(project.root()));
697 }
698
699 #[test]
700 fn resolves_selected_agent() {
701 let dir = project().file("PROMPT.md", "Be helpful");
702 let config = AetherSettings {
703 agent: Some("beta".to_string()),
704 agents: vec![agent_config("alpha"), agent_config("beta")],
705 ..AetherSettings::default()
706 };
707
708 let catalog = AgentCatalog::from_settings(dir.root(), config).unwrap();
709
710 assert_eq!(catalog.default_agent().map(|spec| spec.name.as_str()), Some("beta"));
711 }
712
713 #[test]
714 fn rejects_selected_agent_that_is_not_user_invocable() {
715 let mut internal = agent_config("internal");
716 internal.user_invocable = false;
717 internal.agent_invocable = true;
718 let config =
719 AetherSettings { agent: Some("internal".to_string()), agents: vec![internal], ..AetherSettings::default() };
720
721 let err = AgentCatalog::from_settings(Path::new("/tmp"), config).unwrap_err();
722
723 assert!(matches!(err, SettingsError::NonUserInvocableAgentSelector { .. }));
724 }
725
726 #[test]
727 fn settings_file_paths_are_project_relative() {
728 let dir = project()
729 .file("PROMPT.md", "Be helpful")
730 .file(
731 "nested/config.json",
732 r#"{"agents":[{"name":"alpha","description":"Alpha","model":"anthropic:claude-sonnet-4-5","userInvocable":true,"prompts":[{"type":"file","path":"PROMPT.md"}]}]}"#,
733 );
734
735 let config = AetherSettings::load(
736 dir.root(),
737 [AetherSettingsSource::File(SettingsFileSource::new("nested/config.json", dir.root()))],
738 )
739 .unwrap();
740 let catalog = AgentCatalog::from_settings(dir.root(), config).unwrap();
741
742 assert_eq!(catalog.all()[0].name, "alpha");
743 }
744
745 #[test]
746 fn load_merges_sources_with_rightmost_agent_winning() {
747 let dir = project();
748 let base = AetherSettings {
749 agent: Some("alpha".to_string()),
750 prompts: vec![PromptSource::file("BASE.md")],
751 agents: vec![AgentConfig { description: "Base alpha".to_string(), ..agent_config("alpha") }],
752 ..AetherSettings::default()
753 };
754 let override_config = AetherSettings {
755 agent: Some("beta".to_string()),
756 prompts: vec![PromptSource::file("OVERRIDE.md")],
757 agents: vec![
758 AgentConfig { description: "Override alpha".to_string(), ..agent_config("alpha") },
759 agent_config("beta"),
760 ],
761 ..AetherSettings::default()
762 };
763
764 let config = AetherSettings::load(
765 dir.root(),
766 [AetherSettingsSource::Value(Box::new(base)), AetherSettingsSource::Value(Box::new(override_config))],
767 )
768 .unwrap();
769
770 assert_eq!(
771 config,
772 AetherSettings {
773 agent: Some("beta".to_string()),
774 prompts: vec![PromptSource::file("OVERRIDE.md")],
775 agents: vec![
776 AgentConfig { description: "Override alpha".to_string(), ..agent_config("alpha") },
777 agent_config("beta"),
778 ],
779 ..AetherSettings::default()
780 }
781 );
782 }
783
784 #[test]
785 fn load_default_merges_user_and_project_settings_with_project_winning() {
786 let project = project();
787 let home = home().settings(
788 r#"{
789 "agent":"shared",
790 "prompts":["USER.md"],
791 "agents":[
792 {"name":"shared","description":"User shared","model":"anthropic:claude-sonnet-4-5","userInvocable":true},
793 {"name":"user-only","description":"User only","model":"anthropic:claude-sonnet-4-5","userInvocable":true}
794 ]
795 }"#,
796 );
797 project.write(
798 ".aether/settings.json",
799 r#"{
800 "agent":"project-only",
801 "prompts":["PROJECT.md"],
802 "agents":[
803 {"name":"shared","description":"Project shared","model":"anthropic:claude-sonnet-4-5","userInvocable":true},
804 {"name":"project-only","description":"Project only","model":"anthropic:claude-sonnet-4-5","userInvocable":true}
805 ]
806 }"#,
807 );
808
809 let aether_home = home.aether();
810 let config = load_default_from_home(project.root(), &aether_home).unwrap();
811 assert_eq!(
812 config,
813 AetherSettings {
814 agent: Some("project-only".to_string()),
815 prompts: vec![PromptSource::file("PROJECT.md")],
816 agents: vec![
817 settings_agent("shared", "Project shared"),
818 settings_agent("user-only", "User only"),
819 settings_agent("project-only", "Project only"),
820 ],
821 ..AetherSettings::default()
822 }
823 );
824 }
825
826 #[test]
827 fn load_default_uses_user_settings_when_project_settings_are_missing() {
828 let project = project();
829 let home = home().settings(
830 r#"{"agents":[{"name":"user-only","description":"User only","model":"anthropic:claude-sonnet-4-5","userInvocable":true}]}"#,
831 );
832
833 let aether_home = home.aether();
834 let config = load_default_from_home(project.root(), &aether_home).unwrap();
835 assert_eq!(
836 config,
837 AetherSettings { agents: vec![settings_agent("user-only", "User only")], ..AetherSettings::default() }
838 );
839 }
840
841 #[test]
842 fn load_default_resolves_user_agent_paths_from_aether_home() {
843 let project = project();
844 let home = home()
845 .file(".aether/agents/user.md", "User instructions")
846 .file(".aether/mcp/user.json", r#"{"servers":{}}"#)
847 .settings(
848 r#"{
849 "agents":[{
850 "name":"user-only",
851 "description":"User only",
852 "model":"anthropic:claude-sonnet-4-5",
853 "userInvocable":true,
854 "prompts":["agents/user.md"],
855 "mcps":["mcp/user.json"]
856 }]
857 }"#,
858 );
859
860 let aether_home = home.aether();
861 let config = load_default_from_home(project.root(), &aether_home).unwrap();
862 let catalog = AgentCatalog::from_settings(project.root(), config).unwrap();
863 let spec = catalog.resolve("user-only").unwrap();
864
865 let expected_prompt = aether_home.join("agents/user.md");
866 assert!(spec.prompts.iter().any(|prompt| match prompt {
867 Prompt::File { path, .. } => path == &expected_prompt,
868 Prompt::Text(_) | Prompt::McpInstructions(_) => false,
869 }));
870 assert!(matches!(
871 &spec.mcp_config_sources[0],
872 McpConfigSource::File { path, defer_tools: false } if path == &aether_home.join("mcp/user.json")
873 ));
874 }
875
876 #[test]
877 fn load_default_uses_project_settings_when_user_settings_are_missing() {
878 let home = home();
879 let project = project().file(
880 ".aether/settings.json",
881 r#"{"agents":[{"name":"project-only","description":"Project only","model":"anthropic:claude-sonnet-4-5","userInvocable":true}]}"#,
882 );
883
884 let aether_home = home.aether();
885 let config = load_default_from_home(project.root(), &aether_home).unwrap();
886
887 assert_eq!(
888 config,
889 AetherSettings {
890 agents: vec![settings_agent("project-only", "Project only")],
891 ..AetherSettings::default()
892 }
893 );
894 }
895
896 #[test]
897 fn load_default_returns_default_when_user_and_project_settings_are_missing() {
898 let project = project();
899 let home = home();
900 let aether_home = home.aether();
901 let config = load_default_from_home(project.root(), &aether_home).unwrap();
902 assert_eq!(config, AetherSettings::default());
903 }
904
905 #[test]
906 fn load_default_rejects_malformed_user_settings() {
907 let project = project();
908 let home = home().settings("{not-json");
909 let aether_home = home.aether();
910 let err = load_default_from_home(project.root(), &aether_home).unwrap_err();
911 assert!(matches!(err, SettingsError::ParseError(_)));
912 }
913
914 #[test]
915 fn strict_file_source_errors_when_missing() {
916 let project = project();
917 let err = AetherSettings::load(
918 project.root(),
919 [AetherSettingsSource::File(SettingsFileSource::new("missing.json", project.root()))],
920 )
921 .unwrap_err();
922
923 assert!(matches!(err, SettingsError::IoError(_)));
924 }
925
926 #[test]
927 fn optional_file_source_returns_default_when_missing() {
928 let project = project();
929 let config = AetherSettings::load(
930 project.root(),
931 [AetherSettingsSource::OptionalFile(SettingsFileSource::new("missing.json", project.root()))],
932 )
933 .unwrap();
934
935 assert_eq!(config, AetherSettings::default());
936 }
937
938 #[test]
939 fn inline_resources_replaces_file_sources_with_their_contents() {
940 let project = project()
941 .file("BASE.md", "Be helpful")
942 .file("AGENT.md", "Edit carefully")
943 .file("mcp.json", r#"{"servers":{"coding":{"type":"stdio","command":"run"}}}"#);
944
945 let mut settings = AetherSettings {
946 prompts: vec![PromptSource::file("BASE.md")],
947 mcps: vec![McpSourceSpec::file("mcp.json")],
948 agents: vec![AgentConfig {
949 prompts: vec![PromptSource::file("AGENT.md")],
950 mcps: vec![McpSourceSpec::file("mcp.json")],
951 ..agent_config("alpha")
952 }],
953 ..AetherSettings::default()
954 };
955
956 settings.inline_resources(project.root()).unwrap();
957
958 assert_eq!(settings.prompts, vec![PromptSource::Text { text: "Be helpful".to_string() }]);
959 assert_eq!(settings.agents[0].prompts, vec![PromptSource::Text { text: "Edit carefully".to_string() }]);
960 assert!(matches!(&settings.mcps[0], McpSourceSpec::Inline { servers } if servers.contains_key("coding")));
961 assert!(
962 matches!(&settings.agents[0].mcps[0], McpSourceSpec::Inline { servers } if servers.contains_key("coding"))
963 );
964
965 let serialized = serde_json::to_string(&settings).unwrap();
966 assert!(!serialized.contains("BASE.md") && !serialized.contains("mcp.json"), "{serialized}");
967 }
968
969 #[test]
970 fn inline_resources_drops_optional_missing_sources() {
971 let project = project().file("PROMPT.md", "Agent prompt");
972 let mut settings = AetherSettings {
973 prompts: vec![PromptSource::file("absent.md").optional()],
974 mcps: vec![McpSourceSpec::File(McpFileSpec::new("absent.json").optional())],
975 agents: vec![agent_config("alpha")],
976 ..AetherSettings::default()
977 };
978
979 settings.inline_resources(project.root()).unwrap();
980
981 assert!(settings.prompts.is_empty());
982 assert!(settings.mcps.is_empty());
983 }
984
985 #[test]
986 fn inline_resources_errors_on_required_missing_mcp() {
987 let project = project();
988 let mut settings = AetherSettings {
989 mcps: vec![McpSourceSpec::file("absent.json")],
990 agents: vec![agent_config("alpha")],
991 ..AetherSettings::default()
992 };
993
994 let err = settings.inline_resources(project.root()).unwrap_err();
995 assert!(matches!(err, SettingsError::InvalidMcpConfigPath { .. }));
996 }
997
998 #[test]
999 fn resolves_inline_mcp_config() {
1000 let dir = project().file("PROMPT.md", "Be helpful");
1001 let config = AetherSettings {
1002 agent: None,
1003 agents: vec![AgentConfig {
1004 mcps: vec![McpSourceSpec::Inline { servers: BTreeMap::new() }],
1005 ..agent_config("alpha")
1006 }],
1007 ..AetherSettings::default()
1008 };
1009
1010 let catalog = AgentCatalog::from_settings(dir.root(), config).unwrap();
1011 let spec = catalog.resolve("alpha").unwrap();
1012
1013 assert_eq!(spec.mcp_config_sources.len(), 1);
1014 assert!(matches!(spec.mcp_config_sources[0], McpConfigSource::Inline(_)));
1015 }
1016
1017 #[test]
1018 fn parses_top_level_prompt_and_mcp_defaults() {
1019 let config = AetherSettings::try_from(
1020 r#"{
1021 "prompts": [{"type":"file","path":"BASE.md"}],
1022 "mcps": [{"type":"file","path":"mcp.json"}],
1023 "agents": [{
1024 "name":"alpha",
1025 "description":"Alpha",
1026 "model":"anthropic:claude-sonnet-4-5",
1027 "userInvocable":true
1028 }]
1029 }"#,
1030 )
1031 .unwrap();
1032
1033 assert_eq!(
1034 config,
1035 AetherSettings {
1036 prompts: vec![PromptSource::file("BASE.md")],
1037 mcps: vec![McpSourceSpec::file("mcp.json")],
1038 agents: vec![settings_agent("alpha", "Alpha")],
1039 ..AetherSettings::default()
1040 }
1041 );
1042 }
1043
1044 #[test]
1045 fn parses_and_serializes_string_shorthand_for_file_sources() {
1046 let config = AetherSettings::try_from(
1047 r#"{
1048 "prompts": ["BASE.md"],
1049 "mcps": ["mcp.json"],
1050 "agents": [{
1051 "name":"alpha",
1052 "description":"Alpha",
1053 "model":"anthropic:claude-sonnet-4-5",
1054 "userInvocable":true,
1055 "prompts":["AGENT.md"],
1056 "mcps":["agent-mcp.json"]
1057 }]
1058 }"#,
1059 )
1060 .unwrap();
1061
1062 assert_eq!(
1063 config,
1064 AetherSettings {
1065 prompts: vec![PromptSource::file("BASE.md")],
1066 mcps: vec![McpSourceSpec::file("mcp.json")],
1067 agents: vec![AgentConfig {
1068 prompts: vec![PromptSource::file("AGENT.md")],
1069 mcps: vec![McpSourceSpec::file("agent-mcp.json")],
1070 ..settings_agent("alpha", "Alpha")
1071 }],
1072 ..AetherSettings::default()
1073 }
1074 );
1075
1076 let value = serde_json::to_value(&config).unwrap();
1077 assert_eq!(value["prompts"], serde_json::json!(["BASE.md"]));
1078 assert_eq!(value["mcps"], serde_json::json!(["mcp.json"]));
1079 assert_eq!(value["agents"][0]["prompts"], serde_json::json!(["AGENT.md"]));
1080 assert_eq!(value["agents"][0]["mcps"], serde_json::json!(["agent-mcp.json"]));
1081 }
1082
1083 #[test]
1084 fn deserializes_legacy_proxy_mcp_file_as_deferred() {
1085 let source: McpSourceSpec =
1086 serde_json::from_value(serde_json::json!({"type":"file", "path":"mcp.json", "proxy":true})).unwrap();
1087
1088 assert!(matches!(source, McpSourceSpec::File(McpFileSpec { defer_tools: true, .. })));
1089 }
1090
1091 #[test]
1092 fn serializes_deferred_mcp_file_as_typed_object() {
1093 let source: McpSourceSpec = McpFileSpec::new("mcp.json").defer_tools().into();
1094
1095 let value = serde_json::to_value(source).unwrap();
1096
1097 assert_eq!(value, serde_json::json!({"type":"file", "path":"mcp.json", "deferTools":true}));
1098 }
1099
1100 #[test]
1101 fn rejects_old_top_level_mcp_servers_field() {
1102 let err = AetherSettings::try_from(
1103 r#"{
1104 "mcpServers": ["mcp.json"],
1105 "agents": [{
1106 "name":"alpha",
1107 "description":"Alpha",
1108 "model":"anthropic:claude-sonnet-4-5",
1109 "userInvocable":true,
1110 "prompts":[{"type":"file","path":"PROMPT.md"}]
1111 }]
1112 }"#,
1113 )
1114 .unwrap_err();
1115
1116 assert!(matches!(err, SettingsError::ParseError(message) if message.contains("mcpServers")));
1117 }
1118
1119 #[test]
1120 fn load_default_resolves_workspace_scoped_user_prompt_and_mcp_paths() {
1121 let project = project().file("AGENTS.md", "Agent instructions").file(".aether/mcp.json", r#"{"servers":{}}"#);
1122 let home = home().file(".aether/agents/planner/SYSTEM.md", "System instructions").settings(
1123 r#"{
1124 "agents":[{
1125 "name":"planner",
1126 "description":"Plans work",
1127 "model":"anthropic:claude-sonnet-4-5",
1128 "userInvocable":true,
1129 "prompts":[
1130 "agents/planner/SYSTEM.md",
1131 {"type":"file","path":"${WORKSPACE}/AGENTS.md"}
1132 ],
1133 "mcps":[
1134 {"type":"file","path":"${WORKSPACE}/.aether/mcp.json"}
1135 ]
1136 }]
1137 }"#,
1138 );
1139
1140 let aether_home = home.aether();
1141 let config = load_default_from_home(project.root(), &aether_home).unwrap();
1142 let catalog = AgentCatalog::from_settings(project.root(), config).unwrap();
1143 let spec = catalog.resolve("planner").unwrap();
1144
1145 let expected_system = aether_home.join("agents/planner/SYSTEM.md");
1146 let expected_agents = project.root().join("AGENTS.md");
1147 assert!(spec.prompts.iter().any(|p| match p {
1148 Prompt::File { path, .. } => path == &expected_system,
1149 _ => false,
1150 }));
1151 assert!(spec.prompts.iter().any(|p| match p {
1152 Prompt::File { path, .. } => path == &expected_agents,
1153 _ => false,
1154 }));
1155 assert!(matches!(
1156 &spec.mcp_config_sources[0],
1157 McpConfigSource::File { path, defer_tools: false } if *path == project.root().join(".aether/mcp.json")
1158 ));
1159 }
1160
1161 #[test]
1162 fn workspace_scoped_paths_expand_in_project_settings_without_absolutizing_normal_relative_paths() {
1163 let project = project().file("PROJECT.md", "Project prompt").file("AGENTS.md", "Agent prompt").file(
1164 ".aether/settings.json",
1165 r#"{
1166 "agents":[{
1167 "name":"alpha",
1168 "description":"Alpha",
1169 "model":"anthropic:claude-sonnet-4-5",
1170 "userInvocable":true,
1171 "prompts":["PROJECT.md", {"type":"file","path":"${WORKSPACE}/AGENTS.md"}]
1172 }]
1173 }"#,
1174 );
1175
1176 let config = AetherSettings::load(
1177 project.root(),
1178 [AetherSettingsSource::OptionalFile(SettingsFileSource::new(PROJECT_SETTINGS_PATH, project.root()))],
1179 )
1180 .unwrap();
1181
1182 assert_eq!(config.agents[0].prompts[0], PromptSource::file("PROJECT.md"));
1183 assert_eq!(config.agents[0].prompts[1], PromptSource::file("${WORKSPACE}/AGENTS.md"));
1184 }
1185
1186 #[test]
1187 fn json_and_value_sources_preserve_workspace_scoped_paths_losslessly() {
1188 let project = project();
1189
1190 let json_config = AetherSettings::load(
1191 project.root(),
1192 [AetherSettingsSource::Json(
1193 r#"{
1194 "agents":[{
1195 "name":"alpha",
1196 "description":"Alpha",
1197 "model":"anthropic:claude-sonnet-4-5",
1198 "userInvocable":true,
1199 "prompts":["${WORKSPACE}/AGENTS.md"]
1200 }]
1201 }"#
1202 .to_string(),
1203 )],
1204 )
1205 .unwrap();
1206
1207 assert_eq!(json_config.agents[0].prompts[0], PromptSource::file("${WORKSPACE}/AGENTS.md"));
1208
1209 let value_config = AetherSettings::load(
1210 project.root(),
1211 [AetherSettingsSource::Value(Box::new(AetherSettings {
1212 agents: vec![AgentConfig {
1213 prompts: vec![PromptSource::file("${WORKSPACE}/AGENTS.md")],
1214 ..agent_config("alpha")
1215 }],
1216 ..AetherSettings::default()
1217 }))],
1218 )
1219 .unwrap();
1220 assert_eq!(value_config.agents[0].prompts[0], PromptSource::file("${WORKSPACE}/AGENTS.md"));
1221 }
1222
1223 #[test]
1224 fn optional_workspace_scoped_mcp_source_is_skipped_when_missing() {
1225 let project = project().file("BASE.md", "Base instructions");
1226 let config = AetherSettings {
1227 agents: vec![AgentConfig {
1228 prompts: vec![PromptSource::file("BASE.md")],
1229 mcps: vec![McpFileSpec::new("${WORKSPACE}/.aether/mcp.json").optional().into()],
1230 ..agent_config("alpha")
1231 }],
1232 ..AetherSettings::default()
1233 };
1234
1235 let config = AetherSettings::load(project.root(), [AetherSettingsSource::Value(Box::new(config))]).unwrap();
1236 let catalog = AgentCatalog::from_settings(project.root(), config).unwrap();
1237 let spec = catalog.resolve("alpha").unwrap();
1238
1239 assert!(spec.mcp_config_sources.is_empty());
1240 }
1241
1242 #[test]
1243 fn optional_mcp_source_skips_unresolved_variable() {
1244 let project = project().file("BASE.md", "Base instructions");
1245 let config = AetherSettings {
1246 agents: vec![AgentConfig {
1247 prompts: vec![PromptSource::file("BASE.md")],
1248 mcps: vec![McpFileSpec::new("${DEFINITELY_NOT_SET_VAR_MCP_OPTIONAL}/mcp.json").optional().into()],
1249 ..agent_config("alpha")
1250 }],
1251 ..AetherSettings::default()
1252 };
1253
1254 let config = AetherSettings::load(project.root(), [AetherSettingsSource::Value(Box::new(config))]).unwrap();
1255 let catalog = AgentCatalog::from_settings(project.root(), config).unwrap();
1256 let spec = catalog.resolve("alpha").unwrap();
1257
1258 assert!(spec.mcp_config_sources.is_empty());
1259 }
1260
1261 #[test]
1262 fn required_mcp_source_errors_on_unresolved_variable() {
1263 let project = project().file("BASE.md", "Base instructions");
1264 let config = AetherSettings {
1265 agents: vec![AgentConfig {
1266 prompts: vec![PromptSource::file("BASE.md")],
1267 mcps: vec![McpSourceSpec::file("${DEFINITELY_NOT_SET_VAR_MCP_REQ}/mcp.json")],
1268 ..agent_config("alpha")
1269 }],
1270 ..AetherSettings::default()
1271 };
1272
1273 let err = AgentCatalog::from_settings(project.root(), config).unwrap_err();
1274 assert!(matches!(err, SettingsError::UnresolvedMcpConfigVariable { .. }));
1275 }
1276
1277 #[test]
1278 fn required_workspace_scoped_mcp_source_errors_when_missing() {
1279 let project = project().file("BASE.md", "Base instructions");
1280 let config = AetherSettings {
1281 agents: vec![AgentConfig {
1282 prompts: vec![PromptSource::file("BASE.md")],
1283 mcps: vec![McpSourceSpec::file("nonexistent.json")],
1284 ..agent_config("alpha")
1285 }],
1286 ..AetherSettings::default()
1287 };
1288
1289 let err = AgentCatalog::from_settings(project.root(), config).unwrap_err();
1290 assert!(matches!(err, SettingsError::InvalidMcpConfigPath { .. }));
1291 }
1292
1293 #[test]
1294 fn optional_existing_mcp_source_preserves_defer_tools_flag() {
1295 let project = project().file("BASE.md", "Base instructions").file("mcp.json", r#"{"servers":{}}"#);
1296 let config = AetherSettings {
1297 agents: vec![AgentConfig {
1298 prompts: vec![PromptSource::file("BASE.md")],
1299 mcps: vec![McpFileSpec::new("mcp.json").defer_tools().optional().into()],
1300 ..agent_config("alpha")
1301 }],
1302 ..AetherSettings::default()
1303 };
1304
1305 let catalog = AgentCatalog::from_settings(project.root(), config).unwrap();
1306 let spec = catalog.resolve("alpha").unwrap();
1307
1308 assert!(matches!(&spec.mcp_config_sources[0], McpConfigSource::File { defer_tools: true, .. }));
1309 }
1310
1311 #[test]
1312 fn optional_mcp_source_serializes_as_typed_object() {
1313 let source: McpSourceSpec = McpFileSpec::new("${WORKSPACE}/.aether/mcp.json").optional().into();
1314 let value = serde_json::to_value(source).unwrap();
1315 assert_eq!(value, serde_json::json!({"type":"file", "path":"${WORKSPACE}/.aether/mcp.json", "optional":true}));
1316 }
1317
1318 #[test]
1319 fn optional_prompt_source_serializes_as_typed_object() {
1320 let source = PromptSource::file("${WORKSPACE}/AGENTS.md").optional();
1321 let value = serde_json::to_value(&source).unwrap();
1322 assert_eq!(value, serde_json::json!({"type":"file", "path":"${WORKSPACE}/AGENTS.md", "optional":true}));
1323 }
1324
1325 #[test]
1326 fn all_optional_prompts_missing_errors_with_no_prompts() {
1327 let project = project();
1328 let config = AetherSettings {
1329 agents: vec![AgentConfig {
1330 prompts: vec![PromptSource::file("MISSING.md").optional()],
1331 ..agent_config("alpha")
1332 }],
1333 ..AetherSettings::default()
1334 };
1335
1336 let err = AgentCatalog::from_settings(project.root(), config).unwrap_err();
1337 assert!(matches!(err, SettingsError::AllOptionalPromptsMissing { agent } if agent == "alpha"));
1338 }
1339
1340 #[test]
1341 fn settings_round_trip_preserves_workspace_prefix_and_relative_paths() {
1342 let original = r#"{"agents":[{
1343 "name":"alpha",
1344 "description":"Alpha",
1345 "model":"anthropic:claude-sonnet-4-5",
1346 "userInvocable":true,
1347 "prompts":[
1348 "AGENTS.md",
1349 "${WORKSPACE}/SYSTEM.md",
1350 {"type":"file","path":"${WORKSPACE}/.aether/rules.md","optional":true},
1351 {"type":"glob","pattern":"${WORKSPACE}/.aether/rules/*.md"}
1352 ],
1353 "mcps":[
1354 "mcp.json",
1355 {"type":"file","path":"${WORKSPACE}/.aether/mcp.json","optional":true}
1356 ]
1357 }]}"#;
1358
1359 let settings = AetherSettings::try_from(original).unwrap();
1360 let reserialized = serde_json::to_string(&settings).unwrap();
1361 let reparsed = AetherSettings::try_from(reserialized.as_str()).unwrap();
1362
1363 assert_eq!(settings, reparsed, "settings should round-trip losslessly through serde");
1364 }
1365
1366 #[test]
1367 fn user_settings_relative_paths_absolutize_at_load_but_workspace_token_is_preserved() {
1368 let project = project().file("AGENTS.md", "agents");
1369 let home = home().file(".aether/agents/planner/SYSTEM.md", "system").settings(
1370 r#"{"agents":[{
1371 "name":"planner",
1372 "description":"Plans",
1373 "model":"anthropic:claude-sonnet-4-5",
1374 "userInvocable":true,
1375 "prompts":["agents/planner/SYSTEM.md", "${WORKSPACE}/AGENTS.md"]
1376 }]}"#,
1377 );
1378
1379 let aether_home = home.aether();
1380 let settings = load_default_from_home(project.root(), &aether_home).unwrap();
1381
1382 let expected_user = aether_home.join("agents/planner/SYSTEM.md").to_string_lossy().to_string();
1383 assert_eq!(
1384 settings.agents[0].prompts,
1385 vec![PromptSource::file(expected_user), PromptSource::file("${WORKSPACE}/AGENTS.md")],
1386 "user-rooted relative paths must absolutize; ${{WORKSPACE}}/ paths must be preserved",
1387 );
1388 }
1389
1390 fn load_default_from_home(project_root: &Path, aether_home: &Path) -> Result<AetherSettings, SettingsError> {
1391 AetherSettings::load(project_root, default_sources_for_home(project_root, Some(aether_home)))
1392 }
1393
1394 #[test]
1395 fn parses_credentials_store_keyring() {
1396 let config = AetherSettings::try_from(
1397 r#"{
1398 "credentialsStore": { "type": "keyring" },
1399 "agents": [{"name":"alpha","description":"Alpha","model":"anthropic:claude-sonnet-4-5","userInvocable":true}]
1400 }"#,
1401 )
1402 .unwrap();
1403
1404 assert_eq!(config.credentials_store, Some(CredentialsStoreConfig::Keyring));
1405 }
1406
1407 #[test]
1408 fn parses_credentials_store_memory() {
1409 let config = AetherSettings::try_from(
1410 r#"{
1411 "credentialsStore": { "type": "memory" },
1412 "agents": [{"name":"alpha","description":"Alpha","model":"anthropic:claude-sonnet-4-5","userInvocable":true}]
1413 }"#,
1414 )
1415 .unwrap();
1416
1417 assert_eq!(config.credentials_store, Some(CredentialsStoreConfig::Memory));
1418 }
1419
1420 #[test]
1421 fn expands_otlp_header_environment_variables() {
1422 let settings = OtlpTelemetrySettings {
1423 headers: BTreeMap::from([
1424 ("authorization".to_string(), "Bearer $POSTHOG_PROJECT_TOKEN".to_string()),
1425 ("x-project".to_string(), "${POSTHOG_PROJECT_TOKEN}".to_string()),
1426 ("x-literal".to_string(), "$$POSTHOG_PROJECT_TOKEN".to_string()),
1427 ]),
1428 ..OtlpTelemetrySettings::default()
1429 };
1430 let vars = Vars::new().with_env_lookup(|name| (name == "POSTHOG_PROJECT_TOKEN").then(|| "token-value".into()));
1431
1432 assert_eq!(
1433 settings.resolved_headers(&vars).unwrap(),
1434 BTreeMap::from([
1435 ("authorization".to_string(), "Bearer token-value".to_string()),
1436 ("x-project".to_string(), "token-value".to_string()),
1437 ("x-literal".to_string(), "$POSTHOG_PROJECT_TOKEN".to_string()),
1438 ])
1439 );
1440 }
1441
1442 #[test]
1443 fn reports_missing_otlp_header_environment_variables() {
1444 let settings = OtlpTelemetrySettings {
1445 headers: BTreeMap::from([("authorization".to_string(), "Bearer $POSTHOG_PROJECT_TOKEN".to_string())]),
1446 ..OtlpTelemetrySettings::default()
1447 };
1448
1449 assert!(matches!(
1450 settings.resolved_headers(&Vars::new().with_env_lookup(|_| None)),
1451 Err(VarError::NotFound(variable)) if variable == "POSTHOG_PROJECT_TOKEN"
1452 ));
1453 }
1454
1455 #[test]
1456 fn parses_credentials_store_encrypted_file_with_options() {
1457 let config = AetherSettings::try_from(
1458 r#"{
1459 "credentialsStore": {
1460 "type": "encryptedFile",
1461 "path": "/custom/creds.enc",
1462 "passwordEnv": "MY_SECRET"
1463 },
1464 "agents": [{"name":"alpha","description":"Alpha","model":"anthropic:claude-sonnet-4-5","userInvocable":true}]
1465 }"#,
1466 )
1467 .unwrap();
1468
1469 assert!(matches!(
1470 &config.credentials_store,
1471 Some(CredentialsStoreConfig::EncryptedFile { path, password_env })
1472 if path == &Some(PathBuf::from("/custom/creds.enc"))
1473 && password_env == &Some("MY_SECRET".to_string())
1474 ));
1475 }
1476}