waterui-cli 0.4.0

Cross-platform tooling for WaterUI applications
Documentation
# AGENTS.md

The `water` command: it creates, builds, runs, previews, tests, benchmarks
and packages WaterUI applications. The framework's design principles and the
repository-boundary policy live in `water-rs/waterui` (`AGENTS.md` there) and
govern this crate too; this file carries only what is specific to this
repository. `CLAUDE.md` is a symlink to this file.

## Workflow

- **Finding a problem → GitHub issue in this repository. Solving it → pull
  request to `dev`.** One PR resolves one issue or lands one discrete fix, and
  its body links the issue (`Fixes #N`). `dev` and `main` are pull-request only;
  `main` is the release branch. Merging is the user's decision. A defect in the
  *framework* — a resolution channel that cannot carry a value the CLI needs, a
  scaffold-time fact missing from `framework.json` — is an issue in
  `water-rs/waterui`, and the CLI change waits for it.
- Lint bar: `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`,
  `cargo nextest run`, `cargo check --no-default-features --all-targets`. No new
  warnings, clippy warnings included. Format changed files with
  `rustfmt --edition 2024 <file>`.
- `Cargo.lock` is committed: this is a binary crate and CI, `cargo install
  --locked`, and `dist` all build the locked graph.
- release-plz owns the version and `CHANGELOG.md`; write conventional commits
  (`feat:`, `fix:`, `feat!:`) and never edit either by hand. The release
  workflow builds the prebuilt binaries with `dist`, hands the Homebrew formula
  to `water-rs/homebrew-water`, and publishes to crates.io over OIDC trusted
  publishing bound to `.github/workflows/release.yml` — renaming that file
  breaks publishing.

## Dependencies on the framework

The CLI links a handful of framework crates (`waterui-assets-core`,
`waterui-assets-planner`, the preview / inspector / MCP protocol crates). They
are pinned to one `water-rs/waterui` revision as git dependencies carrying the
`version` a release will resolve to, the same form `water-rs/gtk-backend` uses:
every framework crate at the same `rev`, so a shared type never exists twice.
Move the pin deliberately, all entries at once, with `cargo update -p` for the
lock; never mix a git copy of one crate with a registry copy of its sibling.
`cargo publish` strips `git` and keeps `version`, so a release can only ship
once those versions are on crates.io.

Everything else the CLI knows about the framework it learns at run time, never
at build time:

- The framework channel (`dev` / `nightly` / `stable`) is resolved in the
  library (`src/project_model/framework.rs`) into exact revisions and versions
  and persisted with the project. `stable` reads the `framework.json` the
  framework's release publishes; `dev` reads the integration branch; `nightly`
  reads the last certified prerelease. No channel falls back to another one.
- Framework-owned scaffold facts — backend coordinates, the Android API floor,
  `minimum-cli-version` — live in the framework's root
  `[package.metadata.waterui]` and reach the CLI through the resolved
  framework. `[package.metadata.waterui-scaffold]` in this manifest holds only
  what no framework manifest supplies, and `build.rs` refuses any other key.
- `ANDROID_NDK_VERSION` (`src/android/ndk_version.rs`) is a source literal so a
  `cargo install`ed CLI can name the NDK package to install; the nightly job
  checks it against the Android runtime's declared `ndkVersion` at the pinned
  framework revision.

## Design rules that bite here

- **Never recover semantics from user source code.** The CLI locates
  previews, tests, benchmarks, assets and capabilities by reading compiler
  artifacts (`waterui_meta_*` symbols in the user crate's rlib,
  `src/artifact_symbols.rs`), the manifests it owns (`Water.toml`), and
  `cargo metadata` — never by grepping or parsing `.rs` files. When no channel
  carries a fact, build the channel in the framework; do not scrape.
- **The terminal layer is thin.** `src/terminal/` parses arguments and formats
  output through `Shell`; every decision — resolution, device selection, build
  orchestration, doctor checks — lives in the library and is unit-testable
  without a terminal.
- **Every probe of the machine goes through `Host`** (`src/toolchain/host.rs`):
  PATH lookup, environment variables, process spawning. `Host::current()` is the
  real machine; tests build a `Host` over a scratch PATH of fake tools
  (`src/toolchain/testing.rs`, `src/toolchain/testdata/fake_tools.{sh,cmd}`)
  and an explicit environment map. No `std::env::var`, `which::which`, or
  `Command::new` outside the host seam, and no environment mutation in tests.
