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