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, McpJsonFileRef, ToolFilter};
5use aether_core::core::Prompt;
6use glob::glob;
7use llm::{LlmModel, ReasoningEffort};
8use std::collections::HashSet;
9use std::path::Path;
10
11/// An entry in the `mcpServers` array: either a plain path string or an object with options.
12#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
13#[serde(untagged)]
14pub enum McpServerEntry {
15    Path(String),
16    Config {
17        path: String,
18        #[serde(default)]
19        proxy: bool,
20    },
21}
22
23impl McpServerEntry {
24    pub fn path_str(&self) -> &str {
25        match self {
26            McpServerEntry::Path(p) => p,
27            McpServerEntry::Config { path, .. } => path,
28        }
29    }
30
31    pub fn proxy(&self) -> bool {
32        match self {
33            McpServerEntry::Path(_) => false,
34            McpServerEntry::Config { proxy, .. } => *proxy,
35        }
36    }
37}
38
39impl From<&str> for McpServerEntry {
40    fn from(s: &str) -> Self {
41        Self::Path(s.to_string())
42    }
43}
44
45/// Settings DTO for `.aether/settings.json`.
46#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
47#[serde(default, rename_all = "camelCase")]
48pub struct Settings {
49    /// Inherited prompts for all agents.
50    #[serde(skip_serializing_if = "Vec::is_empty")]
51    pub prompts: Vec<String>,
52    /// Inherited MCP configs for all agents, applied in order (last wins on collisions).
53    #[serde(skip_serializing_if = "Vec::is_empty")]
54    pub mcp_servers: Vec<McpServerEntry>,
55    /// The canonical authored agent registry.
56    pub agents: Vec<AgentEntry>,
57}
58
59/// Agent entry DTO for `.aether/settings.json`.
60#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
61#[serde(default, rename_all = "camelCase")]
62pub struct AgentEntry {
63    pub name: String,
64    pub description: String,
65    pub model: String,
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub reasoning_effort: Option<ReasoningEffort>,
68    #[serde(default)]
69    pub user_invocable: bool,
70    #[serde(default)]
71    pub agent_invocable: bool,
72    #[serde(default)]
73    pub prompts: Vec<String>,
74    #[serde(default, skip_serializing_if = "Vec::is_empty")]
75    pub mcp_servers: Vec<McpServerEntry>,
76    #[serde(default, skip_serializing_if = "ToolFilter::is_empty")]
77    pub tools: ToolFilter,
78}
79
80/// Load and resolve the agent catalog from a project root.
81///
82/// If `.aether/settings.json` is absent, returns a valid empty catalog.
83/// If the settings file is malformed or contains invalid entries, returns an error.
84pub fn load_agent_catalog(project_root: &Path) -> Result<super::catalog::AgentCatalog, SettingsError> {
85    let settings_path = project_root.join(".aether/settings.json");
86
87    let settings = match std::fs::read_to_string(&settings_path) {
88        Ok(content) => {
89            if content.trim().is_empty() {
90                Settings::default()
91            } else {
92                serde_json::from_str(&content).map_err(|e| SettingsError::ParseError(e.to_string()))?
93            }
94        }
95        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
96            return Ok(super::catalog::AgentCatalog::empty(project_root.to_path_buf()));
97        }
98        Err(e) => {
99            return Err(SettingsError::IoError(format!("Failed to read {}: {}", settings_path.display(), e)));
100        }
101    };
102
103    resolve_settings(project_root, settings)
104}
105
106/// Resolve settings into a catalog of agent specs.
107fn resolve_settings(project_root: &Path, settings: Settings) -> Result<super::catalog::AgentCatalog, SettingsError> {
108    let Settings { prompts: inherited_patterns, mcp_servers, agents } = settings;
109
110    validate_prompt_entries(project_root, &inherited_patterns, None)?;
111    let inherited_mcp_config_refs = resolve_mcp_config_refs(project_root, &mcp_servers)?;
112    let inherited_prompts = build_inherited_prompts(&inherited_patterns, project_root);
113
114    let mut seen_names = HashSet::new();
115    let mut specs = Vec::with_capacity(agents.len());
116
117    for (index, entry) in agents.into_iter().enumerate() {
118        specs.push(resolve_agent_entry(project_root, &inherited_prompts, entry, index, &mut seen_names)?);
119    }
120
121    Ok(super::catalog::AgentCatalog::new(
122        project_root.to_path_buf(),
123        inherited_prompts,
124        inherited_mcp_config_refs,
125        specs,
126    ))
127}
128
129fn resolve_agent_entry(
130    project_root: &Path,
131    inherited_prompts: &[Prompt],
132    entry: AgentEntry,
133    index: usize,
134    seen_names: &mut HashSet<String>,
135) -> Result<AgentSpec, SettingsError> {
136    let name = entry.name.trim().to_string();
137    if name.is_empty() {
138        return Err(SettingsError::EmptyAgentName { index });
139    }
140
141    if name == "__default__" {
142        return Err(SettingsError::ReservedAgentName { name });
143    }
144
145    if !seen_names.insert(name.clone()) {
146        return Err(SettingsError::DuplicateAgentName { name });
147    }
148
149    let description = entry.description.trim().to_string();
150    if description.is_empty() {
151        return Err(SettingsError::MissingField { agent: name.clone(), field: "description".to_string() });
152    }
153
154    let model = parse_model(&name, &entry.model)?;
155
156    if !entry.user_invocable && !entry.agent_invocable {
157        return Err(SettingsError::NoInvocationSurface { agent: name.clone() });
158    }
159
160    validate_prompt_entries(project_root, &entry.prompts, Some(&name))?;
161
162    if inherited_prompts.is_empty() && entry.prompts.is_empty() {
163        return Err(SettingsError::NoPrompts { agent: name.clone() });
164    }
165
166    let mcp_config_refs = resolve_mcp_config_refs(project_root, &entry.mcp_servers)?;
167
168    let prompts = if entry.prompts.is_empty() {
169        inherited_prompts.to_vec()
170    } else {
171        entry.prompts.iter().map(|p| Prompt::from_globs(vec![p.clone()], project_root.to_path_buf())).collect()
172    };
173
174    Ok(AgentSpec {
175        name,
176        description,
177        model,
178        reasoning_effort: entry.reasoning_effort,
179        prompts,
180        mcp_config_refs,
181        exposure: AgentSpecExposure { user_invocable: entry.user_invocable, agent_invocable: entry.agent_invocable },
182        tools: entry.tools,
183    })
184}
185
186fn parse_model(agent: &str, model: &str) -> Result<String, SettingsError> {
187    canonicalize_model_spec(model).map_err(|error| SettingsError::InvalidModel {
188        agent: agent.to_string(),
189        model: model.to_string(),
190        error,
191    })
192}
193
194fn canonicalize_model_spec(model: &str) -> Result<String, String> {
195    let trimmed = model.trim();
196    if trimmed.is_empty() {
197        return Err("Model spec cannot be empty".to_string());
198    }
199
200    let mut canonical_parts = Vec::new();
201    for part in trimmed.split(',').map(str::trim) {
202        if part.is_empty() {
203            return Err("Model spec contains an empty entry".to_string());
204        }
205
206        part.parse::<LlmModel>().map_err(|error: String| error)?;
207        canonical_parts.push(part.to_string());
208    }
209
210    Ok(canonical_parts.join(","))
211}
212
213fn validate_prompt_entries(
214    project_root: &Path,
215    patterns: &[String],
216    agent_name: Option<&str>,
217) -> Result<(), SettingsError> {
218    for pattern in patterns {
219        validate_prompt_entry(project_root, pattern, agent_name)?;
220    }
221    Ok(())
222}
223
224fn resolve_mcp_config_refs(
225    project_root: &Path,
226    entries: &[McpServerEntry],
227) -> Result<Vec<McpJsonFileRef>, SettingsError> {
228    let mut resolved = Vec::with_capacity(entries.len());
229    for entry in entries {
230        let full_path = project_root.join(entry.path_str());
231        if full_path.is_file() {
232            resolved.push(McpJsonFileRef { path: full_path, proxy: entry.proxy() });
233        } else {
234            return Err(SettingsError::InvalidMcpConfigPath { path: entry.path_str().to_string() });
235        }
236    }
237    Ok(resolved)
238}
239
240/// Validate that a prompt entry resolves to at least one file.
241fn validate_prompt_entry(project_root: &Path, pattern: &str, agent_name: Option<&str>) -> Result<(), SettingsError> {
242    let full_pattern = if Path::new(pattern).is_absolute() {
243        pattern.to_string()
244    } else {
245        project_root.join(pattern).to_string_lossy().to_string()
246    };
247
248    let has_file_match = glob(&full_pattern)
249        .map_err(|e| {
250            if let Some(agent) = agent_name {
251                SettingsError::InvalidGlobPattern {
252                    agent: agent.to_string(),
253                    pattern: pattern.to_string(),
254                    error: e.to_string(),
255                }
256            } else {
257                SettingsError::InvalidInheritedGlobPattern { pattern: pattern.to_string(), error: e.to_string() }
258            }
259        })?
260        .filter_map(Result::ok)
261        .any(|path| path.is_file());
262
263    if has_file_match {
264        Ok(())
265    } else if let Some(agent) = agent_name {
266        Err(SettingsError::ZeroMatchPrompt { agent: agent.to_string(), pattern: pattern.to_string() })
267    } else {
268        Err(SettingsError::ZeroMatchInheritedPrompt { pattern: pattern.to_string() })
269    }
270}
271
272/// Build the inherited prompts from patterns.
273///
274/// Each pattern becomes one `Prompt::PromptGlobs` value.
275fn build_inherited_prompts(patterns: &[String], project_root: &Path) -> Vec<Prompt> {
276    patterns.iter().map(|pattern| Prompt::from_globs(vec![pattern.clone()], project_root.to_path_buf())).collect()
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use aether_core::agent_spec::McpJsonFileRef;
283    use std::fs;
284
285    fn create_temp_project() -> tempfile::TempDir {
286        tempfile::tempdir().unwrap()
287    }
288
289    fn write_settings(dir: &Path, content: &str) {
290        let aether_dir = dir.join(".aether");
291        fs::create_dir_all(&aether_dir).unwrap();
292        fs::write(aether_dir.join("settings.json"), content).unwrap();
293    }
294
295    fn write_file(dir: &Path, path: &str, content: &str) {
296        let full_path = dir.join(path);
297        if let Some(parent) = full_path.parent() {
298            fs::create_dir_all(parent).unwrap();
299        }
300        fs::write(full_path, content).unwrap();
301    }
302
303    /// Standard agent JSON with customizable fields. `extra` is injected into the agent object.
304    fn agent_settings(extra: &str) -> String {
305        let comma = if extra.is_empty() { "" } else { "," };
306        format!(
307            r#"{{"agents": [{{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]{comma} {extra}}}]}}"#
308        )
309    }
310
311    /// Setup a project with AGENTS.md, write settings JSON, and load the catalog.
312    fn setup_and_load(json: &str) -> (tempfile::TempDir, Result<super::super::catalog::AgentCatalog, SettingsError>) {
313        let dir = create_temp_project();
314        write_file(dir.path(), "AGENTS.md", "Be helpful");
315        write_settings(dir.path(), json);
316        let result = load_agent_catalog(dir.path());
317        (dir, result)
318    }
319
320    fn setup_and_load_ok(json: &str) -> (tempfile::TempDir, super::super::catalog::AgentCatalog) {
321        let (dir, result) = setup_and_load(json);
322        (dir, result.unwrap())
323    }
324
325    #[test]
326    fn missing_settings_yields_empty_catalog() {
327        let dir = create_temp_project();
328        let catalog = load_agent_catalog(dir.path()).unwrap();
329        assert!(catalog.all().is_empty());
330    }
331
332    #[test]
333    fn exposure_flags_parsed_correctly() {
334        for (user, agent) in [(true, true), (true, false), (false, true)] {
335            let json = format!(
336                r#"{{"agents": [{{
337                    "name": "planner", "description": "Planner agent",
338                    "model": "anthropic:claude-sonnet-4-5",
339                    "userInvocable": {user}, "agentInvocable": {agent},
340                    "prompts": ["AGENTS.md"]
341                }}]}}"#
342            );
343            let (_, catalog) = setup_and_load_ok(&json);
344            let spec = catalog.get("planner").unwrap();
345            assert_eq!(spec.exposure.user_invocable, user);
346            assert_eq!(spec.exposure.agent_invocable, agent);
347        }
348    }
349
350    #[test]
351    fn invalid_model_string_rejected() {
352        let (_, result) = setup_and_load(
353            r#"{"agents": [{"name": "planner", "description": "Planner agent", "model": "invalid:model", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
354        );
355        assert!(matches!(result, Err(SettingsError::InvalidModel { .. })));
356    }
357
358    #[test]
359    fn alloy_model_string_is_accepted() {
360        let json = r#"{"agents": [{"name": "alloy", "description": "Alloy agent", "model": "anthropic:claude-sonnet-4-5,deepseek:deepseek-chat", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#;
361        let (_, catalog) = setup_and_load_ok(json);
362        assert_eq!(catalog.get("alloy").unwrap().model.clone(), "anthropic:claude-sonnet-4-5,deepseek:deepseek-chat");
363    }
364
365    #[test]
366    fn alloy_model_string_with_unknown_member_is_rejected() {
367        let (_, result) = setup_and_load(
368            r#"{"agents": [{"name": "alloy", "description": "Alloy agent", "model": "anthropic:claude-sonnet-4-5,mystery:nope", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
369        );
370        assert!(matches!(result, Err(SettingsError::InvalidModel { .. })));
371    }
372
373    #[test]
374    fn invalid_reasoning_effort_rejected() {
375        let (_, result) = setup_and_load(&agent_settings(r#""reasoningEffort": "invalid""#));
376        assert!(matches!(result, Err(SettingsError::ParseError(_))));
377    }
378
379    #[test]
380    fn duplicate_agent_names_rejected() {
381        let (_, result) = setup_and_load(
382            r#"{"agents": [
383                {"name": "planner", "description": "First", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]},
384                {"name": "planner", "description": "Second", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}
385            ]}"#,
386        );
387        assert!(matches!(result, Err(SettingsError::DuplicateAgentName { .. })));
388    }
389
390    #[test]
391    fn agent_prompts_override_inherited() {
392        let dir = create_temp_project();
393        write_file(dir.path(), "BASE.md", "Base instructions");
394        write_file(dir.path(), "AGENTS.md", "Agent instructions");
395        write_settings(
396            dir.path(),
397            r#"{"prompts": ["BASE.md"], "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "agentInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
398        );
399
400        let catalog = load_agent_catalog(dir.path()).unwrap();
401        // Agent-local prompts override inherited, not additive
402        assert_eq!(catalog.get("planner").unwrap().prompts.len(), 1);
403    }
404
405    #[test]
406    fn agent_without_prompts_inherits_top_level() {
407        let dir = create_temp_project();
408        write_file(dir.path(), "BASE.md", "Base instructions");
409        write_settings(
410            dir.path(),
411            r#"{"prompts": ["BASE.md"], "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true}]}"#,
412        );
413
414        let catalog = load_agent_catalog(dir.path()).unwrap();
415        assert_eq!(catalog.get("planner").unwrap().prompts.len(), 1);
416    }
417
418    #[test]
419    fn one_prompt_globs_per_entry() {
420        let dir = create_temp_project();
421        write_file(dir.path(), "a.md", "A");
422        write_file(dir.path(), "b.md", "B");
423        write_settings(
424            dir.path(),
425            r#"{"agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["a.md", "b.md"]}]}"#,
426        );
427
428        let catalog = load_agent_catalog(dir.path()).unwrap();
429        // Should have 2 PromptGlobs entries, not 1 combined
430        assert_eq!(catalog.get("planner").unwrap().prompts.len(), 2);
431    }
432
433    #[test]
434    fn zero_match_prompt_rejected() {
435        let dir = create_temp_project();
436        // No AGENTS.md created — prompt won't match
437        write_settings(
438            dir.path(),
439            r#"{"agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["nonexistent.md"]}]}"#,
440        );
441        assert!(matches!(load_agent_catalog(dir.path()), Err(SettingsError::ZeroMatchPrompt { .. })));
442    }
443
444    #[test]
445    fn prompt_matching_only_directories_is_rejected() {
446        let dir = create_temp_project();
447        std::fs::create_dir_all(dir.path().join("prompts")).unwrap();
448        write_settings(
449            dir.path(),
450            r#"{"agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["prompts/*"]}]}"#,
451        );
452        assert!(matches!(load_agent_catalog(dir.path()), Err(SettingsError::ZeroMatchPrompt { .. })));
453    }
454
455    #[test]
456    fn no_invocation_surface_rejected() {
457        let (_, result) = setup_and_load(
458            r#"{"agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": false, "agentInvocable": false, "prompts": ["AGENTS.md"]}]}"#,
459        );
460        assert!(matches!(result, Err(SettingsError::NoInvocationSurface { .. })));
461    }
462
463    #[test]
464    fn empty_and_whitespace_names_rejected() {
465        for name in ["", "   "] {
466            let json = format!(
467                r#"{{"agents": [{{"name": "{name}", "description": "Agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}}]}}"#
468            );
469            let (_, result) = setup_and_load(&json);
470            assert!(
471                matches!(result, Err(SettingsError::EmptyAgentName { .. })),
472                "expected EmptyAgentName for name={name:?}"
473            );
474        }
475    }
476
477    #[test]
478    fn empty_and_whitespace_descriptions_rejected() {
479        for desc in ["", "   "] {
480            let json = format!(
481                r#"{{"agents": [{{"name": "planner", "description": "{desc}", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}}]}}"#
482            );
483            let (_, result) = setup_and_load(&json);
484            assert!(
485                matches!(result, Err(SettingsError::MissingField { .. })),
486                "expected MissingField for desc={desc:?}"
487            );
488        }
489    }
490
491    #[test]
492    fn duplicate_agent_names_after_trim_rejected() {
493        let (_, result) = setup_and_load(
494            r#"{"agents": [
495                {"name": "planner", "description": "First", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]},
496                {"name": " planner ", "description": "Second", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}
497            ]}"#,
498        );
499        assert!(matches!(result, Err(SettingsError::DuplicateAgentName { .. })));
500    }
501
502    #[test]
503    fn agent_name_and_description_are_trimmed() {
504        let (_, catalog) = setup_and_load_ok(
505            r#"{"agents": [{"name": " planner ", "description": " Planner agent ", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
506        );
507        let spec = catalog.get("planner").unwrap();
508        assert_eq!(spec.name, "planner");
509        assert_eq!(spec.description, "Planner agent");
510    }
511
512    #[test]
513    fn no_prompts_rejected() {
514        let dir = create_temp_project();
515        write_settings(
516            dir.path(),
517            r#"{"agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true}]}"#,
518        );
519        assert!(matches!(load_agent_catalog(dir.path()), Err(SettingsError::NoPrompts { .. })));
520    }
521
522    #[test]
523    fn malformed_json_rejected() {
524        let dir = create_temp_project();
525        write_settings(dir.path(), "not valid json");
526        assert!(matches!(load_agent_catalog(dir.path()), Err(SettingsError::ParseError(_))));
527    }
528
529    #[test]
530    fn invalid_mcp_servers_path_rejected() {
531        let (_, result) = setup_and_load(
532            r#"{"mcpServers": ["nonexistent.json"], "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
533        );
534        assert!(matches!(result, Err(SettingsError::InvalidMcpConfigPath { .. })));
535    }
536
537    #[test]
538    fn invalid_agent_mcp_servers_path_rejected() {
539        let (_, result) = setup_and_load(&agent_settings(r#""mcpServers": ["nonexistent.json"]"#));
540        assert!(matches!(result, Err(SettingsError::InvalidMcpConfigPath { .. })));
541    }
542
543    #[test]
544    fn valid_mcp_servers_path_accepted() {
545        let dir = create_temp_project();
546        write_file(dir.path(), "AGENTS.md", "Be helpful");
547        write_file(dir.path(), ".aether/mcp/default.json", "{}");
548        write_settings(
549            dir.path(),
550            r#"{"mcpServers": [".aether/mcp/default.json"], "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
551        );
552
553        let catalog = load_agent_catalog(dir.path()).unwrap();
554        let resolved = catalog.resolve("planner", dir.path()).unwrap();
555        assert_eq!(resolved.mcp_config_refs, vec![McpJsonFileRef::direct(dir.path().join(".aether/mcp/default.json"))]);
556    }
557
558    #[test]
559    fn top_level_mcp_servers_array_parses_and_resolves_in_order() {
560        let dir = create_temp_project();
561        write_file(dir.path(), "AGENTS.md", "Be helpful");
562        write_file(dir.path(), ".aether/mcp/a.json", "{}");
563        write_file(dir.path(), ".aether/mcp/b.json", "{}");
564        write_settings(
565            dir.path(),
566            r#"{"mcpServers": [".aether/mcp/a.json", ".aether/mcp/b.json"], "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
567        );
568
569        let catalog = load_agent_catalog(dir.path()).unwrap();
570        let resolved = catalog.resolve("planner", dir.path()).unwrap();
571        assert_eq!(
572            resolved.mcp_config_refs,
573            vec![
574                McpJsonFileRef::direct(dir.path().join(".aether/mcp/a.json")),
575                McpJsonFileRef::direct(dir.path().join(".aether/mcp/b.json")),
576            ]
577        );
578    }
579
580    #[test]
581    fn top_level_mcp_servers_invalid_path_in_middle_of_array_rejected() {
582        let dir = create_temp_project();
583        write_file(dir.path(), "AGENTS.md", "Be helpful");
584        write_file(dir.path(), "good.json", "{}");
585        write_settings(
586            dir.path(),
587            r#"{"mcpServers": ["good.json", "bad.json"], "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
588        );
589
590        let result = load_agent_catalog(dir.path());
591        match result {
592            Err(SettingsError::InvalidMcpConfigPath { path }) => assert_eq!(path, "bad.json"),
593            other => panic!("expected InvalidMcpConfigPath for bad.json, got {other:?}"),
594        }
595    }
596
597    #[test]
598    fn agent_mcp_servers_array_parses() {
599        let dir = create_temp_project();
600        write_file(dir.path(), "AGENTS.md", "Be helpful");
601        write_file(dir.path(), "a.json", "{}");
602        write_file(dir.path(), "b.json", "{}");
603        write_settings(
604            dir.path(),
605            r#"{"agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"], "mcpServers": ["a.json", "b.json"]}]}"#,
606        );
607
608        let catalog = load_agent_catalog(dir.path()).unwrap();
609        let resolved = catalog.resolve("planner", dir.path()).unwrap();
610        assert_eq!(
611            resolved.mcp_config_refs,
612            vec![McpJsonFileRef::direct(dir.path().join("a.json")), McpJsonFileRef::direct(dir.path().join("b.json"))]
613        );
614    }
615
616    #[test]
617    fn agent_mcp_servers_overrides_inherited_array() {
618        let dir = create_temp_project();
619        write_file(dir.path(), "AGENTS.md", "Be helpful");
620        write_file(dir.path(), "base.json", "{}");
621        write_file(dir.path(), "override.json", "{}");
622        write_settings(
623            dir.path(),
624            r#"{"mcpServers": ["base.json"], "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"], "mcpServers": ["override.json"]}]}"#,
625        );
626
627        let catalog = load_agent_catalog(dir.path()).unwrap();
628        let resolved = catalog.resolve("planner", dir.path()).unwrap();
629        assert_eq!(resolved.mcp_config_refs, vec![McpJsonFileRef::direct(dir.path().join("override.json"))]);
630    }
631
632    #[test]
633    fn empty_mcp_servers_array_falls_back_to_cwd_mcp() {
634        let dir = create_temp_project();
635        write_file(dir.path(), "AGENTS.md", "Be helpful");
636        write_file(dir.path(), "mcp.json", "{}");
637        write_settings(
638            dir.path(),
639            r#"{"mcpServers": [], "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
640        );
641
642        let catalog = load_agent_catalog(dir.path()).unwrap();
643        let resolved = catalog.resolve("planner", dir.path()).unwrap();
644        assert_eq!(resolved.mcp_config_refs, vec![McpJsonFileRef::direct(dir.path().join("mcp.json"))]);
645    }
646
647    #[test]
648    fn any_invalid_agent_entry_fails_catalog_load() {
649        let (_, result) = setup_and_load(
650            r#"{"agents": [
651                {"name": "valid", "description": "Valid agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]},
652                {"name": "invalid", "description": "Invalid agent", "model": "invalid:model", "userInvocable": true, "prompts": ["AGENTS.md"]}
653            ]}"#,
654        );
655        assert!(matches!(result, Err(SettingsError::InvalidModel { .. })));
656    }
657
658    fn two_agent_json() -> &'static str {
659        r#"{"agents": [
660            {"name": "zebra", "description": "Z agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]},
661            {"name": "alpha", "description": "A agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}
662        ]}"#
663    }
664
665    #[test]
666    fn preserves_authored_agent_order_and_lookup() {
667        let (_, catalog) = setup_and_load_ok(two_agent_json());
668        let names: Vec<_> = catalog.all().iter().map(|s| s.name.as_str()).collect();
669        assert_eq!(names, vec!["zebra", "alpha"]); // not alphabetized
670        assert_eq!(catalog.get("alpha").unwrap().name, "alpha");
671        assert_eq!(catalog.get("zebra").unwrap().name, "zebra");
672    }
673
674    #[test]
675    fn tools_filter_parsed_from_settings() {
676        let (_, catalog) = setup_and_load_ok(
677            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*"]}}]}"#,
678        );
679        let spec = catalog.get("researcher").unwrap();
680        assert_eq!(spec.tools.allow, vec!["coding__grep", "coding__read_file"]);
681        assert_eq!(spec.tools.deny, vec!["coding__write*"]);
682    }
683
684    #[test]
685    fn absent_tools_field_yields_default_filter() {
686        let (_, catalog) = setup_and_load_ok(&agent_settings(""));
687        let spec = catalog.get("planner").unwrap();
688        assert!(spec.tools.allow.is_empty());
689        assert!(spec.tools.deny.is_empty());
690    }
691
692    #[test]
693    fn reserved_agent_name_rejected() {
694        let (_, result) = setup_and_load(
695            r#"{"agents": [{"name": "__default__", "description": "Sneaky agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
696        );
697        assert!(matches!(result, Err(SettingsError::ReservedAgentName { .. })));
698    }
699
700    #[test]
701    fn mcp_server_entry_object_form_with_proxy() {
702        let dir = create_temp_project();
703        write_file(dir.path(), "AGENTS.md", "Be helpful");
704        write_file(dir.path(), ".mcp.json", r#"{"servers": {}}"#);
705        write_settings(
706            dir.path(),
707            r#"{"mcpServers": [{"path": ".mcp.json", "proxy": true}], "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
708        );
709
710        let catalog = load_agent_catalog(dir.path()).unwrap();
711        let resolved = catalog.resolve("planner", dir.path()).unwrap();
712        assert_eq!(resolved.mcp_config_refs, vec![McpJsonFileRef::proxied(dir.path().join(".mcp.json"))]);
713    }
714
715    #[test]
716    fn mcp_server_entry_object_form_proxy_defaults_false() {
717        let dir = create_temp_project();
718        write_file(dir.path(), "AGENTS.md", "Be helpful");
719        write_file(dir.path(), "a.json", r#"{"servers": {}}"#);
720        write_settings(
721            dir.path(),
722            r#"{"mcpServers": [{"path": "a.json"}], "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
723        );
724
725        let catalog = load_agent_catalog(dir.path()).unwrap();
726        let resolved = catalog.resolve("planner", dir.path()).unwrap();
727        assert_eq!(resolved.mcp_config_refs, vec![McpJsonFileRef::direct(dir.path().join("a.json"))]);
728    }
729
730    #[test]
731    fn mcp_server_entry_mixed_string_and_object() {
732        let dir = create_temp_project();
733        write_file(dir.path(), "AGENTS.md", "Be helpful");
734        write_file(dir.path(), "direct.json", r#"{"servers": {}}"#);
735        write_file(dir.path(), "proxied.json", r#"{"servers": {}}"#);
736        write_settings(
737            dir.path(),
738            r#"{"mcpServers": ["direct.json", {"path": "proxied.json", "proxy": true}], "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
739        );
740
741        let catalog = load_agent_catalog(dir.path()).unwrap();
742        let resolved = catalog.resolve("planner", dir.path()).unwrap();
743        assert_eq!(
744            resolved.mcp_config_refs,
745            vec![
746                McpJsonFileRef::direct(dir.path().join("direct.json")),
747                McpJsonFileRef::proxied(dir.path().join("proxied.json")),
748            ]
749        );
750    }
751
752    #[test]
753    fn mcp_server_entry_object_form_invalid_path_rejected() {
754        let (_, result) = setup_and_load(
755            r#"{"mcpServers": [{"path": "nonexistent.json", "proxy": true}], "agents": [{"name": "planner", "description": "Planner agent", "model": "anthropic:claude-sonnet-4-5", "userInvocable": true, "prompts": ["AGENTS.md"]}]}"#,
756        );
757        assert!(matches!(result, Err(SettingsError::InvalidMcpConfigPath { .. })));
758    }
759}