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::fs;
7use std::path::{Path, PathBuf};
8
9use serde_json::{Value, json};
10use tempfile::TempDir;
11
12use crate::prompt_file::{PromptFile, PromptTriggers};
13use crate::{AgentConfig, PromptSource, SKILL_FILENAME};
14use aether_core::agent_spec::{AgentSpec, AgentSpecExposure};
15use aether_core::mcp::{McpConfig, ToolFilter};
16use llm::{ModelSettings, ProviderConnectionOverrides};
17
18/// The model id every shared agent fixture resolves to.
19pub const DEFAULT_MODEL: &str = "anthropic:claude-sonnet-4-5";
20
21/// A temporary project directory for tests that need on-disk settings, prompts, or skills.
22pub fn mcp_json(server: &str) -> String {
23    json!({ "servers": { server: { "command": format!("{server}-server") } } }).to_string()
24}
25
26pub fn project() -> TestProject {
27    TestProject { root: temp_dir("project") }
28}
29
30/// A temporary user home directory for tests that exercise `AETHER_HOME` lookups.
31pub fn home() -> TestHome {
32    TestHome { root: temp_dir("home") }
33}
34
35/// A minimal user-invocable [`AgentConfig`] as it would be parsed from `settings.json`.
36pub fn settings_agent(name: &str, description: &str) -> AgentConfig {
37    AgentConfig {
38        name: name.to_string(),
39        description: description.to_string(),
40        model: DEFAULT_MODEL.to_string(),
41        user_invocable: true,
42        ..AgentConfig::default()
43    }
44}
45
46/// A `settings.json` agent object for `name` and `description`: the
47/// [`DEFAULT_MODEL`], user-invocable, with no prompts or MCPs.
48pub fn agent_json(name: &str, description: &str) -> Value {
49    json!({
50        "name": name,
51        "description": description,
52        "model": DEFAULT_MODEL,
53        "userInvocable": true,
54    })
55}
56
57/// [`agent_json`] merged with `extra` fields, e.g.
58/// `agent_json_with("planner", "Plans", json!({ "prompts": ["BASE.md"] }))`.
59pub fn agent_json_with(name: &str, description: &str, extra: Value) -> Value {
60    let Value::Object(extra) = extra else {
61        panic!("extra agent fields must be a JSON object, got {extra}");
62    };
63    let mut agent = agent_json(name, description);
64    agent.as_object_mut().expect("agent_json builds an object").extend(extra);
65    agent
66}
67
68/// A user-invocable [`AgentConfig`] named `name` whose prompt is the file `PROMPT.md`.
69pub fn agent_config(name: &str) -> AgentConfig {
70    AgentConfig { prompts: vec![PromptSource::file("PROMPT.md")], ..settings_agent(name, &format!("{name} agent")) }
71}
72
73/// A [`PromptFile`] with valid defaults, built fluently for tests that exercise
74/// skill parsing, writing, and rating behavior.
75pub fn prompt_file(name: &str) -> PromptFileBuilder {
76    PromptFileBuilder {
77        file: PromptFile {
78            name: name.to_string(),
79            description: format!("{name} skill"),
80            body: format!("# {name}\n\nSkill body."),
81            path: PathBuf::new(),
82            user_invocable: true,
83            agent_invocable: true,
84            argument_hint: None,
85            tags: Vec::new(),
86            triggers: PromptTriggers::default(),
87            agent_authored: false,
88            helpful: 0,
89            harmful: 0,
90        },
91    }
92}
93
94/// A minimal [`AgentSpec`] with the given name and exposure.
95pub fn fake_spec(name: &str, exposure: AgentSpecExposure) -> AgentSpec {
96    AgentSpec {
97        name: name.to_string(),
98        description: format!("{name} agent"),
99        model: DEFAULT_MODEL.to_string(),
100        reasoning_effort: None,
101        model_settings: ModelSettings::default(),
102        context_window: None,
103        prompts: vec![],
104        provider_connections: ProviderConnectionOverrides::default(),
105        mcp: McpConfig::default(),
106        exposure,
107        tools: ToolFilter::default(),
108    }
109}
110
111/// A temporary project directory whose files are set up fluently.
112pub struct TestProject {
113    root: TempDir,
114}
115
116impl TestProject {
117    /// The project root path.
118    pub fn root(&self) -> &Path {
119        self.root.path()
120    }
121
122    /// Writes `content` to `path` relative to the project root, creating parent directories.
123    pub fn file(self, path: &str, content: &str) -> Self {
124        self.write(path, content);
125        self
126    }
127
128    /// Writes `content` to `<name>/SKILL.md`, creating the skill directory.
129    pub fn skill(self, name: &str, content: &str) -> Self {
130        self.file(&format!("{name}/{SKILL_FILENAME}"), content)
131    }
132
133    /// Writes a file after the fixture exists, e.g. to exercise a state transition.
134    pub fn write(&self, path: &str, content: &str) {
135        write_file(self.root.path(), path, content);
136    }
137}
138
139/// A temporary home directory whose `.aether` contents are set up fluently.
140pub struct TestHome {
141    root: TempDir,
142}
143
144impl TestHome {
145    /// The `.aether` directory inside this home.
146    pub fn aether(&self) -> PathBuf {
147        self.root.path().join(".aether")
148    }
149
150    /// The home root path.
151    pub fn root(&self) -> &Path {
152        self.root.path()
153    }
154
155    /// Writes `content` to `path` relative to the home root, creating parent directories.
156    pub fn file(self, path: &str, content: &str) -> Self {
157        write_file(self.root.path(), path, content);
158        self
159    }
160
161    /// Writes user settings to `.aether/settings.json`.
162    pub fn settings(self, json: &str) -> Self {
163        self.file(".aether/settings.json", json)
164    }
165}
166
167/// A fluent builder for test [`PromptFile`]s; see [`prompt_file`].
168pub struct PromptFileBuilder {
169    file: PromptFile,
170}
171
172impl PromptFileBuilder {
173    /// The skill description shown to users selecting the skill.
174    pub fn description(mut self, description: &str) -> Self {
175        self.file.description = description.to_string();
176        self
177    }
178
179    /// The skill body below the frontmatter.
180    pub fn body(mut self, body: &str) -> Self {
181        self.file.body = body.to_string();
182        self
183    }
184
185    /// Whether the user can invoke the skill as a slash command.
186    pub fn user_invocable(mut self, user_invocable: bool) -> Self {
187        self.file.user_invocable = user_invocable;
188        self
189    }
190
191    /// Whether the agent can invoke the skill on its own.
192    pub fn agent_invocable(mut self, agent_invocable: bool) -> Self {
193        self.file.agent_invocable = agent_invocable;
194        self
195    }
196
197    /// The argument hint shown to users when invoking the skill.
198    pub fn argument_hint(mut self, argument_hint: &str) -> Self {
199        self.file.argument_hint = Some(argument_hint.to_string());
200        self
201    }
202
203    /// The skill's tags.
204    pub fn tags(mut self, tags: &[&str]) -> Self {
205        self.file.tags = tags.iter().map(ToString::to_string).collect();
206        self
207    }
208
209    /// Read-trigger glob patterns activating the skill when matching files are read.
210    pub fn read_triggers(mut self, patterns: &[&str]) -> Self {
211        self.file.triggers = PromptTriggers::new(patterns.iter().map(ToString::to_string).collect())
212            .expect("read_triggers patterns must be valid globs");
213        self
214    }
215
216    /// Whether the agent authored this skill.
217    pub fn agent_authored(mut self, agent_authored: bool) -> Self {
218        self.file.agent_authored = agent_authored;
219        self
220    }
221
222    /// The helpful/harmful rating counts collected for this skill.
223    pub fn ratings(mut self, helpful: u32, harmful: u32) -> Self {
224        self.file.helpful = helpful;
225        self.file.harmful = harmful;
226        self
227    }
228
229    /// The finished [`PromptFile`].
230    pub fn build(self) -> PromptFile {
231        self.file
232    }
233}
234
235fn temp_dir(label: &str) -> TempDir {
236    TempDir::new().unwrap_or_else(|error| panic!("failed to create temp {label} dir: {error}"))
237}
238
239fn write_file(root: &Path, path: &str, content: &str) {
240    let full_path = root.join(path);
241    if let Some(parent) = full_path.parent() {
242        fs::create_dir_all(parent).expect("failed to create fixture parent directories");
243    }
244
245    fs::write(full_path, content).expect("failed to write fixture file");
246}