Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,38 @@ The `webui-test-utils` crate provides common test helpers, builders, and fixture

---

## Terminal output styling

All terminal output uses `console::style()` from the `console` crate. This is the **only** approved method for colored/styled CLI output.

- Use `console::style(text).green()` for success indicators (`✔`).
- Use `console::style(text).red().bold()` for errors (`✘`).
- Use `console::style(text).cyan().bold()` for headers and highlights (`▸`).
- Use `console::style(text).yellow()` for warnings and hints.
- Use `console::style(text).dim()` for secondary/contextual info.
- Use `console::style(text).bold()` for values (file names, counts, paths).
- **Do not** create `Style` structs or `Printer` wrappers — use `console::style()` inline.
- Semantic output helpers live as **free functions** in `webui-cli/src/utils/output.rs` (`header`, `field`, `success`, `finish`, `error`, `hint`).

---

## Developer tooling auto-installation

`cargo xtask check` automatically installs missing Rust ecosystem tools:

- **Rustup components** (`clippy`, `rustfmt`) — via `rustup component add`.
- **Rustup targets** (`wasm32-unknown-unknown`) — via `rustup target add`.
- **Cargo tools** (`cargo-deny`, `wasm-pack`) — via `cargo install`.

Use the helpers in `xtask/src/util.rs`:
- `ensure_rustup_component(name)` — for rustup components.
- `ensure_rustup_target(name)` — for compilation targets.
- `ensure_cargo_install(crate_name, binary)` — for cargo-installed tools.

System-level tools (LLVM, wasi-sdk) cannot be auto-installed; show actionable per-platform hints using `console::style()` formatting.

---

## Acceptance checklist

Before finishing any task, confirm **all** of these:
Expand Down
2 changes: 2 additions & 0 deletions .github/skills/quality-gate/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ cargo xtask check

This runs, in order: `fmt → clippy → deny → test → build → doc`.

Missing Rust tools (`clippy`, `rustfmt`, `cargo-deny`, `wasm-pack`, `wasm32-unknown-unknown` target) are **auto-installed** on first run — no manual setup needed.

Work is not complete until it passes cleanly.

## Fast iteration sequence
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ log = "0.4.29"
env_logger = "0.11.9"
async-trait = "0.1.89"
tokio = { version = "1.49.0", features = ["full"] }
clap = { version = "4", features = ["derive"] }
clap = { version = "4", features = ["derive", "color"] }
console = "0.15"
ctrlc = "3.4"
prost = "0.13"
Expand Down
42 changes: 20 additions & 22 deletions crates/webui-cli/src/commands/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use webui_parser::plugin::FastParserPlugin;
use webui_parser::{CssStrategy, HtmlParser};
use webui_protocol::WebUIProtocol;

use crate::utils::output::Printer;
use crate::utils::output;

