diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5c71657a..34a6d496 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -129,15 +129,17 @@ Every code change ships with tests. No exceptions. The `docs/` directory is a VitePress site for external developers consuming WebUI. - Any change to user-visible behavior, CLI usage, or public API **must** include a corresponding docs update in the same PR. -- New features get a guide page (`docs/guide/`) or tutorial (`docs/tutorials/`). -- **User-facing docs show template syntax, state, and rendered output only.** Never expose protocol internals (fragment types, proto fields, stream IDs) in `/docs`. Protocol details belong in `DESIGN.md` and `docs/guide/advanced/protocol.md`. -- Verify with `cd docs && pnpm build` when possible. +- For the full docs synchronization workflow, use the skill: `skills/docs-sync/SKILL.md`. --- ## Skills -- **Pull request** — `.github/skills/pr/SKILL.md` +- **Pull request** — `skills/pr/SKILL.md` +- **Quality gate** — `skills/quality-gate/SKILL.md` +- **FFI boundary** — `skills/ffi/SKILL.md` +- **Protobuf schema evolution** — `skills/protobuf/SKILL.md` +- **Docs synchronization** — `skills/docs-sync/SKILL.md` --- @@ -160,27 +162,18 @@ The `docs/` directory is a VitePress site for external developers consuming WebU ## FFI boundary (`webui-ffi`) -The FFI crate exposes WebUI to **any** host language (C, C#, Go, Ruby, Python, Node.js, etc.) via a C-compatible ABI. Treat it as the project's most sensitive surface. +The FFI crate exposes WebUI to many host languages via a C-compatible ABI and remains a high-sensitivity surface. -- Every `pub extern "C" fn` must have a `# Safety` doc section explaining pointer validity, lifetime, and ownership expectations. -- All `unsafe` blocks require a `// SAFETY:` comment. No exceptions. -- Assume callers are in a different language with no Rust safety net — validate every input (null pointers, invalid UTF-8, out-of-range values) before dereferencing or converting. -- Never panic across the FFI boundary. Catch all errors and return them as error codes or null pointers. A panic in FFI is undefined behavior. -- C header generation is handled by `cbindgen` in `build.rs`. After changing any `#[no_mangle]` function signature, verify the generated header in `include/webui_ffi.h` is correct. -- Keep the FFI surface minimal and stable — additions are easy, removals break every consumer. -- Platform-specific code must be gated behind `#[cfg(target_os = "...")]` and every platform path must be tested or at least compile-checked. -- Design for ABI stability: prefer opaque pointers and integer error codes over exposing Rust struct layouts. +- Treat all FFI changes as safety- and compatibility-critical. +- Use the full workflow and checklist in: `skills/ffi/SKILL.md`. --- ## Protobuf schema evolution -The protocol is defined in `crates/webui-protocol/proto/webui.proto` and compiled by `prost` via `build.rs`. Schema changes cascade through the entire stack: **protocol → handler → FFI → CLI**. +Schema changes cascade through protocol → handler → FFI → CLI and should optimize runtime performance first. -- Never remove or renumber existing proto fields — mark them `reserved` instead. -- Add new fields as optional with sensible defaults so older serialized data remains decodable. -- After any `.proto` change, rebuild the full workspace (`cargo xtask build`) and run all tests — not just the protocol crate. -- Update `DESIGN.md` protocol section in the same commit. +- Use the protocol evolution workflow in: `skills/protobuf/SKILL.md`. --- @@ -234,7 +227,7 @@ Before finishing any task, confirm **all** of these: - [ ] No new `unwrap`/`expect` in library code. - [ ] No unnecessary allocations introduced; buffers reused where possible. - [ ] FFI changes include `# Safety` docs and never panic across the boundary. -- [ ] Proto schema changes are backward-compatible and cascade-tested. +- [ ] Proto schema changes prioritize performance and are cascade-tested. - [ ] New dependencies use `workspace = true` and pass `cargo deny check`. - [ ] Commit is on a feature branch, not `main`. - [ ] Commit message is imperative with Copilot co-author trailer. diff --git a/.github/skills/docs-sync/SKILL.md b/.github/skills/docs-sync/SKILL.md new file mode 100644 index 00000000..ebee29b3 --- /dev/null +++ b/.github/skills/docs-sync/SKILL.md @@ -0,0 +1,37 @@ +--- +name: docs-sync +description: Keep user-facing docs and DESIGN specification aligned with behavior and API changes. +--- + +# Docs Synchronization Workflow + +Use this skill for user-visible behavior changes or API/contract changes. + +## DESIGN.md (spec) requirements + +Update `DESIGN.md` in the same change when modifying: + +- public APIs +- protocol fields +- behavioral contracts +- error variants + +Treat `DESIGN.md` as the implementation specification. + +## docs/ requirements + +Update `docs/` in the same change when behavior is user-visible: + +- CLI usage changes +- template syntax/state/render output changes +- integration behavior that external users depend on + +Keep protocol internals out of general user docs unless placed in advanced protocol documentation. + +## Optional validation + +When docs are changed substantially, validate with: + +```bash +cd docs && pnpm build +``` diff --git a/.github/skills/ffi/SKILL.md b/.github/skills/ffi/SKILL.md new file mode 100644 index 00000000..22a6d96b --- /dev/null +++ b/.github/skills/ffi/SKILL.md @@ -0,0 +1,30 @@ +--- +name: ffi +description: Rules and checklist for safe, stable changes to the webui-ffi C ABI boundary. +--- + +# FFI Boundary Workflow + +Use this skill when touching `crates/webui-ffi` or C ABI signatures. + +## Mandatory safety rules + +- Every `pub extern "C" fn` must include a `# Safety` doc section. +- Every `unsafe` block must include a `// SAFETY:` justification comment. +- Never allow panic to cross the FFI boundary. +- Validate all foreign inputs before dereferencing or conversion: + - null pointers + - invalid UTF-8 + - out-of-range values + +## ABI stability expectations + +- Keep exported surface minimal and stable. +- Prefer opaque pointers and integer error codes over exposing Rust layouts. +- Use `#[cfg(target_os = "...")]` for platform-specific code paths. + +## Header sync + +If any `#[no_mangle]` signature changes, verify generated header output in: + +- `crates/webui-ffi/include/webui_ffi.h` diff --git a/.github/skills/pr/SKILL.md b/.github/skills/pr/SKILL.md index fcc50e04..c9c55ee3 100644 --- a/.github/skills/pr/SKILL.md +++ b/.github/skills/pr/SKILL.md @@ -1,3 +1,8 @@ +--- +name: pr +description: Guidance for branch naming, commit messages, and PR titles. +--- + # Pull Request Conventions ## Branch discipline @@ -15,6 +20,7 @@ PR titles must use a [Conventional Commits](https://www.conventionalcommits.org/ | `fix:` | Bug fix | `fix: render missing signals as empty` | | `chore:` | Maintenance, refactoring, CI, docs, dependencies | `chore: move shared files to examples/app` | + The prefix is **lowercase**, followed by a colon and a space, then a short imperative description. ## Linking PRs to issues diff --git a/.github/skills/protobuf/SKILL.md b/.github/skills/protobuf/SKILL.md new file mode 100644 index 00000000..920e5efd --- /dev/null +++ b/.github/skills/protobuf/SKILL.md @@ -0,0 +1,34 @@ +--- +name: protobuf +description: Performance-first protobuf schema workflow for WebUI protocol changes. +--- + +# Protobuf Evolution Workflow + +Use this skill when modifying `crates/webui-protocol/proto/webui.proto`. + +## Performance-first schema rules + +- Prioritize decode speed, memory layout, and smaller payloads over wire compatibility. +- Breaking field changes are allowed when they improve performance measurably. +- When introducing a breaking schema, update all affected layers in one change: + - protocol + - handler + - FFI + - CLI +- Remove unused fields and message shapes that add decode overhead. + +## Required validation + +After schema updates, run: + +```bash +cargo xtask build +cargo xtask test +``` + +Schema changes affect the whole stack: protocol → handler → FFI → CLI. Keep the stack synchronized in the same change. + +## Documentation sync + +When protocol behavior changes, update `DESIGN.md` protocol sections in the same change. \ No newline at end of file diff --git a/.github/skills/quality-gate/SKILL.md b/.github/skills/quality-gate/SKILL.md new file mode 100644 index 00000000..194bf9f4 --- /dev/null +++ b/.github/skills/quality-gate/SKILL.md @@ -0,0 +1,35 @@ +--- +name: quality-gate +description: Required verification workflow for formatting, linting, tests, dependency audits, and builds. +--- + +# Quality Gate Workflow + +Use this skill whenever work changes code, tests, dependencies, or docs in this repository. + +## Required gate + +Before any commit, run: + +```bash +cargo xtask check +``` + +This runs, in order: `fmt → clippy → deny → test → build → doc`. + +Work is not complete until it passes cleanly. + +## Fast iteration sequence + +When iterating locally, use this order: + +1. Targeted crate checks first (for faster feedback): + - `cargo test -p ` +2. Then full gate: + - `cargo xtask check` + +## Expectations + +- Fix reported issues rather than suppressing them. +- Do not merge or commit with a failing gate. +- Keep fixes minimal and scoped to the task. diff --git a/crates/webui-test-utils/Cargo.toml b/crates/webui-test-utils/Cargo.toml index d1158571..51294f8e 100644 --- a/crates/webui-test-utils/Cargo.toml +++ b/crates/webui-test-utils/Cargo.toml @@ -1,6 +1,7 @@ [package] name = "webui-test-utils" description = "Test utilities for WebUI framework" +publish = false version.workspace = true edition.workspace = true authors.workspace = true diff --git a/deny.toml b/deny.toml index 8849bd5d..e55eed3b 100644 --- a/deny.toml +++ b/deny.toml @@ -9,7 +9,6 @@ allow = [ "Apache-2.0", "MPL-2.0", "Unicode-3.0", - "Unicode-DFS-2016", ] confidence-threshold = 0.8 @@ -17,4 +16,17 @@ confidence-threshold = 0.8 ignore = true [bans] -multiple-versions = "warn" +multiple-versions = "deny" +skip = [ + { name = "wasi" }, + { name = "windows-sys" }, + { name = "windows-targets" }, + { name = "windows_aarch64_gnullvm" }, + { name = "windows_aarch64_msvc" }, + { name = "windows_i686_gnu" }, + { name = "windows_i686_gnullvm" }, + { name = "windows_i686_msvc" }, + { name = "windows_x86_64_gnu" }, + { name = "windows_x86_64_gnullvm" }, + { name = "windows_x86_64_msvc" }, +] diff --git a/examples/integration/hyper/Cargo.toml b/examples/integration/hyper/Cargo.toml index 0e32f374..2c20e68d 100644 --- a/examples/integration/hyper/Cargo.toml +++ b/examples/integration/hyper/Cargo.toml @@ -2,6 +2,7 @@ name = "webui-hyper-integration" version = "0.1.0" edition = "2021" +publish = false [[bin]] name = "webui-hyper-integration" diff --git a/examples/integration/hyper/src/main.rs b/examples/integration/hyper/src/main.rs index 175e4734..157e6f2e 100644 --- a/examples/integration/hyper/src/main.rs +++ b/examples/integration/hyper/src/main.rs @@ -48,8 +48,8 @@ struct Cli { fn main() { let cli = Cli::parse(); - let result = run(&cli); - if result.is_err() { + if let Err(err) = run(&cli) { + eprintln!("\n ✘ {err:#}"); std::process::exit(1); } } diff --git a/examples/integration/tiny_http/Cargo.toml b/examples/integration/tiny_http/Cargo.toml index 20b97e16..2052b797 100644 --- a/examples/integration/tiny_http/Cargo.toml +++ b/examples/integration/tiny_http/Cargo.toml @@ -2,6 +2,7 @@ name = "webui-tiny-http-integration" version = "0.1.0" edition = "2021" +publish = false [[bin]] name = "webui-tiny-http-integration" diff --git a/examples/integration/tiny_http/src/main.rs b/examples/integration/tiny_http/src/main.rs index 46c7289d..844a709b 100644 --- a/examples/integration/tiny_http/src/main.rs +++ b/examples/integration/tiny_http/src/main.rs @@ -38,8 +38,8 @@ struct Cli { fn main() { let cli = Cli::parse(); - let result = run(&cli); - if result.is_err() { + if let Err(err) = run(&cli) { + eprintln!("\n ✘ {err:#}"); std::process::exit(1); } } diff --git a/examples/shared/rust/hmr.rs b/examples/shared/rust/hmr.rs index 87a4ae52..c03c7476 100644 --- a/examples/shared/rust/hmr.rs +++ b/examples/shared/rust/hmr.rs @@ -6,9 +6,7 @@ use std::time::SystemTime; /// of the template and data files. Returns a millisecond timestamp /// or `"0"` if neither file has a retrievable modification time. pub fn hmr_version(template: &Path, data: &Path) -> String { - let template_mtime = fs::metadata(template) - .and_then(|m| m.modified()) - .ok(); + let template_mtime = fs::metadata(template).and_then(|m| m.modified()).ok(); let data_mtime = fs::metadata(data).and_then(|m| m.modified()).ok(); let latest: Option = match (template_mtime, data_mtime) { diff --git a/xtask/src/main.rs b/xtask/src/main.rs index f1cf948e..4a9f870b 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1,16 +1,28 @@ use std::process::{Command, ExitCode}; +use std::{ + fs, + path::{Path, PathBuf}, +}; fn main() -> ExitCode { - let task = std::env::args().nth(1); + let args: Vec = std::env::args().collect(); + let task = args.get(1).map(|s| s.as_str()); - match task.as_deref() { + match task { Some("check") => check(), Some("fmt") => run_steps(&[Step::FMT]), Some("clippy") => run_steps(&[Step::CLIPPY]), Some("deny") => run_steps(&[Step::DENY]), Some("test") => run_steps(&[Step::TEST]), - Some("build") => run_steps(&[Step::BUILD]), + Some("build") => run_steps(&[Step::BUILD, Step::BUILD_INTEGRATIONS, Step::BUILD_APPS]), + Some("build-integrations") => run_steps(&[Step::BUILD_INTEGRATIONS]), + Some("build-apps") => run_steps(&[Step::BUILD_APPS]), Some("docs") => run_steps(&[Step::DOCS]), + Some("run") => { + let integration = args.get(2).map(|s| s.as_str()); + let app = args.get(3).map(|s| s.as_str()); + run_integration_app(integration, app) + } _ => usage(), } } @@ -25,6 +37,10 @@ fn usage() -> ExitCode { deny Run cargo-deny license/advisory checks\n \ test Run all tests\n \ build Build the workspace\n \ + build-integrations Build all examples/integration targets\n \ + build-apps Build all examples/app templates through webui-cli\n \ + run Run an integration with an app\n \ + usage: cargo xtask run \n \ docs Build the documentation site" ); ExitCode::FAILURE @@ -37,62 +53,101 @@ fn check() -> ExitCode { Step::DENY, Step::TEST, Step::BUILD, + Step::BUILD_INTEGRATIONS, + Step::BUILD_APPS, Step::DOCS, ]) } struct Step { name: &'static str, + run: fn() -> Result<(), String>, +} + +struct BuildCommand { cmd: &'static str, args: &'static [&'static str], + cwd: Option<&'static str>, } +struct IntegrationBuild { + name: &'static str, + commands: &'static [BuildCommand], + run_commands: &'static [BuildCommand], +} + +const INTEGRATION_BUILDS: &[IntegrationBuild] = &[ + IntegrationBuild { + name: "hyper", + commands: &[BuildCommand { + cmd: "cargo", + args: &["build"], + cwd: Some("examples/integration/hyper"), + }], + run_commands: &[BuildCommand { + cmd: "cargo", + args: &["run", "--"], + cwd: Some("examples/integration/hyper"), + }], + }, + IntegrationBuild { + name: "tiny_http", + commands: &[BuildCommand { + cmd: "cargo", + args: &["build"], + cwd: Some("examples/integration/tiny_http"), + }], + run_commands: &[BuildCommand { + cmd: "cargo", + args: &["run", "--"], + cwd: Some("examples/integration/tiny_http"), + }], + }, +]; + impl Step { const FMT: Self = Self { name: "fmt", - cmd: "cargo", - args: &["fmt", "--all", "--check"], + run: run_fmt, }; const CLIPPY: Self = Self { name: "clippy", - cmd: "cargo", - args: &["clippy", "--workspace", "--", "-D", "warnings"], + run: run_clippy, }; const DENY: Self = Self { name: "deny", - cmd: "cargo", - args: &["deny", "check"], + run: run_deny, }; const TEST: Self = Self { name: "test", - cmd: "cargo", - args: &["test", "--workspace"], + run: run_test, }; const BUILD: Self = Self { name: "build", - cmd: "cargo", - args: &["build", "--workspace"], + run: run_workspace_build, + }; + const BUILD_INTEGRATIONS: Self = Self { + name: "build (integrations)", + run: run_integration_builds, + }; + const BUILD_APPS: Self = Self { + name: "build (apps via webui-cli)", + run: run_app_builds, }; const DOCS: Self = Self { name: "docs", - cmd: "pnpm", - args: &["--filter", "@webui/docs", "build"], + run: run_docs, }; } fn run_steps(steps: &[Step]) -> ExitCode { for step in steps { eprintln!("\n▸ {}", step.name); - let status = Command::new(step.cmd).args(step.args).status(); - - match status { - Ok(s) if s.success() => eprintln!(" ✔ {}", step.name), - Ok(s) => { + match (step.run)() { + Ok(()) => eprintln!(" ✔ {}", step.name), + Err(message) => { eprintln!(" ✘ {}", step.name); - return ExitCode::from(s.code().unwrap_or(1) as u8); - } - Err(e) => { - eprintln!(" ✘ {} — {}", step.name, e); + eprintln!(" ✘ {} — {}", step.name, message); return ExitCode::FAILURE; } } @@ -100,3 +155,199 @@ fn run_steps(steps: &[Step]) -> ExitCode { eprintln!("\n✨ All checks passed\n"); ExitCode::SUCCESS } + +fn run_fmt() -> Result<(), String> { + run_command("cargo", &["fmt", "--all", "--check"], None) +} + +fn run_clippy() -> Result<(), String> { + run_command( + "cargo", + &["clippy", "--workspace", "--", "-D", "warnings"], + None, + ) +} + +fn run_deny() -> Result<(), String> { + run_command("cargo", &["deny", "check"], None) +} + +fn run_test() -> Result<(), String> { + run_command("cargo", &["test", "--workspace"], None) +} + +fn run_workspace_build() -> Result<(), String> { + run_command("cargo", &["build", "--workspace"], None) +} + +fn run_docs() -> Result<(), String> { + run_command("pnpm", &["--filter", "@webui/docs", "build"], None) +} + +fn run_integration_builds() -> Result<(), String> { + if INTEGRATION_BUILDS.is_empty() { + eprintln!(" • no integration build entries configured"); + return Ok(()); + } + + for integration in INTEGRATION_BUILDS { + eprintln!(" • integration: {}", integration.name); + for command in integration.commands { + let cwd = command.cwd.map(Path::new); + run_command(command.cmd, command.args, cwd).map_err(|message| { + format!( + "integration '{}' command failed: {}", + integration.name, message + ) + })?; + } + } + + Ok(()) +} + +fn run_app_builds() -> Result<(), String> { + let apps_root = Path::new("examples/app"); + let app_dirs = collect_child_dirs(apps_root)?; + + if app_dirs.is_empty() { + eprintln!(" • no example apps found under examples/app"); + return Ok(()); + } + + for app_dir in app_dirs { + let app_name = display_name(&app_dir); + let templates_dir = app_dir.join("templates"); + if !templates_dir.is_dir() { + return Err(format!( + "app '{}' is missing templates directory at {}", + app_name, + templates_dir.display() + )); + } + + let output_dir = PathBuf::from("target") + .join("xtask") + .join("app-builds") + .join(app_name.as_str()); + + eprintln!(" • app: {}", app_name); + run_command( + "cargo", + &[ + "run", + "-p", + "webui-cli", + "--", + "build", + templates_dir.to_string_lossy().as_ref(), + "--out", + output_dir.to_string_lossy().as_ref(), + ], + None, + ) + .map_err(|message| format!("app '{}' build failed: {}", app_name, message))?; + } + + Ok(()) +} + +fn find_integration(name: &str) -> Option<&'static IntegrationBuild> { + INTEGRATION_BUILDS.iter().find(|b| b.name == name) +} + +fn available_integrations() -> String { + INTEGRATION_BUILDS + .iter() + .map(|b| b.name) + .collect::>() + .join(", ") +} + +fn available_apps() -> Result { + let dirs = collect_child_dirs(Path::new("examples/app"))?; + let names: Vec = dirs.iter().map(|d| display_name(d)).collect(); + Ok(names.join(", ")) +} + +fn run_integration_app(integration: Option<&str>, app: Option<&str>) -> ExitCode { + let (Some(integration_name), Some(app_name)) = (integration, app) else { + eprintln!( + "Usage: cargo xtask run \n\n\ + Available integrations: {}\n\ + Available apps: {}", + available_integrations(), + available_apps().unwrap_or_else(|_| "(unable to list)".into()), + ); + return ExitCode::FAILURE; + }; + + let Some(build) = find_integration(integration_name) else { + eprintln!( + "Unknown integration '{}'\nAvailable: {}", + integration_name, + available_integrations(), + ); + return ExitCode::FAILURE; + }; + + let app_dir = Path::new("examples/app").join(app_name); + if !app_dir.is_dir() { + eprintln!( + "Unknown app '{}'\nAvailable: {}", + app_name, + available_apps().unwrap_or_else(|_| "(unable to list)".into()), + ); + return ExitCode::FAILURE; + } + + eprintln!("▸ running {} with app {}", integration_name, app_name); + for cmd in build.run_commands { + let mut args: Vec<&str> = cmd.args.to_vec(); + args.extend_from_slice(&["--app", app_name]); + let cwd = cmd.cwd.map(Path::new); + if let Err(message) = run_command(cmd.cmd, &args, cwd) { + eprintln!(" ✘ {}", message); + return ExitCode::FAILURE; + } + } + + ExitCode::SUCCESS +} + +fn run_command(cmd: &str, args: &[&str], cwd: Option<&Path>) -> Result<(), String> { + let mut command = Command::new(cmd); + command.args(args); + if let Some(dir) = cwd { + command.current_dir(dir); + } + + match command.status() { + Ok(status) if status.success() => Ok(()), + Ok(status) => Err(format!("exit code {}", status.code().unwrap_or(1))), + Err(error) => Err(error.to_string()), + } +} + +fn collect_child_dirs(root: &Path) -> Result, String> { + let entries = fs::read_dir(root).map_err(|error| error.to_string())?; + let mut dirs = Vec::new(); + + for entry in entries { + let entry = entry.map_err(|error| error.to_string())?; + let path = entry.path(); + if path.is_dir() { + dirs.push(path); + } + } + + dirs.sort(); + Ok(dirs) +} + +fn display_name(path: &Path) -> String { + path.file_name() + .and_then(|name| name.to_str()) + .map(|name| name.to_string()) + .unwrap_or_else(|| path.display().to_string()) +}