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::{AgentConfig, PromptSource, SKILL_FILENAME};
13use aether_core::agent_spec::{AgentSpec, AgentSpecExposure};
14use llm::{ModelSettings, ProviderConnectionOverrides};
15use mcp_utils::client::ToolFilter;
16
17/// The model id every shared agent fixture resolves to.
18pub const DEFAULT_MODEL: &str = "anthropic:claude-sonnet-4-5";
19
20/// A temporary project directory for tests that need on-disk settings, prompts, or skills.
21pub fn project() -> TestProject {
22    TestProject { root: temp_dir("project") }
23}
24
25/// A temporary user home directory for tests that exercise `AETHER_HOME` lookups.
26pub fn home() -> TestHome {
27    TestHome { root: temp_dir("home") }
28}
29
30/// A minimal user-invocable [`AgentConfig`] as it would be parsed from `settings.json`.
31pub fn settings_agent(name: &str, description: &str) -> AgentConfig {
32    AgentConfig {
33        name: name.to_string(),
34        description: description.to_string(),
35        model: DEFAULT_MODEL.to_string(),
36        user_invocable: true,
37        ..AgentConfig::default()
38    }
39}
40
41/// A `settings.json` agent object for `name` and `description`: the
42/// [`DEFAULT_MODEL`], user-invocable, with no prompts or MCPs.
43pub fn agent_json(name: &str, description: &str) -> Value {
44    json!({
45        "name": name,
46        "description": description,
47        "model": DEFAULT_MODEL,
48        "userInvocable": true,
49    })
50}
51
52/// [`agent_json`] merged with `extra` fields, e.g.
53/// `agent_json_with("planner", "Plans", json!({ "prompts": ["BASE.md"] }))`.
54pub fn agent_json_with(name: &str, description: &str, extra: Value) -> Value {
55    let Value::Object(extra) = extra else {
56        panic!("extra agent fields must be a JSON object, got {extra}");
57    };
58    let mut agent = agent_json(name, description);
59    agent.as_object_mut().expect("agent_json builds an object").extend(extra);
60    agent
61}
62
63/// A user-invocable [`AgentConfig`] named `name` whose prompt is the file `PROMPT.md`.
64pub fn agent_config(name: &str) -> AgentConfig {
65    AgentConfig { prompts: vec![PromptSource::file("PROMPT.md")], ..settings_agent(name, &format!("{name} agent")) }
66}
67
68/// A minimal [`AgentSpec`] with the given name and exposure.
69pub fn fake_spec(name: &str, exposure: AgentSpecExposure) -> AgentSpec {
70    AgentSpec {
71        name: name.to_string(),
72        description: format!("{name} agent"),
73        model: DEFAULT_MODEL.to_string(),
74        reasoning_effort: None,
75        model_settings: ModelSettings::default(),
76        context_window: None,
77        prompts: vec![],
78        provider_connections: ProviderConnectionOverrides::default(),
79        mcp_config_sources: Vec::new(),
80        exposure,
81        tools: ToolFilter::default(),
82    }
83}
84
85/// A temporary project directory whose files are set up fluently.
86pub struct TestProject {
87    root: TempDir,
88}
89
90impl TestProject {
91    /// The project root path.
92    pub fn root(&self) -> &Path {
93        self.root.path()
94    }
95
96    /// Writes `content` to `path` relative to the project root, creating parent directories.
97    pub fn file(self, path: &str, content: &str) -> Self {
98        self.write(path, content);
99        self
100    }
101
102    /// Writes `content` to `<name>/SKILL.md`, creating the skill directory.
103    pub fn skill(self, name: &str, content: &str) -> Self {
104        self.file(&format!("{name}/{SKILL_FILENAME}"), content)
105    }
106
107    /// Writes a file after the fixture exists, e.g. to exercise a state transition.
108    pub fn write(&self, path: &str, content: &str) {
109        write_file(self.root.path(), path, content);
110    }
111}
112
113/// A temporary home directory whose `.aether` contents are set up fluently.
114pub struct TestHome {
115    root: TempDir,
116}
117
118impl TestHome {
119    /// The `.aether` directory inside this home.
120    pub fn aether(&self) -> PathBuf {
121        self.root.path().join(".aether")
122    }
123
124    /// The home root path.
125    pub fn root(&self) -> &Path {
126        self.root.path()
127    }
128
129    /// Writes `content` to `path` relative to the home root, creating parent directories.
130    pub fn file(self, path: &str, content: &str) -> Self {
131        write_file(self.root.path(), path, content);
132        self
133    }
134
135    /// Writes user settings to `.aether/settings.json`.
136    pub fn settings(self, json: &str) -> Self {
137        self.file(".aether/settings.json", json)
138    }
139}
140
141fn temp_dir(label: &str) -> TempDir {
142    TempDir::new().unwrap_or_else(|error| panic!("failed to create temp {label} dir: {error}"))
143}
144
145fn write_file(root: &Path, path: &str, content: &str) {
146    let full_path = root.join(path);
147    if let Some(parent) = full_path.parent() {
148        fs::create_dir_all(parent).expect("failed to create fixture parent directories");
149    }
150
151    fs::write(full_path, content).expect("failed to write fixture file");
152}