/// CSS delivery strategy for component stylesheets.
#[derive(ValueEnum, Clone, Copy, Debug, Default)]
Expand Down Expand Up @@ -54,14 +54,13 @@ pub struct BuildArgs {

pub fn execute(args: &BuildArgs) -> Result<()> {
run(args).map_err(|err| {
let printer = Printer::new();
printer.error(&err);
output::error(&err);

let err_msg = format!("{:#}", err);
if err_msg.contains("App folder not found") {
printer.hint("Check that the app folder path exists");
output::hint("Check that the app folder path exists");
} else if err_msg.contains("Failed to read") && args.entry == "index.html" {
printer.hint("Try using --entry <file> to specify a different entry file");
output::hint("Try using --entry <file> to specify a different entry file");
}
eprintln!();
err
Expand All @@ -70,7 +69,6 @@ pub fn execute(args: &BuildArgs) -> Result<()> {

fn run(args: &BuildArgs) -> Result<()> {
let started = Instant::now();
let printer = Printer::new();

let app_input = expand_tilde(&args.app)
.with_context(|| format!("Failed to expand app path: {}", args.app.display()))?
Expand All @@ -83,13 +81,13 @@ fn run(args: &BuildArgs) -> Result<()> {
.canonicalize()
.with_context(|| format!("App folder not found: {}", args.app.display()))?;

printer.header("WebUI Build");
printer.field("App", &app.display());
printer.field("Entry", &args.entry);
printer.field("Output", &out.display());
printer.field("CSS", &format!("{:?}", args.css));
output::header("WebUI Build");
output::field("App", &app.display());
output::field("Entry", &args.entry);
output::field("Output", &out.display());
output::field("CSS", &format!("{:?}", args.css));
if let Some(ref plugin_name) = args.plugin {
printer.field("Plugin", plugin_name);
output::field("Plugin", plugin_name);
}
eprintln!();

Expand All @@ -110,9 +108,9 @@ fn run(args: &BuildArgs) -> Result<()> {
.context("Failed to register components")?;

let component_count = parser.component_registry_mut().len();
printer.success(&format!(
output::success(&format!(
"Registered {} component{}",
printer.bold.apply_to(component_count),
console::style(component_count).bold(),
if component_count == 1 { "" } else { "s" }
));

Expand Down Expand Up @@ -143,10 +141,10 @@ fn run(args: &BuildArgs) -> Result<()> {
fragments: fragment_records,
};

printer.success(&format!(
output::success(&format!(
"Parsed {} ({} fragment{})",
printer.bold.apply_to(&args.entry),
printer.bold.apply_to(fragment_count),
console::style(&args.entry).bold(),
console::style(fragment_count).bold(),
if fragment_count == 1 { "" } else { "s" }
));

Expand All @@ -157,7 +155,7 @@ fn run(args: &BuildArgs) -> Result<()> {
let protocol_path = out.join("protocol.bin");
fs::write(&protocol_path, &bytes)
.with_context(|| format!("Failed to write {}", protocol_path.display()))?;
printer.success(&format!("Wrote {}", printer.bold.apply_to("protocol.bin")));
output::success(&format!("Wrote {}", console::style("protocol.bin").bold()));

let mut files_written: usize = 1;

Expand All @@ -167,17 +165,17 @@ fn run(args: &BuildArgs) -> Result<()> {
let css_path = out.join(filename);
fs::write(&css_path, css_content)
.with_context(|| format!("Failed to write {}", css_path.display()))?;
printer.success(&format!("Wrote {}", printer.bold.apply_to(filename)));
output::success(&format!("Wrote {}", console::style(filename).bold()));
files_written += 1;
}
}

let elapsed = started.elapsed();
printer.finish(&format!(
output::finish(&format!(
"Build complete ({} file{} written) {}",
printer.bold.apply_to(files_written),
console::style(files_written).bold(),
if files_written == 1 { "" } else { "s" },
printer.dim.apply_to(format!("in {elapsed:.0?}")),
console::style(format!("in {elapsed:.0?}")).dim(),
));

Ok(())
Expand Down
50 changes: 27 additions & 23 deletions crates/webui-cli/src/commands/start.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use webui_parser::{CssStrategy, HtmlParser};
use webui_protocol::WebUIProtocol;

use super::build::CssMode;
use crate::utils::output::Printer;
use crate::utils::output;

#[derive(Args)]
pub struct StartArgs {
Expand Down Expand Up @@ -241,24 +241,22 @@ impl HmrBackend for PollingHmrBackend {

pub fn execute(args: &StartArgs) -> Result<()> {
run(args).map_err(|err| {
let printer = Printer::new();
printer.error(&err);
output::error(&err);

let err_msg = format!("{:#}", err);
if err_msg.contains("App folder not found") {
printer.hint("Check that the app folder path exists");
output::hint("Check that the app folder path exists");
} else if err_msg.contains("State file not found") {
printer.hint("Pass a valid --state path to a JSON file");
output::hint("Pass a valid --state path to a JSON file");
} else if err_msg.contains("Serve directory not found") {
printer.hint("Pass a valid --servedir path for static assets");
output::hint("Pass a valid --servedir path for static assets");
}
eprintln!();
err
})
}

fn run(args: &StartArgs) -> Result<()> {
let printer = Printer::new();
let paths = StartPaths::from_args(args)?;
let hmr_backend: Option<Arc<dyn HmrBackend>> = if args.watch {
Some(Arc::new(PollingHmrBackend::new("/hmr", 1000)))
Expand All @@ -274,26 +272,26 @@ fn run(args: &StartArgs) -> Result<()> {
plugin: args.plugin.clone(),
};

printer.header("WebUI Dev Server");
printer.field("App", &paths.app_dir.display());
printer.field("State", &paths.state_file.display());
output::header("WebUI Dev Server");
output::field("App", &paths.app_dir.display());
output::field("State", &paths.state_file.display());
match &paths.serve_dir {
Some(serve_dir) => printer.field("ServeDir", &serve_dir.display()),
None => printer.field("ServeDir", &"(disabled)"),
Some(serve_dir) => output::field("ServeDir", &serve_dir.display()),
None => output::field("ServeDir", &"(disabled)"),
}
printer.field("Entry", &args.entry);
printer.field("Port", &args.port);
printer.field("CSS", &format!("{:?}", args.css));
output::field("Entry", &args.entry);
output::field("Port", &args.port);
output::field("CSS", &format!("{:?}", args.css));
if args.watch {
printer.field("HMR", &"enabled (polling /hmr)");
output::field("HMR", &"enabled (polling /hmr)");
} else {
printer.field("HMR", &"disabled (pass --watch to enable)");
output::field("HMR", &"disabled (pass --watch to enable)");
}
eprintln!();

// Initial build + render
let initial_html = build_and_render(&render_config, hmr_backend.as_deref())?;
printer.success("Initial build and render complete");
output::success("Initial build and render complete");

let state = Arc::new(Mutex::new(SharedState {
rendered_html: initial_html,
Expand All @@ -307,14 +305,14 @@ fn run(args: &StartArgs) -> Result<()> {
render_config: render_config.clone(),
hmr_backend: Arc::clone(active_hmr_backend),
});
printer.success("File watcher started");
output::success("File watcher started");
}

let addr = format!("127.0.0.1:{}", args.port);
let bind_addr = addr.clone();

printer.field("URL", &format!("http://{addr}/"));
printer.finish("Server is running \u{2014} press Ctrl+C to stop");
output::field("URL", &format!("http://{addr}/"));
output::finish("Server is running \u{2014} press Ctrl+C to stop");

let server_context = web::Data::new(ServerContext {
state,
Expand Down Expand Up @@ -603,10 +601,16 @@ fn start_file_watcher(config: WatcherConfig) {
s.rendered_html = html;
s.bump_version();
}
eprintln!(" \u{21bb} Rebuilt and re-rendered (HMR version updated)");
eprintln!(
" {} Rebuilt and re-rendered (HMR version updated)",
console::style("\u{21bb}").green()
);
}
Err(err) => {
eprintln!(" \u{2718} Rebuild failed: {err:#}");
eprintln!(
" {} Rebuild failed: {err:#}",
console::style("\u{2718}").red().bold()
);
}
}

Expand Down
11 changes: 8 additions & 3 deletions crates/webui-cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
mod commands;
mod utils;

use clap::Parser;
use clap::{CommandFactory, Parser};
use commands::Commands;

#[derive(Parser)]
#[command(name = "webui", about = "WebUI build tool")]
struct Cli {
#[command(subcommand)]
command: Commands,
command: Option<Commands>,
}

fn main() {
let cli = Cli::parse();

let result = match &cli.command {
let Some(command) = &cli.command else {
Cli::command().print_help().ok();
return;
};

let result = match command {
Commands::Build(args) => commands::build::execute(args),
Commands::Inspect(args) => commands::inspect::execute(args),
Commands::Start(args) => commands::start::execute(args),
Expand Down
Loading
Loading