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