Replace the dev server's wasm-pack, cargo-watch, and concurrently tools with custom cargo-run tooling (#4254)

Replace wasm-pack, cargo-watch and concurrently with custom build tooling

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
Timon
2026-06-21 19:23:08 +00:00
committed by Keavon Chambers
parent 1f05000238
commit a72ac39059
22 changed files with 978 additions and 479 deletions

View File

@@ -9,3 +9,5 @@ default-run = "cargo-run"
[dependencies]
thiserror = { workspace = true }
semver = "1"
duct = "1"

View File

@@ -0,0 +1,11 @@
[package]
name = "cargo-run-internal-watch"
edition.workspace = true
version.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
cargo-run = { path = "../.." }
notify = "8.2.0"
notify-debouncer-full = "0.7.0"

View File

@@ -0,0 +1,93 @@
use cargo_run::Error;
use cargo_run::cmd::prelude::*;
use notify::RecursiveMode;
use notify::event::{EventKind, ModifyKind};
use notify_debouncer_full::{Debouncer, RecommendedCache, new_debouncer, notify::RecommendedWatcher};
use std::collections::HashSet;
use std::path::Path;
use std::process::ExitCode;
use std::time::Duration;
const EXCLUDED_DIRECTORIES: &[&str] = &["target", ".git", "frontend/node_modules", "frontend/dist", "frontend/wrapper/pkg"];
const INCLUDED_EXTENSIONS: &[&str] = &["rs"];
const DEBOUNCE: Duration = Duration::from_millis(500);
fn main() -> ExitCode {
let release = std::env::args().nth(1).as_deref() == Some("release");
let _guard = match watch(cargo_run::frontend::build_wasm_steps(release, false)) {
Ok(guard) => guard,
Err(e) => {
eprintln!("Error setting up file watcher: {e}");
return ExitCode::FAILURE;
}
};
println!("Watching for changes...");
loop {
std::thread::park();
}
}
pub struct WatchGuard {
_debouncer: Debouncer<RecommendedWatcher, RecommendedCache>,
}
pub fn watch(steps: impl IntoIterator<Item = Expression>) -> Result<WatchGuard, Error> {
let steps: Vec<Expression> = steps.into_iter().collect();
let root = std::env::current_dir()
.and_then(|p| p.canonicalize())
.map_err(|e| Error::Io(e, "Failed to resolve root for file watcher".into()))?;
let root_clone = root.clone();
let mut current: Option<Sequence> = None;
let mut debouncer = new_debouncer(DEBOUNCE, None, move |result: notify_debouncer_full::DebounceEventResult| match result {
Ok(events) => {
let mut seen = HashSet::new();
let mut triggered = false;
for ev in events {
if !matches!(
&ev.event.kind,
EventKind::Create(_) | EventKind::Remove(_) | EventKind::Modify(ModifyKind::Data(_) | ModifyKind::Name(_) | ModifyKind::Any)
) {
continue;
}
for path in &ev.event.paths {
if is_excluded(path, &root) {
continue;
}
if seen.insert(path.clone()) {
triggered = true;
}
}
}
if triggered {
if let Some(c) = current.take() {
c.kill();
}
current = Some(sequence(steps.clone()));
}
}
Err(errors) => {
for e in errors {
eprintln!("watch: {e}");
}
}
})
.map_err(|e| Error::Io(std::io::Error::other(e.to_string()), "file watcher".into()))?;
debouncer
.watch(&root_clone, RecursiveMode::Recursive)
.map_err(|e| Error::Io(std::io::Error::other(e.to_string()), "file watcher".into()))?;
Ok(WatchGuard { _debouncer: debouncer })
}
fn is_excluded(path: &Path, root: &Path) -> bool {
let rel = path.strip_prefix(root).unwrap_or(path);
if EXCLUDED_DIRECTORIES.iter().any(|d| rel.starts_with(d)) {
return true;
}
!path.extension().and_then(|e| e.to_str()).is_some_and(|e| INCLUDED_EXTENSIONS.contains(&e))
}

334
tools/cargo-run/src/cmd.rs Normal file
View File

