aether_project/
testing.rs1use 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
18pub const DEFAULT_MODEL: &str = "anthropic:claude-sonnet-4-5";
20
21pub 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
30pub fn home() -> TestHome {
32 TestHome { root: temp_dir("home") }
33}
34
35pub 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
46pub 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
57pub 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
68pub fn agent_config(name: &str) -> AgentConfig {
70 AgentConfig { prompts: vec![PromptSource::file("PROMPT.md")], ..settings_agent(name, &format!("{name} agent")) }
71}
72
73pub 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
94pub 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
111pub struct TestProject {
113 root: TempDir,
114}
115
116impl TestProject {
117 pub fn root(&self) -> &Path {
119 self.root.path()
120 }
121
122 pub fn file(self, path: &str, content: &str) -> Self {
124 self.write(path, content);
125 self
126 }
127
128 pub fn skill(self, name: &str, content: &str) -> Self {
130 self.file(&format!("{name}/{SKILL_FILENAME}"), content)
131 }
132
133 pub fn write(&self, path: &str, content: &str) {
135 write_file(self.root.path(), path, content);
136 }
137}
138
139pub struct TestHome {
141 root: TempDir,
142}
143
144impl TestHome {
145 pub fn aether(&self) -> PathBuf {
147 self.root.path().join(".aether")
148 }
149
150 pub fn root(&self) -> &Path {
152 self.root.path()
153 }
154
155 pub fn file(self, path: &str, content: &str) -> Self {
157 write_file(self.root.path(), path, content);
158 self
159 }
160
161 pub fn settings(self, json: &str) -> Self {
163 self.file(".aether/settings.json", json)
164 }
165}
166
167pub struct PromptFileBuilder {
169 file: PromptFile,
170}
171
172impl PromptFileBuilder {
173 pub fn description(mut self, description: &str) -> Self {
175 self.file.description = description.to_string();
176 self
177 }
178
179 pub fn body(mut self, body: &str) -> Self {
181 self.file.body = body.to_string();
182 self
183 }
184
185 pub fn user_invocable(mut self, user_invocable: bool) -> Self {
187 self.file.user_invocable = user_invocable;
188 self
189 }
190
191 pub fn agent_invocable(mut self, agent_invocable: bool) -> Self {
193 self.file.agent_invocable = agent_invocable;
194 self
195 }
196
197 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 pub fn tags(mut self, tags: &[&str]) -> Self {
205 self.file.tags = tags.iter().map(ToString::to_string).collect();
206 self
207 }
208
209 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 pub fn agent_authored(mut self, agent_authored: bool) -> Self {
218 self.file.agent_authored = agent_authored;
219 self
220 }
221
222 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 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}