Skip to main content

aether_project/
aether_settings.rs

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