1use std::borrow::Cow;
10use std::path::{Path, PathBuf};
11
12use aither_core::llm::tool::{Tool, ToolResult};
13use eyre::{Context as _, Result, bail};
14use schemars::JsonSchema;
15use serde::Deserialize;
16use tracing::info;
17
18use crate::preview::request::{
19 self, CliHydrolysisPreviewTheme, CliPreviewBackend, CliPreviewPlatform, DEFAULT_FRAME,
20 PreviewRequest, PreviewTarget,
21};
22use crate::preview::{
23 HydrolysisPreviewRequest, launch_preview_session, render_preview_with_hydrolysis,
24};
25use crate::project::read_project_crate_name;
26
27#[derive(Debug, Deserialize, JsonSchema)]
36pub struct PreviewArgs {
37 pub target: String,
40
41 #[serde(default)]
44 pub expr: bool,
45
46 #[serde(default)]
48 pub frame: Option<String>,
49
50 #[serde(default)]
53 pub backend: Option<CliPreviewBackend>,
54
55 #[serde(default)]
58 pub theme: Option<CliHydrolysisPreviewTheme>,
59
60 #[serde(default)]
63 pub platform: Option<CliPreviewPlatform>,
64}
65
66impl PreviewArgs {
67 pub fn resolve(&self, crate_name: &str) -> Result<PreviewRequest> {
74 let frame = self.frame.as_deref().unwrap_or(DEFAULT_FRAME);
75 let (width, height) = request::parse_frame(frame)?;
76 let platform = request::resolve_preview_platform(self.platform)?;
77 let backend = request::resolve_preview_backend(platform, self.backend)?;
78 let hydrolysis_theme = request::resolve_hydrolysis_preview_theme(backend, self.theme)?;
79 let target = request::resolve_preview_target(crate_name, &self.target, self.expr);
80 Ok(PreviewRequest {
81 platform,
82 backend,
83 hydrolysis_theme,
84 target,
85 width,
86 height,
87 })
88 }
89}
90
91fn sanitize_target_name(target: &str) -> String {
94 let mut name = String::with_capacity(target.len());
95 for ch in target.chars() {
96 if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '-') {
97 name.push(ch);
98 } else if !name.ends_with('_') {
99 name.push('_');
100 }
101 }
102 name
103}
104
105#[derive(Debug)]
107pub struct PreviewTool {
108 project_path: PathBuf,
109 sccache_path: Option<PathBuf>,
110}
111
112impl PreviewTool {
113 #[must_use]
115 pub const fn new(project_path: PathBuf, sccache_path: Option<PathBuf>) -> Self {
116 Self {
117 project_path,
118 sccache_path,
119 }
120 }
121
122 async fn render(&self, args: PreviewArgs) -> ToolResult {
125 match self.run(&args).await {
126 Ok((output_path, bytes)) => {
127 info!(path = %output_path.display(), "preview rendered");
128 ToolResult::image(bytes, "image/png")
129 }
130 Err(error) => ToolResult::error(format!("{error:#}")),
131 }
132 }
133
134 async fn output_path(&self, request: &PreviewRequest) -> Result<PathBuf> {
137 let dir = crate::water_dir::build_cache_container_for(&self.project_path)
138 .await?
139 .join("mcp")
140 .join("preview");
141 smol::fs::create_dir_all(&dir)
142 .await
143 .wrap_err_with(|| format!("failed to create {}", dir.display()))?;
144 Ok(dir.join(format!(
145 "{}-{}x{}.png",
146 sanitize_target_name(request.target.display_name()),
147 request.width,
148 request.height
149 )))
150 }
151
152 async fn run(&self, args: &PreviewArgs) -> Result<(PathBuf, Vec<u8>)> {
153 let crate_name = read_project_crate_name(&self.project_path).await?;
154 let request = args.resolve(&crate_name)?;
155 request::check_toolchain_for_backend(request.platform, request.backend).await?;
156 let output_path = self.output_path(&request).await?;
157
158 match request.backend {
159 CliPreviewBackend::Hydrolysis => {
160 render_preview_with_hydrolysis(
161 HydrolysisPreviewRequest {
162 project_path: &self.project_path,
163 source: request.target.hydrolysis_source(),
164 theme: request
165 .hydrolysis_theme
166 .expect("resolve guarantees a theme for hydrolysis"),
167 width: request.width,
168 height: request.height,
169 sccache_path: self.sccache_path.clone(),
170 },
171 &output_path,
172 None,
173 )
174 .await?;
175 }
176 CliPreviewBackend::Apple | CliPreviewBackend::Android => {
177 let PreviewTarget::Function {
178 function_path,
179 symbol,
180 } = &request.target
181 else {
182 bail!(
183 "Expression preview is currently supported only with the `hydrolysis` backend."
184 );
185 };
186 self.render_support_app(&request, function_path, symbol, &output_path)
187 .await?;
188 }
189 }
190
191 let bytes = smol::fs::read(&output_path).await?;
192 Ok((output_path, bytes))
193 }
194
195 async fn render_support_app(
200 &self,
201 request: &PreviewRequest,
202 function_path: &str,
203 symbol: &str,
204 output_path: &Path,
205 ) -> Result<()> {
206 let mut session = launch_preview_session(
207 &self.project_path,
208 request.platform.into(),
209 self.sccache_path.clone(),
210 )
211 .await?;
212
213 let result = async {
214 let dylib = session.build_dylib(&self.project_path).await?;
215 let png_data = request::render_with_symbol(
216 &mut session,
217 function_path,
218 symbol,
219 dylib.id,
220 &dylib.path,
221 request.width,
222 request.height,
223 )
224 .await?;
225 if png_data.is_empty() {
226 bail!("Preview returned empty PNG data");
227 }
228 smol::fs::write(output_path, &png_data).await?;
229 Ok(())
230 }
231 .await;
232
233 match result {
234 Ok(()) => {
235 session.detach();
236 Ok(())
237 }
238 Err(err) => match session.shutdown().await {
239 Ok(()) => Err(err),
240 Err(shutdown_error) => Err(err.wrap_err(format!(
241 "preview support app shutdown also failed: {shutdown_error}"
242 ))),
243 },
244 }
245 }
246}
247
248impl Tool for PreviewTool {
249 type Arguments = PreviewArgs;
250 type Res = ToolResult;
251
252 fn name(&self) -> Cow<'static, str> {
253 "preview".into()
254 }
255
256 async fn call(&self, args: Self::Arguments) -> aither_core::Result<Self::Res> {
257 Ok(self.render(args).await)
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 #[test]
266 fn args_default_to_water_preview_defaults() {
267 let args: PreviewArgs =
268 serde_json::from_str(r#"{"target": "views::home"}"#).expect("minimal args parse");
269 assert_eq!(args.target, "views::home");
270 assert!(!args.expr);
271 assert_eq!(args.frame, None);
272 assert_eq!(args.backend, None);
273 assert_eq!(args.theme, None);
274 assert_eq!(args.platform, None);
275 }
276
277 #[test]
278 fn args_parse_all_fields() {
279 let args: PreviewArgs = serde_json::from_str(
280 r#"{
281 "target": "text(\"hi\")",
282 "expr": true,
283 "frame": "800x600",
284 "backend": "hydrolysis",
285 "theme": "material3",
286 "platform": "macos"
287 }"#,
288 )
289 .expect("full args parse");
290 assert!(args.expr);
291 assert_eq!(args.frame.as_deref(), Some("800x600"));
292 assert_eq!(args.backend, Some(CliPreviewBackend::Hydrolysis));
293 assert_eq!(args.theme, Some(CliHydrolysisPreviewTheme::Material3));
294 assert_eq!(args.platform, Some(CliPreviewPlatform::Macos));
295 }
296
297 #[test]
298 fn sanitize_collapses_unsafe_runs() {
299 assert_eq!(sanitize_target_name("views::home"), "views_home");
300 assert_eq!(sanitize_target_name("text(\"hello\")"), "text_hello_");
301 assert_eq!(sanitize_target_name("a.b-c_d"), "a.b-c_d");
302 assert_eq!(sanitize_target_name("**"), "_");
303 }
304
305 #[test]
306 fn resolves_to_the_same_request_as_water_preview() {
307 let args: PreviewArgs = serde_json::from_str(
310 r#"{
311 "target": "text(\"hi\")",
312 "expr": true,
313 "frame": "800x600",
314 "backend": "hydrolysis",
315 "theme": "material3",
316 "platform": "macos"
317 }"#,
318 )
319 .expect("args parse");
320 let request = args.resolve("demo_app").expect("resolve");
321 assert_eq!(
322 request,
323 PreviewRequest {
324 platform: CliPreviewPlatform::Macos,
325 backend: CliPreviewBackend::Hydrolysis,
326 hydrolysis_theme: Some(crate::preview::HydrolysisPreviewTheme::Material3),
327 target: PreviewTarget::Expression {
328 expression: "text(\"hi\")".to_string(),
329 },
330 width: 800.0,
331 height: 600.0,
332 }
333 );
334 }
335}