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 llm::{ModelSettings, ProviderConnectionOverrides};
16use mcp_utils::client::ToolFilter;
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 project() -> TestProject {
23    TestProject { root: temp_dir("project") }
24}
25
26/// A temporary user home directory for tests that exercise `AETHER_HOME` lookups.
27pub fn home() -> TestHome {
28    TestHome { root: temp_dir("home") }
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
69/// A [`PromptFile`] with valid defaults, built fluently for tests that exercise
70/// skill parsing, writing, and rating behavior.
71pub fn prompt_file(name: &str) -> PromptFileBuilder {
72    PromptFileBuilder {
73        file: PromptFile {
74            name: name.to_string(),
75            description: format!("{name} skill"),
76            body: format!("# {name}\n\nSkill body."),
77            path: PathBuf::new(),
78            user_invocable: true,
79            agent_invocable: true,
80            argument_hint: None,
81            tags: Vec::new(),
82            triggers: PromptTriggers::default(),
83            agent_authored: false,
84            helpful: 0,
85            harmful: 0,
86        },
87    }
88}
89
90/// A minimal [`AgentSpec`] with the given name and exposure.
91pub fn fake_spec(name: &str, exposure: AgentSpecExposure) -> AgentSpec {
92    AgentSpec {
93        name: name.to_string(),
94        description: format!("{name} agent"),
95        model: DEFAULT_MODEL.to_string(),
96        reasoning_effort: None,
97        model_settings: ModelSettings::default(),
98        context_window: None,
99        prompts: vec![],
100        provider_connections: ProviderConnectionOverrides::default(),
101        mcp_config_sources: Vec::new(),
102        exposure,
103        tools: ToolFilter::default(),
104    }
105}
106
107/// A temporary project directory whose files are set up fluently.
108pub struct TestProject {
109    root: TempDir,
110}
111
112impl TestProject {
113    /// The project root path.
114    pub fn root(&self) -> &Path {
115        self.root.path()
116    }
117
118    /// Writes `content` to `path` relative to the project root, creating parent directories.
119    pub fn file(self, path: &str, content: &str) -> Self {
120        self.write(path, content);
121        self
122    }
123
124    /// Writes `content` to `<name>/SKILL.md`, creating the skill directory.
125    pub fn skill(self, name: &str, content: &str) -> Self {
126        self.file(&format!("{name}/{SKILL_FILENAME}"), content)
127    }
128
129    /// Writes a file after the fixture exists, e.g. to exercise a state transition.
130    pub fn write(&self, path: &str, content: &str) {
131        write_file(self.root.path(), path, content);
132    }
133}
134
135/// A temporary home directory whose `.aether` contents are set up fluently.
136pub struct TestHome {
137    root: TempDir,
138}
139
140impl TestHome {
141    /// The `.aether` directory inside this home.
142    pub fn aether(&self) -> PathBuf {
143        self.root.path().join(".aether")
144    }
145
146    /// The home root path.
147    pub fn root(&self) -> &Path {
148        self.root.path()
149    }
150
151    /// Writes `content` to `path` relative to the home root, creating parent directories.
152    pub fn file(self, path: &str, content: &str) -> Self {
153        write_file(self.root.path(), path, content);
154        self
155    }
156
157    /// Writes user settings to `.aether/settings.json`.
158    pub fn settings(self, json: &str) -> Self {
159        self.file(".aether/settings.json", json)
160    }
161}
162
163/// A fluent builder for test [`PromptFile`]s; see [`prompt_file`].
164pub struct PromptFileBuilder {
165    file: PromptFile,
166}
167
168impl PromptFileBuilder {
169    /// The skill description shown to users selecting the skill.
170    pub fn description(mut self, description: &str) -> Self {
171        self.file.description = description.to_string();
172        self
173    }
174
175    /// The skill body below the frontmatter.
176    pub fn body(mut self, body: &str) -> Self {
177        self.file.body = body.to_string();
178        self
179    }
180
181    /// Whether the user can invoke the skill as a slash command.
182    pub fn user_invocable(mut self, user_invocable: bool) -> Self {
183        self.file.user_invocable = user_invocable;
184        self
185    }
186
187    /// Whether the agent can invoke the skill on its own.
188    pub fn agent_invocable(mut self, agent_invocable: bool) -> Self {
189        self.file.agent_invocable = agent_invocable;
190        self
191    }
192
193    /// The argument hint shown to users when invoking the skill.
194    pub fn argument_hint(mut self, argument_hint: &str) -> Self {
195        self.file.argument_hint = Some(argument_hint.to_string());
196        self
197    }
198
199    /// The skill's tags.
200    pub fn tags(mut self, tags: &[&str]) -> Self {
201        self.file.tags = tags.iter().map(ToString::to_string).collect();
202        self
203    }
204
205    /// Read-trigger glob patterns activating the skill when matching files are read.
206    pub fn read_triggers(mut self, patterns: &[&str]) -> Self {
207        self.file.triggers = PromptTriggers::new(patterns.iter().map(ToString::to_string).collect())
208            .expect("read_triggers patterns must be valid globs");
209        self
210    }
211
212    /// Whether the agent authored this skill.
213    pub fn agent_authored(mut self, agent_authored: bool) -> Self {
214        self.file.agent_authored = agent_authored;
215        self
216    }
217
218    /// The helpful/harmful rating counts collected for this skill.
219    pub fn ratings(mut self, helpful: u32, harmful: u32) -> Self {
220        self.file.helpful = helpful;
221        self.file.harmful = harmful;
222        self
223    }
224
225    /// The finished [`PromptFile`].
226    pub fn build(self) -> PromptFile {
227        self.file
228    }
229}
230
231fn temp_dir(label: &str) -> TempDir {
232    TempDir::new().unwrap_or_else(|error| panic!("failed to create temp {label} dir: {error}"))
233}
234
235fn write_file(root: &Path, path: &str, content: &str) {
236    let full_path = root.join(path);
237    if let Some(parent) = full_path.parent() {
238        fs::create_dir_all(parent).expect("failed to create fixture parent directories");
239    }
240
241    fs::write(full_path, content).expect("failed to write fixture file");
242}