Skip to main content

aether_project/
aether_settings.rs

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