use std::path::{Path, PathBuf};
use clap::{Args as ClapArgs, Subcommand};
use eyre::{Result, bail};
use serde::Deserialize;
use crate::shell::Shell;
use crate::{error, header, note, success};
use waterui_cli::artifact_symbols::{ArtifactSymbols, build_host_rlib};
use waterui_cli::build::BuildProgress;
use waterui_cli::mcp::preview::PreviewArgs;
use waterui_cli::preview::request::{
self, CliHydrolysisPreviewTheme, CliPreviewBackend, CliPreviewPlatform, PreviewTarget,
};
use waterui_cli::preview::{
HydrolysisPreviewEventKind, HydrolysisPreviewPointerButton, HydrolysisPreviewRequest,
HydrolysisPreviewScenario, HydrolysisPreviewScenarioEvent, PreviewPlatform,
launch_preview_session, render_preview_with_hydrolysis, test_preview_with_hydrolysis,
};
use waterui_cli::project::read_project_crate_name;
async fn run_preview_test(shell: &Shell, args: PreviewTestArgs) -> Result<()> {
let platform = request::resolve_preview_platform(args.platform)?;
request::ensure_hydrolysis_preview_platform(platform)?;
let (width, height) = request::parse_frame(&args.frame)?;
let project_path = crate::project_path::canonicalize(&args.path)?;
let crate_name = read_project_crate_name(&project_path).await?;
let sccache_path =
super::detect_sccache_path(shell, &waterui_cli::toolchain::Host::current()).await;
let targets = resolve_test_targets(
&project_path,
&crate_name,
args.target.as_deref(),
args.expr,
args.all,
sccache_path.as_deref(),
Some(&shell.build_progress()),
)
.await?;
let automation_body = load_automation_body(
args.code.as_deref(),
args.code_file.as_deref(),
"",
"`water preview test`",
)
.await?;
for target in targets {
header!(shell, "Preview test: {}", target.display_name());
let spinner = shell.spinner("Building and testing with hydrolysis...");
let output = test_preview_with_hydrolysis(
HydrolysisPreviewRequest {
project_path: &project_path,
source: target.hydrolysis_source(),
theme: args.theme.into(),
width,
height,
sccache_path: sccache_path.clone(),
progress: Some(shell.build_progress()),
},
&automation_body,
)
.await?;
if let Some(s) = spinner {
s.finish_and_clear();
}
emit_child_output(shell, &output);
success!(
shell,
"Preview semantic test passed: {}",
target.display_name()
);
}
Ok(())
}
#[derive(ClapArgs, Debug)]
#[command(args_conflicts_with_subcommands = true)]
pub struct Args {
#[command(subcommand)]
command: Option<PreviewCommand>,
target: Option<String>,
#[arg(long)]
expr: bool,
#[arg(short, long, value_enum)]
platform: Option<CliPreviewPlatform>,
#[arg(long, value_enum)]
backend: Option<CliPreviewBackend>,
#[arg(long, value_enum)]
theme: Option<CliHydrolysisPreviewTheme>,
#[arg(short, long, default_value = request::DEFAULT_FRAME)]
frame: String,
#[arg(short, long, default_value = "preview.png")]
output: PathBuf,
#[arg(long)]
scenario: Option<PathBuf>,
#[arg(long)]
output_dir: Option<PathBuf>,
#[arg(long, default_value = ".")]
path: PathBuf,
}
impl Args {
fn preview_args(&self, target: &str) -> PreviewArgs {
PreviewArgs {
target: target.to_string(),
expr: self.expr,
frame: Some(self.frame.clone()),
backend: self.backend,
theme: self.theme,
platform: self.platform,
}
}
}
#[derive(Subcommand, Debug)]
enum PreviewCommand {
Test(PreviewTestArgs),
}
#[derive(ClapArgs, Debug)]
struct PreviewTestArgs {
target: Option<String>,
#[arg(long)]
all: bool,
#[arg(long)]
expr: bool,
#[arg(short, long, value_enum)]
platform: Option<CliPreviewPlatform>,
#[arg(long, value_enum)]
theme: CliHydrolysisPreviewTheme,
#[arg(short, long, default_value = "375x667")]
frame: String,
#[arg(long)]
code: Option<String>,
#[arg(long)]
code_file: Option<PathBuf>,
#[arg(long, default_value = ".")]
path: PathBuf,
}
#[expect(
clippy::too_many_lines,
reason = "keeps preview command dispatch and support-app cleanup in one linear lifecycle"
)]
pub async fn run(shell: &Shell, args: Args) -> Result<()> {
match args.command {
Some(PreviewCommand::Test(args)) => return run_preview_test(shell, args).await,
None => {}
}
let Some(target) = args.target.as_deref() else {
bail!(
"`water preview` requires a target. Use `water preview <target>` or `water preview test`."
);
};
let project_path = crate::project_path::canonicalize(&args.path)?;
let crate_name = read_project_crate_name(&project_path).await?;
let request = args.preview_args(target).resolve(&crate_name)?;
header!(shell, "Preview: {}", request.target.display_name());
request::check_toolchain_for_backend(request.platform, request.backend).await?;
let sccache_path =
super::detect_sccache_path(shell, &waterui_cli::toolchain::Host::current()).await;
if request.backend == CliPreviewBackend::Hydrolysis {
let scenario = load_hydrolysis_scenario(args.scenario.as_deref(), args.output_dir).await?;
let spinner = shell.spinner("Building and rendering with hydrolysis...");
render_preview_with_hydrolysis(
HydrolysisPreviewRequest {
project_path: &project_path,
source: request.target.hydrolysis_source(),
theme: request
.hydrolysis_theme
.expect("hydrolysis preview theme must be resolved"),
width: request.width,
height: request.height,
sccache_path,
progress: Some(shell.build_progress()),
},
&args.output,
scenario.as_ref(),
)
.await?;
if let Some(s) = spinner {
s.finish_and_clear();
}
if let Some(scenario) = scenario {
success!(
shell,
"Preview frames saved to {}",
scenario.output_dir.display()
);
} else {
success!(shell, "Preview saved to {}", args.output.display());
}
return Ok(());
}
if args.scenario.is_some() || args.output_dir.is_some() {
bail!("`--scenario` and `--output-dir` are supported only with `--backend hydrolysis`.");
}
let PreviewTarget::Function {
function_path,
symbol,
} = &request.target
else {
bail!("Expression preview is currently supported only with `--backend hydrolysis`.");
};
let spinner = shell.spinner("Connecting to preview app...");
let preview_platform: PreviewPlatform = request.platform.into();
let mut session = launch_preview_session(
&project_path,
preview_platform,
sccache_path.clone(),
Some(shell.build_progress()),
)
.await?;
if let Some(s) = spinner {
s.finish_and_clear();
}
let result = async {
let spinner = shell.spinner("Building project...");
let dylib = session.build_dylib(&project_path).await?;
if let Some(s) = spinner {
s.finish_and_clear();
}
let spinner = shell.spinner("Rendering view...");
let png_data = request::render_with_symbol(
&mut session,
function_path,
symbol,
dylib.id,
&dylib.path,
request.width,
request.height,
)
.await?;
if let Some(s) = spinner {
s.finish_and_clear();
}
if png_data.is_empty() {
error!(shell, "Preview returned empty PNG data");
bail!("Preview returned empty PNG data");
}
smol::fs::write(&args.output, &png_data).await?;
success!(shell, "Preview saved to {}", args.output.display());
Ok(())
}
.await;
match result {
Ok(()) => {
session.detach();
Ok(())
}
Err(err) => {
match session.shutdown().await {
Ok(()) => Err(err),
Err(shutdown_error) => Err(err.wrap_err(format!(
"preview support app shutdown also failed: {shutdown_error}"
))),
}
}
}
}
#[derive(Debug, Deserialize)]
struct ScenarioFile {
captures_ms: Vec<u64>,
#[serde(default)]
events: Vec<ScenarioEventFile>,
}
#[derive(Debug, Deserialize)]
struct ScenarioEventFile {
at_ms: u64,
kind: String,
x: Option<f32>,
y: Option<f32>,
button: Option<String>,
dx: Option<f32>,
dy: Option<f32>,
is_line_delta: Option<bool>,
}
async fn load_hydrolysis_scenario(
scenario_path: Option<&std::path::Path>,
output_dir: Option<PathBuf>,
) -> Result<Option<HydrolysisPreviewScenario>> {
let Some(scenario_path) = scenario_path else {
if output_dir.is_some() {
bail!("`--output-dir` requires `--scenario`.");
}
return Ok(None);
};
let Some(output_dir) = output_dir else {
bail!("`--scenario` requires `--output-dir`.");
};
let source = smol::fs::read_to_string(scenario_path).await?;
let mut scenario: ScenarioFile = toml::from_str(&source)?;
if scenario.captures_ms.is_empty() {
bail!("Hydrolysis preview scenario must contain at least one capture timestamp.");
}
scenario.captures_ms.sort_unstable();
let capture_count = scenario.captures_ms.len();
scenario.captures_ms.dedup();
if scenario.captures_ms.len() != capture_count {
bail!("Hydrolysis preview scenario capture timestamps must be unique.");
}
let mut events = scenario
.events
.iter()
.map(parse_scenario_event)
.collect::<Result<Vec<_>>>()?;
events.sort_by_key(|event| event.at_ms);
Ok(Some(HydrolysisPreviewScenario {
captures_ms: scenario.captures_ms,
events,
output_dir,
}))
}
fn parse_scenario_event(event: &ScenarioEventFile) -> Result<HydrolysisPreviewScenarioEvent> {
let kind = match event.kind.as_str() {
"pointer_move" | "hover" => HydrolysisPreviewEventKind::PointerMove,
"pointer_down" => HydrolysisPreviewEventKind::PointerDown,
"pointer_up" => HydrolysisPreviewEventKind::PointerUp,
"pointer_cancel" => HydrolysisPreviewEventKind::PointerCancel,
"scroll" | "wheel" => HydrolysisPreviewEventKind::Scroll,
other => {
bail!("unsupported Hydrolysis preview scenario event kind `{other}`");
}
};
let button = event
.button
.as_deref()
.map(|button| match button {
"primary" => Ok(HydrolysisPreviewPointerButton::Primary),
"secondary" => Ok(HydrolysisPreviewPointerButton::Secondary),
"middle" => Ok(HydrolysisPreviewPointerButton::Middle),
other => {
bail!("unsupported Hydrolysis preview pointer button `{other}`");
}
})
.transpose()?
.unwrap_or_default();
let needs_point = !matches!(kind, HydrolysisPreviewEventKind::PointerCancel);
let x = match event.x {
Some(x) => x,
None if needs_point => {
bail!("Hydrolysis preview scenario event requires x coordinate");
}
None => 0.0,
};
let y = match event.y {
Some(y) => y,
None if needs_point => {
bail!("Hydrolysis preview scenario event requires y coordinate");
}
None => 0.0,
};
let dx = event.dx.unwrap_or(0.0);
let dy = event.dy.unwrap_or(0.0);
if matches!(kind, HydrolysisPreviewEventKind::Scroll)
&& dx.abs() <= f32::EPSILON
&& dy.abs() <= f32::EPSILON
{
bail!("Hydrolysis preview scroll event requires non-zero dx or dy");
}
Ok(HydrolysisPreviewScenarioEvent {
at_ms: event.at_ms,
kind,
x,
y,
button,
dx,
dy,
is_line_delta: event.is_line_delta.unwrap_or(false),
})
}
async fn resolve_test_targets(
project_path: &Path,
crate_name: &str,
target: Option<&str>,
force_expression: bool,
all: bool,
sccache_path: Option<&Path>,
progress: Option<&BuildProgress>,
) -> Result<Vec<PreviewTarget>> {
match (all, target) {
(true, Some(_)) => {
bail!("`--all` cannot be combined with an explicit preview target.");
}
(true, None) if force_expression => {
bail!("`--all` cannot be combined with `--expr`.");
}
(true, None) => {
discover_preview_targets(project_path, crate_name, sccache_path, progress).await
}
(false, Some(target)) => {
if force_expression {
Ok(vec![PreviewTarget::Expression {
expression: target.to_string(),
}])
} else {
Ok(vec![request::resolve_preview_target(
crate_name, target, false,
)])
}
}
(false, None) => {
bail!("preview test requires a target or `--all`.");
}
}
}
async fn discover_preview_targets(
project_path: &Path,
crate_name: &str,
sccache_path: Option<&Path>,
progress: Option<&BuildProgress>,
) -> Result<Vec<PreviewTarget>> {
let rlib = build_host_rlib(
project_path,
&waterui_cli::water_dir::shared_host_target_dir().await?,
sccache_path,
progress,
)
.await?;
let symbols = ArtifactSymbols::read(&rlib)?;
let prefix = format!("waterui_preview_{}_", crate_name.replace('-', "_"));
let symbols_found = symbols.leaves_with_prefix(&prefix);
if symbols_found.is_empty() {
bail!("no `waterui_preview_*` exports found in {}", rlib.display());
}
Ok(symbols_found
.into_iter()
.map(|symbol| PreviewTarget::Function {
function_path: symbol[prefix.len()..].to_string(),
symbol,
})
.collect())
}
async fn load_automation_body(
code: Option<&str>,
code_file: Option<&Path>,
default_body: &str,
command_name: &str,
) -> Result<String> {
match (code, code_file) {
(Some(_), Some(_)) => {
bail!("{command_name} accepts either `--code` or `--code-file`, not both.");
}
(Some(code), None) => Ok(code.to_string()),
(None, Some(path)) => smol::fs::read_to_string(path).await.map_err(Into::into),
(None, None) => Ok(default_body.to_string()),
}
}
fn emit_child_output(shell: &Shell, output: &str) {
for line in output.lines().filter(|line| !line.trim().is_empty()) {
note!(shell, "{line}");
}
}
#[cfg(test)]
mod tests {
use clap::Parser;
use super::*;
#[derive(Parser)]
struct PreviewCommandLine {
#[command(flatten)]
args: Args,
}
fn parse(args: &[&str]) -> Args {
PreviewCommandLine::try_parse_from(args)
.expect("args parse")
.args
}
#[test]
fn cli_and_mcp_args_resolve_to_the_same_request() {
let cli_args = parse(&[
"preview",
"text(\"hi\")",
"--expr",
"--frame",
"800x600",
"--backend",
"hydrolysis",
"--theme",
"material3",
"--platform",
"macos",
]);
let cli_request = cli_args
.preview_args(cli_args.target.as_deref().expect("target"))
.resolve("demo_app")
.expect("cli resolve");
let mcp_args: PreviewArgs = serde_json::from_str(
r#"{
"target": "text(\"hi\")",
"expr": true,
"frame": "800x600",
"backend": "hydrolysis",
"theme": "material3",
"platform": "macos"
}"#,
)
.expect("mcp args parse");
let mcp_request = mcp_args.resolve("demo_app").expect("mcp resolve");
assert_eq!(cli_request, mcp_request);
}
#[test]
fn cli_and_mcp_defaults_resolve_to_the_same_request() {
let cli_args = parse(&["preview", "views::home", "--platform", "macos"]);
let cli_request = cli_args
.preview_args(cli_args.target.as_deref().expect("target"))
.resolve("demo_app")
.expect("cli resolve");
let mcp_args: PreviewArgs =
serde_json::from_str(r#"{"target": "views::home", "platform": "macos"}"#)
.expect("mcp args parse");
let mcp_request = mcp_args.resolve("demo_app").expect("mcp resolve");
assert_eq!(cli_request, mcp_request);
}
#[test]
fn rejects_non_positive_frame_values() {
assert!(request::parse_frame("0x100").is_err());
assert!(request::parse_frame("-1x100").is_err());
assert!(request::parse_frame("100x0").is_err());
assert!(request::parse_frame("100x-1").is_err());
}
#[test]
fn rejects_non_finite_frame_values() {
assert!(request::parse_frame("NaNx100").is_err());
assert!(request::parse_frame("100xinf").is_err());
}
#[test]
fn resolves_plain_path_as_preview_function() {
let target = request::resolve_preview_target("my-crate", "dashboard::card", false);
let PreviewTarget::Function {
function_path,
symbol,
} = target
else {
panic!("expected function target");
};
assert_eq!(function_path, "dashboard::card");
assert_eq!(symbol, "waterui_preview_my_crate_card");
}
#[test]
fn resolves_expression_syntax_as_expression_preview() {
let target = request::resolve_preview_target("my-crate", "button(\"Save\")", false);
let PreviewTarget::Expression { expression } = target else {
panic!("expected expression target");
};
assert_eq!(expression, "button(\"Save\")");
}
#[test]
fn expr_flag_forces_identifier_as_expression_preview() {
let target = request::resolve_preview_target("my-crate", "main_view", true);
let PreviewTarget::Expression { expression } = target else {
panic!("expected expression target");
};
assert_eq!(expression, "main_view");
}
#[test]
fn hydrolysis_preview_requires_explicit_theme() {
let result = request::resolve_hydrolysis_preview_theme(CliPreviewBackend::Hydrolysis, None);
assert!(result.is_err());
}
#[test]
fn rejects_theme_for_non_hydrolysis_preview() {
let result = request::resolve_hydrolysis_preview_theme(
CliPreviewBackend::Apple,
Some(CliHydrolysisPreviewTheme::Material3),
);
assert!(result.is_err());
}
}