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