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