Skip to main content

aether_project/
settings.rs

1//! Settings file parsing and validation.
2
3use crate::error::SettingsError;
4use aether_core::agent_spec::{AgentSpec, AgentSpecExposure, ToolFilter};
5use aether_core::core::Prompt;
6use glob::glob;
7use llm::{LlmModel, ReasoningEffort};
8use std::collections::HashSet;
9use std::path::Path;
10
11/// Settings DTO for deserializing `.aether/settings.json`.
12#[derive(Debug, Clone, Default, serde::Deserialize)]
13#[serde(default, rename_all = "camelCase")]
14struct Settings {
15    /// Inherited prompts for all agents.
16    prompts: Vec<String>,
17    /// Path to inherited MCP config for all agents.
18    mcp_servers: Option<String>,
19    /// The canonical authored agent registry.
20    agents: Vec<AgentEntry>,
21}
22
23/// Agent entry DTO for deserializing from settings.
24#[derive(Debug, Clone, serde::Deserialize)]
25#[serde(rename_all = "camelCase")]
26struct AgentEntry {
27    name: String,
28    description: String,
29    model: String,
30    #[serde(default)]
31    reasoning_effort: Option<String>,
32    #[serde(default)]
33    user_invocable: bool,
34    #[serde(default)]
35    agent_invocable: bool,
36    #[serde(default)]
37    prompts: Vec<String>,
38    mcp_servers: Option<String>,
39    #[serde(default)]
40    tools: ToolFilter,
41}
42
43/// Load and resolve the agent catalog from a project root.
44///
45/// If `.aether/settings.json` is absent, returns a valid empty catalog.
46/// If the settings file is malformed or contains invalid entries, returns an error.
47pub fn load_agent_catalog(project_root: &Path) -> Result<super::catalog::AgentCatalog, SettingsError> {
48    let settings_path = project_root.join(".aether/settings.json");
49
50    let settings = match std::fs::read_to_string(&settings_path) {
51        Ok(content) => {
52            if content.trim().is_empty() {
53                Settings::default()
54            } else {
55                serde_json::from_str(&content).map_err(|e| SettingsError::ParseError(e.to_string()))?
56            }
57        }
58        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
59            return Ok(super::catalog::AgentCatalog::empty(project_root.to_path_buf()));
60        }
61        Err(e) => {
62            return Err(SettingsError::IoError(format!("Failed to read {}: {}", settings_path.display(), e)));
63        }
64    };
65
66    resolve_settings(project_root, settings)
67}
68
69/// Resolve settings into a catalog of agent specs.
70fn resolve_settings(project_root: &Path, settings: Settings) -> Result<super::catalog::AgentCatalog, SettingsError> {
71    let Settings { prompts: inherited_patterns, mcp_servers, agents } = settings;
72
73    validate_prompt_entries(project_root, &inherited_patterns, None)?;
74    let inherited_mcp_config_path = resolve_mcp_config_path(project_root, mcp_servers.as_deref())?;
75    let inherited_prompts = build_inherited_prompts(&inherited_patterns, project_root);
76
77    let mut seen_names = HashSet::new();
78    let mut specs = Vec::with_capacity(agents.len());
79
80    for (index, entry) in agents.into_iter().enumerate() {
81        specs.push(resolve_agent_entry(project_root, &inherited_prompts, entry, index, &mut seen_names)?);
82    }
83
84    Ok(super::catalog::AgentCatalog::new(
85        project_root.to_path_buf(),
86        inherited_prompts,
87        inherited_mcp_config_path,
88        specs,
89    ))
90}
91
92fn resolve_agent_entry(
93    project_root: &Path,
94    inherited_prompts: &[Prompt],
95    entry: AgentEntry,
96    index: usize,
97    seen_names: &mut HashSet<String>,
98) -> Result<AgentSpec, SettingsError> {
99    let name = entry.name.trim().to_string();
100    if name.is_empty() {
101        return Err(SettingsError::EmptyAgentName { index });
102    }
103
104    if name == "__default__" {
105        return Err(SettingsError::ReservedAgentName { name });
106    }
107
108    if !seen_names.insert(name.clone()) {
109        return Err(SettingsError::DuplicateAgentName { name });
110    }
111
112    let description = entry.description.trim().to_string();
113    if description.is_empty() {
114        return Err(SettingsError::MissingField { agent: name.clone(), field: "description".to_string() });
115    }
116
117    let model = parse_model(&name, &entry.model)?;
118    let reasoning_effort = parse_reasoning_effort(&name, entry.reasoning_effort)?;
119
120    if !entry.user_invocable && !entry.agent_invocable {
121        return Err(SettingsError::NoInvocationSurface { agent: name.clone() });
122    }
123
124    validate_prompt_entries(project_root, &entry.prompts, Some(&name))?;
125
126    if inherited_prompts.is_empty() && entry.prompts.is_empty() {
127        return Err(SettingsError::NoPrompts { agent: name.clone() });
128    }
129
130    let mcp_config_path = resolve_mcp_config_path(project_root, entry.mcp_servers.as_deref())?;
131
132    let mut prompts = Vec::with_capacity(inherited_prompts.len() + entry.prompts.len());
133    prompts.extend_from_slice(inherited_prompts);
134    for pattern in &entry.prompts {
135        prompts.push(Prompt::from_globs(vec![pattern.clone()], project_root.to_path_buf()));
136    }
137
138    Ok(AgentSpec {
139        name,
140        description,
141        model,
142        reasoning_effort,
143        prompts,
144        mcp_config_path,
145        exposure: AgentSpecExposure { user_invocable: entry.user_invocable, agent_invocable: entry.agent_invocable },
146        tools: entry.tools,
147    })
148}
149
150fn parse_model(agent: &str, model: &str) -> Result<String, SettingsError> {
151    canonicalize_model_spec(model).map_err(|error| SettingsError::InvalidModel {
152        agent: agent.to_string(),
153        model: model.to_string(),
154        error,
155    })
156}
157
158fn canonicalize_model_spec(model: &str) -> Result<String, String> {
159    let trimmed = model.trim();
160    if trimmed.is_empty() {
161        return Err("Model spec cannot be empty".to_string());
162    }
163
164    let mut canonical_parts = Vec::new();
165    for part in trimmed.split(',').map(str::trim) {
166        if part.is_empty() {
167            return Err("Model spec contains an empty entry".to_string());
168        }
169
170        part.parse::<LlmModel>().map_err(|error: String| error)?;
171        canonical_parts.push(part.to_string());
172    }
173
174    Ok(canonical_parts.join(","))
175}
176
177fn parse_reasoning_effort(
178    agent: &str,
179    reasoning_effort: Option<String>,
180) -> Result<Option<ReasoningEffort>, SettingsError> {
181    match reasoning_effort {
182        None => Ok(None),
183        Some(value) => {
184            let value = value.trim();
185            if value.is_empty() {
186                return Ok(None);
187            }
188
189            ReasoningEffort::parse(value).map_err(|error| SettingsError::InvalidReasoningEffort {
190                agent: agent.to_string(),
191                effort: value.to_string(),
192                error,
193            })
194        }
195    }
196}
197
198fn validate_prompt_entries(
199    project_root: &Path,
200    patterns: &[String],
201    agent_name: Option<&str>,
202) -> Result<(), SettingsError> {
203    for pattern in patterns {
204        validate_prompt_entry(project_root, pattern, agent_name)?;
205    }
206    Ok(())
207}
208
209fn resolve_mcp_config_path(
210    project_root: &Path,
211    mcp_path: Option<&str>,
212) -> Result<Option<std::path::PathBuf>, SettingsError> {
213    match mcp_path {
214        None => Ok(None),
215        Some(path) => {
216            let full_path = project_root.join(path);
217            if full_path.is_file() {
218                Ok(Some(full_path))
219            } else {
220                Err(SettingsError::InvalidMcpConfigPath { path: path.to_string() })
221            }
222        }
223    }
224}
225
226/// Validate that a prompt entry resolves to at least one file.
227fn validate_prompt_entry(project_root: &Path, pattern: &str, agent_name: Option<&str>) -> Result<(), SettingsError> {
228    let full_pattern = if Path::new(pattern).is_absolute() {
229        pattern.to_string()
230    } else {
231        project_root.join(pattern).to_string_lossy().to_string()
232    };
233
234    let has_file_match = glob(&full_pattern)
235        .map_err(|e| {
236            if let Some(agent) = agent_name {
237                SettingsError::InvalidGlobPattern {
238                    agent: agent.to_string(),
239                    pattern: pattern.to_string(),
240                    error: e.to_string(),
241                }
242            } else {
243                SettingsError::InvalidInheritedGlobPattern { pattern: pattern.to_string(), error: e.to_string() }
244            }
245        })?
246        .filter_map(Result::ok)
247        .any(|path| path.is_file());
248
249    if has_file_match {
250        Ok(())
251    } else if let Some(agent) = agent_name {
252        Err(SettingsError::ZeroMatchPrompt { agent: agent.to_string(), pattern: pattern.to_string() })
253    } else {
254        Err(SettingsError::ZeroMatchInheritedPrompt { pattern: pattern.to_string() })
255    }
256}
257
258/// Build the inherited prompts from patterns.
259///
260/// Each pattern becomes one `Prompt::PromptGlobs` value.
261fn build_inherited_prompts(patterns: &[String], project_root: &Path) -> Vec<Prompt> {
262    patterns.iter().map(|pattern| Prompt::from_globs(vec![pattern.clone()], project_root.to_path_buf())).collect()
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use std::fs;
269
270    fn create_temp_project() -> tempfile::TempDir {
271        tempfile::tempdir().unwrap()
272    }
273
274    fn write_settings(dir: &Path, content: &str) {
275        let aether_dir = dir.join(".aether");
276        fs::create_dir_all(&aether_dir).unwrap();
277        fs::write(aether_dir.join("settings.json"), content).unwrap();
278    }
279
280    fn write_file(dir: &Path, path: &str, content: &str) {
281        let full_path = dir.join(path);
282        if let Some(parent) = full_path.parent() {
283            fs::create_dir_all(parent).unwrap();
284        }
285        fs::write(full_path, content).unwrap();
286    }
287
288    /// Standard agent JSON with customizable fields. `extra` is injected into the agent object.
289    fn agent_settings(extra: &str) -> String {
290        let comma = if extra.is_empty() { "" } else { "," };
291        format!(
292            r#"{{"agents": [{{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]{comma} {extra}}}]}}"#
293        )
294    }
295
296    /// Setup a project with AGENTS.md, write settings JSON, and load the catalog.
297    fn setup_and_load(json: &str) -> (tempfile::TempDir, Result<super::super::catalog::AgentCatalog, SettingsError>) {
298        let dir = create_temp_project();
299        write_file(dir.path(), "AGENTS.md", "Be helpful");
300        write_settings(dir.path(), json);
301        let result = load_agent_catalog(dir.path());
302        (dir, result)
303    }
304
305    fn setup_and_load_ok(json: &str) -> (tempfile::TempDir, super::super::catalog::AgentCatalog) {
306        let (dir, result) = setup_and_load(json);
307        (dir, result.unwrap())
308    }
309
310    #[test]
311    fn missing_settings_yields_empty_catalog() {
312        let dir = create_temp_project();
313        let catalog = load_agent_catalog(dir.path()).unwrap();
314        assert!(catalog.all().is_empty());
315    }
316
317    #[test]
318    fn exposure_flags_parsed_correctly() {
319        for (user, agent) in [(true, true), (true, false), (false, true)] {
320            let json = format!(
321                r#"{{"agents": [{{
322                    "name": "planner", "description": "Planner agent",
323                    "model": "anthropic:claude-sonnet-4-5",
324                    "userInvocable": {user}, "agentInvocable": {agent},
325                    "prompts": ["AGENTS.md"]
326                }}]}}"#
327            );
328            let (_, catalog) = setup_and_load_ok(&json);
329            let spec = catalog.get("planner").unwrap();
330            assert_eq!(spec.exposure.user_invocable, user);
331            assert_eq!(spec.exposure.agent_invocable, agent);
332        }
333    }
334
335    #[test]
336    fn invalid_model_string_rejected() {
337        let (_, result) = setup_and_load(
338            r#"{"agents": [{"name": "planner", "description": "Planner agent", "model": "invalid:model", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
339        );
340        assert!(matches!(result, Err(SettingsError::InvalidModel { .. })));
341    }
342
343    #[test]
344    fn alloy_model_string_is_accepted() {
345        let json = r#"{"agents": [{"name": "alloy", "description": "Alloy agent", "model": "anthropic:claude-sonnet-4-5,deepseek:deepseek-chat", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#;
346        let (_, catalog) = setup_and_load_ok(json);
347        assert_eq!(catalog.get("alloy").unwrap().model.clone(), "anthropic:claude-sonnet-4-5,deepseek:deepseek-chat");
348    }
349
350    #[test]
351    fn alloy_model_string_with_unknown_member_is_rejected() {
352        let (_, result) = setup_and_load(
353            r#"{"agents": [{"name": "alloy", "description": "Alloy agent", "model": "anthropic:claude-sonnet-4-5,mystery:nope", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
354        );
355        assert!(matches!(result, Err(SettingsError::InvalidModel { .. })));
356    }
357
358    #[test]
359    fn invalid_reasoning_effort_rejected() {
360        let (_, result) = setup_and_load(&agent_settings(r#""reasoningEffort": "invalid""#));
361        assert!(matches!(result, Err(SettingsError::InvalidReasoningEffort { .. })));
362    }
363
364    #[test]
365    fn duplicate_agent_names_rejected() {
366        let (_, result) = setup_and_load(
367            r#"{"agents": [
368                {"name": "planner", "description": "First", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]},
369                {"name": "planner", "description": "Second", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}
370            ]}"#,
371        );
372        assert!(matches!(result, Err(SettingsError::DuplicateAgentName { .. })));
373    }
374
375    #[test]
376    fn top_level_prompts_inherited_by_all_agents() {
377        let dir = create_temp_project();
378        write_file(dir.path(), "BASE.md", "Base instructions");
379        write_file(dir.path(), "AGENTS.md", "Agent instructions");
380        write_settings(
381            dir.path(),
382            r#"{"prompts": ["BASE.md"], "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "agentInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
383        );
384
385        let catalog = load_agent_catalog(dir.path()).unwrap();
386        // Should have 2 prompts: 1 inherited + 1 local
387        assert_eq!(catalog.get("planner").unwrap().prompts.len(), 2);
388    }
389
390    #[test]
391    fn one_prompt_globs_per_entry() {
392        let dir = create_temp_project();
393        write_file(dir.path(), "a.md", "A");
394        write_file(dir.path(), "b.md", "B");
395        write_settings(
396            dir.path(),
397            r#"{"agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["a.md", "b.md"]}]}"#,
398        );
399
400        let catalog = load_agent_catalog(dir.path()).unwrap();
401        // Should have 2 PromptGlobs entries, not 1 combined
402        assert_eq!(catalog.get("planner").unwrap().prompts.len(), 2);
403    }
404
405    #[test]
406    fn zero_match_prompt_rejected() {
407        let dir = create_temp_project();
408        // No AGENTS.md created — prompt won't match
409        write_settings(
410            dir.path(),
411            r#"{"agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["nonexistent.md"]}]}"#,
412        );
413        assert!(matches!(load_agent_catalog(dir.path()), Err(SettingsError::ZeroMatchPrompt { .. })));
414    }
415
416    #[test]
417    fn prompt_matching_only_directories_is_rejected() {
418        let dir = create_temp_project();
419        std::fs::create_dir_all(dir.path().join("prompts")).unwrap();
420        write_settings(
421            dir.path(),
422            r#"{"agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["prompts/*"]}]}"#,
423        );
424        assert!(matches!(load_agent_catalog(dir.path()), Err(SettingsError::ZeroMatchPrompt { .. })));
425    }
426
427    #[test]
428    fn no_invocation_surface_rejected() {
429        let (_, result) = setup_and_load(
430            r#"{"agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": false, "agentInvocable": false, "prompts": ["AGENTS.md"]}]}"#,
431        );
432        assert!(matches!(result, Err(SettingsError::NoInvocationSurface { .. })));
433    }
434
435    #[test]
436    fn empty_and_whitespace_names_rejected() {
437        for name in ["", "   "] {
438            let json = format!(
439                r#"{{"agents": [{{"name": "{name}", "description": "Agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}}]}}"#
440            );
441            let (_, result) = setup_and_load(&json);
442            assert!(
443                matches!(result, Err(SettingsError::EmptyAgentName { .. })),
444                "expected EmptyAgentName for name={name:?}"
445            );
446        }
447    }
448
449    #[test]
450    fn empty_and_whitespace_descriptions_rejected() {
451        for desc in ["", "   "] {
452            let json = format!(
453                r#"{{"agents": [{{"name": "planner", "description": "{desc}", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}}]}}"#
454            );
455            let (_, result) = setup_and_load(&json);
456            assert!(
457                matches!(result, Err(SettingsError::MissingField { .. })),
458                "expected MissingField for desc={desc:?}"
459            );
460        }
461    }
462
463    #[test]
464    fn duplicate_agent_names_after_trim_rejected() {
465        let (_, result) = setup_and_load(
466            r#"{"agents": [
467                {"name": "planner", "description": "First", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]},
468                {"name": " planner ", "description": "Second", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}
469            ]}"#,
470        );
471        assert!(matches!(result, Err(SettingsError::DuplicateAgentName { .. })));
472    }
473
474    #[test]
475    fn agent_name_and_description_are_trimmed() {
476        let (_, catalog) = setup_and_load_ok(
477            r#"{"agents": [{"name": " planner ", "description": " Planner agent ", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
478        );
479        let spec = catalog.get("planner").unwrap();
480        assert_eq!(spec.name, "planner");
481        assert_eq!(spec.description, "Planner agent");
482    }
483
484    #[test]
485    fn no_prompts_rejected() {
486        let dir = create_temp_project();
487        write_settings(
488            dir.path(),
489            r#"{"agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true}]}"#,
490        );
491        assert!(matches!(load_agent_catalog(dir.path()), Err(SettingsError::NoPrompts { .. })));
492    }
493
494    #[test]
495    fn malformed_json_rejected() {
496        let dir = create_temp_project();
497        write_settings(dir.path(), "not valid json");
498        assert!(matches!(load_agent_catalog(dir.path()), Err(SettingsError::ParseError(_))));
499    }
500
501    #[test]
502    fn invalid_mcp_servers_path_rejected() {
503        let (_, result) = setup_and_load(
504            r#"{"mcpServers": "nonexistent.json", "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
505        );
506        assert!(matches!(result, Err(SettingsError::InvalidMcpConfigPath { .. })));
507    }
508
509    #[test]
510    fn invalid_agent_mcp_servers_path_rejected() {
511        let (_, result) = setup_and_load(&agent_settings(r#""mcpServers": "nonexistent.json""#));
512        assert!(matches!(result, Err(SettingsError::InvalidMcpConfigPath { .. })));
513    }
514
515    #[test]
516    fn valid_mcp_servers_path_accepted() {
517        let dir = create_temp_project();
518        write_file(dir.path(), "AGENTS.md", "Be helpful");
519        write_file(dir.path(), ".aether/mcp/default.json", "{}");
520        write_settings(
521            dir.path(),
522            r#"{"mcpServers": ".aether/mcp/default.json", "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
523        );
524
525        let catalog = load_agent_catalog(dir.path()).unwrap();
526        assert!(catalog.resolve("planner", dir.path()).unwrap().mcp_config_path.is_some());
527    }
528
529    #[test]
530    fn any_invalid_agent_entry_fails_catalog_load() {
531        let (_, result) = setup_and_load(
532            r#"{"agents": [
533                {"name": "valid", "description": "Valid agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]},
534                {"name": "invalid", "description": "Invalid agent", "model": "invalid:model", "userInvocable": true, "prompts": ["AGENTS.md"]}
535            ]}"#,
536        );
537        assert!(matches!(result, Err(SettingsError::InvalidModel { .. })));
538    }
539
540    fn two_agent_json() -> &'static str {
541        r#"{"agents": [
542            {"name": "zebra", "description": "Z agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]},
543            {"name": "alpha", "description": "A agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}
544        ]}"#
545    }
546
547    #[test]
548    fn preserves_authored_agent_order_and_lookup() {
549        let (_, catalog) = setup_and_load_ok(two_agent_json());
550        let names: Vec<_> = catalog.all().iter().map(|s| s.name.as_str()).collect();
551        assert_eq!(names, vec!["zebra", "alpha"]); // not alphabetized
552        assert_eq!(catalog.get("alpha").unwrap().name, "alpha");
553        assert_eq!(catalog.get("zebra").unwrap().name, "zebra");
554    }
555
556    #[test]
557    fn tools_filter_parsed_from_settings() {
558        let (_, catalog) = setup_and_load_ok(
559            r#"{"agents": [{"name": "researcher", "description": "Read-only agent", "model": "anthropic:claude-sonnet-4-5", "agentInvocable": true, "prompts": ["AGENTS.md"], "tools": {"allow": ["coding__grep", "coding__read_file"], "deny": ["coding__write*"]}}]}"#,
560        );
561        let spec = catalog.get("researcher").unwrap();
562        assert_eq!(spec.tools.allow, vec!["coding__grep", "coding__read_file"]);
563        assert_eq!(spec.tools.deny, vec!["coding__write*"]);
564    }
565
566    #[test]
567    fn absent_tools_field_yields_default_filter() {
568        let (_, catalog) = setup_and_load_ok(&agent_settings(""));
569        let spec = catalog.get("planner").unwrap();
570        assert!(spec.tools.allow.is_empty());
571        assert!(spec.tools.deny.is_empty());
572    }
573
574    #[test]
575    fn reserved_agent_name_rejected() {
576        let (_, result) = setup_and_load(
577            r#"{"agents": [{"name": "__default__", "description": "Sneaky agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
578        );
579        assert!(matches!(result, Err(SettingsError::ReservedAgentName { .. })));
580    }
581}