waterui_cli/tui/mod.rs
1//! Experimental terminal (TUI) backend support.
2//!
3//! The renderer lives in the standalone `water-rs/tui` repository — the CLI
4//! only generates a thin launcher crate under the project's managed build cache
5//! and hands the invoking terminal to the built binary. The backend is opt-in
6//! through `water run --tui` alone: it is not a managed [`crate::backend::Backend`],
7//! does not appear in backend selection, and cannot be configured in
8//! `Water.toml` while experimental.
9//!
10//! Dependency resolution for the generated launcher follows the project's own
11//! framework mode: a `waterui_path` checkout supplies path dependencies and the
12//! checkout's `[patch]` table, a channel supplies the resolved framework's
13//! `[patch]` table, and a stable project resolves everything from the registry.
14//! The `waterui-tui` crate itself comes from `WATERUI_TUI_PATH`, a `water-rs/tui`
15//! checkout beside a local `waterui_path`, or the pinned
16//! [`crate::build_info::TUI_BACKEND`] revision, in that order.
17
18use std::path::{Path, PathBuf};
19
20use crate::{
21 build::{BuildProgress, RustBuild, RustLinkage},
22 project::Project,
23 templates::{self, TemplateContext},
24 water_dir,
25};
26
27/// Directory the launcher's sources are generated into.
28///
29/// The TUI launcher always lives in the project's managed build cache —
30/// including for application projects — because an experimental backend never
31/// writes into the project's own tree.
32async fn launcher_dir(project: &Project) -> eyre::Result<PathBuf> {
33 Ok(water_dir::project_build_cache_dir(project.root())
34 .await?
35 .join("tui"))
36}
37
38async fn template_context(project: &Project, dir: &Path) -> eyre::Result<TemplateContext> {
39 let manifest = project.manifest();
40 let app_name = manifest
41 .package
42 .name
43 .chars()
44 .filter(|c| c.is_alphanumeric())
45 .collect::<String>();
46 Ok(TemplateContext::for_project_manifest(
47 manifest,
48 project.crate_name().clone(),
49 app_name,
50 &project.resolved_framework().await?,
51 )
52 .with_backend_project_path(dir.to_path_buf())
53 .with_project_root_path(project.root().to_path_buf()))
54}
55
56/// Whether the generated launcher's sources differ from what the current
57/// templates would produce for this project.
58async fn requires_regeneration(project: &Project, dir: &Path) -> eyre::Result<bool> {
59 let ctx = template_context(project, dir).await?;
60 for (relative, expected) in
61 templates::tui::rendered_outputs(&ctx, project.tui_backend_crate_name().as_str())?
62 {
63 match std::fs::read(dir.join(&relative)) {
64 Ok(existing) if existing == expected => {}
65 Ok(_) | Err(_) => return Ok(true),
66 }
67 }
68 Ok(false)
69}
70
71/// Regenerate the launcher crate when it is missing or stale and return its
72/// directory.
73///
74/// # Errors
75///
76/// Returns an error if the build cache cannot be resolved, template rendering
77/// fails, or the launcher's sources cannot be written.
78pub async fn ensure_launcher(project: &Project) -> eyre::Result<PathBuf> {
79 let dir = launcher_dir(project).await?;
80 if requires_regeneration(project, &dir).await? {
81 let ctx = template_context(project, &dir).await?;
82 templates::tui::scaffold(&dir, &ctx, project.tui_backend_crate_name().as_str()).await?;
83 }
84 Ok(dir)
85}
86
87/// Build the launcher binary for the host and return its path.
88///
89/// The TUI launcher always builds the static-runtime variant: it is a leaf
90/// binary, not a plugin host, so the shared-runtime linkage has nothing to
91/// offer it.
92///
93/// # Errors
94///
95/// Returns an error if the project's target directory cannot be resolved or
96/// the Cargo build fails.
97pub async fn build(
98 project: &Project,
99 launcher_dir: &Path,
100 sccache_path: Option<PathBuf>,
101 progress: Option<BuildProgress>,
102) -> eyre::Result<PathBuf> {
103 let mut build = RustBuild::new(launcher_dir, target_lexicon::Triple::host())
104 .with_project(project)
105 .with_target_dir(project.water_target_dir(RustLinkage::Static).await?);
106 if let Some(sccache_path) = sccache_path {
107 build = build.with_sccache(sccache_path);
108 }
109 if let Some(progress) = progress {
110 build = build.with_progress(progress);
111 }
112 build
113 .build_binary(project.tui_backend_crate_name().as_str(), false)
114 .await
115 .map_err(|error| eyre::eyre!("failed to build the TUI launcher: {error}"))
116}
117
118/// Hand the invoking terminal to the built launcher.
119///
120/// On Unix the launcher replaces the CLI process via `exec`, so the terminal is
121/// owned by exactly one process and its exit status propagates unchanged — a
122/// spawned child would instead share the process group with `water` and keep
123/// the TTY after `water` itself dies on `SIGINT`. Elsewhere the launcher is
124/// spawned with inherited stdio and awaited.
125///
126/// # Errors
127///
128/// Returns an error if the launcher cannot be started; on Unix a successful
129/// `exec` never returns.
130pub fn exec(binary: &Path) -> eyre::Result<()> {
131 use std::io::Write as _;
132 // Anything still buffered in the CLI's stdout would be lost (exec) or
133 // interleave with the launcher's own escape sequences (spawn), so flush
134 // before handing over the terminal.
135 let _ = std::io::stdout().flush();
136 #[cfg(unix)]
137 {
138 use eyre::WrapErr as _;
139 use std::os::unix::process::CommandExt as _;
140 Err(std::process::Command::new(binary).exec())
141 .wrap_err_with(|| format!("failed to launch the TUI binary {}", binary.display()))
142 }
143 #[cfg(not(unix))]
144 {
145 // Blocking here is the point: the launcher owns the terminal until it
146 // exits, and nothing runs after this call.
147 let status = std::process::Command::new(binary).status()?;
148 if !status.success() {
149 eyre::bail!("the TUI application exited with {status}");
150 }
151 Ok(())
152 }
153}