- **Fail fast, no fallbacks.** An unresolvable channel, a missing manifest key,
  a toolchain that reports the wrong shape — each is an error naming what was
  found and what was required, never a silent substitute. `doctor` classifies a
  problem as fixable or not; it does not paper over it.
- Diagnostics go through `tracing` (`RUST_LOG=debug water …` prints them to
  stderr; `--logs` on `water run` is the *device* log level), never `println!`.
  Structured text is serialized (`serde`) or rendered from a typed `askama`
  template under `templates/`, never concatenated.
- No blind sleeps: waiting is on a readiness signal (a port, a file, a device
  state), and `std::thread::sleep` is banned in tests.

## Testing

- `cargo nextest run` is the runner; every test runs in its own process, so
  nothing may rely on state a sibling test initialized.
- `tests/doctor_json.rs` runs the built binary with `doctor --json` and checks
  identity and schema of every item, never status — it must stay deterministic
  on any machine.
- Tests that need a framework checkout or the network are `#[ignore]` with the
  reason and run only in `nightly.yml` (`--run-ignored ignored-only`); the
  per-PR gate must pass offline on a clean runner.
- The nightly end-to-end legs run `doctor --fix`, `create`, `build`, `package`
  and `run` as a fresh OS user on macOS, Windows and Linux against the pinned
  framework revision. A failure there is a bug in this crate or the framework,
  not flakiness to retry.

## Architecture

### Crate Structure

The CLI is split into two parts:

1. **Library (`src/lib.rs`)** - Core logic, platform abstractions, device management
2. **Terminal (`src/terminal/`)** - User interface, argument parsing, output formatting

**Key principle: Terminal handles interaction only, library handles real logic.**

```
src/
├── lib.rs               # Library entry point (re-exports modules)
├── terminal/            # Binary entry point (UI layer)
│   ├── main.rs          # CLI argument parsing (clap)
│   ├── shell.rs         # Output formatting (spinners, colors, macros)
│   └── commands/        # Command implementations (thin wrappers)
├── platforming/         # `TargetPlatform`, `Backend`, bundle/share layouts
├── project_model/       # Water.toml, framework resolution, templates, assets
├── toolchain/           # `Host` seam, doctor, per-tool checks and installers
├── apple/ android/ gtk4/ hydrolysis/ esp32/   # Platform implementations
├── preview/ workflows/ mcp/ tui/ bench/       # Development-loop surfaces
├── artifact_symbols.rs  # `waterui_meta_*` symbol reads from compiled rlibs
└── templates/           # Scaffolding templates (askama; assets under templates/)
```

### Core Abstractions

### Device Trait (`platforming/`, per-platform `device.rs`)

Represents something that can run an app (simulator, emulator, physical device).

```rust
pub trait Device: Send {
    type Platform: Platform;
    
    /// Launch the device (boot simulator/emulator). No-op for physical devices.
    fn launch(&self) -> impl Future<Output = eyre::Result<()>> + Send;
    
    /// Run an artifact on the device. Device must be launched first.
    fn run(&self, artifact: Artifact, options: RunOptions) 
        -> impl Future<Output = Result<Running, FailToRun>> + Send;
    
    fn platform(&self) -> Self::Platform;
}
```

**Important**: `launch()` handles booting. For emulators that need to be started from cold, `launch()` should start the emulator process and wait until it's ready. For already-connected devices, `launch()` is a no-op.

Implementations:
- `AppleSimulator` - iOS/tvOS/watchOS simulator (boots via `simctl boot`)
- `MacOS` - Current machine (no-op launch)
- `AndroidDevice` - Connected Android device (waits for device via adb)
- `AndroidEmulator` - AVD that needs to be launched (starts emulator process)

### Platform Trait (`platforming/platform.rs`)

Represents a build target platform.

