Skip to main content

aether_project/
aether_settings.rs

1use utils::SettingsStore;
2
3use crate::agent_config::AgentConfig;
4use crate::error::SettingsError;
5use crate::{McpSourceSpec, PromptSource};
6use llm::ProviderConnectionOverrides;
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use std::fs::read_to_string;
10use std::path::{Path, PathBuf};
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
13#[serde(tag = "type", rename_all = "camelCase")]
14pub enum CredentialsStoreConfig {
15    /// Holds credentials in the OS keyring
16    Keyring,
17
18    /// Holds credentials in-memory and only for the lifetime of the
19    /// process. Intended for tests and ephemeral runs that must not touch the OS
20    /// keychain.
21    Memory,
22
23    /// Holds credentials in an encrypted file
24    EncryptedFile {
25        /// File path for the encrypted credential blob. Defaults to
26        /// `.aether/credentials.enc` in the Aether home directory when unset.
27        #[serde(default, skip_serializing_if = "Option::is_none")]
28        path: Option<PathBuf>,
29        /// Environment variable name to read the passphrase from. Uses
30        /// `AETHER_CREDENTIALS_PASSWORD` when unset.
31        #[serde(default, skip_serializing_if = "Option::is_none", rename = "passwordEnv")]
32        password_env: Option<String>,
33    },
34}
35
36const PROJECT_SETTINGS_PATH: &str = ".aether/settings.json";
37const USER_SETTINGS_FILENAME: &str = "settings.json";
38
39pub fn user_settings_path() -> Option<PathBuf> {
40    SettingsStore::new("AETHER_HOME", ".aether").map(|store| store.home().join(USER_SETTINGS_FILENAME))
41}
42
43pub fn user_settings_exist() -> bool {
44    user_settings_path().is_some_and(|p| p.is_file())
45}
46
47pub fn project_settings_path(project_root: &Path) -> PathBuf {
48    project_root.join(PROJECT_SETTINGS_PATH)
49}
50
51pub fn project_settings_exist(project_root: &Path) -> bool {
52    project_settings_path(project_root).is_file()
53}
54
55#[doc = include_str!("docs/aether_settings.md")]
56#[derive(Debug, Clone, Default, PartialEq, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
57#[serde(rename_all = "camelCase", deny_unknown_fields)]
58pub struct AetherSettings {
59    /// Name of the agent to launch by default. Must match a `name` in `agents`.
60    /// When unset, Aether falls back to the first user-invocable agent.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub agent: Option<String>,
63    /// Default prompt sources shared by all agents. An agent inherits these only
64    /// when its own `prompts` array is empty.
65    #[serde(default, skip_serializing_if = "Vec::is_empty")]
66    pub prompts: Vec<PromptSource>,
67    /// Default MCP sources shared by all agents. An agent inherits these only when
68    /// its own `mcps` array is empty.
69    #[serde(default, skip_serializing_if = "Vec::is_empty")]
70    pub mcps: Vec<McpSourceSpec>,
71    /// Provider connection overrides (credentials, base URLs, inference profiles)
72    /// applied to every agent unless overridden per-agent.
73    #[serde(default, skip_serializing_if = "ProviderConnectionOverrides::is_empty")]
74    pub providers: ProviderConnectionOverrides,
75    /// Credential storage backend for OAuth tokens. Defaults to the OS keyring
76    /// when unset.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub credentials_store: Option<CredentialsStoreConfig>,
79    /// The agents defined for this project. At least one agent is required.
80    #[schemars(length(min = 1))]
81    pub agents: Vec<AgentConfig>,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct SettingsFileSource {
86    pub path: PathBuf,
87    pub root: PathBuf,
88}
89
90#[derive(Debug, Clone)]
91pub enum AetherSettingsSource {
92    File(SettingsFileSource),
93    OptionalFile(SettingsFileSource),
94    Json(String),
95    Value(AetherSettings),
96}
97
98impl SettingsFileSource {
99    pub fn new(path: impl Into<PathBuf>, root: impl Into<PathBuf>) -> Self {
100        Self { path: path.into(), root: root.into() }
101    }
102}
103
104impl AetherSettings {
105    pub fn load_default(project_root: &Path) -> Result<Self, SettingsError> {
106        Self::load(project_root, default_sources(project_root))
107    }
108
109    pub fn load(
110        project_root: &Path,
111        sources: impl IntoIterator<Item = AetherSettingsSource>,
112    ) -> Result<Self, SettingsError> {
113        sources.into_iter().try_fold(Self::default(), |config, source| {
114            let next = Self::load_source(project_root, source)?;
115            Ok(config.merge(next))
116        })
117    }
118
119    pub fn merge(mut self, next: Self) -> Self {
120        if next.agent.is_some() {
121            self.agent = next.agent;
122        }
123
124        if !next.prompts.is_empty() {
125            self.prompts = next.prompts;
126        }
127        if !next.mcps.is_empty() {
128            self.mcps = next.mcps;
129        }
130        self.providers.merge(next.providers);
131
132        if next.credentials_store.is_some() {
133            self.credentials_store = next.credentials_store;
134        }
135
136        for next_agent in next.agents {
137            if let Some(existing) = self.agents.iter_mut().find(|agent| agent.name.trim() == next_agent.name.trim()) {
138                *existing = next_agent;
139            } else {
140                self.agents.push(next_agent);
141            }
142        }
143
144        self
145    }
146
147    fn load_source(project_root: &Path, source: AetherSettingsSource) -> Result<Self, SettingsError> {
148        match source {
149            AetherSettingsSource::File(source) => load_file_source(project_root, source, false),
150            AetherSettingsSource::OptionalFile(source) => load_file_source(project_root, source, true),
151            AetherSettingsSource::Json(json) => Self::try_from(json.as_str()),
152            AetherSettingsSource::Value(settings) => Ok(settings),
153        }
154    }
155}
156
157fn default_sources(project_root: &Path) -> Vec<AetherSettingsSource> {
158    let aether_home = SettingsStore::new("AETHER_HOME", ".aether").map(|store| store.home().to_path_buf());
159    default_sources_for_home(project_root, aether_home.as_deref())
160}
161
162fn default_sources_for_home(project_root: &Path, aether_home: Option<&Path>) -> Vec<AetherSettingsSource> {
163    let mut sources = Vec::new();
164    if let Some(aether_home) = aether_home {
165        sources.push(AetherSettingsSource::OptionalFile(SettingsFileSource::new("settings.json", aether_home)));
166    }
167    sources.push(AetherSettingsSource::OptionalFile(SettingsFileSource::new(PROJECT_SETTINGS_PATH, project_root)));
168    sources
169}
170
171fn load_file_source(
172    project_root: &Path,
173    source: SettingsFileSource,
174    missing_is_empty: bool,
175) -> Result<AetherSettings, SettingsError> {
176    let root = resolve_against(project_root, source.root);
177    let path = resolve_against(&root, source.path);
178    let settings = load_file(&path, missing_is_empty)?;
179    let source_root = (root != project_root).then_some(root.as_path());
180    Ok(normalize_resource_paths(settings, source_root))
181}
182
183fn resolve_against(base: &Path, path: PathBuf) -> PathBuf {
184    if path.is_absolute() { path } else { base.join(path) }
185}
186
187fn load_file(path: &Path, missing_is_empty: bool) -> Result<AetherSettings, SettingsError> {
188    match read_to_string(path) {
189        Ok(content) if content.trim().is_empty() => Ok(AetherSettings::default()),
190        Ok(content) => AetherSettings::try_from(content.as_str()),
191        Err(error) if missing_is_empty && error.kind() == std::io::ErrorKind::NotFound => Ok(AetherSettings::default()),
192        Err(error) => Err(SettingsError::IoError(format!("Failed to read {}: {}", path.display(), error))),
193    }
194}
195
196fn normalize_resource_paths(mut settings: AetherSettings, source_root: Option<&Path>) -> AetherSettings {
197    let Some(root) = source_root else { return settings };
198    promote_prompt_sources(&mut settings.prompts, root);
199    promote_mcp_sources(&mut settings.mcps, root);
200
201    for agent in &mut settings.agents {
202        promote_prompt_sources(&mut agent.prompts, root);
203        promote_mcp_sources(&mut agent.mcps, root);
204    }
205
206    settings
207}
208
209fn promote_prompt_sources(sources: &mut [PromptSource], source_root: &Path) {
210    for source in sources {
211        match source {
212            PromptSource::File { path, .. } | PromptSource::Glob { pattern: path, .. } => {
213                path.promote_relative(source_root);
214            }
215            PromptSource::Text { .. } => {}
216        }
217    }
218}
219
220fn promote_mcp_sources(sources: &mut [McpSourceSpec], source_root: &Path) {
221    for source in sources {
222        if let McpSourceSpec::File(file) = source {
223            file.path.promote_relative(source_root);
224        }
225    }
226}
227
228impl TryFrom<&str> for AetherSettings {
229    type Error = SettingsError;
230
231    fn try_from(content: &str) -> Result<Self, Self::Error> {
232        serde_json::from_str(content).map_err(|e| SettingsError::ParseError(e.to_string()))
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use crate::{AgentCatalog, McpFileSpec, McpSourceSpec, PromptSource};
240    use aether_core::agent_spec::McpConfigSource;
241    use aether_core::core::Prompt;
242    use std::collections::BTreeMap;
243    use std::fs::{create_dir_all, write};
244
245    #[test]
246    fn project_settings_path_points_at_project_aether_settings() {
247        assert_eq!(project_settings_path(Path::new("/repo")), PathBuf::from("/repo/.aether/settings.json"));
248    }
249
250    #[test]
251    fn project_settings_exist_checks_project_settings_file() {
252        let dir = tempfile::tempdir().unwrap();
253        assert!(!project_settings_exist(dir.path()));
254        write_file(dir.path(), PROJECT_SETTINGS_PATH, "{}");
255        assert!(project_settings_exist(dir.path()));
256    }
257
258    #[test]
259    fn resolves_selected_agent() {
260        let dir = tempfile::tempdir().unwrap();
261        write_file(dir.path(), "PROMPT.md", "Be helpful");
262        let config = AetherSettings {
263            agent: Some("beta".to_string()),
264            agents: vec![agent_config("alpha"), agent_config("beta")],
265            ..AetherSettings::default()
266        };
267
268        let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
269
270        assert_eq!(catalog.default_agent().map(|spec| spec.name.as_str()), Some("beta"));
271    }
272
273    #[test]
274    fn rejects_selected_agent_that_is_not_user_invocable() {
275        let mut internal = agent_config("internal");
276        internal.user_invocable = false;
277        internal.agent_invocable = true;
278        let config =
279            AetherSettings { agent: Some("internal".to_string()), agents: vec![internal], ..AetherSettings::default() };
280
281        let err = AgentCatalog::from_settings(Path::new("/tmp"), config).unwrap_err();
282
283        assert!(matches!(err, SettingsError::NonUserInvocableAgentSelector { .. }));
284    }
285
286    #[test]
287    fn settings_file_paths_are_project_relative() {
288        let dir = tempfile::tempdir().unwrap();
289        write_file(dir.path(), "PROMPT.md", "Be helpful");
290        write_file(
291            dir.path(),
292            "nested/config.json",
293            r#"{"agents":[{"name":"alpha","description":"Alpha","model":"anthropic:claude-sonnet-4-5","userInvocable":true,"prompts":[{"type":"file","path":"PROMPT.md"}]}]}"#,
294        );
295
296        let config = AetherSettings::load(
297            dir.path(),
298            [AetherSettingsSource::File(SettingsFileSource::new("nested/config.json", dir.path()))],
299        )
300        .unwrap();
301        let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
302
303        assert_eq!(catalog.all()[0].name, "alpha");
304    }
305
306    #[test]
307    fn load_merges_sources_with_rightmost_agent_winning() {
308        let dir = tempfile::tempdir().unwrap();
309        let base = AetherSettings {
310            agent: Some("alpha".to_string()),
311            prompts: vec![PromptSource::file("BASE.md")],
312            agents: vec![AgentConfig { description: "Base alpha".to_string(), ..agent_config("alpha") }],
313            ..AetherSettings::default()
314        };
315        let override_config = AetherSettings {
316            agent: Some("beta".to_string()),
317            prompts: vec![PromptSource::file("OVERRIDE.md")],
318            agents: vec![
319                AgentConfig { description: "Override alpha".to_string(), ..agent_config("alpha") },
320                agent_config("beta"),
321            ],
322            ..AetherSettings::default()
323        };
324
325        let config = AetherSettings::load(
326            dir.path(),
327            [AetherSettingsSource::Value(base), AetherSettingsSource::Value(override_config)],
328        )
329        .unwrap();
330
331        assert_eq!(
332            config,
333            AetherSettings {
334                agent: Some("beta".to_string()),
335                prompts: vec![PromptSource::file("OVERRIDE.md")],
336                agents: vec![
337                    AgentConfig { description: "Override alpha".to_string(), ..agent_config("alpha") },
338                    agent_config("beta"),
339                ],
340                ..AetherSettings::default()
341            }
342        );
343    }
344
345    #[test]
346    fn load_default_merges_user_and_project_settings_with_project_winning() {
347        let project = tempfile::tempdir().unwrap();
348        let home = tempfile::tempdir().unwrap();
349        let aether_home = home.path().join(".aether");
350        write_file(
351            &aether_home,
352            "settings.json",
353            r#"{
354                "agent":"shared",
355                "prompts":["USER.md"],
356                "agents":[
357                    {"name":"shared","description":"User shared","model":"anthropic:claude-sonnet-4-5","userInvocable":true},
358                    {"name":"user-only","description":"User only","model":"anthropic:claude-sonnet-4-5","userInvocable":true}
359                ]
360            }"#,
361        );
362        write_file(
363            project.path(),
364            ".aether/settings.json",
365            r#"{
366                "agent":"project-only",
367                "prompts":["PROJECT.md"],
368                "agents":[
369                    {"name":"shared","description":"Project shared","model":"anthropic:claude-sonnet-4-5","userInvocable":true},
370                    {"name":"project-only","description":"Project only","model":"anthropic:claude-sonnet-4-5","userInvocable":true}
371                ]
372            }"#,
373        );
374
375        let config = load_default_from_home(project.path(), &aether_home).unwrap();
376        assert_eq!(
377            config,
378            AetherSettings {
379                agent: Some("project-only".to_string()),
380                prompts: vec![PromptSource::file("PROJECT.md")],
381                agents: vec![
382                    settings_agent("shared", "Project shared"),
383                    settings_agent("user-only", "User only"),
384                    settings_agent("project-only", "Project only"),
385                ],
386                ..AetherSettings::default()
387            }
388        );
389    }
390
391    #[test]
392    fn load_default_uses_user_settings_when_project_settings_are_missing() {
393        let project = tempfile::tempdir().unwrap();
394        let home = tempfile::tempdir().unwrap();
395        let aether_home = home.path().join(".aether");
396        write_file(
397            &aether_home,
398            "settings.json",
399            r#"{"agents":[{"name":"user-only","description":"User only","model":"anthropic:claude-sonnet-4-5","userInvocable":true}]}"#,
400        );
401
402        let config = load_default_from_home(project.path(), &aether_home).unwrap();
403        assert_eq!(
404            config,
405            AetherSettings { agents: vec![settings_agent("user-only", "User only")], ..AetherSettings::default() }
406        );
407    }
408
409    #[test]
410    fn load_default_resolves_user_agent_paths_from_aether_home() {
411        let project = tempfile::tempdir().unwrap();
412        let home = tempfile::tempdir().unwrap();
413        let aether_home = home.path().join(".aether");
414        write_file(&aether_home, "agents/user.md", "User instructions");
415        write_file(&aether_home, "mcp/user.json", r#"{"servers":{}}"#);
416        write_file(
417            &aether_home,
418            "settings.json",
419            r#"{
420                "agents":[{
421                    "name":"user-only",
422                    "description":"User only",
423                    "model":"anthropic:claude-sonnet-4-5",
424                    "userInvocable":true,
425                    "prompts":["agents/user.md"],
426                    "mcps":["mcp/user.json"]
427                }]
428            }"#,
429        );
430
431        let config = load_default_from_home(project.path(), &aether_home).unwrap();
432        let catalog = AgentCatalog::from_settings(project.path(), config).unwrap();
433        let spec = catalog.resolve("user-only").unwrap();
434
435        let expected_prompt = aether_home.join("agents/user.md");
436        assert!(spec.prompts.iter().any(|prompt| match prompt {
437            Prompt::File { path, .. } => path == &expected_prompt,
438            Prompt::Text(_) | Prompt::McpInstructions(_) => false,
439        }));
440        assert!(matches!(
441            &spec.mcp_config_sources[0],
442            McpConfigSource::File { path, proxy: false } if path == &aether_home.join("mcp/user.json")
443        ));
444    }
445
446    #[test]
447    fn load_default_uses_project_settings_when_user_settings_are_missing() {
448        let project = tempfile::tempdir().unwrap();
449        let home = tempfile::tempdir().unwrap();
450        let aether_home = home.path().join(".aether");
451        write_file(
452            project.path(),
453            ".aether/settings.json",
454            r#"{"agents":[{"name":"project-only","description":"Project only","model":"anthropic:claude-sonnet-4-5","userInvocable":true}]}"#,
455        );
456
457        let config = load_default_from_home(project.path(), &aether_home).unwrap();
458
459        assert_eq!(
460            config,
461            AetherSettings {
462                agents: vec![settings_agent("project-only", "Project only")],
463                ..AetherSettings::default()
464            }
465        );
466    }
467
468    #[test]
469    fn load_default_returns_default_when_user_and_project_settings_are_missing() {
470        let project = tempfile::tempdir().unwrap();
471        let home = tempfile::tempdir().unwrap();
472        let aether_home = home.path().join(".aether");
473        let config = load_default_from_home(project.path(), &aether_home).unwrap();
474        assert_eq!(config, AetherSettings::default());
475    }
476
477    #[test]
478    fn load_default_rejects_malformed_user_settings() {
479        let project = tempfile::tempdir().unwrap();
480        let home = tempfile::tempdir().unwrap();
481        let aether_home = home.path().join(".aether");
482        write_file(&aether_home, "settings.json", "{not-json");
483        let err = load_default_from_home(project.path(), &aether_home).unwrap_err();
484        assert!(matches!(err, SettingsError::ParseError(_)));
485    }
486
487    #[test]
488    fn strict_file_source_errors_when_missing() {
489        let project = tempfile::tempdir().unwrap();
490        let err = AetherSettings::load(
491            project.path(),
492            [AetherSettingsSource::File(SettingsFileSource::new("missing.json", project.path()))],
493        )
494        .unwrap_err();
495
496        assert!(matches!(err, SettingsError::IoError(_)));
497    }
498
499    #[test]
500    fn optional_file_source_returns_default_when_missing() {
501        let project = tempfile::tempdir().unwrap();
502        let config = AetherSettings::load(
503            project.path(),
504            [AetherSettingsSource::OptionalFile(SettingsFileSource::new("missing.json", project.path()))],
505        )
506        .unwrap();
507
508        assert_eq!(config, AetherSettings::default());
509    }
510
511    #[test]
512    fn resolves_inline_mcp_config() {
513        let dir = tempfile::tempdir().unwrap();
514        write_file(dir.path(), "PROMPT.md", "Be helpful");
515        let config = AetherSettings {
516            agent: None,
517            agents: vec![AgentConfig {
518                mcps: vec![McpSourceSpec::Inline { servers: BTreeMap::new() }],
519                ..agent_config("alpha")
520            }],
521            ..AetherSettings::default()
522        };
523
524        let catalog = AgentCatalog::from_settings(dir.path(), config).unwrap();
525        let spec = catalog.resolve("alpha").unwrap();
526
527        assert_eq!(spec.mcp_config_sources.len(), 1);
528        assert!(matches!(spec.mcp_config_sources[0], McpConfigSource::Inline(_)));
529    }
530
531    #[test]
532    fn parses_top_level_prompt_and_mcp_defaults() {
533        let config = AetherSettings::try_from(
534            r#"{
535                "prompts": [{"type":"file","path":"BASE.md"}],
536                "mcps": [{"type":"file","path":"mcp.json"}],
537                "agents": [{
538                    "name":"alpha",
539                    "description":"Alpha",
540                    "model":"anthropic:claude-sonnet-4-5",
541                    "userInvocable":true
542                }]
543            }"#,
544        )
545        .unwrap();
546
547        assert_eq!(
548            config,
549            AetherSettings {
550                prompts: vec![PromptSource::file("BASE.md")],
551                mcps: vec![McpSourceSpec::file("mcp.json")],
552                agents: vec![settings_agent("alpha", "Alpha")],
553                ..AetherSettings::default()
554            }
555        );
556    }
557
558    #[test]
559    fn parses_and_serializes_string_shorthand_for_file_sources() {
560        let config = AetherSettings::try_from(
561            r#"{
562                "prompts": ["BASE.md"],
563                "mcps": ["mcp.json"],
564                "agents": [{
565                    "name":"alpha",
566                    "description":"Alpha",
567                    "model":"anthropic:claude-sonnet-4-5",
568                    "userInvocable":true,
569                    "prompts":["AGENT.md"],
570                    "mcps":["agent-mcp.json"]
571                }]
572            }"#,
573        )
574        .unwrap();
575
576        assert_eq!(
577            config,
578            AetherSettings {
579                prompts: vec![PromptSource::file("BASE.md")],
580                mcps: vec![McpSourceSpec::file("mcp.json")],
581                agents: vec![AgentConfig {
582                    prompts: vec![PromptSource::file("AGENT.md")],
583                    mcps: vec![McpSourceSpec::file("agent-mcp.json")],
584                    ..settings_agent("alpha", "Alpha")
585                }],
586                ..AetherSettings::default()
587            }
588        );
589
590        let value = serde_json::to_value(&config).unwrap();
591        assert_eq!(value["prompts"], serde_json::json!(["BASE.md"]));
592        assert_eq!(value["mcps"], serde_json::json!(["mcp.json"]));
593        assert_eq!(value["agents"][0]["prompts"], serde_json::json!(["AGENT.md"]));
594        assert_eq!(value["agents"][0]["mcps"], serde_json::json!(["agent-mcp.json"]));
595    }
596
597    #[test]
598    fn serializes_proxied_mcp_file_as_typed_object() {
599        let source: McpSourceSpec = McpFileSpec::new("mcp.json").proxy().into();
600
601        let value = serde_json::to_value(source).unwrap();
602
603        assert_eq!(value, serde_json::json!({"type":"file", "path":"mcp.json", "proxy":true}));
604    }
605
606    #[test]
607    fn rejects_old_top_level_mcp_servers_field() {
608        let err = AetherSettings::try_from(
609            r#"{
610                "mcpServers": ["mcp.json"],
611                "agents": [{
612                    "name":"alpha",
613                    "description":"Alpha",
614                    "model":"anthropic:claude-sonnet-4-5",
615                    "userInvocable":true,
616                    "prompts":[{"type":"file","path":"PROMPT.md"}]
617                }]
618            }"#,
619        )
620        .unwrap_err();
621
622        assert!(matches!(err, SettingsError::ParseError(message) if message.contains("mcpServers")));
623    }
624
625    #[test]
626    fn load_default_resolves_workspace_scoped_user_prompt_and_mcp_paths() {
627        let project = tempfile::tempdir().unwrap();
628        let home = tempfile::tempdir().unwrap();
629        let aether_home = home.path().join(".aether");
630        write_file(&aether_home, "agents/planner/SYSTEM.md", "System instructions");
631        write_file(project.path(), "AGENTS.md", "Agent instructions");
632        write_file(project.path(), ".aether/mcp.json", r#"{"servers":{}}"#);
633        write_file(
634            &aether_home,
635            "settings.json",
636            r#"{
637                "agents":[{
638                    "name":"planner",
639                    "description":"Plans work",
640                    "model":"anthropic:claude-sonnet-4-5",
641                    "userInvocable":true,
642                    "prompts":[
643                        "agents/planner/SYSTEM.md",
644                        {"type":"file","path":"${WORKSPACE}/AGENTS.md"}
645                    ],
646                    "mcps":[
647                        {"type":"file","path":"${WORKSPACE}/.aether/mcp.json"}
648                    ]
649                }]
650            }"#,
651        );
652
653        let config = load_default_from_home(project.path(), &aether_home).unwrap();
654        let catalog = AgentCatalog::from_settings(project.path(), config).unwrap();
655        let spec = catalog.resolve("planner").unwrap();
656
657        let expected_system = aether_home.join("agents/planner/SYSTEM.md");
658        let expected_agents = project.path().join("AGENTS.md");
659        assert!(spec.prompts.iter().any(|p| match p {
660            Prompt::File { path, .. } => path == &expected_system,
661            _ => false,
662        }));
663        assert!(spec.prompts.iter().any(|p| match p {
664            Prompt::File { path, .. } => path == &expected_agents,
665            _ => false,
666        }));
667        assert!(matches!(
668            &spec.mcp_config_sources[0],
669            McpConfigSource::File { path, proxy: false } if *path == project.path().join(".aether/mcp.json")
670        ));
671    }
672
673    #[test]
674    fn workspace_scoped_paths_expand_in_project_settings_without_absolutizing_normal_relative_paths() {
675        let project = tempfile::tempdir().unwrap();
676        write_file(project.path(), "PROJECT.md", "Project prompt");
677        write_file(project.path(), "AGENTS.md", "Agent prompt");
678        write_file(
679            project.path(),
680            ".aether/settings.json",
681            r#"{
682                "agents":[{
683                    "name":"alpha",
684                    "description":"Alpha",
685                    "model":"anthropic:claude-sonnet-4-5",
686                    "userInvocable":true,
687                    "prompts":["PROJECT.md", {"type":"file","path":"${WORKSPACE}/AGENTS.md"}]
688                }]
689            }"#,
690        );
691
692        let config = AetherSettings::load(
693            project.path(),
694            [AetherSettingsSource::OptionalFile(SettingsFileSource::new(PROJECT_SETTINGS_PATH, project.path()))],
695        )
696        .unwrap();
697
698        assert_eq!(config.agents[0].prompts[0], PromptSource::file("PROJECT.md"));
699        assert_eq!(config.agents[0].prompts[1], PromptSource::file("${WORKSPACE}/AGENTS.md"));
700    }
701
702    #[test]
703    fn json_and_value_sources_preserve_workspace_scoped_paths_losslessly() {
704        let project = tempfile::tempdir().unwrap();
705
706        let json_config = AetherSettings::load(
707            project.path(),
708            [AetherSettingsSource::Json(
709                r#"{
710                    "agents":[{
711                        "name":"alpha",
712                        "description":"Alpha",
713                        "model":"anthropic:claude-sonnet-4-5",
714                        "userInvocable":true,
715                        "prompts":["${WORKSPACE}/AGENTS.md"]
716                    }]
717                }"#
718                .to_string(),
719            )],
720        )
721        .unwrap();
722
723        assert_eq!(json_config.agents[0].prompts[0], PromptSource::file("${WORKSPACE}/AGENTS.md"));
724
725        let value_config = AetherSettings::load(
726            project.path(),
727            [AetherSettingsSource::Value(AetherSettings {
728                agents: vec![AgentConfig {
729                    prompts: vec![PromptSource::file("${WORKSPACE}/AGENTS.md")],
730                    ..agent_config("alpha")
731                }],
732                ..AetherSettings::default()
733            })],
734        )
735        .unwrap();
736        assert_eq!(value_config.agents[0].prompts[0], PromptSource::file("${WORKSPACE}/AGENTS.md"));
737    }
738
739    #[test]
740    fn optional_workspace_scoped_mcp_source_is_skipped_when_missing() {
741        let project = tempfile::tempdir().unwrap();
742        write_file(project.path(), "BASE.md", "Base instructions");
743        let config = AetherSettings {
744            agents: vec![AgentConfig {
745                prompts: vec![PromptSource::file("BASE.md")],
746                mcps: vec![McpFileSpec::new("${WORKSPACE}/.aether/mcp.json").optional().into()],
747                ..agent_config("alpha")
748            }],
749            ..AetherSettings::default()
750        };
751
752        let config = AetherSettings::load(project.path(), [AetherSettingsSource::Value(config)]).unwrap();
753        let catalog = AgentCatalog::from_settings(project.path(), config).unwrap();
754        let spec = catalog.resolve("alpha").unwrap();
755
756        assert!(spec.mcp_config_sources.is_empty());
757    }
758
759    #[test]
760    fn optional_mcp_source_skips_unresolved_variable() {
761        let project = tempfile::tempdir().unwrap();
762        write_file(project.path(), "BASE.md", "Base instructions");
763        let config = AetherSettings {
764            agents: vec![AgentConfig {
765                prompts: vec![PromptSource::file("BASE.md")],
766                mcps: vec![McpFileSpec::new("${DEFINITELY_NOT_SET_VAR_MCP_OPTIONAL}/mcp.json").optional().into()],
767                ..agent_config("alpha")
768            }],
769            ..AetherSettings::default()
770        };
771
772        let config = AetherSettings::load(project.path(), [AetherSettingsSource::Value(config)]).unwrap();
773        let catalog = AgentCatalog::from_settings(project.path(), config).unwrap();
774        let spec = catalog.resolve("alpha").unwrap();
775
776        assert!(spec.mcp_config_sources.is_empty());
777    }
778
779    #[test]
780    fn required_mcp_source_errors_on_unresolved_variable() {
781        let project = tempfile::tempdir().unwrap();
782        write_file(project.path(), "BASE.md", "Base instructions");
783        let config = AetherSettings {
784            agents: vec![AgentConfig {
785                prompts: vec![PromptSource::file("BASE.md")],
786                mcps: vec![McpSourceSpec::file("${DEFINITELY_NOT_SET_VAR_MCP_REQ}/mcp.json")],
787                ..agent_config("alpha")
788            }],
789            ..AetherSettings::default()
790        };
791
792        let err = AgentCatalog::from_settings(project.path(), config).unwrap_err();
793        assert!(matches!(err, SettingsError::UnresolvedMcpConfigVariable { .. }));
794    }
795
796    #[test]
797    fn required_workspace_scoped_mcp_source_errors_when_missing() {
798        let project = tempfile::tempdir().unwrap();
799        write_file(project.path(), "BASE.md", "Base instructions");
800        let config = AetherSettings {
801            agents: vec![AgentConfig {
802                prompts: vec![PromptSource::file("BASE.md")],
803                mcps: vec![McpSourceSpec::file("nonexistent.json")],
804                ..agent_config("alpha")
805            }],
806            ..AetherSettings::default()
807        };
808
809        let err = AgentCatalog::from_settings(project.path(), config).unwrap_err();
810        assert!(matches!(err, SettingsError::InvalidMcpConfigPath { .. }));
811    }
812
813    #[test]
814    fn optional_existing_mcp_source_preserves_proxy_flag() {
815        let project = tempfile::tempdir().unwrap();
816        write_file(project.path(), "BASE.md", "Base instructions");
817        write_file(project.path(), "mcp.json", r#"{"servers":{}}"#);
818        let config = AetherSettings {
819            agents: vec![AgentConfig {
820                prompts: vec![PromptSource::file("BASE.md")],
821                mcps: vec![McpFileSpec::new("mcp.json").proxy().optional().into()],
822                ..agent_config("alpha")
823            }],
824            ..AetherSettings::default()
825        };
826
827        let catalog = AgentCatalog::from_settings(project.path(), config).unwrap();
828        let spec = catalog.resolve("alpha").unwrap();
829
830        assert!(matches!(&spec.mcp_config_sources[0], McpConfigSource::File { proxy: true, .. }));
831    }
832
833    #[test]
834    fn optional_mcp_source_serializes_as_typed_object() {
835        let source: McpSourceSpec = McpFileSpec::new("${WORKSPACE}/.aether/mcp.json").optional().into();
836        let value = serde_json::to_value(source).unwrap();
837        assert_eq!(value, serde_json::json!({"type":"file", "path":"${WORKSPACE}/.aether/mcp.json", "optional":true}));
838    }
839
840    #[test]
841    fn optional_prompt_source_serializes_as_typed_object() {
842        let source = PromptSource::file("${WORKSPACE}/AGENTS.md").optional();
843        let value = serde_json::to_value(&source).unwrap();
844        assert_eq!(value, serde_json::json!({"type":"file", "path":"${WORKSPACE}/AGENTS.md", "optional":true}));
845    }
846
847    #[test]
848    fn all_optional_prompts_missing_errors_with_no_prompts() {
849        let project = tempfile::tempdir().unwrap();
850        let config = AetherSettings {
851            agents: vec![AgentConfig {
852                prompts: vec![PromptSource::file("MISSING.md").optional()],
853                ..agent_config("alpha")
854            }],
855            ..AetherSettings::default()
856        };
857
858        let err = AgentCatalog::from_settings(project.path(), config).unwrap_err();
859        assert!(matches!(err, SettingsError::AllOptionalPromptsMissing { agent } if agent == "alpha"));
860    }
861
862    #[test]
863    fn settings_round_trip_preserves_workspace_prefix_and_relative_paths() {
864        let original = r#"{"agents":[{
865            "name":"alpha",
866            "description":"Alpha",
867            "model":"anthropic:claude-sonnet-4-5",
868            "userInvocable":true,
869            "prompts":[
870                "AGENTS.md",
871                "${WORKSPACE}/SYSTEM.md",
872                {"type":"file","path":"${WORKSPACE}/.aether/rules.md","optional":true},
873                {"type":"glob","pattern":"${WORKSPACE}/.aether/rules/*.md"}
874            ],
875            "mcps":[
876                "mcp.json",
877                {"type":"file","path":"${WORKSPACE}/.aether/mcp.json","optional":true}
878            ]
879        }]}"#;
880
881        let settings = AetherSettings::try_from(original).unwrap();
882        let reserialized = serde_json::to_string(&settings).unwrap();
883        let reparsed = AetherSettings::try_from(reserialized.as_str()).unwrap();
884
885        assert_eq!(settings, reparsed, "settings should round-trip losslessly through serde");
886    }
887
888    #[test]
889    fn user_settings_relative_paths_absolutize_at_load_but_workspace_token_is_preserved() {
890        let project = tempfile::tempdir().unwrap();
891        let home = tempfile::tempdir().unwrap();
892        let aether_home = home.path().join(".aether");
893        write_file(&aether_home, "agents/planner/SYSTEM.md", "system");
894        write_file(project.path(), "AGENTS.md", "agents");
895        write_file(
896            &aether_home,
897            "settings.json",
898            r#"{"agents":[{
899                "name":"planner",
900                "description":"Plans",
901                "model":"anthropic:claude-sonnet-4-5",
902                "userInvocable":true,
903                "prompts":["agents/planner/SYSTEM.md", "${WORKSPACE}/AGENTS.md"]
904            }]}"#,
905        );
906
907        let settings = load_default_from_home(project.path(), &aether_home).unwrap();
908
909        let expected_user = aether_home.join("agents/planner/SYSTEM.md").to_string_lossy().to_string();
910        assert_eq!(
911            settings.agents[0].prompts,
912            vec![PromptSource::file(expected_user), PromptSource::file("${WORKSPACE}/AGENTS.md")],
913            "user-rooted relative paths must absolutize; ${{WORKSPACE}}/ paths must be preserved",
914        );
915    }
916
917    fn load_default_from_home(project_root: &Path, aether_home: &Path) -> Result<AetherSettings, SettingsError> {
918        AetherSettings::load(project_root, default_sources_for_home(project_root, Some(aether_home)))
919    }
920
921    fn write_file(dir: &Path, path: &str, content: &str) {
922        let full = dir.join(path);
923        if let Some(parent) = full.parent() {
924            create_dir_all(parent).unwrap();
925        }
926
927        write(full, content).unwrap();
928    }
929
930    fn settings_agent(name: &str, description: &str) -> AgentConfig {
931        AgentConfig {
932            name: name.to_string(),
933            description: description.to_string(),
934            model: "anthropic:claude-sonnet-4-5".to_string(),
935            user_invocable: true,
936            ..AgentConfig::default()
937        }
938    }
939
940    fn agent_config(name: &str) -> AgentConfig {
941        AgentConfig {
942            name: name.to_string(),
943            description: format!("{name} agent"),
944            model: "anthropic:claude-sonnet-4-5".to_string(),
945            user_invocable: true,
946            prompts: vec![PromptSource::file("PROMPT.md")],
947            ..AgentConfig::default()
948        }
949    }
950
951    #[test]
952    fn parses_credentials_store_keyring() {
953        let config = AetherSettings::try_from(
954            r#"{
955                "credentialsStore": { "type": "keyring" },
956                "agents": [{"name":"alpha","description":"Alpha","model":"anthropic:claude-sonnet-4-5","userInvocable":true}]
957            }"#,
958        )
959        .unwrap();
960
961        assert_eq!(config.credentials_store, Some(CredentialsStoreConfig::Keyring));
962    }
963
964    #[test]
965    fn parses_credentials_store_memory() {
966        let config = AetherSettings::try_from(
967            r#"{
968                "credentialsStore": { "type": "memory" },
969                "agents": [{"name":"alpha","description":"Alpha","model":"anthropic:claude-sonnet-4-5","userInvocable":true}]
970            }"#,
971        )
972        .unwrap();
973
974        assert_eq!(config.credentials_store, Some(CredentialsStoreConfig::Memory));
975    }
976
977    #[test]
978    fn parses_credentials_store_encrypted_file_with_options() {
979        let config = AetherSettings::try_from(
980            r#"{
981                "credentialsStore": {
982                    "type": "encryptedFile",
983                    "path": "/custom/creds.enc",
984                    "passwordEnv": "MY_SECRET"
985                },
986                "agents": [{"name":"alpha","description":"Alpha","model":"anthropic:claude-sonnet-4-5","userInvocable":true}]
987            }"#,
988        )
989        .unwrap();
990
991        assert!(matches!(
992            &config.credentials_store,
993            Some(CredentialsStoreConfig::EncryptedFile { path, password_env })
994            if path == &Some(PathBuf::from("/custom/creds.enc"))
995                && password_env == &Some("MY_SECRET".to_string())
996        ));
997    }
998}