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