```rust
pub trait Platform: Send {
    type Toolchain: Toolchain;
    type Device: Device;
    
    fn scan(&self) -> impl Future<Output = eyre::Result<Vec<Self::Device>>> + Send;
    fn build(&self, project: &Project, options: BuildOptions) -> impl Future<...>;
    fn package(&self, project: &Project, options: PackageOptions) -> impl Future<...>;
    fn clean(&self, project: &Project) -> impl Future<...>;
    fn toolchain(&self) -> Self::Toolchain;
    fn triple(&self) -> Triple;
}
```

Implementations:
- `ApplePlatform` - iOS, iOS Simulator, macOS, tvOS, etc.
- `AndroidPlatform` - Android with different ABIs (arm64-v8a, x86_64, etc.)

### Project (`project_model/project.rs`)

Manages `Water.toml` manifest and orchestrates builds.

Key methods:
- `Project::open()` - Open existing project
- `Project::create()` - Create new project
- `Project::run()` - Build, package, launch device, and run app
- `Project::build()` - Build Rust library
- `Project::package()` - Package for platform

### Terminal Layer Conventions

Terminal commands in `src/terminal/commands/` should:

1. **Parse arguments** using clap
2. **Show progress** using `shell::spinner()`, `success!()`, `error!()`, etc.
3. **Delegate to library** for actual work
4. **Format output** for the user

Example pattern:
```rust
pub async fn run(args: Args) -> Result<()> {
    let project = Project::open(&args.path).await?;
    
    // Show progress
    let spinner = shell::spinner("Building...");
    
    // Delegate to library
    let result = project.build(platform, options).await;
    
    // Handle result with user-friendly output
    match result {
        Ok(_) => success!("Build complete"),
        Err(e) => error!("Build failed: {e}"),
    }
}
```

**Do NOT put heavy logic in terminal commands.** If you find yourself writing complex logic (loops, polling, process management), it belongs in the library layer.

### Device Lifecycle

The correct flow for running an app:

1. **Scan** - `Platform::scan()` returns available devices
2. **Select** - Choose a device (or create an emulator device if none available)
3. **Launch** - Terminal calls `Device::launch()` to boot simulator/emulator (can run in background while building)
4. **Run** - `Project::run()` builds, packages, and runs on the device (assumes device is already launched)

**Important**: `Project::run()` does NOT launch the device - it assumes the device is already launched and ready. The terminal layer (`water run` command) is responsible for:
- Spawning `device.launch()` as a background task
- Building and packaging the app in parallel with device launch
- Waiting for device to be ready before running the app

This allows simulator/emulator boot time to overlap with build time for better UX.

### Adding New Device Types

When adding a new device type:

1. Create a struct in the platform's `device.rs`
2. Implement `Device` trait
3. Put launching logic in `launch()` method
4. Reuse existing device's `run()` when possible

Example: `AndroidEmulator` (in `android/device.rs`):
```rust
pub struct AndroidEmulator {
    avd_name: String,
    device: OnceLock<AndroidDevice>,  // Set after launch
}

impl Device for AndroidEmulator {
    async fn launch(&self) -> Result<()> {
        // Start emulator process
        // Wait for it to boot (poll adb devices)
        // Store resulting AndroidDevice in self.device
    }
    
    async fn run(&self, artifact, options) -> Result<Running, FailToRun> {
        // Delegate to the inner AndroidDevice
        self.device.get().unwrap().run(artifact, options).await
    }
}
```

The terminal command just needs to create the right device type:
```rust
// In terminal/commands/run.rs
if let Some(dev) = devices.into_iter().next() {
    Ok(SelectedDevice::AndroidDevice(dev))
} else {
    // No connected devices - create an emulator device
    let avd = AndroidPlatform::list_avds().await?.first()...;
    Ok(SelectedDevice::AndroidEmulator(AndroidEmulator::new(avd)))
}
```

Then `Project::run()` calls `device.launch()` which handles the emulator startup.

### Error Handling

- Use `color_eyre::eyre::Result` for library functions
- Use `thiserror` for custom error enums (e.g., `FailToRun`, `FailToOpenProject`)
- Terminal layer converts errors to user-friendly messages

### Async Runtime

Uses `smol` for async:
- `smol::process::Command` for spawning processes
- `smol::spawn()` for background tasks
- `smol::Timer` for delays
- `smol::channel` for event streaming
- `smol::future::zip` for parallel operations