Skip to main content

aether_project/
testing.rs

1//! Shared fixtures and fake builders for exercising `aether-project` in tests.
2//!
3//! Compiled for the crate's own tests and for any consumer that enables the
4//! `testing` feature. Prefer these over hand-rolling per-suite fixtures.
5
6use std::path::{Path, PathBuf};
7
8use serde_json::{Value, json};
9
10use crate::prompt_file::{PromptFile, PromptTriggers};
11use crate::{AetherSettings, AgentConfig, McpSourceSpec, PromptSource};
12use aether_core::agent_spec::{AgentSpec, AgentSpecExposure};
13use aether_core::core::Prompt;
14pub use utils::testing::TestWorkspace;
15
16/// The model id every shared agent fixture resolves to.
17pub const DEFAULT_MODEL: &str = "anthropic:claude-sonnet-4-5";
18
19pub fn mcp_json(server: &str) -> String {
20    json!({ "servers": { server: { "command": format!("{server}-server") } } }).to_string()
21}
22
23pub fn project() -> TestWorkspace {
24    TestWorkspace::new()
25}
26
27pub fn home() -> TestWorkspace {
28    TestWorkspace::new()
29}
30
31/// A minimal user-invocable [`AgentConfig`] as it would be parsed from `settings.json`.
32pub fn settings_agent(name: &str, description: &str) -> AgentConfig {
33    AgentConfig {
34        name: name.to_string(),
35        description: description.to_string(),
36        model: DEFAULT_MODEL.to_string(),
37        user_invocable: true,
38        ..AgentConfig::default()
39    }
40}
41
42/// A `settings.json` agent object for `name` and `description`: the
43/// [`DEFAULT_MODEL`], user-invocable, with no prompts or MCPs.
44pub fn agent_json(name: &str, description: &str) -> Value {
45    json!({
46        "name": name,
47        "description": description,
48        "model": DEFAULT_MODEL,
49        "userInvocable": true,
50    })
51}
52
53/// [`agent_json`] merged with `extra` fields, e.g.
54/// `agent_json_with("planner", "Plans", json!({ "prompts": ["BASE.md"] }))`.
55pub fn agent_json_with(name: &str, description: &str, extra: Value) -> Value {
56    let Value::Object(extra) = extra else {
57        panic!("extra agent fields must be a JSON object, got {extra}");
58    };
59    let mut agent = agent_json(name, description);
60    agent.as_object_mut().expect("agent_json builds an object").extend(extra);
61    agent
62}
63
64/// A user-invocable [`AgentConfig`] named `name` whose prompt is the file `PROMPT.md`.
65pub fn agent_config(name: &str) -> AgentConfig {
66    AgentConfig { prompts: vec![PromptSource::file("PROMPT.md")], ..settings_agent(name, &format!("{name} agent")) }
67}
68
69pub fn settings_with_mcps(mcps: Vec<McpSourceSpec>) -> AetherSettings {
70    AetherSettings { agents: vec![AgentConfig { mcps, ..agent_config("alpha") }], ..AetherSettings::default() }
71}
72
73pub fn has_prompt_file(spec: &AgentSpec, expected: impl AsRef<Path>) -> bool {
74    spec.prompts.iter().any(|prompt| matches!(prompt, Prompt::File { path, .. } if path.ends_with(&expected)))
75}
76
77/// A [`PromptFile`] with valid defaults, built fluently for tests that exercise
78/// skill parsing, writing, and rating behavior.
79pub fn prompt_file(name: &str) -> PromptFileBuilder {
80    PromptFileBuilder {
81        file: PromptFile {
82            name: name.to_string(),
83            description: format!("{name} skill"),
84            body: format!("# {name}\n\nSkill body."),
85            path: PathBuf::new(),
86            user_invocable: true,
87            agent_invocable: true,
88            argument_hint: None,
89            tags: Vec::new(),
90            triggers: PromptTriggers::default(),
91            agent_authored: false,
92            helpful: 0,
93            harmful: 0,
94        },
95    }
96}
97
98/// A minimal [`AgentSpec`] with the given name and exposure.
99pub fn fake_spec(name: &str, exposure: AgentSpecExposure) -> AgentSpec {
100    AgentSpec {
101        name: name.to_string(),
102        description: format!("{name} agent"),
103        model: DEFAULT_MODEL.to_string(),
104        exposure,
105        ..AgentSpec::default()
106    }
107}
108
109/// A fluent builder for test [`PromptFile`]s; see [`prompt_file`].
110pub struct PromptFileBuilder {
111    file: PromptFile,
112}
113
114impl PromptFileBuilder {
115    /// The skill description shown to users selecting the skill.
116    pub fn description(mut self, description: &str) -> Self {
117        self.file.description = description.to_string();
118        self
119    }
120
121    /// The skill body below the frontmatter.
122    pub fn body(mut self, body: &str) -> Self {
123        self.file.body = body.to_string();
124        self
125    }
126
127    /// Whether the user can invoke the skill as a slash command.
128    pub fn user_invocable(mut self, user_invocable: bool) -> Self {
129        self.file.user_invocable = user_invocable;
130        self
131    }
132
133    /// Whether the agent can invoke the skill on its own.
134    pub fn agent_invocable(mut self, agent_invocable: bool) -> Self {
135        self.file.agent_invocable = agent_invocable;
136        self
137    }
138
139    /// The argument hint shown to users when invoking the skill.
140    pub fn argument_hint(mut self, argument_hint: &str) -> Self {
141        self.file.argument_hint = Some(argument_hint.to_string());
142        self
143    }
144
145    /// The skill's tags.
146    pub fn tags(mut self, tags: &[&str]) -> Self {
147        self.file.tags = tags.iter().map(ToString::to_string).collect();
148        self
149    }
150
151    /// Read-trigger glob patterns activating the skill when matching files are read.
152    pub fn read_triggers(mut self, patterns: &[&str]) -> Self {
153        self.file.triggers = PromptTriggers::new(patterns.iter().map(ToString::to_string).collect())
154            .expect("read_triggers patterns must be valid globs");
155        self
156    }
157
158    /// Whether the agent authored this skill.
159    pub fn agent_authored(mut self, agent_authored: bool) -> Self {
160        self.file.agent_authored = agent_authored;
161        self
162    }
163
164    /// The helpful/harmful rating counts collected for this skill.
165    pub fn ratings(mut self, helpful: u32, harmful: u32) -> Self {
166        self.file.helpful = helpful;
167        self.file.harmful = harmful;
168        self
169    }
170
171    /// The finished [`PromptFile`].
172    pub fn build(self) -> PromptFile {
173        self.file
174    }
175}