@@ -0,0 +1,334 @@
use crate::Error;
use duct::{Handle, ReaderHandle};
use std::ffi::OsString;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
pub use duct::{Expression, cmd};
pub mod prelude {
pub use super::{Expression, ExpressionExt, Sequence, TerminalColor, cmd, sequence, supervise, utils};
}
pub trait ExpressionExt {
fn arg(self, arg: impl Into<OsString>) -> Self;
fn args<I, S>(self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<OsString>;
fn arg_if(self, cond: bool, arg: impl Into<OsString>) -> Self;
fn args_if<I, S>(self, cond: bool, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<OsString>;
fn run(self) -> Result<(), Error>;
fn read(self) -> Result<String, Error>;
fn output_unchecked(self) -> Result<std::process::Output, Error>;
}
impl ExpressionExt for Expression {
fn arg(self, arg: impl Into<OsString>) -> Self {
let arg = arg.into();
self.before_spawn(move |c| {
c.arg(&arg);
Ok(())
})
}
fn args<I, S>(self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
self.before_spawn(move |c| {
c.args(&args);
Ok(())
})
}
fn arg_if(self, cond: bool, arg: impl Into<OsString>) -> Self {
if cond { self.arg(arg) } else { self }
}
fn args_if<I, S>(self, cond: bool, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
if cond { self.args(args) } else { self }
}
fn run(self) -> Result<(), Error> {
Expression::run(&self).map_err(Error::Command)?;
Ok(())
}
fn read(self) -> Result<String, Error> {
Expression::read(&self).map_err(Error::Command)
}
fn output_unchecked(self) -> Result<std::process::Output, Error> {
let e = self.unchecked().stdout_capture().stderr_capture();
Expression::run(&e).map_err(Error::Command)
}
}
pub enum TerminalColor {
Black,
Red,
Green,
Yellow,
Blue,
Magenta,
Cyan,
White,
Reset,
}
impl TerminalColor {
fn as_str(&self) -> &'static str {
match self {
Self::Black => "\x1b[30m",
Self::Red => "\x1b[31m",
Self::Green => "\x1b[32m",
Self::Yellow => "\x1b[33m",
Self::Blue => "\x1b[34m",
Self::Magenta => "\x1b[35m",
Self::Cyan => "\x1b[36m",
Self::White => "\x1b[37m",
Self::Reset => "\x1b[0m",
}
}
}
pub fn sequence<I: IntoIterator<Item = Expression>>(expressions: I) -> Sequence {
let expressions: Vec<Expression> = expressions.into_iter().collect();
let current: Arc<Mutex<Option<Arc<Handle>>>> = Arc::new(Mutex::new(None));
let killed = Arc::new(AtomicBool::new(false));
let worker_current = Arc::clone(&current);
let worker_killed = Arc::clone(&killed);
let worker = std::thread::spawn(move || {
for expr in expressions {
if worker_killed.load(Ordering::SeqCst) {
return;
}
let handle = match expr.start() {
Ok(h) => Arc::new(h),
Err(e) => {
eprintln!("sequence: failed to start step: {e}");
return;
}
};
{
let mut slot = worker_current.lock().unwrap();
if worker_killed.load(Ordering::SeqCst) {
let _ = handle.kill();
return;
}
*slot = Some(Arc::clone(&handle));
}
let result = handle.wait().map(|_| ());
worker_current.lock().unwrap().take();
if worker_killed.load(Ordering::SeqCst) {
return;
}
if let Err(e) = result {
eprintln!("sequence: step failed: {e}");
return;
}
}
});
Sequence {
current,
killed,
worker: Some(worker),
}
}
pub struct Sequence {
current: Arc<Mutex<Option<Arc<Handle>>>>,
killed: Arc<AtomicBool>,
worker: Option<JoinHandle<()>>,
}
impl Sequence {
pub fn kill(&self) {
let slot = self.current.lock().unwrap();
self.killed.store(true, Ordering::SeqCst);
if let Some(handle) = slot.as_ref() {
let _ = handle.kill();
}
}
pub fn wait(&mut self) {
if let Some(w) = self.worker.take() {
let _ = w.join();
}
}
}
impl Drop for Sequence {
fn drop(&mut self) {
self.kill();
self.wait();
}
}
pub fn supervise<I, S>(children: I) -> Result<(), Error>
where
I: IntoIterator<Item = (S, TerminalColor, Expression)>,
S: Into<String>,
{
use std::io::{BufRead, BufReader, IsTerminal, Write};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
#[cfg(target_os = "windows")]
windows_ctrl_c::install();
let mut handles: Vec<(String, TerminalColor, Arc<ReaderHandle>)> = Vec::new();
for (label, color, expr) in children {
#[cfg(target_os = "windows")]
let expr = expr.before_spawn(|cmd| {
use std::os::windows::process::CommandExt;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP);
Ok(())
});
let handle = expr.stderr_to_stdout().reader().map_err(Error::Command)?;
handles.push((label.into(), color, Arc::new(handle)));
}
let mut io_threads = Vec::new();
for (label, color, handle) in handles.iter() {
let prefix = if std::io::stdout().is_terminal() {
format!("{color}[{label}]{reset} ", color = color.as_str(), reset = TerminalColor::Reset.as_str())
} else {
format!("[{label}] ")
};
let handle = handle.clone();
io_threads.push(thread::spawn(move || {
let reader = BufReader::new(&*handle);
for line in reader.lines().map_while(Result::ok) {
let out = std::io::stdout();
let mut out = out.lock();
let _ = writeln!(out, "{prefix}{line}");
}
}));
}
let mut reason: Option<(String, std::process::ExitStatus)> = None;
loop {
#[cfg(target_os = "windows")]
if windows_ctrl_c::interrupted() {
break;
}
for (label, _color, handle) in handles.iter() {
if let Ok(Some(output)) = handle.try_wait() {
reason = Some((label.clone(), output.status));
break;
}
}
if reason.is_some() {
break;
}
thread::sleep(Duration::from_millis(200));
}
for (_, _color, handle) in handles.iter() {
if matches!(handle.try_wait(), Ok(Some(_))) {
continue;
}
#[cfg(target_os = "windows")]
{
for pid in handle.pids() {
let _ = std::process::Command::new("taskkill")
.args(["/T", "/F", "/PID", &pid.to_string()])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
}
#[cfg(not(target_os = "windows"))]
{
let _ = handle.kill();
}
}
for t in io_threads {
let _ = t.join();
}
match reason {
Some((label, status)) if !status.success() => Err(Error::Command(std::io::Error::other(format!("supervised child '{label}' exited with status {status}")))),
_ => Ok(()),
}
}
pub mod utils {
use super::*;
pub fn internal(name: &str) -> Expression {
let package = format!("cargo-run-internal-{name}");
cmd!("cargo", "run", "-p", package, "--")
}
pub fn npm<I, S>(args: I) -> Expression
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
let prog = if cfg!(target_os = "windows") { "npm.cmd" } else { "npm" };
cmd(prog, args)
}
pub fn node_bin(rel: &str) -> Expression {
cmd!("node", format!("node_modules/{rel}"))
}
pub fn open_url(url: &str) -> Result<(), Error> {
#[cfg(target_os = "windows")]
let expr = cmd!("cmd", "/c", "start", url);
#[cfg(target_os = "macos")]
let expr = cmd!("open", url);
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
let expr = cmd!("xdg-open", url);
expr.run()
}
}
#[cfg(target_os = "windows")]
mod windows_ctrl_c {
use std::sync::Once;
use std::sync::atomic::{AtomicBool, Ordering};
static INTERRUPTED: AtomicBool = AtomicBool::new(false);
#[link(name = "kernel32")]
unsafe extern "system" {
fn SetConsoleCtrlHandler(handler: Option<unsafe extern "system" fn(u32) -> i32>, add: i32) -> i32;
}
unsafe extern "system" fn handler(_ctrl_type: u32) -> i32 {
INTERRUPTED.store(true, Ordering::SeqCst);
1 // Report the event as handled
}
pub fn install() {
INTERRUPTED.store(false, Ordering::SeqCst);
static REGISTER: Once = Once::new();
REGISTER.call_once(|| unsafe {
SetConsoleCtrlHandler(Some(handler), 1);
});
}
pub fn interrupted() -> bool {
INTERRUPTED.load(Ordering::SeqCst)
}
}

