Skip to main content

waterui_cli/project_model/
web.rs

1//! Web-frontend toolchain plumbing.
2//!
3//! The `[web]` manifest section, the package manager it declares, the
4//! frontend build that packaging runs, and the pure planning behind
5//! `water init`'s frontend decision tree.
6
7use std::io;
8use std::io::Write as _;
9use std::path::{Path, PathBuf};
10use std::process::Stdio;
11
12use askama::Template;
13use color_eyre::eyre::{self, Context, bail};
14use serde::{Deserialize, Serialize};
15use smol::process::Command;
16use waterui_assets_planner::{BUNDLE_META_PREFIX, BundleMountMeta};
17
18use crate::artifact_symbols::{ArtifactSymbols, build_host_rlib};
19use crate::build::BuildProgress;
20use crate::project::Project;
21use crate::project_model::templates::embedded;
22
23/// The JavaScript package manager a project declares in
24/// `[web] package_manager`.
25///
26/// This is the single source of truth for which executable the CLI invokes:
27/// a project that declares `pnpm` is never built with `bun`.
28#[derive(Deserialize, Serialize, Clone, Copy, PartialEq, Eq, Debug, Default, clap::ValueEnum)]
29#[serde(rename_all = "lowercase")]
30pub enum PackageManager {
31    /// Bun (`bun`).
32    #[default]
33    Bun,
34    /// pnpm.
35    Pnpm,
36    /// npm, the Node.js default.
37    Npm,
38    /// Yarn.
39    Yarn,
40}
41
42impl PackageManager {
43    /// The executable looked up on `PATH`.
44    #[must_use]
45    pub const fn binary(self) -> &'static str {
46        match self {
47            Self::Bun => "bun",
48            Self::Pnpm => "pnpm",
49            Self::Npm => "npm",
50            Self::Yarn => "yarn",
51        }
52    }
53
54    /// `<pm> run <script>` — every supported manager accepts this form.
55    #[must_use]
56    pub fn run(self, script: &str) -> Command {
57        let mut command = Command::new(self.binary());
58        command.arg("run").arg(script);
59        command
60    }
61
62    /// `<pm> install` — installs the dependencies of the current directory.
63    #[must_use]
64    pub fn install(self) -> Command {
65        let mut command = Command::new(self.binary());
66        command.arg("install");
67        command
68    }
69
70    /// `<pm> create vite <dir>`; npm names its initializer `vite@latest`.
71    ///
72    /// `template`, when given, is forwarded to `create-vite` after a `--`
73    /// separator (`--template <t>`), which every manager passes through and
74    /// which skips Vite's interactive framework picker.
75    #[must_use]
76    pub fn create_vite(self, dir: &str, template: Option<&str>) -> Command {
77        let mut command = Command::new(self.binary());
78        command.arg("create");
79        match self {
80            Self::Npm => command.arg("vite@latest"),
81            Self::Bun | Self::Pnpm | Self::Yarn => command.arg("vite"),
82        };
83        command.arg(dir);
84        if let Some(template) = template {
85            // npm needs `--` to forward args to the initializer; bun, pnpm
86            // and yarn pass them through directly.
87            if self == Self::Npm {
88                command.arg("--");
89            }
90            command.args(["--template", template]);
91        }
92        command
93    }
94
95    /// Whether the manager's binary resolves on `PATH`.
96    pub async fn is_installed(self) -> bool {
97        crate::utils::which(self.binary()).await.is_ok()
98    }
99
100    /// The official installation instruction, shown when the declared manager
101    /// is missing.
102    #[must_use]
103    pub const fn install_hint(self) -> &'static str {
104        match self {
105            Self::Bun => "curl -fsSL https://bun.sh/install | bash",
106            Self::Pnpm => "npm install -g pnpm (or see https://pnpm.io/installation)",
107            Self::Npm => "install Node.js from https://nodejs.org/",
108            Self::Yarn => {
109                "npm install -g yarn (or see https://yarnpkg.com/getting-started/install)"
110            }
111        }
112    }
113}
114
115/// The `[web]` section of `Water.toml`: web-frontend toolchain declarations.
116///
117/// The macro never reads this — only the CLI does. Absent section and absent
118/// key both mean [`PackageManager::Bun`].
119#[derive(Debug, Clone, Default, Serialize, Deserialize)]
120pub struct WebConfig {
121    /// The package manager used for `run build`, `run dev`, `create vite`,
122    /// and `install` in the frontend project.
123    #[serde(default)]
124    pub package_manager: PackageManager,
125}
126
127/// The `include_web!` mount declared by the project's compiled library.
128///
129/// Read from the `waterui_meta_bundle_*` statics in the host rlib, which is
130/// ground truth for what the application actually declared.
131///
132/// # Errors
133///
134/// Returns an error when the host build fails, the rlib cannot be read, or a
135/// declared mount's payload does not decode.
136pub async fn web_mount(
137    project: &Project,
138    sccache_path: Option<&Path>,
139    progress: Option<&BuildProgress>,
140) -> eyre::Result<Option<BundleMountMeta>> {
141    let rlib = build_host_rlib(
142        project.root(),
143        &project.host_target_dir().await?,
144        sccache_path,
145        progress,
146    )
147    .await?;
148    let symbols = ArtifactSymbols::read(&rlib)?;
149    decode_web_mount(&symbols)
150}
151
152/// Decode the `web` mount — the single mount a `project` field marks as a
153/// toolchain-produced frontend — from an artifact's symbol table.
154///
155/// # Errors
156///
157/// Returns an error when a mount payload does not decode or two mounts claim
158/// a frontend project.
159pub fn decode_web_mount(symbols: &ArtifactSymbols) -> eyre::Result<Option<BundleMountMeta>> {
160    let mut frontend = None;
161    for leaf in symbols.leaves_with_prefix(BUNDLE_META_PREFIX) {
162        let meta = symbols.bundle_mount_meta(&leaf)?;
163        if meta.project.is_none() {
164            continue;
165        }
166        if frontend.replace(meta).is_some() {
167            bail!("more than one include_web! mount is declared in the artifact");
168        }
169    }
170    Ok(frontend)
171}
172
173/// Build a toolchain-produced mount's frontend: `<pm> run build` inside the
174/// declared project root with stdio inherited, so the user sees their own
175/// bundler's output and diagnostics verbatim.
176///
177/// # Errors
178///
179/// Returns an error when the build fails or does not produce the mount's
180/// declared output directory.
181///
182/// # Panics
183///
184/// Panics when `meta` declares no `project` — callers only reach this for
185/// `include_web!` mounts.
186pub async fn build_frontend(
187    package_manager: PackageManager,
188    meta: &BundleMountMeta,
189) -> eyre::Result<()> {
190    let root = meta
191        .project
192        .as_ref()
193        .expect("build_frontend is only called for mounts that declare a project");
194    let pm = package_manager.binary();
195    let status = package_manager
196        .run("build")
197        .current_dir(root)
198        .stdin(Stdio::inherit())
199        .stdout(Stdio::inherit())
200        .stderr(Stdio::inherit())
201        .status()
202        .await?;
203    if !status.success() {
204        bail!("`{pm} run build` failed in {}: {status}", root.display());
205    }
206    if !meta.path.is_dir() {
207        bail!(
208            "`{pm} run build` did not produce `{}`; set `out_dir` on `include_web!` to the bundler's output directory",
209            meta.path.display()
210        );
211    }
212    Ok(())
213}
214
215// ---------------------------------------------------------------------------
216// `water run` dev server
217// ---------------------------------------------------------------------------
218
219/// The environment variable that carries the dev-server URL to a debug app.
220///
221/// Every launch channel reduces to this name: desktop spawns it directly,
222/// `simctl launch` forwards it as `SIMCTL_CHILD_WATERUI_DEV_URL`,
223/// `devicectl device process launch` carries it in the `-e` environment
224/// dictionary, and Android ships it as the `waterui.env.WATERUI_DEV_URL`
225/// intent extra that the generated `MainActivity` turns back into an
226/// environment variable before the runtime initializes.
227pub const DEV_URL_ENV: &str = "WATERUI_DEV_URL";
228
229/// A target `water run` can hand a dev-server URL to.
230#[derive(Debug, Clone, Copy, PartialEq, Eq)]
231pub enum DevTarget {
232    /// A desktop process the CLI spawns (macOS, GTK, Hydrolysis).
233    Desktop,
234    /// `simctl launch` on an iOS simulator.
235    IosSimulator,
236    /// `devicectl device process launch` on a physical iOS device.
237    IosDevice,
238    /// `am start` on an Android emulator or device.
239    Android,
240}
241
242/// The URL the target should open, rewritten when the target cannot reach
243/// this machine's loopback.
244///
245/// `WATERUI_DEV_URL` travels in the launch environment on every target —
246/// `devicectl device process launch -e` carries it to a physical iOS device
247/// exactly as `SIMCTL_CHILD_*` carries it to a simulator.
248///
249/// On a physical iOS device `localhost` is the phone itself, so the URL's
250/// loopback host is replaced with the Mac's LAN address — the address the
251/// device can actually route to. The dev server must be bound to a
252/// non-loopback interface for this to work; [`WebDevServer::spawn`] does
253/// that when its target is a physical device.
254///
255/// # Errors
256/// Fails for [`DevTarget::IosDevice`] when no LAN-facing IPv4 address can be
257/// determined — passing the phone a `localhost` URL would fail silently, so
258/// this errors instead.
259pub fn device_facing_url(target: DevTarget, url: &url::Url) -> eyre::Result<url::Url> {
260    if target != DevTarget::IosDevice {
261        return Ok(url.clone());
262    }
263    let mut url = url.clone();
264    let host = lan_ipv4()?.to_string();
265    url.set_host(Some(&host))
266        .wrap_err_with(|| format!("dev-server URL cannot carry a LAN host: {url}"))?;
267    Ok(url)
268}
269
270/// The IPv4 address this Mac presents on the LAN, found by asking the kernel
271/// which interface would carry outbound traffic.
272///
273/// `UdpSocket::connect` to a public address performs no I/O — it only forces
274/// a routing decision, and the resulting local address is the interface
275/// address a LAN peer (the iPhone) can reach.
276fn lan_ipv4() -> eyre::Result<std::net::Ipv4Addr> {
277    let socket = std::net::UdpSocket::bind((std::net::Ipv4Addr::UNSPECIFIED, 0))
278        .wrap_err("failed to bind a UDP socket for LAN address detection")?;
279    socket
280        .connect((std::net::Ipv4Addr::new(192, 0, 0, 1), 80))
281        .wrap_err(
282            "no outbound route — cannot determine this Mac's LAN address for the iOS device",
283        )?;
284    match socket.local_addr()?.ip() {
285        std::net::IpAddr::V4(ip) if !ip.is_loopback() => Ok(ip),
286        other => Err(eyre::eyre!(
287            "the outbound interface has no usable LAN IPv4 address ({other}); connect the Mac to the same LAN as the iOS device"
288        )),
289    }
290}
291
292/// `adb -s <device> reverse tcp:<port> tcp:<port>` — maps the device's
293/// loopback port onto the host's so the dev server is reachable from an
294/// Android emulator or a USB-connected device alike.
295#[must_use]
296pub fn adb_reverse_args(device_id: &str, port: u16) -> Vec<String> {
297    vec![
298        "-s".to_string(),
299        device_id.to_string(),
300        "reverse".to_string(),
301        format!("tcp:{port}"),
302        format!("tcp:{port}"),
303    ]
304}
305
306/// The port an `adb reverse` must forward, read from the `WATERUI_DEV_URL`
307/// entry of a launch environment. `None` when no dev-server handoff is
308/// present.
309///
310/// # Errors
311///
312/// Returns an error when the variable is set but is not a URL — a malformed
313/// handoff is a bug to report, not a state to launch in.
314pub fn dev_url_port<'a>(
315    mut env_vars: impl Iterator<Item = (&'a str, &'a str)>,
316) -> eyre::Result<Option<u16>> {
317    let Some((_, value)) = env_vars.find(|(key, _)| *key == DEV_URL_ENV) else {
318        return Ok(None);
319    };
320    let url: url::Url = value
321        .parse()
322        .wrap_err_with(|| format!("{DEV_URL_ENV} is set but is not a URL: {value}"))?;
323    url.port_or_known_default().map_or_else(
324        || Err(eyre::eyre!("{DEV_URL_ENV} has no port to forward: {value}")),
325        |port| Ok(Some(port)),
326    )
327}
328
329/// The script that starts the frontend's dev server: the first of `dev`,
330/// `serve`, `start` declared in the project's `package.json`.
331///
332/// # Errors
333///
334/// Fails when `package.json` cannot be read or parsed, or declares none of
335/// the known dev scripts.
336pub fn dev_script(root: &Path) -> eyre::Result<String> {
337    let package_json_path = root.join("package.json");
338    let manifest = std::fs::read_to_string(&package_json_path)
339        .wrap_err_with(|| format!("failed to read {}", package_json_path.display()))?;
340    let package: serde_json::Value = serde_json::from_str(&manifest)
341        .wrap_err_with(|| format!("failed to parse {}", package_json_path.display()))?;
342    package
343        .get("scripts")
344        .and_then(|scripts| {
345            ["dev", "serve", "start"]
346                .iter()
347                .find(|name| scripts.get(**name).is_some())
348        })
349        .map(|name| (*name).to_string())
350        .ok_or_else(|| {
351            eyre::eyre!(
352                "`{}` declares none of the dev scripts `dev`, `serve`, `start`",
353                package_json_path.display()
354            )
355        })
356}
357
358/// Extract the loopback URL a bundler prints once its dev server is
359/// listening.
360///
361/// Any whitespace-separated token matching
362/// `https?://(localhost|127.0.0.1|\[::1\]):<port>` counts — Vite's
363/// `Local: http://localhost:5173/` is the canonical producer. The port must
364/// be explicit and the host loopback; `Network:` URLs (LAN addresses) and
365/// every other token on the line are ignored.
366#[must_use]
367pub fn dev_url_from_line(line: &str) -> Option<url::Url> {
368    line.split_whitespace().find_map(|token| {
369        let url = token.parse::<url::Url>().ok()?;
370        if !matches!(url.scheme(), "http" | "https") {
371            return None;
372        }
373        let loopback = url
374            .host_str()
375            .is_some_and(|host| host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1")
376            || url.host() == Some(url::Host::Ipv6(std::net::Ipv6Addr::LOCALHOST));
377        (loopback && url.port().is_some()).then_some(url)
378    })
379}
380
381/// A running `<pm> run <script>` process group whose printed dev-server URL
382/// has been captured.
383///
384/// The child leads its own process tree — a process group on Unix, a job
385/// object on Windows — so dropping the guard terminates the whole tree:
386/// `bun run dev` re-execs `node vite`, which a lone `kill_on_drop` on the
387/// direct child would orphan. `water run` holds the guard across the app's
388/// lifetime, so a normal exit, an app exit, and the Ctrl-C future-drop path
389/// all stop the dev server.
390#[derive(Debug)]
391pub struct WebDevServer {
392    url: url::Url,
393    child: Option<(std::process::Child, dev_server_tree::DevServerTree)>,
394    _drain: smol::Task<()>,
395}
396
397impl Drop for WebDevServer {
398    fn drop(&mut self) {
399        let Some((mut child, tree)) = self.child.take() else {
400            return;
401        };
402        tree.signal(true);
403        std::thread::spawn(move || {
404            let mut exited = false;
405            for _ in 0..40 {
406                std::thread::sleep(std::time::Duration::from_millis(50));
407                if matches!(child.try_wait(), Ok(Some(_))) {
408                    exited = true;
409                    break;
410                }
411            }
412            if !exited {
413                tree.signal(false);
414            }
415            let _ = child.wait();
416        });
417    }
418}
419
420impl WebDevServer {
421    /// Spawn `<pm> run <script>` inside `root` and read its stdout until the
422    /// listening URL appears.
423    ///
424    /// stdout is piped so the URL can be parsed; every line is echoed to the
425    /// terminal as it arrives and a background task keeps draining after the
426    /// URL is found so the bundler never blocks on a full pipe. stderr is
427    /// inherited — the bundler's diagnostics reach the user verbatim.
428    ///
429    /// # Errors
430    ///
431    /// Fails when the process cannot be spawned or its stdout ends without a
432    /// loopback dev-server URL ever appearing.
433    ///
434    /// `expose_on_lan` appends `--host 0.0.0.0` to the dev script so the
435    /// bundler listens beyond loopback — required when the target is a
436    /// physical device, for which `localhost` is the device itself.
437    ///
438    /// # Panics
439    ///
440    /// Panics if the spawned child has no piped stdout — impossible, since
441    /// the spawn configures it above.
442    pub async fn spawn(
443        package_manager: PackageManager,
444        root: &Path,
445        script: &str,
446        expose_on_lan: bool,
447    ) -> eyre::Result<Self> {
448        use smol::io::{AsyncBufReadExt, BufReader};
449        use smol::stream::StreamExt as _;
450
451        let pm = package_manager.binary();
452        // `std::process::Command`, not `smol`'s: the child must lead its own
453        // process group so the guard's drop can signal the whole tree.
454        let mut command = std::process::Command::new(pm);
455        command.arg("run").arg(script).current_dir(root);
456        if expose_on_lan {
457            // npm needs `--` to forward args to the script; bun, pnpm and
458            // yarn pass them through directly — the same convention
459            // `PackageManager::create_vite` follows.
460            if package_manager == PackageManager::Npm {
461                command.arg("--");
462            }
463            command.args(["--host", "0.0.0.0"]);
464        }
465        command
466            .stdin(Stdio::null())
467            .stdout(Stdio::piped())
468            .stderr(Stdio::inherit());
469        #[cfg(unix)]
470        {
471            use std::os::unix::process::CommandExt as _;
472            command.process_group(0);
473        }
474        let mut child = command.spawn().wrap_err_with(|| {
475            format!("failed to spawn `{pm} run {script}` in {}", root.display())
476        })?;
477        let tree = dev_server_tree::DevServerTree::adopt(&child)
478            .wrap_err_with(|| format!("failed to group the `{pm} run {script}` process tree"))?;
479        let stdout = child.stdout.take().expect("stdout is piped");
480        let mut lines = BufReader::new(smol::Unblock::new(stdout)).lines();
481
482        let url = loop {
483            match lines.next().await {
484                Some(Ok(line)) => {
485                    echo_dev_server_line(&line);
486                    if let Some(url) = dev_url_from_line(&line) {
487                        break url;
488                    }
489                }
490                Some(Err(error)) => {
491                    tree.signal(false);
492                    let _ = smol::unblock(move || child.wait()).await;
493                    bail!("failed to read `{pm} run {script}` output: {error}");
494                }
495                None => {
496                    let status = child.try_wait().ok().flatten();
497                    tree.signal(false);
498                    let _ = smol::unblock(move || child.wait()).await;
499                    match status {
500                        Some(status) => bail!(
501                            "`{pm} run {script}` exited with {status} without printing a dev-server URL"
502                        ),
503                        None => bail!(
504                            "`{pm} run {script}` closed its output without printing a dev-server URL"
505                        ),
506                    }
507                }
508            }
509        };
510
511        let drain = smol::spawn(async move {
512            while let Some(line) = lines.next().await {
513                match line {
514                    Ok(line) => echo_dev_server_line(&line),
515                    Err(_) => break,
516                }
517            }
518        });
519
520        Ok(Self {
521            url,
522            child: Some((child, tree)),
523            _drain: drain,
524        })
525    }
526
527    /// The dev-server URL the app should open.
528    #[must_use]
529    pub const fn url(&self) -> &url::Url {
530        &self.url
531    }
532}
533
534/// The handle on the dev server's whole process tree, whichever the platform
535/// offers: the process group the child was spawned to lead on Unix, a job
536/// object the child is assigned to on Windows.
537mod dev_server_tree {
538    use std::io;
539
540    /// The dev server and every process it re-execs, addressable as one.
541    #[cfg(unix)]
542    #[derive(Debug)]
543    pub struct DevServerTree {
544        group: nix::unistd::Pid,
545    }
546
547    #[cfg(unix)]
548    impl DevServerTree {
549        /// The process group `child` leads. It was spawned with
550        /// `process_group(0)`; a child that did not end up leading its own
551        /// group is refused rather than signalled, because a group signal
552        /// would then reach whatever group it shares — `water` included.
553        pub fn adopt(child: &std::process::Child) -> io::Result<Self> {
554            let pid = nix::unistd::Pid::from_raw(
555                i32::try_from(child.id()).expect("process identifiers fit in i32"),
556            );
557            let group = nix::unistd::getpgid(Some(pid))?;
558            if group != pid {
559                return Err(io::Error::other(format!(
560                    "process {pid} belongs to group {group} instead of leading its own"
561                )));
562            }
563            Ok(Self { group })
564        }
565
566        /// Signal every process in the group: SIGTERM when `graceful`, else
567        /// SIGKILL.
568        pub fn signal(&self, graceful: bool) {
569            let signal = if graceful {
570                nix::sys::signal::Signal::SIGTERM
571            } else {
572                nix::sys::signal::Signal::SIGKILL
573            };
574            let _ = nix::sys::signal::killpg(self.group, signal);
575        }
576    }
577
578    /// The dev server and every process it re-execs, addressable as one: a
579    /// job object that kills its members when the last handle closes, so the
580    /// tree cannot outlive `water` even when it exits without dropping the
581    /// guard.
582    #[cfg(windows)]
583    #[derive(Debug)]
584    pub struct DevServerTree {
585        job: windows_sys::Win32::Foundation::HANDLE,
586    }
587
588    // SAFETY: a job handle is a process-wide kernel object with no thread
589    // affinity; the guard's drop hands it to a waiting thread.
590    #[cfg(windows)]
591    unsafe impl Send for DevServerTree {}
592
593    #[cfg(windows)]
594    impl DevServerTree {
595        /// A new job that kills its members on close, with `child` assigned to
596        /// it — the processes `child` spawns from now on join automatically.
597        pub fn adopt(child: &std::process::Child) -> io::Result<Self> {
598            use std::os::windows::io::AsRawHandle as _;
599
600            use windows_sys::Win32::System::JobObjects::{
601                AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
602                JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
603                SetInformationJobObject,
604            };
605
606            // SAFETY: an unnamed job with default security; the null pointers
607            // are the documented arguments for that.
608            let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
609            if job.is_null() {
610                return Err(io::Error::last_os_error());
611            }
612            let tree = Self { job };
613            // SAFETY: an all-zero limit block is the documented starting
614            // point; only the kill-on-close flag is set below.
615            let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() };
616            limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
617            let size = u32::try_from(std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>())
618                .expect("the limit block is far smaller than u32::MAX bytes");
619            // SAFETY: `job` is the live handle created above and `limits` is
620            // a fully initialised block of the size passed.
621            let configured = unsafe {
622                SetInformationJobObject(
623                    job,
624                    JobObjectExtendedLimitInformation,
625                    (&raw const limits).cast(),
626                    size,
627                )
628            };
629            if configured == 0 {
630                return Err(io::Error::last_os_error());
631            }
632            // SAFETY: both handles are live; the child's comes from the
633            // standard library's `Child` and stays open while `child` is.
634            let assigned = unsafe { AssignProcessToJobObject(job, child.as_raw_handle().cast()) };
635            if assigned == 0 {
636                return Err(io::Error::last_os_error());
637            }
638            Ok(tree)
639        }
640
641        /// Terminate every process in the job. Windows has no graceful
642        /// signal a console process observes, so `graceful` selects nothing
643        /// here; the guard's drop escalates to this call after its grace
644        /// period regardless.
645        pub fn signal(&self, graceful: bool) {
646            use windows_sys::Win32::System::JobObjects::TerminateJobObject;
647
648            if graceful {
649                return;
650            }
651            // SAFETY: `self.job` is a live job handle owned by this value.
652            let _ = unsafe { TerminateJobObject(self.job, 1) };
653        }
654    }
655
656    #[cfg(windows)]
657    impl Drop for DevServerTree {
658        fn drop(&mut self) {
659            use windows_sys::Win32::Foundation::CloseHandle;
660
661            // SAFETY: the handle was created by `adopt` and is closed exactly
662            // once, here; closing the last handle kills the job's members.
663            let _ = unsafe { CloseHandle(self.job) };
664        }
665    }
666}
667
668/// Echo one line of dev-server output on the CLI's terminal channel — the
669/// same stderr stream the shell's human output uses.
670fn echo_dev_server_line(line: &str) {
671    let _ = writeln!(anstream::stderr().lock(), "{line}");
672}
673
674// ---------------------------------------------------------------------------
675// `water init` planning
676// ---------------------------------------------------------------------------
677
678/// Where the frontend of an initialized project comes from.
679#[derive(Debug, Clone, PartialEq, Eq)]
680pub enum WebSource {
681    /// Scaffold a new Vite project into `web/`.
682    New,
683    /// An existing frontend project at this path.
684    Existing(PathBuf),
685}
686
687/// What to do with a frontend found outside `web/`.
688#[derive(Debug, Clone, Copy, PartialEq, Eq)]
689pub enum ExistingFrontendMode {
690    /// Copy it into `web/` (excluding `node_modules` and `.git`), then install.
691    Copy,
692    /// Leave it in place; `include_web!` references it by relative path.
693    Reference,
694}
695
696/// The answers `water init` needs, each supplied by a flag or a prompt.
697#[derive(Debug, Clone, Default)]
698pub struct InitAnswers {
699    /// `--web new|<path>`: the frontend source, if the flag settled it.
700    pub web: Option<WebSource>,
701    /// `--web-mode copy|reference`: how an existing frontend joins, if the
702    /// flag settled it.
703    pub web_mode: Option<ExistingFrontendMode>,
704    /// `--package-manager`: the declared manager, if the flag settled it.
705    pub package_manager: Option<PackageManager>,
706}
707
708/// One step of `water init`. The command executes these in order; the plan is
709/// pure so the decision tree is testable without touching disk.
710#[derive(Debug, Clone, PartialEq, Eq)]
711pub enum InitAction {
712    /// Move the listed top-level entries into `web/`.
713    MoveFrontendToWeb {
714        /// Top-level entries of the project root to move.
715        entries: Vec<PathBuf>,
716    },
717    /// `<pm> create vite web` inside the project root.
718    ScaffoldVite,
719    /// Copy an existing project into `web/` (skipping `node_modules` and
720    /// `.git`), then install its dependencies.
721    CopyFrontend {
722        /// The project to copy.
723        source: PathBuf,
724    },
725    /// `<pm> install` inside `web/`.
726    InstallDependencies,
727    /// Scaffold the Rust shell whose root view is `include_web!(<arg>)`.
728    ScaffoldShell {
729        /// The `include_web!` argument — `"web"` or a relative path.
730        web_arg: String,
731    },
732}
733
734/// Top-level entries that never move into `web/` when a CWD frontend is
735/// relocated: the Rust shell and project metadata stay at the root.
736fn root_only_entries(has_rust_manifest: bool, entry: &str) -> bool {
737    if matches!(
738        entry,
739        ".git" | ".github" | ".water" | "Water.toml" | "Water.lock" | "backends" | "target" | "web"
740    ) || entry.starts_with("README")
741        || entry.starts_with("LICENSE")
742    {
743        return true;
744    }
745    // `src/` and the Cargo files are the frontend's own when no Rust manifest
746    // exists yet; once Cargo.toml is present they belong to the shell.
747    has_rust_manifest && matches!(entry, "Cargo.toml" | "Cargo.lock" | "src")
748}
749
750/// The package manager a lockfile implies, used as the prompt's initial value.
751#[must_use]
752pub fn lockfile_package_manager(entries: &[String]) -> Option<PackageManager> {
753    if entries.iter().any(|e| e == "bun.lock" || e == "bun.lockb") {
754        Some(PackageManager::Bun)
755    } else if entries.iter().any(|e| e == "pnpm-lock.yaml") {
756        Some(PackageManager::Pnpm)
757    } else if entries.iter().any(|e| e == "yarn.lock") {
758        Some(PackageManager::Yarn)
759    } else if entries.iter().any(|e| e == "package-lock.json") {
760        Some(PackageManager::Npm)
761    } else {
762        None
763    }
764}
765
766/// The ordered steps `water init` performs, decided from the CWD's top-level
767/// listing and the already-resolved answers.
768///
769/// The caller resolves prompts first: every field of `answers` that is still
770/// `None` is a prompt it must ask (with [`lockfile_package_manager`] as the
771/// package-manager default) before planning.
772///
773/// # Errors
774///
775/// Returns an error when a referenced frontend path escapes the project root
776/// in a way `include_web!` cannot express, or the answers are inconsistent
777/// (`--web new` combined with `--web-mode`).
778pub fn plan_init(
779    project_root: &Path,
780    entries: &[String],
781    answers: &InitAnswers,
782) -> eyre::Result<Vec<InitAction>> {
783    if entries.iter().any(|e| e == "package.json") {
784        let has_rust_manifest = entries.iter().any(|e| e == "Cargo.toml");
785        let move_entries = entries
786            .iter()
787            .filter(|entry| !root_only_entries(has_rust_manifest, entry))
788            .map(PathBuf::from)
789            .collect();
790        return Ok(vec![
791            InitAction::MoveFrontendToWeb {
792                entries: move_entries,
793            },
794            InitAction::ScaffoldShell {
795                web_arg: "web".to_string(),
796            },
797        ]);
798    }
799
800    match answers.web.clone() {
801        Some(WebSource::New) | None => Ok(vec![
802            InitAction::ScaffoldVite,
803            InitAction::InstallDependencies,
804            InitAction::ScaffoldShell {
805                web_arg: "web".to_string(),
806            },
807        ]),
808        Some(WebSource::Existing(source)) => {
809            match answers.web_mode.unwrap_or(ExistingFrontendMode::Copy) {
810                ExistingFrontendMode::Copy => Ok(vec![
811                    InitAction::CopyFrontend { source },
812                    InitAction::InstallDependencies,
813                    InitAction::ScaffoldShell {
814                        web_arg: "web".to_string(),
815                    },
816                ]),
817                ExistingFrontendMode::Reference => {
818                    let arg = relative_path_arg(project_root, &source)?;
819                    Ok(vec![InitAction::ScaffoldShell { web_arg: arg }])
820                }
821            }
822        }
823    }
824}
825
826/// The `include_web!` argument for a frontend outside `web/`: a relative path
827/// from the project root, with `..` segments for directories outside it.
828fn relative_path_arg(project_root: &Path, source: &Path) -> eyre::Result<String> {
829    let root = dunce::canonicalize(project_root)?;
830    let source = dunce::canonicalize(source)?;
831    let mut root_components = root.components().peekable();
832    let mut source_components = source.components().peekable();
833    while root_components.peek() == source_components.peek() && root_components.peek().is_some() {
834        root_components.next();
835        source_components.next();
836    }
837    let mut arg = String::new();
838    for _ in root_components {
839        if !arg.is_empty() {
840            arg.push('/');
841        }
842        arg.push_str("..");
843    }
844    for component in source_components {
845        if !arg.is_empty() {
846            arg.push('/');
847        }
848        arg.push_str(
849            component
850                .as_os_str()
851                .to_str()
852                .ok_or_else(|| eyre::eyre!("frontend path is not valid UTF-8"))?,
853        );
854    }
855    if arg.is_empty() {
856        bail!("the frontend is the project root itself; put its files in `web/`");
857    }
858    Ok(arg)
859}
860
861// ---------------------------------------------------------------------------
862// Branded starter overlay
863// ---------------------------------------------------------------------------
864
865/// The framework a freshly scaffolded Vite project declares.
866///
867/// Read from `web/package.json` dependencies — the dependencies the template
868/// ships are the one ground truth `create vite` gives us.
869#[derive(Debug, Clone, Copy, PartialEq, Eq)]
870pub enum WebFramework {
871    /// `vanilla`/`vanilla-ts`: no framework dependency.
872    Vanilla,
873    /// `react` in the dependencies.
874    React,
875    /// `preact` in the dependencies.
876    Preact,
877    /// `vue` in the dependencies.
878    Vue,
879    /// `svelte` — a devDependency in Vite's starter.
880    Svelte,
881    /// `solid-js` in the dependencies.
882    Solid,
883    /// `lit` in the dependencies.
884    Lit,
885    /// Dependencies declare something we do not know — e.g. Qwik.
886    Other,
887}
888
889impl WebFramework {
890    /// The name the branded page prints.
891    #[must_use]
892    pub const fn display_name(self) -> &'static str {
893        match self {
894            Self::Vanilla => "Vanilla",
895            Self::React => "React",
896            Self::Preact => "Preact",
897            Self::Vue => "Vue",
898            Self::Svelte => "Svelte",
899            Self::Solid => "Solid",
900            Self::Lit => "Lit",
901            Self::Other => "web",
902        }
903    }
904
905    /// Whether the overlay knows how to replace the starter's page.
906    const fn supports_branding(self) -> bool {
907        matches!(self, Self::Vanilla | Self::React | Self::Vue | Self::Svelte)
908    }
909}
910
911/// What `web/package.json` says about a scaffolded frontend: the framework
912/// dependency and whether `typescript` is a devDependency.
913#[derive(Debug, Clone, Copy, PartialEq, Eq)]
914pub struct WebFrontend {
915    /// The framework the starter's dependencies declare.
916    pub framework: WebFramework,
917    /// Whether `typescript` is a devDependency (else JavaScript).
918    pub typescript: bool,
919}
920
921/// The dependency names that identify each framework, checked in order.
922const FRAMEWORK_DEPENDENCIES: &[(&str, WebFramework)] = &[
923    ("react", WebFramework::React),
924    ("preact", WebFramework::Preact),
925    ("vue", WebFramework::Vue),
926    ("svelte", WebFramework::Svelte),
927    ("solid-js", WebFramework::Solid),
928    ("lit", WebFramework::Lit),
929];
930
931/// Read the framework and language of a scaffolded frontend from its
932/// `package.json` text. `None` when the manifest cannot be parsed.
933///
934/// Framework markers are looked up in both `dependencies` and
935/// `devDependencies` — Vite's Svelte starter compiles the framework away and
936/// declares it as a devDependency. `typescript` counts only in
937/// `devDependencies`. A manifest with no `dependencies` at all is Vanilla;
938/// one whose dependencies name none of the known frameworks is
939/// [`WebFramework::Other`].
940#[must_use]
941pub fn detect_web_frontend(package_json: &str) -> Option<WebFrontend> {
942    let package: serde_json::Value = serde_json::from_str(package_json).ok()?;
943    let dependencies = package
944        .get("dependencies")
945        .and_then(serde_json::Value::as_object);
946    let dev_dependencies = package
947        .get("devDependencies")
948        .and_then(serde_json::Value::as_object);
949    let has_marker = |name: &str| {
950        dependencies.is_some_and(|deps| deps.contains_key(name))
951            || dev_dependencies.is_some_and(|deps| deps.contains_key(name))
952    };
953    let framework = FRAMEWORK_DEPENDENCIES
954        .iter()
955        .find(|(name, _)| has_marker(name))
956        .map_or_else(
957            || {
958                if dependencies.is_none_or(serde_json::Map::is_empty) {
959                    WebFramework::Vanilla
960                } else {
961                    WebFramework::Other
962                }
963            },
964            |(_, framework)| *framework,
965        );
966    let typescript = dev_dependencies.is_some_and(|deps| deps.contains_key("typescript"));
967    Some(WebFrontend {
968        framework,
969        typescript,
970    })
971}
972
973/// What [`apply_brand_overlay`] did, and what it could not.
974#[derive(Debug, Default)]
975pub struct WebOverlayReport {
976    /// The detected frontend (`None` when `package.json` did not parse).
977    pub frontend: Option<WebFrontend>,
978    /// Whether the starter's visible page was replaced with the branded one —
979    /// `false` means the framework's own starter page remains.
980    pub branded: bool,
981    /// Non-fatal misses: expected files the layout did not ship.
982    pub warnings: Vec<String>,
983}
984
985/// Values the branded starter templates interpolate.
986struct WebOverlayContext<'a> {
987    /// The name the heading prints — "React", or "TypeScript" for a vanilla
988    /// starter.
989    framework: &'a str,
990    /// The file the "edit and save" line names — "src/App.tsx".
991    entry: &'a str,
992    /// The import specifier the framework logo resolves to — the starter's
993    /// own logo file, e.g. `./assets/typescript.svg`.
994    logo: &'a str,
995    /// Whether the project is TypeScript; selects `lang="ts"` in SFCs and the
996    /// typed bridge signature.
997    typescript: bool,
998}
999
1000macro_rules! web_overlay_templates {
1001    ($($name:ident => $path:literal),* $(,)?) => {$(
1002        #[derive(Template)]
1003        #[template(path = $path, escape = "none")]
1004        struct $name<'a> {
1005            ctx: &'a WebOverlayContext<'a>,
1006        }
1007    )*};
1008}
1009
1010web_overlay_templates! {
1011    VanillaMainTsTemplate => "src/templates/web/vanilla/main.ts.tpl",
1012    VanillaMainJsTemplate => "src/templates/web/vanilla/main.js.tpl",
1013    ReactAppTsxTemplate => "src/templates/web/react/App.tsx.tpl",
1014    ReactAppJsxTemplate => "src/templates/web/react/App.jsx.tpl",
1015    VueAppTemplate => "src/templates/web/vue/App.vue.tpl",
1016    SvelteAppTemplate => "src/templates/web/svelte/App.svelte.tpl",
1017}
1018
1019/// One entry-file template the overlay can render.
1020#[derive(Clone, Copy)]
1021enum OverlayTemplate {
1022    VanillaTs,
1023    VanillaJs,
1024    ReactTsx,
1025    ReactJsx,
1026    Vue,
1027    Svelte,
1028}
1029
1030impl OverlayTemplate {
1031    fn render(self, ctx: &WebOverlayContext) -> io::Result<String> {
1032        let rendered = match self {
1033            Self::VanillaTs => VanillaMainTsTemplate { ctx }.render(),
1034            Self::VanillaJs => VanillaMainJsTemplate { ctx }.render(),
1035            Self::ReactTsx => ReactAppTsxTemplate { ctx }.render(),
1036            Self::ReactJsx => ReactAppJsxTemplate { ctx }.render(),
1037            Self::Vue => VueAppTemplate { ctx }.render(),
1038            Self::Svelte => SvelteAppTemplate { ctx }.render(),
1039        };
1040        rendered.map_err(|error| {
1041            io::Error::other(format!("web overlay template render failed: {error}"))
1042        })
1043    }
1044}
1045
1046/// A destination the overlay may fill with a rendered entry file, probed in
1047/// order — `src/main.ts` first, `src/main.js` when the project is JavaScript.
1048struct EntryCandidate {
1049    /// The destination path inside `web/`; also the path the page's "edit and
1050    /// save" line names.
1051    dest: &'static str,
1052    /// The template that renders the file.
1053    template: OverlayTemplate,
1054    /// Candidate paths of the framework logo the branded page shows beside
1055    /// the `WaterUI` mark — the starter moved it between Vite versions.
1056    logos: &'static [&'static str],
1057}
1058
1059/// The branded-overlay layout of one supported framework.
1060struct OverlaySpec {
1061    /// Entry-file candidates in probe order.
1062    entries: &'static [EntryCandidate],
1063    /// Stylesheets that receive the branded stylesheet verbatim.
1064    styles: &'static [&'static str],
1065    /// The starter's second, global stylesheet receiving the small baseline
1066    /// (React's `index.css`; every other supported framework has only the one
1067    /// branded stylesheet).
1068    base_style: Option<&'static str>,
1069    /// Starter files the overlay removes, grouped so several candidates count
1070    /// as one expected file; a group matching nothing warns.
1071    deletions: &'static [&'static [&'static str]],
1072}
1073
1074const fn overlay_spec(framework: WebFramework) -> Option<OverlaySpec> {
1075    Some(match framework {
1076        WebFramework::Vanilla => OverlaySpec {
1077            entries: &[
1078                EntryCandidate {
1079                    dest: "src/main.ts",
1080                    template: OverlayTemplate::VanillaTs,
1081                    logos: &["src/assets/typescript.svg", "src/typescript.svg"],
1082                },
1083                EntryCandidate {
1084                    dest: "src/main.js",
1085                    template: OverlayTemplate::VanillaJs,
1086                    logos: &["src/assets/javascript.svg", "src/javascript.svg"],
1087                },
1088            ],
1089            styles: &["src/style.css"],
1090            base_style: None,
1091            deletions: &[&["src/counter.ts", "src/counter.js"]],
1092        },
1093        WebFramework::React => OverlaySpec {
1094            entries: &[
1095                EntryCandidate {
1096                    dest: "src/App.tsx",
1097                    template: OverlayTemplate::ReactTsx,
1098                    logos: &["src/assets/react.svg", "src/react.svg"],
1099                },
1100                EntryCandidate {
1101                    dest: "src/App.jsx",
1102                    template: OverlayTemplate::ReactJsx,
1103                    logos: &["src/assets/react.svg", "src/react.svg"],
1104                },
1105            ],
1106            styles: &["src/App.css"],
1107            base_style: Some("src/index.css"),
1108            deletions: &[],
1109        },
1110        WebFramework::Vue => OverlaySpec {
1111            entries: &[EntryCandidate {
1112                dest: "src/App.vue",
1113                template: OverlayTemplate::Vue,
1114                logos: &["src/assets/vue.svg", "src/vue.svg"],
1115            }],
1116            styles: &["src/style.css"],
1117            base_style: None,
1118            deletions: &[&["src/components/HelloWorld.vue"]],
1119        },
1120        WebFramework::Svelte => OverlaySpec {
1121            entries: &[EntryCandidate {
1122                dest: "src/App.svelte",
1123                template: OverlayTemplate::Svelte,
1124                logos: &["src/assets/svelte.svg", "src/svelte.svg"],
1125            }],
1126            styles: &["src/app.css"],
1127            base_style: None,
1128            deletions: &[&["src/lib/Counter.svelte"]],
1129        },
1130        _ => return None,
1131    })
1132}
1133
1134/// A verbatim asset the embedded `web/` template dir ships.
1135fn web_template_asset(relative: &str) -> &'static [u8] {
1136    embedded::ROOT
1137        .get_file(format!("web/{relative}"))
1138        .unwrap_or_else(|| panic!("web overlay asset `{relative}` must ship in the CLI"))
1139        .contents()
1140}
1141
1142/// Apply the WaterUI-branded overlay to a freshly `create vite`-scaffolded
1143/// frontend in `web_dir`.
1144///
1145/// Every project — branded or not — gets `public/waterui.svg`, loses
1146/// `public/vite.svg`, and has its `index.html` retitled to `display_name`
1147/// with its favicon repointed to the `WaterUI` mark. The supported matrix
1148/// (Vanilla, React, Vue, Svelte — TypeScript or JavaScript) additionally gets
1149/// its starter page replaced; anything else keeps the framework's default
1150/// page and reports `branded: false`. Files an unexpected layout does not
1151/// ship are skipped with a warning, never an error.
1152///
1153/// # Errors
1154///
1155/// Returns an error only on real I/O failures writing the overlay.
1156///
1157/// # Panics
1158///
1159/// Panics when the embedded `web/` template assets are missing — they are
1160/// compiled into the CLI.
1161pub fn apply_brand_overlay(web_dir: &Path, display_name: &str) -> io::Result<WebOverlayReport> {
1162    let mut report = WebOverlayReport::default();
1163
1164    let logo = embedded::ROOT
1165        .get_file("icon.svg")
1166        .expect("the WaterUI logo ships in the template bundle");
1167    write_overlay_file(web_dir, "public/waterui.svg", logo.contents())?;
1168    // `index.html` is repointed at `/waterui.svg` below, so the starter's own
1169    // favicons are orphaned — Vite 7 ships `vite.svg`, Vite 8 `favicon.svg`.
1170    for orphaned in ["public/vite.svg", "public/favicon.svg"] {
1171        let path = web_dir.join(orphaned);
1172        if path.exists() {
1173            std::fs::remove_file(&path)?;
1174        }
1175    }
1176    retitle_index_html(web_dir, display_name, &mut report.warnings)?;
1177
1178    let Some(frontend) = read_frontend(web_dir, &mut report.warnings) else {
1179        return Ok(report);
1180    };
1181    report.frontend = Some(frontend);
1182    if frontend.framework.supports_branding() {
1183        report.branded = brand_framework_page(web_dir, frontend, &mut report.warnings)?;
1184    }
1185    Ok(report)
1186}
1187
1188/// Write `contents` to `web_dir/relative`, creating parent directories.
1189fn write_overlay_file(web_dir: &Path, relative: &str, contents: &[u8]) -> io::Result<()> {
1190    let dest = web_dir.join(relative);
1191    if let Some(parent) = dest.parent() {
1192        std::fs::create_dir_all(parent)?;
1193    }
1194    std::fs::write(dest, contents)
1195}
1196
1197/// Read the scaffolded frontend's framework and language; an unreadable or
1198/// unparsable `package.json` warns and yields `None`.
1199fn read_frontend(web_dir: &Path, warnings: &mut Vec<String>) -> Option<WebFrontend> {
1200    if let Ok(manifest) = std::fs::read_to_string(web_dir.join("package.json")) {
1201        detect_web_frontend(&manifest).or_else(|| {
1202            warnings.push(
1203                "web/package.json did not parse — the starter page was left in place".to_string(),
1204            );
1205            None
1206        })
1207    } else {
1208        warnings
1209            .push("web/package.json is missing — the starter page was left in place".to_string());
1210        None
1211    }
1212}
1213
1214/// Replace the starter's visible page with the branded one. Returns `false`
1215/// when the layout is not recognized — the framework's page then stays.
1216fn brand_framework_page(
1217    web_dir: &Path,
1218    frontend: WebFrontend,
1219    warnings: &mut Vec<String>,
1220) -> io::Result<bool> {
1221    let Some(spec) = overlay_spec(frontend.framework) else {
1222        return Ok(false);
1223    };
1224    let Some(entry) = spec
1225        .entries
1226        .iter()
1227        .find(|candidate| web_dir.join(candidate.dest).is_file())
1228    else {
1229        warnings.push(format!(
1230            "{} is missing — the {} starter layout is not recognized; its default page remains",
1231            spec.entries[0].dest,
1232            frontend.framework.display_name(),
1233        ));
1234        return Ok(false);
1235    };
1236
1237    // The heading pairs WaterUI with what the starter actually showcases —
1238    // the framework logo it ships. A vanilla starter's mark is the language
1239    // logo, so "TypeScript" reads truer than "Vanilla".
1240    let framework = if frontend.framework == WebFramework::Vanilla {
1241        if frontend.typescript {
1242            "TypeScript"
1243        } else {
1244            "JavaScript"
1245        }
1246    } else {
1247        frontend.framework.display_name()
1248    };
1249    // Every entry file lives in `src/`; the logo specifier is relative to it.
1250    // When the starter ships no logo the import points at the WaterUI mark we
1251    // just wrote to `public/` — an import the bundler still resolves.
1252    let logo = entry
1253        .logos
1254        .iter()
1255        .find(|logo| web_dir.join(logo).is_file())
1256        .map_or_else(
1257            || {
1258                warnings.push(format!(
1259                    "{} is missing — the branded page falls back to the WaterUI mark",
1260                    entry.logos[0]
1261                ));
1262                "../public/waterui.svg".to_string()
1263            },
1264            |logo| format!("./{}", logo.strip_prefix("src/").unwrap_or(logo)),
1265        );
1266
1267    let ctx = WebOverlayContext {
1268        framework,
1269        entry: entry.dest,
1270        logo: &logo,
1271        typescript: frontend.typescript,
1272    };
1273    write_overlay_file(web_dir, entry.dest, entry.template.render(&ctx)?.as_bytes())?;
1274
1275    for style in spec.styles {
1276        if web_dir.join(style).is_file() {
1277            write_overlay_file(web_dir, style, web_template_asset("brand.css"))?;
1278        } else {
1279            warnings.push(format!("{style} is missing — branded stylesheet skipped"));
1280        }
1281    }
1282    if let Some(base_style) = spec.base_style {
1283        if web_dir.join(base_style).is_file() {
1284            write_overlay_file(web_dir, base_style, web_template_asset("base.css"))?;
1285        } else {
1286            warnings.push(format!(
1287                "{base_style} is missing — baseline stylesheet skipped"
1288            ));
1289        }
1290    }
1291    for group in spec.deletions {
1292        let mut removed = false;
1293        for file in *group {
1294            let path = web_dir.join(file);
1295            if path.is_file() {
1296                std::fs::remove_file(path)?;
1297                removed = true;
1298            }
1299        }
1300        if !removed {
1301            warnings.push(format!("{} is missing — nothing to remove", group[0]));
1302        }
1303    }
1304    // The branded page references nothing else the starter put in `public/`;
1305    // `icons.svg` is its sprite sheet.
1306    let sprite = web_dir.join("public/icons.svg");
1307    if sprite.exists() {
1308        std::fs::remove_file(&sprite)?;
1309    }
1310    if frontend.typescript {
1311        write_overlay_file(
1312            web_dir,
1313            "src/waterui.d.ts",
1314            web_template_asset("waterui.d.ts"),
1315        )?;
1316    }
1317    Ok(true)
1318}
1319
1320/// Retitle `index.html` to the app's display name and repoint its favicon to
1321/// `/waterui.svg`. The file is patched rather than replaced, so markup a
1322/// starter puts there survives.
1323fn retitle_index_html(
1324    web_dir: &Path,
1325    display_name: &str,
1326    warnings: &mut Vec<String>,
1327) -> io::Result<()> {
1328    let path = web_dir.join("index.html");
1329    if !path.is_file() {
1330        warnings.push("index.html is missing — title and favicon unchanged".to_string());
1331        return Ok(());
1332    }
1333    let mut html = std::fs::read_to_string(&path)?;
1334    match (html.find("<title>"), html.find("</title>")) {
1335        (Some(start), Some(end)) if start + "<title>".len() <= end => {
1336            html.replace_range(
1337                start + "<title>".len()..end,
1338                &escape_html_text(display_name),
1339            );
1340        }
1341        _ => warnings.push("index.html has no <title> to retitle".to_string()),
1342    }
1343    // Repoint whichever favicon the starter links — `/vite.svg` on Vite 7,
1344    // `/favicon.svg` on Vite 8 — at the WaterUI mark.
1345    let mut repointed = false;
1346    for favicon in ["/favicon.svg", "./favicon.svg", "/vite.svg", "./vite.svg"] {
1347        let quoted = format!("\"{favicon}\"");
1348        if html.contains(&quoted) {
1349            html = html.replace(&quoted, "\"/waterui.svg\"");
1350            repointed = true;
1351        }
1352    }
1353    if !repointed {
1354        if let Some(head_end) = html.find("</head>") {
1355            html.insert_str(
1356                head_end,
1357                "    <link rel=\"icon\" type=\"image/svg+xml\" href=\"/waterui.svg\" />\n  ",
1358            );
1359        } else {
1360            warnings.push("index.html has no favicon link or </head> to repoint".to_string());
1361        }
1362    }
1363    std::fs::write(&path, html)
1364}
1365
1366/// Escape the handful of characters that break a `<title>` text node.
1367fn escape_html_text(text: &str) -> String {
1368    text.replace('&', "&amp;")
1369        .replace('<', "&lt;")
1370        .replace('>', "&gt;")
1371}
1372
1373#[cfg(test)]
1374mod tests {
1375    use super::*;
1376
1377    fn entries(names: &[&str]) -> Vec<String> {
1378        names.iter().map(ToString::to_string).collect()
1379    }
1380
1381    #[test]
1382    fn package_manager_serde_round_trip() {
1383        // toml's serializer needs a table at the root; round-trip through the
1384        // `[web]` section shape the manifest actually uses.
1385        #[derive(Debug, Serialize, Deserialize)]
1386        struct Section {
1387            package_manager: PackageManager,
1388        }
1389        for (pm, name) in [
1390            (PackageManager::Bun, "bun"),
1391            (PackageManager::Pnpm, "pnpm"),
1392            (PackageManager::Npm, "npm"),
1393            (PackageManager::Yarn, "yarn"),
1394        ] {
1395            let encoded = toml::to_string(&Section {
1396                package_manager: pm,
1397            })
1398            .unwrap();
1399            assert_eq!(encoded.trim(), format!("package_manager = \"{name}\""));
1400            assert_eq!(
1401                toml::from_str::<Section>(&encoded).unwrap().package_manager,
1402                pm
1403            );
1404        }
1405        let error = toml::from_str::<Section>("package_manager = \"deno\"").unwrap_err();
1406        let message = error.to_string();
1407        for option in ["bun", "pnpm", "npm", "yarn"] {
1408            assert!(
1409                message.contains(option),
1410                "unknown manager error names the options: {message}"
1411            );
1412        }
1413    }
1414
1415    #[test]
1416    fn command_arg_vectors() {
1417        let args = |command: &Command| -> Vec<String> {
1418            std::iter::once(command.get_program().to_string_lossy().into_owned())
1419                .chain(
1420                    command
1421                        .get_args()
1422                        .map(|arg| arg.to_string_lossy().into_owned()),
1423                )
1424                .collect()
1425        };
1426        assert_eq!(
1427            args(&PackageManager::Bun.run("build")),
1428            ["bun", "run", "build"]
1429        );
1430        assert_eq!(args(&PackageManager::Pnpm.install()), ["pnpm", "install"]);
1431        assert_eq!(
1432            args(&PackageManager::Yarn.create_vite("web", None)),
1433            ["yarn", "create", "vite", "web"]
1434        );
1435        assert_eq!(
1436            args(&PackageManager::Bun.create_vite("web", Some("react-ts"))),
1437            ["bun", "create", "vite", "web", "--template", "react-ts"]
1438        );
1439        assert_eq!(
1440            args(&PackageManager::Npm.create_vite("web", Some("vanilla-ts"))),
1441            [
1442                "npm",
1443                "create",
1444                "vite@latest",
1445                "web",
1446                "--",
1447                "--template",
1448                "vanilla-ts"
1449            ]
1450        );
1451    }
1452
1453    #[test]
1454    fn cwd_frontend_moves_into_web_keeping_shell_files() {
1455        let plan = plan_init(
1456            Path::new("/project"),
1457            &entries(&[
1458                "package.json",
1459                "bun.lock",
1460                "index.html",
1461                "src",
1462                "Cargo.toml",
1463                "target",
1464                ".git",
1465                ".github",
1466                "README.md",
1467                "Water.toml",
1468            ]),
1469            &InitAnswers::default(),
1470        )
1471        .unwrap();
1472        let InitAction::MoveFrontendToWeb { entries: moved } = &plan[0] else {
1473            panic!("expected the move step first: {plan:?}")
1474        };
1475        let mut moved: Vec<String> = moved
1476            .iter()
1477            .map(|p| p.to_string_lossy().into_owned())
1478            .collect();
1479        moved.sort();
1480        // `Cargo.toml` and `src/` stay at the root: they are the shell's.
1481        assert_eq!(moved, ["bun.lock", "index.html", "package.json"]);
1482        assert_eq!(
1483            plan[1],
1484            InitAction::ScaffoldShell {
1485                web_arg: "web".to_string()
1486            }
1487        );
1488    }
1489
1490    #[test]
1491    fn pure_frontend_cwd_moves_its_src() {
1492        let plan = plan_init(
1493            Path::new("/project"),
1494            &entries(&["package.json", "src", "vite.config.ts"]),
1495            &InitAnswers::default(),
1496        )
1497        .unwrap();
1498        let InitAction::MoveFrontendToWeb { entries: moved } = &plan[0] else {
1499            panic!("expected the move step first: {plan:?}")
1500        };
1501        assert!(
1502            moved.contains(&PathBuf::from("src")),
1503            "without Cargo.toml, src/ is frontend code: {moved:?}"
1504        );
1505    }
1506
1507    #[test]
1508    fn new_frontend_scaffolds_vite_then_installs() {
1509        let answers = InitAnswers {
1510            web: Some(WebSource::New),
1511            ..InitAnswers::default()
1512        };
1513        let plan = plan_init(Path::new("/project"), &entries(&[]), &answers).unwrap();
1514        assert_eq!(
1515            plan,
1516            [
1517                InitAction::ScaffoldVite,
1518                InitAction::InstallDependencies,
1519                InitAction::ScaffoldShell {
1520                    web_arg: "web".to_string()
1521                },
1522            ]
1523        );
1524    }
1525
1526    #[test]
1527    fn existing_frontend_copy_installs_into_web() {
1528        let answers = InitAnswers {
1529            web: Some(WebSource::Existing(PathBuf::from("/elsewhere/app"))),
1530            web_mode: Some(ExistingFrontendMode::Copy),
1531            ..InitAnswers::default()
1532        };
1533        let plan = plan_init(Path::new("/project"), &entries(&[]), &answers).unwrap();
1534        assert_eq!(
1535            plan,
1536            [
1537                InitAction::CopyFrontend {
1538                    source: PathBuf::from("/elsewhere/app")
1539                },
1540                InitAction::InstallDependencies,
1541                InitAction::ScaffoldShell {
1542                    web_arg: "web".to_string()
1543                },
1544            ]
1545        );
1546    }
1547
1548    #[test]
1549    fn existing_frontend_reference_uses_a_relative_arg() {
1550        let temp = tempfile::tempdir().unwrap();
1551        let root = temp.path().join("project");
1552        let sibling = temp.path().join("frontend");
1553        std::fs::create_dir_all(&root).unwrap();
1554        std::fs::create_dir_all(&sibling).unwrap();
1555        let answers = InitAnswers {
1556            web: Some(WebSource::Existing(sibling)),
1557            web_mode: Some(ExistingFrontendMode::Reference),
1558            ..InitAnswers::default()
1559        };
1560        let plan = plan_init(&root, &entries(&[]), &answers).unwrap();
1561        assert_eq!(
1562            plan,
1563            [InitAction::ScaffoldShell {
1564                web_arg: "../frontend".to_string()
1565            }]
1566        );
1567    }
1568
1569    #[test]
1570    fn detect_frontend_reads_framework_and_language() {
1571        let assert = |manifest: &str, framework: WebFramework, typescript: bool| {
1572            assert_eq!(
1573                detect_web_frontend(manifest),
1574                Some(WebFrontend {
1575                    framework,
1576                    typescript
1577                }),
1578                "{manifest}"
1579            );
1580        };
1581        // vanilla-ts / vanilla ship no dependencies at all.
1582        assert(
1583            r#"{"devDependencies":{"typescript":"~5.9","vite":"^7"}}"#,
1584            WebFramework::Vanilla,
1585            true,
1586        );
1587        assert(
1588            r#"{"devDependencies":{"vite":"^7"}}"#,
1589            WebFramework::Vanilla,
1590            false,
1591        );
1592        assert(
1593            r#"{"dependencies":{"react":"^19","react-dom":"^19"},"devDependencies":{"typescript":"~5.9"}}"#,
1594            WebFramework::React,
1595            true,
1596        );
1597        assert(
1598            r#"{"dependencies":{"react":"^19","react-dom":"^19"}}"#,
1599            WebFramework::React,
1600            false,
1601        );
1602        assert(
1603            r#"{"dependencies":{"preact":"^10"},"devDependencies":{"typescript":"~5.9"}}"#,
1604            WebFramework::Preact,
1605            true,
1606        );
1607        assert(
1608            r#"{"dependencies":{"vue":"^3"},"devDependencies":{"typescript":"~5.9","vue-tsc":"^3"}}"#,
1609            WebFramework::Vue,
1610            true,
1611        );
1612        // Vite's Svelte starter declares the framework as a devDependency —
1613        // it compiles away.
1614        assert(
1615            r#"{"devDependencies":{"svelte":"^5","typescript":"~5.9"}}"#,
1616            WebFramework::Svelte,
1617            true,
1618        );
1619        assert(
1620            r#"{"dependencies":{"solid-js":"^1"}}"#,
1621            WebFramework::Solid,
1622            false,
1623        );
1624        assert(r#"{"dependencies":{"lit":"^3"}}"#, WebFramework::Lit, false);
1625        // A framework we do not know: dependencies exist but match nothing.
1626        assert(
1627            r#"{"dependencies":{"@qwik.dev/core":"^2"}}"#,
1628            WebFramework::Other,
1629            false,
1630        );
1631        assert!(detect_web_frontend("not json").is_none());
1632    }
1633
1634    /// The layout `create vite --template vanilla-ts` produces on Vite 8.
1635    fn write_vanilla_layout(web: &Path) {
1636        std::fs::create_dir_all(web.join("public")).unwrap();
1637        std::fs::create_dir_all(web.join("src/assets")).unwrap();
1638        std::fs::write(
1639            web.join("package.json"),
1640            r#"{"devDependencies":{"typescript":"~6.0","vite":"^8"}}"#,
1641        )
1642        .unwrap();
1643        std::fs::write(
1644            web.join("index.html"),
1645            "<html><head><title>web</title>\
1646             <link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\" />\
1647             </head><body><div id=\"app\"></div></body></html>",
1648        )
1649        .unwrap();
1650        std::fs::write(web.join("public/favicon.svg"), "<svg/>").unwrap();
1651        std::fs::write(web.join("public/icons.svg"), "<svg/>").unwrap();
1652        std::fs::write(web.join("src/main.ts"), "// vite starter").unwrap();
1653        std::fs::write(web.join("src/counter.ts"), "// counter").unwrap();
1654        std::fs::write(web.join("src/style.css"), "/* vite */").unwrap();
1655        std::fs::write(web.join("src/assets/typescript.svg"), "<svg/>").unwrap();
1656    }
1657
1658    #[test]
1659    fn overlay_brands_a_vanilla_layout() {
1660        let temp = tempfile::tempdir().unwrap();
1661        let web = temp.path().join("web");
1662        write_vanilla_layout(&web);
1663
1664        let report = apply_brand_overlay(&web, "My App").unwrap();
1665        assert!(report.branded);
1666        assert!(report.warnings.is_empty(), "{:?}", report.warnings);
1667        assert_eq!(
1668            report.frontend,
1669            Some(WebFrontend {
1670                framework: WebFramework::Vanilla,
1671                typescript: true
1672            })
1673        );
1674
1675        assert!(web.join("public/waterui.svg").is_file());
1676        assert!(!web.join("public/favicon.svg").exists());
1677        assert!(!web.join("public/icons.svg").exists());
1678        assert!(!web.join("src/counter.ts").exists());
1679        assert!(web.join("src/waterui.d.ts").is_file());
1680        let main = std::fs::read_to_string(web.join("src/main.ts")).unwrap();
1681        assert!(main.contains("WaterUI + TypeScript"), "{main}");
1682        assert!(main.contains("'./assets/typescript.svg'"), "{main}");
1683        assert!(main.contains("invoke<string>('greet'"), "{main}");
1684        let html = std::fs::read_to_string(web.join("index.html")).unwrap();
1685        assert!(html.contains("<title>My App</title>"), "{html}");
1686        assert!(html.contains("\"/waterui.svg\""), "{html}");
1687        let style = std::fs::read_to_string(web.join("src/style.css")).unwrap();
1688        assert!(style.contains(".page"), "{style}");
1689    }
1690
1691    #[test]
1692    fn overlay_skips_missing_files_with_warnings() {
1693        let temp = tempfile::tempdir().unwrap();
1694        let web = temp.path().join("web");
1695        std::fs::create_dir_all(&web).unwrap();
1696        // A react-ts manifest but no src/, no index.html: the overlay must
1697        // warn, not fail, and still ship the favicon.
1698        std::fs::write(
1699            web.join("package.json"),
1700            r#"{"dependencies":{"react":"^19"},"devDependencies":{"typescript":"~5.9"}}"#,
1701        )
1702        .unwrap();
1703
1704        let report = apply_brand_overlay(&web, "App").unwrap();
1705        assert!(!report.branded);
1706        assert!(!report.warnings.is_empty());
1707        assert!(
1708            report.warnings.iter().any(|w| w.contains("src/App.tsx")),
1709            "{:?}",
1710            report.warnings
1711        );
1712        assert!(web.join("public/waterui.svg").is_file());
1713    }
1714
1715    /// The tree handle reaches the grandchild the direct child re-execs —
1716    /// the shape `bun run dev` → `node vite` has — and refuses a child that
1717    /// does not lead its own group, since signalling that group would hit
1718    /// `water` itself.
1719    #[test]
1720    #[cfg(unix)]
1721    fn dev_server_tree_signals_the_grandchild_and_refuses_a_shared_group() {
1722        use std::os::unix::process::CommandExt as _;
1723
1724        let mut leader = std::process::Command::new("sh")
1725            .args(["-c", "sleep 30 & wait"])
1726            .stdin(Stdio::null())
1727            .stdout(Stdio::null())
1728            .stderr(Stdio::null())
1729            .process_group(0)
1730            .spawn()
1731            .expect("sh spawns");
1732        let tree =
1733            dev_server_tree::DevServerTree::adopt(&leader).expect("the child leads its group");
1734        tree.signal(false);
1735        let status = leader.wait().expect("the leader is reaped");
1736        assert!(
1737            !status.success(),
1738            "SIGKILL to the group ends the leader: {status}"
1739        );
1740
1741        let mut shared = std::process::Command::new("sh")
1742            .args(["-c", "exit 0"])
1743            .stdin(Stdio::null())
1744            .stdout(Stdio::null())
1745            .stderr(Stdio::null())
1746            .spawn()
1747            .expect("sh spawns");
1748        let refused = dev_server_tree::DevServerTree::adopt(&shared);
1749        let _ = shared.wait();
1750        assert!(refused.is_err(), "a child in our own group must be refused");
1751    }
1752
1753    #[test]
1754    fn dev_url_from_line_finds_vite_local_url() {
1755        for line in [
1756            "  ➜  Local:   http://localhost:5173/",
1757            "  ➜  Local:   https://localhost:5173/",
1758            "Local: http://127.0.0.1:3000",
1759            "Local: http://[::1]:8080/",
1760            "ready in 42ms http://localhost:5173/app/index.html",
1761        ] {
1762            let url = dev_url_from_line(line).unwrap_or_else(|| panic!("no URL in {line:?}"));
1763            assert!(url.port().is_some(), "explicit port required: {line:?}");
1764        }
1765        assert_eq!(
1766            dev_url_from_line("  ➜  Local:   http://localhost:5173/")
1767                .unwrap()
1768                .as_str(),
1769            "http://localhost:5173/"
1770        );
1771    }
1772
1773    #[test]
1774    fn dev_url_from_line_rejects_non_loopback_and_portless_urls() {
1775        for line in [
1776            "  ➜  Network: http://192.168.1.4:5173/",
1777            "  ➜  Network: http://172.20.10.2:5173/",
1778            "see https://localhost:5173.example.com/ for details",
1779            "no url here",
1780            "http://localhost is missing a port",
1781            "VITE v7.0.0  ready in 120 ms",
1782        ] {
1783            assert_eq!(dev_url_from_line(line), None, "unexpected URL in {line:?}");
1784        }
1785    }
1786
1787    #[test]
1788    fn device_facing_url_rewrites_loopback_for_ios_device() {
1789        let url: url::Url = "http://localhost:5173/".parse().unwrap();
1790        for target in [
1791            DevTarget::Desktop,
1792            DevTarget::IosSimulator,
1793            DevTarget::Android,
1794        ] {
1795            assert_eq!(device_facing_url(target, &url).unwrap(), url);
1796        }
1797        let rewritten = device_facing_url(DevTarget::IosDevice, &url).unwrap();
1798        assert_eq!(rewritten.port(), Some(5173));
1799        let host = rewritten.host_str().expect("a host");
1800        let ip: std::net::Ipv4Addr = host.parse().expect("an IPv4 LAN host");
1801        assert!(!ip.is_loopback());
1802    }
1803
1804    #[test]
1805    fn adb_reverse_args_forward_the_dev_url_port() {
1806        assert_eq!(
1807            adb_reverse_args("emulator-5554", 5173),
1808            ["-s", "emulator-5554", "reverse", "tcp:5173", "tcp:5173"]
1809        );
1810    }
1811
1812    #[test]
1813    fn dev_url_port_reads_the_launch_environment() {
1814        assert_eq!(
1815            dev_url_port(std::iter::empty::<(&str, &str)>()).unwrap(),
1816            None
1817        );
1818        assert_eq!(
1819            dev_url_port([("WATERUI_DEV_URL", "http://localhost:5173/")].into_iter()).unwrap(),
1820            Some(5173)
1821        );
1822        assert!(
1823            dev_url_port([("WATERUI_DEV_URL", "not a url")].into_iter()).is_err(),
1824            "a malformed handoff fails loudly"
1825        );
1826    }
1827
1828    #[test]
1829    fn dev_script_probes_dev_serve_start() {
1830        let temp = tempfile::tempdir().unwrap();
1831        let package_json = temp.path().join("package.json");
1832
1833        std::fs::write(
1834            &package_json,
1835            r#"{"scripts":{"build":"vite build","serve":"vite preview"}}"#,
1836        )
1837        .unwrap();
1838        assert_eq!(dev_script(temp.path()).unwrap(), "serve");
1839
1840        std::fs::write(&package_json, r#"{"scripts":{"start":"node server.js"}}"#).unwrap();
1841        assert_eq!(dev_script(temp.path()).unwrap(), "start");
1842
1843        std::fs::write(
1844            &package_json,
1845            r#"{"scripts":{"dev":"vite","serve":"vite preview"}}"#,
1846        )
1847        .unwrap();
1848        assert_eq!(dev_script(temp.path()).unwrap(), "dev");
1849
1850        std::fs::write(&package_json, r#"{"scripts":{"build":"vite build"}}"#).unwrap();
1851        let error = dev_script(temp.path()).unwrap_err().to_string();
1852        for script in ["dev", "serve", "start"] {
1853            assert!(error.contains(script), "error names the probes: {error}");
1854        }
1855    }
1856}