Skip to main content

waterui_cli/mcp/
preview.rs

1//! The `preview` tool: renders a `#[preview]` function or `WaterUI`
2//! expression and returns the PNG straight to the model.
3//!
4//! This tool is served by the `water mcp` front itself — it is never
5//! forwarded to the app child, so it answers even while the app's first build
6//! is still compiling (the preview build and the app build serialize on
7//! Cargo's lock).
8
9use 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/// Render a `#[preview]` function or `WaterUI` expression to a PNG image.
28///
29/// Returns the rendered image directly; the PNG is also written under the
30/// project's managed `.water` build-cache directory.
31///
32/// The arguments mirror `water preview`: a function path such as
33/// `views::home`, or — with `expr` — an inline expression such as
34/// `text("hello")`.
35#[derive(Debug, Deserialize, JsonSchema)]
36pub struct PreviewArgs {
37    /// Preview target: a `#[preview]` function path (e.g. `views::home`) or,
38    /// with `expr`, a `WaterUI` expression returning `impl View`.
39    pub target: String,
40
41    /// Treat `target` as a `WaterUI` expression returning `impl View`
42    /// (default `false`). Expression targets require the `hydrolysis` backend.
43    #[serde(default)]
44    pub expr: bool,
45
46    /// Frame size `WIDTHxHEIGHT` (default `375x667`).
47    #[serde(default)]
48    pub frame: Option<String>,
49
50    /// Rendering backend: `apple`, `android`, or `hydrolysis`. Defaults to the
51    /// platform's native backend (`apple` on macOS/iOS, `android` on Android).
52    #[serde(default)]
53    pub backend: Option<CliPreviewBackend>,
54
55    /// Theme package for the `hydrolysis` backend (`material3`). Required when
56    /// `backend` is `hydrolysis`; rejected otherwise.
57    #[serde(default)]
58    pub theme: Option<CliHydrolysisPreviewTheme>,
59
60    /// Target platform: `ios`, `macos`, or `android`. Defaults to this host's
61    /// native preview platform.
62    #[serde(default)]
63    pub platform: Option<CliPreviewPlatform>,
64}
65
66impl PreviewArgs {
67    /// Resolves the shared [`PreviewRequest`] — the same construction
68    /// `water preview` applies to its clap arguments.
69    ///
70    /// # Errors
71    /// Returns an error for a malformed frame or an unsupported
72    /// platform/backend/theme combination.
73    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
91/// Collapse `target` to a file-name-safe form: `[A-Za-z0-9_.-]` characters are
92/// kept, every other run collapses to a single `_`.
93fn 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/// The CLI-served `preview` tool.
106#[derive(Debug)]
107pub struct PreviewTool {
108    project_path: PathBuf,
109    sccache_path: Option<PathBuf>,
110}
111
112impl PreviewTool {
113    /// Binds the tool to a project directory.
114    #[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    /// Renders the requested preview, writes the PNG under the project's
123    /// managed build cache, and returns it as image content.
124    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    /// The deterministic output path for a request:
135    /// `<build-cache container>/mcp/preview/<sanitized target>-<W>x<H>.png`.
136    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    /// The support-app render path shared with `water preview`: launch or
196    /// reuse the preview app, build the project dylib, render the symbol, and
197    /// write the PNG. The app is detached on success so the next call reuses
198    /// it, and shut down on failure so a broken app is never reused.
199    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        // `water preview --expr --frame 800x600 --backend hydrolysis --theme
308        // material3 --platform macos 'text("hi")'`
309        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}