1use 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
21pub const DEFAULT_FRAME: &str = "375x667";
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Deserialize, JsonSchema)]
27#[serde(rename_all = "lowercase")]
28pub enum CliPreviewPlatform {
29 Ios,
31 Macos,
33 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#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Deserialize, JsonSchema)]
49#[serde(rename_all = "lowercase")]
50pub enum CliPreviewBackend {
51 Apple,
53 Android,
55 Hydrolysis,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Deserialize, JsonSchema)]
61#[serde(rename_all = "lowercase")]
62pub enum CliHydrolysisPreviewTheme {
63 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#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum PreviewTarget {
79 Function {
81 function_path: String,
83 symbol: String,
85 },
86 Expression {
88 expression: String,
90 },
91}
92
93impl PreviewTarget {
94 #[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 #[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#[derive(Debug, Clone, PartialEq)]
116pub struct PreviewRequest {
117 pub platform: CliPreviewPlatform,
119 pub backend: CliPreviewBackend,
121 pub hydrolysis_theme: Option<HydrolysisPreviewTheme>,
124 pub target: PreviewTarget,
126 pub width: f32,
128 pub height: f32,
130}
131
132pub 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#[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
198pub 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
231pub 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#[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!(
268 "No native preview platform is configured for this host. Pass `--platform` explicitly."
269 );
270 }
271}
272
273pub 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
284pub 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
308pub 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
345pub 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}