Skip to main content

waterui_cli/preview/
request.rs

1//! Shared preview request resolution for `water preview` and the MCP
2//! `preview` tool.
3//!
4//! Both entry points accept the same arguments — a `#[preview]` function path
5//! or expression target, a frame size, and optional platform/backend/theme
6//! overrides — and resolve them through the functions here, so the two can
7//! never drift apart.
8
9use clap::ValueEnum;
10use eyre::{Result, bail};
11use schemars::JsonSchema;
12use serde::Deserialize;
13
14use crate::apple::toolchain::AppleSdk;
15use crate::preview::protocol::{AppError, DylibId, function_path_to_symbol};
16use crate::preview::{
17    HydrolysisPreviewSource, HydrolysisPreviewTheme, PreviewPlatform, PreviewSession,
18};
19use crate::toolchain_checks;
20
21/// Default frame size shared by `water preview --frame` and the MCP `preview`
22/// tool's `frame` argument.
23pub const DEFAULT_FRAME: &str = "375x667";
24
25/// Target platform for preview.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Deserialize, JsonSchema)]
27#[serde(rename_all = "lowercase")]
28pub enum CliPreviewPlatform {
29    /// iOS Simulator.
30    Ios,
31    /// macOS.
32    Macos,
33    /// Android Emulator.
34    Android,
35}
36
37impl From<CliPreviewPlatform> for PreviewPlatform {
38    fn from(p: CliPreviewPlatform) -> Self {
39        match p {
40            CliPreviewPlatform::Ios => Self::IosSimulator,
41            CliPreviewPlatform::Macos => Self::Macos,
42            CliPreviewPlatform::Android => Self::Android,
43        }
44    }
45}
46
47/// Rendering backend for preview.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Deserialize, JsonSchema)]
49#[serde(rename_all = "lowercase")]
50pub enum CliPreviewBackend {
51    /// Apple preview support app.
52    Apple,
53    /// Android preview support app.
54    Android,
55    /// Hydrolysis direct renderer.
56    Hydrolysis,
57}
58
59/// Theme package for Hydrolysis preview.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Deserialize, JsonSchema)]
61#[serde(rename_all = "lowercase")]
62pub enum CliHydrolysisPreviewTheme {
63    /// Material Design 3 theme package.
64    Material3,
65}
66
67impl From<CliHydrolysisPreviewTheme> for HydrolysisPreviewTheme {
68    fn from(value: CliHydrolysisPreviewTheme) -> Self {
69        match value {
70            CliHydrolysisPreviewTheme::Material3 => Self::Material3,
71        }
72    }
73}
74
75/// What a preview render draws: a `#[preview]` function exported from the
76/// project crate, or an inline `WaterUI` expression.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum PreviewTarget {
79    /// A `#[preview]` function: its crate-relative path and export symbol.
80    Function {
81        /// Function path as written, e.g. `views::home`.
82        function_path: String,
83        /// Export symbol the preview machinery looks up.
84        symbol: String,
85    },
86    /// An inline `WaterUI` expression returning `impl View`.
87    Expression {
88        /// The expression source, e.g. `text("hello")`.
89        expression: String,
90    },
91}
92
93impl PreviewTarget {
94    /// Human-readable target name for logs and output file names.
95    #[must_use]
96    pub fn display_name(&self) -> &str {
97        match self {
98            Self::Function { symbol, .. } => symbol,
99            Self::Expression { expression } => expression,
100        }
101    }
102
103    /// The [`HydrolysisPreviewSource`] for this target.
104    #[must_use]
105    pub fn hydrolysis_source(&self) -> HydrolysisPreviewSource<'_> {
106        match self {
107            Self::Function { symbol, .. } => HydrolysisPreviewSource::Symbol(symbol),
108            Self::Expression { expression } => HydrolysisPreviewSource::Expression(expression),
109        }
110    }
111}
112
113/// A fully resolved preview render, ready to hand to the Hydrolysis or
114/// support-app execution path.
115#[derive(Debug, Clone, PartialEq)]
116pub struct PreviewRequest {
117    /// Resolved target platform.
118    pub platform: CliPreviewPlatform,
119    /// Resolved rendering backend.
120    pub backend: CliPreviewBackend,
121    /// Hydrolysis theme package — `Some` iff `backend` is
122    /// [`CliPreviewBackend::Hydrolysis`].
123    pub hydrolysis_theme: Option<HydrolysisPreviewTheme>,
124    /// What to render.
125    pub target: PreviewTarget,
126    /// Frame width in logical units.
127    pub width: f32,
128    /// Frame height in logical units.
129    pub height: f32,
130}
131
132/// Parse frame size from a `WIDTHxHEIGHT` string.
133///
134/// # Errors
135/// Returns an error if the format is wrong or a dimension is not a positive
136/// finite number.
137pub fn parse_frame(s: &str) -> Result<(f32, f32)> {
138    let parts: Vec<&str> = s.split('x').collect();
139    if parts.len() != 2 {
140        bail!("Invalid frame format: expected WIDTHxHEIGHT (e.g., 375x667)");
141    }
142
143    let width: f32 = parts[0]
144        .parse()
145        .map_err(|_| eyre::eyre!("Invalid frame width"))?;
146    let height: f32 = parts[1]
147        .parse()
148        .map_err(|_| eyre::eyre!("Invalid frame height"))?;
149
150    if !width.is_finite() || width <= 0.0 {
151        bail!("Invalid frame width: must be a positive finite number");
152    }
153    if !height.is_finite() || height <= 0.0 {
154        bail!("Invalid frame height: must be a positive finite number");
155    }
156
157    Ok((width, height))
158}
159
160/// Resolve the preview target: `expr` forces expression mode, and a target
161/// that is not a Rust path is treated as an expression either way.
162#[must_use]
163pub fn resolve_preview_target(
164    crate_name: &str,
165    target: &str,
166    force_expression: bool,
167) -> PreviewTarget {
168    if force_expression || !is_function_path(target) {
169        return PreviewTarget::Expression {
170            expression: target.to_string(),
171        };
172    }
173
174    PreviewTarget::Function {
175        function_path: target.to_string(),
176        symbol: function_path_to_symbol(crate_name, target),
177    }
178}
179
180fn is_function_path(target: &str) -> bool {
181    let mut segments = target.split("::").peekable();
182    if segments.peek().is_none() {
183        return false;
184    }
185
186    segments.all(is_rust_ident)
187}
188
189fn is_rust_ident(segment: &str) -> bool {
190    let mut chars = segment.chars();
191    let Some(first) = chars.next() else {
192        return false;
193    };
194    (first == '_' || first.is_ascii_alphabetic())
195        && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
196}
197
198/// Resolve the rendering backend for a platform, applying the override when
199/// given.
200///
201/// # Errors
202/// Returns an error if the backend does not support the platform.
203pub fn resolve_preview_backend(
204    platform: CliPreviewPlatform,
205    backend_override: Option<CliPreviewBackend>,
206) -> Result<CliPreviewBackend> {
207    let default_backend = match platform {
208        CliPreviewPlatform::Ios | CliPreviewPlatform::Macos => CliPreviewBackend::Apple,
209        CliPreviewPlatform::Android => CliPreviewBackend::Android,
210    };
211
212    let backend = backend_override.unwrap_or(default_backend);
213    let supported = matches!(
214        (platform, backend),
215        (
216            CliPreviewPlatform::Ios | CliPreviewPlatform::Macos,
217            CliPreviewBackend::Apple
218        ) | (CliPreviewPlatform::Macos, CliPreviewBackend::Hydrolysis)
219            | (CliPreviewPlatform::Android, CliPreviewBackend::Android)
220    );
221    if !supported {
222        bail!(
223            "Preview backend {:?} does not support platform {:?}. Valid combinations: ios/apple, macos/apple, macos/hydrolysis, android/android",
224            backend,
225            platform
226        );
227    }
228    Ok(backend)
229}
230
231/// Resolve the preview platform, defaulting to this host's native preview
232/// platform.
233///
234/// # Errors
235/// Returns an error on hosts with no native preview platform when no override
236/// is given.
237pub fn resolve_preview_platform(
238    platform_override: Option<CliPreviewPlatform>,
239) -> Result<CliPreviewPlatform> {
240    if let Some(platform) = platform_override {
241        return Ok(platform);
242    }
243    native_preview_platform()
244}
245
246// Both lints are host-dependent, so neither `expect` can be fulfilled everywhere:
247// on macOS the body is an infallible `const`-compatible `Ok`, while every other host
248// bails at runtime with an unsupported-host error.
249#[allow(
250    clippy::unnecessary_wraps,
251    reason = "non-macOS hosts return an explicit unsupported-host error"
252)]
253#[allow(
254    clippy::missing_const_for_fn,
255    reason = "non-macOS hosts call the non-const `bail!`"
256)]
257fn native_preview_platform() -> Result<CliPreviewPlatform> {
258    #[cfg(target_os = "macos")]
259    {
260        Ok(CliPreviewPlatform::Macos)
261    }
262
263    #[cfg(not(target_os = "macos"))]
264    {
265        // `bail!` expands to a `return`, so the trailing semicolon keeps this a
266        // statement rather than a macro invocation in expression position.
267        bail!(
268            "No native preview platform is configured for this host. Pass `--platform` explicitly."
269        );
270    }
271}
272
273/// `water preview test` supports Hydrolysis on macOS only.
274///
275/// # Errors
276/// Returns an error for any other platform.
277pub fn ensure_hydrolysis_preview_platform(platform: CliPreviewPlatform) -> Result<()> {
278    if platform != CliPreviewPlatform::Macos {
279        bail!("`water preview test` supports Hydrolysis on macos only.");
280    }
281    Ok(())
282}
283
284/// Resolve the Hydrolysis theme: required for the Hydrolysis backend,
285/// rejected for the others.
286///
287/// # Errors
288/// Returns an error if the theme is missing for Hydrolysis or set for another
289/// backend.
290pub fn resolve_hydrolysis_preview_theme(
291    backend: CliPreviewBackend,
292    theme: Option<CliHydrolysisPreviewTheme>,
293) -> Result<Option<HydrolysisPreviewTheme>> {
294    match (backend, theme) {
295        (CliPreviewBackend::Hydrolysis, Some(theme)) => Ok(Some(theme.into())),
296        (CliPreviewBackend::Hydrolysis, None) => {
297            bail!(
298                "Hydrolysis preview requires an explicit theme package. Pass `--theme material3`."
299            );
300        }
301        (_, Some(_)) => {
302            bail!("`--theme` is only supported with `--backend hydrolysis`.");
303        }
304        (_, None) => Ok(None),
305    }
306}
307
308/// Check the host toolchain required by the resolved backend.
309///
310/// # Errors
311/// Returns an error if a required toolchain component is missing.
312pub async fn check_toolchain_for_backend(
313    platform: CliPreviewPlatform,
314    backend: CliPreviewBackend,
315) -> Result<()> {
316    let host = crate::toolchain::Host::current();
317    match backend {
318        CliPreviewBackend::Apple => {
319            let sdk = match platform {
320                CliPreviewPlatform::Ios => AppleSdk::IosSimulator,
321                CliPreviewPlatform::Macos => AppleSdk::Macos,
322                CliPreviewPlatform::Android => {
323                    bail!("Internal error: Apple preview backend is not supported on android");
324                }
325            };
326            toolchain_checks::check_apple(&host, sdk).await?;
327        }
328        CliPreviewBackend::Android => {
329            if platform != CliPreviewPlatform::Android {
330                bail!("Internal error: Android preview backend is not supported on {platform:?}");
331            }
332            toolchain_checks::check_android_run(&host).await?;
333        }
334        CliPreviewBackend::Hydrolysis => {
335            if platform != CliPreviewPlatform::Macos {
336                bail!(
337                    "Internal error: Hydrolysis preview backend is not supported on {platform:?}"
338                );
339            }
340        }
341    }
342    Ok(())
343}
344
345/// Render `symbol` through the support-app session, translating a missing
346/// export into an actionable `#[preview]` hint.
347///
348/// # Errors
349/// Returns an error if the preview app rejects the render or the transport
350/// fails.
351pub async fn render_with_symbol(
352    session: &mut PreviewSession,
353    function_path: &str,
354    symbol: &str,
355    dylib_id: DylibId,
356    dylib_path: &std::path::Path,
357    width: f32,
358    height: f32,
359) -> Result<Vec<u8>> {
360    let prefer_local_path = session.platform == PreviewPlatform::Macos;
361    match session
362        .client
363        .render_with_dylib_file(
364            dylib_id,
365            dylib_path,
366            symbol,
367            width,
368            height,
369            prefer_local_path,
370        )
371        .await
372    {
373        Ok(data) => Ok(data),
374        Err(AppError::SymbolNotFound(_)) => {
375            bail!("{}", missing_preview_symbol_message(function_path, symbol));
376        }
377        Err(err) => {
378            bail!("Preview app error: {err}");
379        }
380    }
381}
382
383fn missing_preview_symbol_message(function_path: &str, symbol: &str) -> String {
384    format!(
385        "Preview component not found: `{function_path}`\nExpected export symbol: `{symbol}`\n\
386The preview function is likely missing `#[preview]` (or the name is wrong).\n\
387Example:\n  #[preview]\n  fn {}() -> impl View {{ ... }}",
388        function_path.rsplit("::").next().unwrap_or(function_path)
389    )
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    #[test]
397    fn formats_missing_preview_symbol_message() {
398        let symbol = "waterui_preview_app_card_preview";
399        let message = missing_preview_symbol_message("dashboard::admin::card_preview", symbol);
400        assert!(message.contains("dashboard::admin::card_preview"));
401        assert!(message.contains("waterui_preview_app_card_preview"));
402        assert!(message.contains("#[preview]"));
403        assert!(message.contains("fn card_preview()"));
404    }
405}