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 progress: None,
173 },
174 &output_path,
175 None,
176 )
177 .await?;
178 }
179 CliPreviewBackend::Apple | CliPreviewBackend::Android => {
180 let PreviewTarget::Function {
181 function_path,
182 symbol,
183 } = &request.target
184 else {
185 bail!(
186 "Expression preview is currently supported only with the `hydrolysis` backend."
187 );
188 };
189 self.render_support_app(&request, function_path, symbol, &output_path)
190 .await?;
191 }
192 }
193
194 let bytes = smol::fs::read(&output_path).await?;
195 Ok((output_path, bytes))
196 }
197
198 async fn render_support_app(
203 &self,
204 request: &PreviewRequest,
205 function_path: &str,
206 symbol: &str,
207 output_path: &Path,
208 ) -> Result<()> {
209 let mut session = launch_preview_session(
210 &self.project_path,
211 request.platform.into(),
212 self.sccache_path.clone(),
213 None,
214 )
215 .await?;
216
217 let result = async {
218 let dylib = session.build_dylib(&self.project_path).await?;
219 let png_data = request::render_with_symbol(
220 &mut session,
221 function_path,
222 symbol,
223 dylib.id,
224 &dylib.path,
225 request.width,
226 request.height,
227 )
228 .await?;
229 if png_data.is_empty() {
230 bail!("Preview returned empty PNG data");
231 }
232 smol::fs::write(output_path, &png_data).await?;
233 Ok(())
234 }
235 .await;
236
237 match result {
238 Ok(()) => {
239 session.detach();
240 Ok(())
241 }
242 Err(err) => match session.shutdown().await {
243 Ok(()) => Err(err),
244 Err(shutdown_error) => Err(err.wrap_err(format!(
245 "preview support app shutdown also failed: {shutdown_error}"
246 ))),
247 },
248 }
249 }
250}
251
252impl Tool for PreviewTool {
253 type Arguments = PreviewArgs;
254 type Res = ToolResult;
255
256 fn name(&self) -> Cow<'static, str> {
257 "preview".into()
258 }
259
260 async fn call(&self, args: Self::Arguments) -> aither_core::Result<Self::Res> {
261 Ok(self.render(args).await)
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 #[test]
270 fn args_default_to_water_preview_defaults() {
271 let args: PreviewArgs =
272 serde_json::from_str(r#"{"target": "views::home"}"#).expect("minimal args parse");
273 assert_eq!(args.target, "views::home");
274 assert!(!args.expr);
275 assert_eq!(args.frame, None);
276 assert_eq!(args.backend, None);
277 assert_eq!(args.theme, None);
278 assert_eq!(args.platform, None);
279 }
280
281 #[test]
282 fn args_parse_all_fields() {
283 let args: PreviewArgs = serde_json::from_str(
284 r#"{
285 "target": "text(\"hi\")",
286 "expr": true,
287 "frame": "800x600",
288 "backend": "hydrolysis",
289 "theme": "material3",
290 "platform": "macos"
291 }"#,
292 )
293 .expect("full args parse");
294 assert!(args.expr);
295 assert_eq!(args.frame.as_deref(), Some("800x600"));
296 assert_eq!(args.backend, Some(CliPreviewBackend::Hydrolysis));
297 assert_eq!(args.theme, Some(CliHydrolysisPreviewTheme::Material3));
298 assert_eq!(args.platform, Some(CliPreviewPlatform::Macos));
299 }
300
301 #[test]
302 fn sanitize_collapses_unsafe_runs() {
303 assert_eq!(sanitize_target_name("views::home"), "views_home");
304 assert_eq!(sanitize_target_name("text(\"hello\")"), "text_hello_");
305 assert_eq!(sanitize_target_name("a.b-c_d"), "a.b-c_d");
306 assert_eq!(sanitize_target_name("**"), "_");
307 }
308
309 #[test]
310 fn resolves_to_the_same_request_as_water_preview() {
311 let args: PreviewArgs = serde_json::from_str(
314 r#"{
315 "target": "text(\"hi\")",
316 "expr": true,
317 "frame": "800x600",
318 "backend": "hydrolysis",
319 "theme": "material3",
320 "platform": "macos"
321 }"#,
322 )
323 .expect("args parse");
324 let request = args.resolve("demo_app").expect("resolve");
325 assert_eq!(
326 request,
327 PreviewRequest {
328 platform: CliPreviewPlatform::Macos,
329 backend: CliPreviewBackend::Hydrolysis,
330 hydrolysis_theme: Some(crate::preview::HydrolysisPreviewTheme::Material3),
331 target: PreviewTarget::Expression {
332 expression: "text(\"hi\")".to_string(),
333 },
334 width: 800.0,
335 height: 600.0,
336 }
337 );
338 }
339}