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                        // MCP serves JSON-RPC over stdio — there is no
171                        // terminal sink to render compile progress into.
172                        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    /// The support-app render path shared with `water preview`: launch or
199    /// reuse the preview app, build the project dylib, render the symbol, and
200    /// write the PNG. The app is detached on success so the next call reuses
201    /// it, and shut down on failure so a broken app is never reused.
202    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        // `water preview --expr --frame 800x600 --backend hydrolysis --theme
312        // material3 --platform macos 'text("hi")'`
313        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}