View File

@@ -0,0 +1,62 @@
use crate::cmd::prelude::*;
use crate::*;
use std::path::PathBuf;
const WRAPPER_CRATE: &str = "graphite-wasm-wrapper";
const WASM_TARGET: &str = "wasm32-unknown-unknown";
const OUT_NAME: &str = "graphite_wasm_wrapper";
pub fn frontend_dir() -> PathBuf {
workspace_dir().join("frontend")
}
pub fn setup() -> Result<(), Error> {
utils::npm(["run", "setup"]).dir(frontend_dir()).run()
}
pub fn build_wasm(release: bool, native: bool) -> Result<(), Error> {
sequence(build_wasm_steps(release, native)).wait();
Ok(())
}
pub fn build_wasm_steps(release: bool, native: bool) -> Vec<Expression> {
let wasm_artifact = target_dir().join(WASM_TARGET).join(if release { "release" } else { "debug" }).join(format!("{OUT_NAME}.wasm"));
let pkg_dir = frontend_dir().join("wrapper").join("pkg");
let mut steps = vec![
cmd!("cargo", "build", "--lib", "--package", WRAPPER_CRATE, "--target", WASM_TARGET)
.arg_if(release, "--release")
.args_if(native, ["--no-default-features", "--features", "native"])
.dir(workspace_dir()),
cmd!("wasm-bindgen", "--target", "web", "--out-name", OUT_NAME, "--out-dir", &pkg_dir, &wasm_artifact)
.arg_if(release, "--no-demangle")
.arg_if(!release, "--debug"),
];
if release {
let wasm_file = pkg_dir.join(format!("{OUT_NAME}_bg.wasm"));
steps.push(cmd!("wasm-opt", "-Os", "-g", &wasm_file, "-o", &wasm_file));
}
steps
}
pub fn vite() -> Expression {
utils::node_bin("vite/bin/vite.js").dir(frontend_dir()).env("CARGO_TARGET_DIR", target_dir())
}
pub fn watch(release: bool) -> Result<(), Error> {
use crate::cmd::prelude::*;
setup()?;
build_wasm(release, false)?;
let vite = vite().env("FORCE_COLOR", "1").env("CARGO_TERM_COLOR", "always");
let rust = utils::internal("watch")
.arg_if(release, "release")
.dir(workspace_dir())
.env("CARGO_TARGET_DIR", target_dir())
.env("CARGO_TERM_COLOR", "always");
supervise([("VITE", TerminalColor::Magenta, vite), ("RUST", TerminalColor::Blue, rust)])
}

