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