View File

@@ -1,6 +1,7 @@
use std::path::PathBuf;
use std::process;
pub mod cmd;
pub mod frontend;
pub mod requirements;
pub enum Action {
@@ -65,70 +66,25 @@ impl Task {
}
}
pub fn run(command: &str) -> Result<(), Error> {
run_from(command, None)
pub fn workspace_dir() -> PathBuf {
PathBuf::from(env!("CARGO_WORKSPACE_DIR"))
}
pub fn npm_run_in_frontend_dir(args: &str) -> Result<(), Error> {
let workspace_dir = std::path::PathBuf::from(env!("CARGO_WORKSPACE_DIR"));
let frontend_dir = workspace_dir.join("frontend");
let npm = if cfg!(target_os = "windows") { "npm.cmd" } else { "npm" };
run_from(&format!("{npm} run {args}"), Some(&frontend_dir))
}
pub fn open_url(url: &str) -> Result<(), Error> {
#[cfg(target_os = "windows")]
let mut cmd = process::Command::new("cmd");
#[cfg(target_os = "windows")]
cmd.args(["/c", "start", url]);
#[cfg(target_os = "macos")]
let mut cmd = process::Command::new("open");
#[cfg(target_os = "macos")]
cmd.arg(url);
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
let mut cmd = process::Command::new("xdg-open");
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
cmd.arg(url);
let command_str = format!("{:?}", cmd);
let exit_code = cmd
.spawn()
.map_err(|e| Error::Io(e, format!("Failed to spawn command '{command_str}'")))?
.wait()
.map_err(|e| Error::Io(e, format!("Failed to wait for command '{command_str}'")))?;
if !exit_code.success() {
return Err(Error::Command(command_str, exit_code));
pub fn target_dir() -> PathBuf {
match std::env::var_os("CARGO_TARGET_DIR") {
Some(custom_dir) => workspace_dir().join(custom_dir),
None => workspace_dir().join("target"),
}
Ok(())
}
fn run_from(command: &str, dir: Option<&PathBuf>) -> Result<(), Error> {
let command = command.split_whitespace().collect::<Vec<_>>();
let mut cmd = process::Command::new(command[0]);
if command.len() > 1 {
cmd.args(&command[1..]);
}
if let Some(dir) = dir {
cmd.current_dir(dir);
}
let exit_code = cmd
.spawn()
.map_err(|e| Error::Io(e, format!("Failed to spawn command '{}'", command.join(" "))))?
.wait()
.map_err(|e| Error::Io(e, format!("Failed to wait for command '{}'", command.join(" "))))?;
if !exit_code.success() {
return Err(Error::Command(command.join(" "), exit_code));
}
Ok(())
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("One or more requirements were not met")]
RequirementsNotMet,
#[error("{1}: {0}")]
Io(#[source] std::io::Error, String),
#[error("Command '{0}' exited with code {1}")]
Command(String, process::ExitStatus),
/// Used by the duct-based `cmd` module; folds in `Command` once call sites are migrated.
#[error("{0}")]
Command(#[source] std::io::Error),
}

View File

@@ -1,5 +1,6 @@
use std::process::ExitCode;
use cargo_run::cmd::prelude::*;
use cargo_run::*;
fn usage() {
@@ -33,6 +34,8 @@ fn usage() {
}
fn main() -> ExitCode {
prepend_path();
let args: Vec<String> = std::env::args().collect();
let args: Vec<&str> = args.iter().skip(1).map(String::as_str).collect();
@@ -67,9 +70,9 @@ fn explore_usage() {
fn run_task(task: &Task) -> Result<(), Error> {
if let Action::Explore(tool) = &task.action {
match tool.as_deref() {
Some("bisect") => return open_url("https://graphite.art/volunteer/guide/codebase-overview/debugging-tips/#build-bisect-tool"),
Some("deps") => return open_url("https://graphite.art/volunteer/guide/codebase-overview/#crate-dependency-graph"),
Some("editor") => return open_url("https://graphite.art/volunteer/guide/codebase-overview/editor-structure/#editor-outline"),
Some("bisect") => return utils::open_url("https://graphite.art/volunteer/guide/codebase-overview/debugging-tips/#build-bisect-tool"),
Some("deps") => return utils::open_url("https://graphite.art/volunteer/guide/codebase-overview/#crate-dependency-graph"),
Some("editor") => return utils::open_url("https://graphite.art/volunteer/guide/codebase-overview/editor-structure/#editor-outline"),
None | Some("--help") => {
explore_usage();
return Ok(());
@@ -85,11 +88,19 @@ fn run_task(task: &Task) -> Result<(), Error> {
requirements::check(task)?;
match (&task.action, &task.target, &task.profile) {
(Action::Run, Target::Web, Profile::Debug | Profile::Default) => npm_run_in_frontend_dir("start")?,
(Action::Run, Target::Web, Profile::Release) => npm_run_in_frontend_dir("production")?,
(Action::Run, Target::Web, Profile::Debug | Profile::Default) => frontend::watch(false)?,
(Action::Run, Target::Web, Profile::Release) => frontend::watch(true)?,
(Action::Build, Target::Web, Profile::Debug) => npm_run_in_frontend_dir("build-dev")?,
(Action::Build, Target::Web, Profile::Release | Profile::Default) => npm_run_in_frontend_dir("build")?,
(Action::Build, Target::Web, Profile::Debug) => {
frontend::setup()?;
frontend::build_wasm(false, false)?;
frontend::vite().args(["build", "--mode", "dev"]).run()?;
}
(Action::Build, Target::Web, Profile::Release | Profile::Default) => {
frontend::setup()?;
frontend::build_wasm(true, false)?;
frontend::vite().args(["build"]).run()?;
}
(action, Target::Desktop, mut profile) => {
if matches!(profile, Profile::Default) {
@@ -100,34 +111,41 @@ fn run_task(task: &Task) -> Result<(), Error> {
}
}
if matches!(profile, Profile::Release) {
npm_run_in_frontend_dir("build-native")?;
} else {
npm_run_in_frontend_dir("build-native-dev")?;
};
// Build the editor's Wasm module with the `native` feature, then bundle the frontend with Vite
frontend::setup()?;
frontend::build_wasm(matches!(profile, Profile::Release), true)?;
frontend::vite().args(["build", "--mode", "native"]).run()?;
run("cargo run -p third-party-licenses --features desktop")?;
cmd!("cargo", "run", "-p", "third-party-licenses", "--features", "desktop").run()?;
let cargo_profile = match profile {
Profile::Debug => "dev",
Profile::Release => "release",
Profile::Default => unreachable!(),
};
let args = if matches!(action, Action::Run) {
format!(" -- open {}", task.args.join(" "))
} else {
"".to_string()
};
run(&format!("cargo run --profile {cargo_profile} -p graphite-desktop-bundle{args}"))?;
cmd!("cargo", "run", "--profile", cargo_profile, "-p", "graphite-desktop-bundle")
.args_if(matches!(action, Action::Run), ["--", "open"].into_iter().chain(task.args.iter().map(String::as_str)))
.run()?;
}
(Action::Run, Target::Cli, Profile::Debug | Profile::Default) => run(&format!("cargo run -p graphene-cli -- {}", task.args.join(" ")))?,
(Action::Run, Target::Cli, Profile::Release) => run(&format!("cargo run -r -p graphene-cli -- {}", task.args.join(" ")))?,
(Action::Run, Target::Cli, Profile::Debug | Profile::Default) => cmd!("cargo", "run", "-p", "graphene-cli", "--").args(&task.args).run()?,
(Action::Run, Target::Cli, Profile::Release) => cmd!("cargo", "run", "-r", "-p", "graphene-cli", "--").args(&task.args).run()?,
(Action::Build, Target::Cli, Profile::Debug) => run("cargo build -p graphene-cli")?,
(Action::Build, Target::Cli, Profile::Release | Profile::Default) => run("cargo build -r -p graphene-cli")?,
(Action::Build, Target::Cli, Profile::Debug) => cmd!("cargo", "build", "-p", "graphene-cli").run()?,
(Action::Build, Target::Cli, Profile::Release | Profile::Default) => cmd!("cargo", "build", "-r", "-p", "graphene-cli").run()?,
(Action::Explore(_), _, _) => unreachable!(),
}
Ok(())
}
fn prepend_path() {
let mut paths = vec![target_dir().join("cargo-run").join("bin")];
if let Some(path) = std::env::var_os("PATH") {
paths.extend(std::env::split_paths(&path));
}
if let Ok(joined) = std::env::join_paths(paths) {
// Safety: this runs before any other threads are spawned
unsafe { std::env::set_var("PATH", joined) };
}
}

View File

@@ -1,15 +1,17 @@
use semver::{Version, VersionReq};
use std::io::IsTerminal;
use std::process::Command;
use crate::cmd::prelude::*;
use crate::*;
#[derive(Default, Clone)]
struct Requirement {
pub struct Requirement {
command: &'static str,
args: &'static [&'static str],
name: &'static str,
check: Check,
version: Option<&'static str>,
install: Option<&'static str>,
install: InstallAction,
skip: Option<&'static dyn Fn(&Task) -> bool>,
}
@@ -22,43 +24,43 @@ fn requirements(task: &Task) -> Vec<Requirement> {
..Default::default()
},
Requirement {
command: "cargo-about",
args: &["--version"],
name: "cargo-about",
install: Some("cargo install cargo-about"),
command: "rustc",
args: &["--print", "target-libdir", "--target", "wasm32-unknown-unknown"],
check: Check::Matches(&|out| std::path::Path::new(out.trim()).is_dir()),
name: "Rust Wasm Toolchain",
install: "rustup target add wasm32-unknown-unknown".into(),
skip: Some(&|task| matches!(task.target, Target::Cli)),
..Default::default()
},
Requirement {
command: "cargo-watch",
command: "cargo-about",
args: &["--version"],
name: "cargo-watch",
install: Some("cargo install cargo-watch"),
name: "Cargo About",
install: "cargo install cargo-about".into(),
skip: Some(&|task| matches!(task.target, Target::Cli)),
..Default::default()
},
Requirement {
command: "wasm-opt",
args: &["--version"],
name: "Wasm Opt",
version: Some(">=130"),
skip: Some(&|task| {
!matches!(
task,
Task {
target: Target::Web,
action: Action::Run,
..
matches!(task.target, Target::Cli)
|| match task.profile {
Profile::Debug => true,
Profile::Release => false,
Profile::Default => matches!(task.action, Action::Run),
}
)
}),
..Default::default()
},
Requirement {
command: "wasm-bindgen",
args: &["--version"],
name: "wasm-bindgen-cli",
version: Some("0.2.121"),
install: Some("cargo install -f wasm-bindgen-cli@0.2.121"),
skip: Some(&|task| matches!(task.target, Target::Cli)),
},
Requirement {
command: "wasm-pack",
args: &["--version"],
name: "wasm-pack",
install: Some("cargo install wasm-pack"),
name: "Wasm Bindgen",
version: Some("=0.2.121"),
install: "cargo install -f wasm-bindgen-cli@0.2.121".into(),
skip: Some(&|task| matches!(task.target, Target::Cli)),
..Default::default()
},
@@ -98,24 +100,44 @@ pub fn check(task: &Task) -> Result<(), Error> {
let mut failures: Vec<String> = Vec::new();
for dep in requirements(task) {
match Command::new(dep.command).args(dep.args).output() {
match cmd(dep.command, dep.args.iter().copied()).output_unchecked() {
Ok(output) if output.status.success() => {
let version = String::from_utf8_lossy(&output.stdout);
let version = version.lines().next().unwrap_or_default().trim();
if let Some(expected) = dep.version {
if version.contains(expected) {
eprintln!("{} ({})", dep.name, version);
} else {
eprintln!("{} (found {}, expected {})", dep.name, version, expected);
if dep.install.is_some() {
installable.push(dep);
} else {
failures.push(format!("{}: version mismatch (found {version}, expected {expected})", dep.name));
let stdout = String::from_utf8_lossy(&output.stdout);
match dep.check {
Check::PrintsVersion => {
let line = stdout.lines().next().unwrap_or_default().trim();
match dep.version {
None => eprintln!("{} ({line})", dep.name),
Some(req_str) => {
let req = VersionReq::parse(req_str).expect("invalid semver requirement");
match extract_version(line) {
Some(version) if req.matches(&version) => eprintln!("{} ({version})", dep.name),
Some(version) => {
eprintln!("{} (found {version}, requires {req_str})", dep.name);
if dep.install.is_some() {
installable.push(dep);
} else {
failures.push(format!("{}: version mismatch (found {version}, requires {req_str})", dep.name));
}
}
None => {
eprintln!("{} (could not parse version from '{line}')", dep.name);
failures.push(format!("{}: could not parse version from '{line}'", dep.name));
}
}
}
}
}
Check::Matches(check) => {
if !check(stdout.to_string()) {
eprintln!("{} - check failed", dep.name);
if dep.install.is_some() {
installable.push(dep);
}
} else {
eprintln!("{}", dep.name);
}
}
} else {
eprintln!("{} ({})", dep.name, version);
}
}
Ok(output) => {
@@ -147,7 +169,7 @@ pub fn check(task: &Task) -> Result<(), Error> {
let total = installable.len() + failures.len();
eprintln!("{total} requirement{} not met:", if total > 1 { "s" } else { "" });
for dep in &installable {
eprintln!(" - {}: {}", dep.name, dep.install.unwrap());
eprintln!(" - {}", dep.name);
}
for msg in &failures {
eprintln!(" - {msg}");
@@ -158,43 +180,149 @@ pub fn check(task: &Task) -> Result<(), Error> {
eprintln!("See: https://graphite.art/volunteer/guide/project-setup/");
}
let is_interactive = std::io::stdout().is_terminal() && std::io::stderr().is_terminal() && std::io::stdin().is_terminal();
// Don't prompt for automatic installation if we're not interactive session
if !std::io::stdout().is_terminal() || !std::io::stderr().is_terminal() || !std::io::stdin().is_terminal() {
if !is_interactive {
return Ok(());
}
if installable.is_empty() {
if installable.is_empty() && failures.is_empty() {
return Ok(());
}
eprintln!();
eprintln!("The following can be installed automatically:");
for dep in &installable {
eprintln!(" {}", dep.install.unwrap());
}
eprintln!();
if installable.len() == 1 {
eprint!("Install it now? [Y/n] ");
} else {
eprint!("Install them now? [Y/n] ");
}
let mut input = String::new();
std::io::stdin().read_line(&mut input).map_err(|e| Error::Io(e, "Failed to read from stdin".into()))?;
let input = input.trim();
if input.is_empty() || input.eq_ignore_ascii_case("y") || input.eq_ignore_ascii_case("yes") {
if !installable.is_empty() {
eprintln!();
eprintln!("The following can be installed automatically:");
for dep in &installable {
let parts: Vec<&str> = dep.install.unwrap().split_whitespace().collect();
eprintln!("Running: {}...", dep.install.unwrap());
let status = Command::new(parts[0])
.args(&parts[1..])
.status()
.map_err(|e| Error::Io(e, format!("Failed to run '{}'", dep.install.unwrap())))?;
if !status.success() {
eprintln!("Failed to install {}", dep.name);
eprintln!(" {}: {}", dep.name, dep.install.description());
}
eprintln!();
if installable.len() == 1 {
eprint!("Install it now? [Y/n] ");
} else {
eprint!("Install them now? [Y/n] ");
}
let mut input = String::new();
std::io::stdin().read_line(&mut input).map_err(|e| Error::Io(e, "Failed to read from stdin".into()))?;
let input = input.trim();
if input.eq_ignore_ascii_case("y") || input.eq_ignore_ascii_case("yes") {
let mut successfully_installed = Vec::new();
for (i, dep) in installable.iter().enumerate() {
eprintln!("Installing {}...", dep.name);
match &dep.install {
InstallAction::Command(install_cmd) => {
let parts: Vec<&str> = install_cmd.split_whitespace().collect();
let expr = cmd(parts[0], parts[1..].iter().copied()).unchecked();
match Expression::run(&expr) {
Ok(output) if output.status.success() => successfully_installed.push(i),
Ok(_) => eprintln!("Failed to install {}", dep.name),
Err(e) => return Err(Error::Command(e)),
}
}
InstallAction::Function { function, .. } => {
if let Err(e) = function() {
eprintln!("{e}");
eprintln!("Failed to install {}", dep.name);
} else {
successfully_installed.push(i);
}
}
InstallAction::None => unreachable!(),
}
}
for i in successfully_installed.into_iter().rev() {
installable.remove(i);
}
}
}
if !failures.is_empty() {
eprintln!();
eprintln!("The following requirements must be resolved manually:");
for msg in &failures {
eprintln!(" - {msg}");
}
}
if (!installable.is_empty() || !failures.is_empty()) && is_interactive {
eprintln!();
eprintln!("Continue without resolving these requirements? [y/N]");
let mut input = String::new();
std::io::stdin().read_line(&mut input).map_err(|e| Error::Io(e, "Failed to read from stdin".into()))?;
let input = input.trim();
if input.eq_ignore_ascii_case("n") || input.eq_ignore_ascii_case("no") {
return Err(Error::RequirementsNotMet);
}
}
Ok(())
}
fn extract_version(line: &str) -> Option<Version> {
line.split_whitespace().find_map(|token| {
let token = token.trim_start_matches('v').trim_end_matches(|c: char| !c.is_ascii_alphanumeric());
if token.is_empty() {
return None;
}
if let Ok(version) = Version::parse(token) {
return Some(version);
}
let (core, suffix) = match token.find(['-', '+']) {
Some(i) => token.split_at(i),
None => (token, ""),
};
let parts: Vec<&str> = core.split('.').collect();
if parts.iter().any(|p| p.is_empty() || !p.chars().all(|c| c.is_ascii_digit())) {
return None;
}
let major = parts[0];
let minor = parts.get(1).copied().unwrap_or("0");
let patch = parts.get(2).copied().unwrap_or("0");
Version::parse(&format!("{major}.{minor}.{patch}{suffix}")).ok()
})
}
#[derive(Clone, Default)]
enum Check {
#[default]
PrintsVersion,
Matches(&'static dyn Fn(String) -> bool),
}
#[derive(Clone, Default)]
enum InstallAction {
#[default]
None,
Command(&'static str),
#[expect(dead_code)] // TODO: Remove after followup pr landed
Function {
description: &'static str,
function: &'static dyn Fn() -> Result<(), Error>,
},
}
impl InstallAction {
fn is_some(&self) -> bool {
!matches!(self, InstallAction::None)
}
fn description(&self) -> &'static str {
match self {
InstallAction::None => "",
InstallAction::Command(cmd) => cmd,
InstallAction::Function { description, .. } => description,
}
}
}
impl From<&'static str> for InstallAction {
fn from(value: &'static str) -> Self {
InstallAction::Command(value)
}
}

View File

@@ -86,7 +86,7 @@ fn parse(parsed: Output) -> Vec<LicenseEntry> {
fn run() -> Result<Output, Error> {
let output = Command::new("cargo")
.args(["about", "generate", "--format", "json", "--frozen"])
.args(["about", "generate", "--format", "json", "--locked"])
.current_dir(env!("CARGO_WORKSPACE_DIR"))
.output()
.map_err(|e| Error::Io(e, "Failed to run cargo about generate".into()))?;

View File

@@ -98,10 +98,9 @@ fn run() -> Result<(), Error> {
let current_hash = format!("{:016x}", hasher.finish());
if current_hash == fs::read_to_string(&current_hash_path).unwrap_or_default() {
eprintln!("No changes in licenses detected, skipping generation.");
return Ok(());
}
eprintln!("Changes in licenses detected, generating new license file.");
eprintln!("Changes in licenses detected, generating new license file...");
let licenses = merge_filter_dedup_and_sort(vec![
cargo_source.licenses()?,