Support Cargo scaffolding and human output, and find boolean expressions

This commit is contained in:
2026-11-11 21:57:37 +00:00
parent c7606b8ae9
commit 652ea7960f
7 changed files with 267 additions and 116 deletions
+5 -1
View File
@@ -7,7 +7,11 @@ The response contains newline-delimited JSON events.
The final `exit` event contains a code and component locations.
Each location contains an executable path and working directory.
The CLI forwards the bytes immediately.
The CLI forwards human output immediately.
The builder captures compiler JSON for artifact discovery without forwarding it to stdout.
Compiler diagnostics remain on stderr. `cargo run` reserves stdout for the program.
`cargo new` and `cargo init` use native Cargo, including `--lib`, `--name`, and `--edition`.
They need no existing manifest. Subsequent builds use the world's default WASI target.
For `test` and `run`, it executes the artifacts through the process interface.
Cargo selects run binaries through a capture runner, so `--bin` and `default-run` retain their normal behavior.
The test runner uses each package's root directory, even when the caller runs Cargo from a subdirectory.
+21 -9
View File
@@ -66,7 +66,7 @@ impl Builder {
ensure!(
[
"build", "check", "clippy", "test", "run", "fmt", "fetch", "add", "remove",
"update", "tree", "metadata", "clean"
"update", "tree", "metadata", "clean", "new", "init"
]
.contains(&action),
"unsupported Cargo command"
@@ -96,7 +96,14 @@ impl Builder {
.checked_add(Duration::from_secs(seconds))
.context("timeout too large")?;
let world_cwd = sausage_vfs::resolve("/", &request.cwd);
let workspace = files::workspace(self.root.as_ref(), &world_cwd).await?;
let scaffold = matches!(action, "new" | "init");
let workspace = if scaffold {
files::workspace(self.root.as_ref(), &world_cwd)
.await
.unwrap_or_else(|_| world_cwd.clone())
} else {
files::workspace(self.root.as_ref(), &world_cwd).await?
};
let world_target = sausage_vfs::resolve(&workspace, "target");
use std::hash::{Hash, Hasher};
let mut hash = std::collections::hash_map::DefaultHasher::new();
@@ -140,10 +147,10 @@ impl Builder {
fetch.arg("fetch");
}
fetch.current_dir(&cwd).env("CARGO_HOME", &registry);
let mut code = if matches!(action, "clean" | "fmt" | "remove") {
let mut code = if scaffold || matches!(action, "clean" | "fmt" | "remove") {
0
} else {
run(fetch, tx, deadline).await?.0
run(fetch, tx, deadline, false).await?.0
};
if code == 0 && action == "add" {
let mut fetch = Command::new("cargo");
@@ -151,7 +158,7 @@ impl Builder {
.arg("fetch")
.current_dir(&cwd)
.env("CARGO_HOME", &registry);
code = run(fetch, tx, deadline).await?.0;
code = run(fetch, tx, deadline, false).await?.0;
}
let mut executables = Vec::new();
if code == 0 {
@@ -191,7 +198,7 @@ impl Builder {
)?;
let mut startup = Command::new("nu");
startup.arg(tools.join("build.nu"));
let (startup_code, _) = run(startup, tx, deadline).await?;
let (startup_code, _) = run(startup, tx, deadline, false).await?;
if startup_code != 0 {
files::copy_back(self.root.as_ref(), &source, &workspace, Some(&before)).await?;
self.evict(20 * 1024 * 1024 * 1024)?;
@@ -303,7 +310,7 @@ impl Builder {
]);
}
cmd.args(&args[split..]);
let (status, stdout) = run(cmd, tx, deadline).await?;
let (status, stdout) = run(cmd, tx, deadline, compiles).await?;
code = status;
if action == "clean" && code == 0 {
files::clean_artifacts(self.root.as_ref(), &target, &world_target).await?;
@@ -439,6 +446,7 @@ async fn pump(
mut reader: impl tokio::io::AsyncRead + Unpin,
tx: &mpsc::Sender<Event>,
stderr: bool,
silent_stdout: bool,
) -> Result<Vec<u8>> {
let mut all = Vec::new();
let mut bytes = vec![0; 8192];
@@ -450,6 +458,9 @@ async fn pump(
if !stderr {
all.extend_from_slice(&bytes[..n]);
}
if !stderr && silent_stdout {
continue;
}
tx.send(if stderr {
Event::Stderr {
bytes: bytes[..n].to_vec(),
@@ -466,6 +477,7 @@ async fn run(
mut cmd: Command,
tx: &mpsc::Sender<Event>,
deadline: Instant,
silent_stdout: bool,
) -> Result<(i32, Vec<u8>)> {
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
@@ -479,8 +491,8 @@ async fn run(
let collect = async {
let (status, out, _) = tokio::try_join!(
async { Ok::<_, anyhow::Error>(child.wait().await?) },
pump(out, tx, false),
pump(err, tx, true)
pump(out, tx, false, silent_stdout),
pump(err, tx, true, false)
)?;
Ok::<_, anyhow::Error>((status.code().unwrap_or(1), out))
};
+6 -6
View File
@@ -191,10 +191,9 @@ async fn timeout_kills_before_output_can_drain() -> Result<()> {
let output = tx.clone();
let mut cmd = Command::new("sh");
cmd.args(["-c", "echo $$; exec sleep 1000"]);
let task =
tokio::spawn(
async move { run(cmd, &output, Instant::now() + Duration::from_secs(1)).await },
);
let task = tokio::spawn(async move {
run(cmd, &output, Instant::now() + Duration::from_secs(1), false).await
});
let Some(Event::Stdout { bytes }) =
tokio::time::timeout(Duration::from_secs(2), rx.recv()).await?
else {
@@ -219,8 +218,9 @@ async fn closed_receiver_cancels_command() -> Result<()> {
let (tx, mut rx) = mpsc::channel(1);
let mut cmd = Command::new("sh");
cmd.args(["-c", "printf ready; exec sleep 1000"]);
let task =
tokio::spawn(async move { run(cmd, &tx, Instant::now() + Duration::from_secs(60)).await });
let task = tokio::spawn(async move {
run(cmd, &tx, Instant::now() + Duration::from_secs(60), false).await
});
// Output must arrive before the command exits, not after collection completes.
let event = tokio::time::timeout(Duration::from_secs(2), rx.recv())
.await?
+75
View File
@@ -54,6 +54,81 @@ async fn cargo_at(
.await?;
Ok((code, String::from_utf8_lossy(&bytes).into_owned()))
}
#[tokio::test(flavor = "multi_thread")]
async fn cargo_scaffold_and_human_output() -> anyhow::Result<()> {
let binaries = programs();
let temp = tempfile::tempdir()?;
let world = World::open(temp.path())?;
let bin = temp.path().join("world/bin");
std::fs::create_dir_all(&bin)?;
std::fs::copy(binaries.join("cargo.wasm"), bin.join("cargo.wasm"))?;
std::fs::copy(binaries.join("brush.wasm"), bin.join("sh.wasm"))?;
let runtime = Runtime::new(world)?;
for (script, cwd, expected, success) in [
(
"cargo new hello && cd hello && cargo run",
"/",
"Hello, world!\n",
true,
),
("cargo build", "/hello", "", true),
(
"cargo new library --lib --name named_lib --edition 2021",
"/",
"",
true,
),
("cargo check", "/library", "", true),
(
"cargo init --lib --name initialized --edition 2024",
"/empty",
"",
true,
),
("cargo check", "/empty", "", true),
("cargo new broken --edition 1900", "/", "", false),
] {
std::fs::create_dir_all(temp.path().join("world/empty"))?;
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let code = runtime
.exec(
Command::Argv(vec!["/bin/sh.wasm".into(), "-c".into(), script.into()]),
Options {
cwd: cwd.into(),
timeout: Some(Duration::from_secs(120)),
..Default::default()
},
&mut |stream, bytes| match stream {
sausage::exec::Stream::Stdout => stdout.extend_from_slice(bytes),
sausage::exec::Stream::Stderr => stderr.extend_from_slice(bytes),
},
)
.await?;
let stderr = String::from_utf8_lossy(&stderr);
assert_eq!(code == 0, success, "{script}: {stderr}");
assert_eq!(
String::from_utf8_lossy(&stdout),
expected,
"{script}: {stderr}"
);
assert!(
!stderr.contains("\"reason\":\"compiler-artifact\""),
"{stderr}"
);
if script == "cargo build" {
assert!(stderr.contains("Finished"), "{stderr}");
}
}
let root = temp.path().join("world");
let manifest: toml::Value =
toml::from_str(&std::fs::read_to_string(root.join("library/Cargo.toml"))?)?;
assert_eq!(manifest["package"]["name"].as_str(), Some("named_lib"));
assert_eq!(manifest["package"]["edition"].as_str(), Some("2021"));
assert!(root.join("library/src/lib.rs").is_file());
assert!(root.join("empty/src/lib.rs").is_file());
Ok(())
}
fn cache_hits(base: &Path) -> anyhow::Result<u64> {
let stats = std::process::Command::new("kache")
.arg("stats")
+4 -2
View File
@@ -25,7 +25,7 @@ fn error(value: impl std::fmt::Debug) -> io::Error {
}
fn local_help(argv: &[String]) -> Option<&'static str> {
matches!(argv.first().map(String::as_str), Some("--help" | "-h" | "help")).then_some(
"Usage: cargo <COMMAND> [OPTIONS]\n\nWorld commands: build, check, clippy, test, run, fmt, fetch, add, remove\n",
"Usage: cargo <COMMAND> [OPTIONS]\n\nWorld commands: build, check, clippy, test, run, fmt, fetch, add, remove, new, init\n",
)
}
pub fn run() -> io::Result<i32> {
@@ -123,7 +123,9 @@ fn help_is_local_and_lists_world_commands() {
for flag in ["--help", "-h", "help"] {
let help = local_help(&[flag.into()]).expect("local help");
assert!(help.starts_with("Usage: cargo <COMMAND> [OPTIONS]\n"));
assert!(help.contains("build, check, clippy, test, run, fmt, fetch, add, remove"));
assert!(
help.contains("build, check, clippy, test, run, fmt, fetch, add, remove, new, init")
);
}
assert!(local_help(&["build".into()]).is_none());
assert!(local_help(&[]).is_none());
+153 -97
View File
@@ -1,11 +1,15 @@
//! Find world paths with short-circuit AND/OR predicates and directory pruning.
//! Find world paths with precedence-aware, short-circuit predicates and directory pruning.
use sausage_spawn::Command;
use std::{
io::{self, Write},
path::PathBuf,
};
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
enum Predicate {
True,
Not(Box<Self>),
And(Box<Self>, Box<Self>),
Or(Box<Self>, Box<Self>),
Name(globset::GlobMatcher),
Path(globset::GlobMatcher),
Type(String),
@@ -15,75 +19,181 @@ enum Predicate {
Print(bool),
Exec(Vec<String>),
}
fn run() -> Result<i32, Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args().skip(1).collect();
let mut i = 0;
let mut roots = Vec::new();
while i < args.len() && !args[i].starts_with('-') {
roots.push(PathBuf::from(&args[i]));
i += 1;
struct Parser<'a> {
args: &'a [String],
i: usize,
maxdepth: usize,
mindepth: usize,
explicit_action: bool,
}
impl Parser<'_> {
fn peek(&self) -> Option<&str> {
self.args.get(self.i).map(String::as_str)
}
if roots.is_empty() {
roots.push(".".into());
fn or(&mut self) -> Result<Predicate> {
let mut value = self.and()?;
while matches!(self.peek(), Some("-o" | "-or")) {
self.i += 1;
value = Predicate::Or(Box::new(value), Box::new(self.and()?));
}
Ok(value)
}
let mut groups = vec![Vec::new()];
let mut maxdepth = usize::MAX;
let mut mindepth = 0;
let mut explicit_action = false;
while i < args.len() {
let flag = &args[i];
i += 1;
let predicate = match flag.as_str() {
"-o" | "-or" => {
groups.push(Vec::new());
continue;
fn and(&mut self) -> Result<Predicate> {
let mut value = self.unary()?;
while !matches!(self.peek(), None | Some("-o" | "-or" | ")")) {
if matches!(self.peek(), Some("-a" | "-and")) {
self.i += 1;
}
value = Predicate::And(Box::new(value), Box::new(self.unary()?));
}
Ok(value)
}
fn unary(&mut self) -> Result<Predicate> {
let flag = self.args.get(self.i).ok_or("missing expression")?;
self.i += 1;
Ok(match flag.as_str() {
"!" | "-not" => Predicate::Not(Box::new(self.unary()?)),
"(" => {
let value = self.or()?;
if self.peek() != Some(")") {
return Err("missing closing parenthesis".into());
}
self.i += 1;
value
}
"-a" | "-and" => continue,
"-prune" => Predicate::Prune,
"-empty" => Predicate::Empty,
"-print" | "-print0" => {
explicit_action = true;
self.explicit_action = true;
Predicate::Print(flag == "-print0")
}
"-name" | "-path" | "-type" | "-maxdepth" | "-mindepth" | "-newer" => {
let value = args.get(i).ok_or("missing predicate argument")?;
i += 1;
let value = self.args.get(self.i).ok_or("missing predicate argument")?;
self.i += 1;
match flag.as_str() {
"-name" => Predicate::Name(globset::Glob::new(value)?.compile_matcher()),
"-path" => Predicate::Path(globset::Glob::new(value)?.compile_matcher()),
"-type" => Predicate::Type(value.clone()),
"-newer" => Predicate::Newer(std::fs::metadata(value)?.modified()?),
"-maxdepth" => {
maxdepth = value.parse()?;
continue;
self.maxdepth = value.parse()?;
Predicate::True
}
_ => {
mindepth = value.parse()?;
continue;
self.mindepth = value.parse()?;
Predicate::True
}
}
}
"-exec" => {
let start = i;
while i < args.len() && args[i] != ";" {
i += 1;
let start = self.i;
while self.i < self.args.len() && self.args[self.i] != ";" {
self.i += 1;
}
if i == args.len() || i == start {
if self.i == self.args.len() || self.i == start {
return Err("-exec requires a command and ;".into());
}
explicit_action = true;
let argv = args[start..i].to_vec();
i += 1;
self.explicit_action = true;
let argv = self.args[start..self.i].to_vec();
self.i += 1;
Predicate::Exec(argv)
}
_ => return Err(format!("unsupported predicate: {flag}").into()),
};
groups.last_mut().unwrap().push(predicate);
})
}
}
fn matches(
predicate: &Predicate,
entry: &walkdir::DirEntry,
prune: &mut bool,
status: &mut i32,
out: &mut impl Write,
) -> Result<bool> {
Ok(match predicate {
Predicate::True => true,
Predicate::Not(value) => !matches(value, entry, prune, status, out)?,
Predicate::And(left, right) => {
matches(left, entry, prune, status, out)? && matches(right, entry, prune, status, out)?
}
Predicate::Or(left, right) => {
matches(left, entry, prune, status, out)? || matches(right, entry, prune, status, out)?
}
Predicate::Name(g) => g.is_match(entry.file_name()),
Predicate::Path(g) => g.is_match(entry.path()),
Predicate::Type(t) => match t.as_str() {
"f" => entry.file_type().is_file(),
"d" => entry.file_type().is_dir(),
"l" => entry.file_type().is_symlink(),
_ => return Err("unsupported file type".into()),
},
Predicate::Newer(time) => entry.metadata()?.modified()? > *time,
Predicate::Empty => {
if entry.file_type().is_dir() {
std::fs::read_dir(entry.path())?.next().is_none()
} else {
entry.file_type().is_file() && entry.metadata()?.len() == 0
}
}
Predicate::Prune => {
*prune = true;
true
}
Predicate::Print(null) => {
out.write_all(entry.path().as_os_str().as_encoded_bytes())?;
out.write_all(if *null { b"\0" } else { b"\n" })?;
true
}
Predicate::Exec(argv) => {
out.flush()?;
let argv: Vec<_> = argv
.iter()
.map(|arg| arg.replace("{}", &entry.path().to_string_lossy()))
.collect();
match Command::new(&argv[0]).args(&argv[1..]).status() {
Ok(code) => code == 0,
Err(e) => {
eprintln!("find: {e}");
*status = 1;
false
}
}
}
})
}
fn run() -> Result<i32> {
let args: Vec<_> = std::env::args().skip(1).collect();
let mut parser = Parser {
args: &args,
i: 0,
maxdepth: usize::MAX,
mindepth: 0,
explicit_action: false,
};
let mut roots = Vec::new();
while parser
.peek()
.is_some_and(|s| !s.starts_with('-') && !matches!(s, "!" | "(" | ")"))
{
roots.push(PathBuf::from(&args[parser.i]));
parser.i += 1;
}
if roots.is_empty() {
roots.push(".".into());
}
let predicate = if parser.peek().is_none() {
Predicate::True
} else {
parser.or()?
};
if parser.peek().is_some() {
return Err("unexpected closing parenthesis".into());
}
let mut status = 0;
let mut out = io::BufWriter::new(io::stdout().lock());
for root in roots {
let mut walk = walkdir::WalkDir::new(root).max_depth(maxdepth).into_iter();
let mut walk = walkdir::WalkDir::new(root)
.max_depth(parser.maxdepth)
.into_iter();
while let Some(entry) = walk.next() {
let entry = match entry {
Ok(e) => e,
@@ -93,69 +203,15 @@ fn run() -> Result<i32, Box<dyn std::error::Error>> {
continue;
}
};
if entry.depth() < mindepth {
if entry.depth() < parser.mindepth {
continue;
}
let mut prune = false;
let mut matches = false;
for group in &groups {
let mut matched = true;
for predicate in group {
matched = match predicate {
Predicate::Name(g) => g.is_match(entry.file_name()),
Predicate::Path(g) => g.is_match(entry.path()),
Predicate::Type(t) => match t.as_str() {
"f" => entry.file_type().is_file(),
"d" => entry.file_type().is_dir(),
"l" => entry.file_type().is_symlink(),
_ => return Err("unsupported file type".into()),
},
Predicate::Newer(time) => entry.metadata()?.modified()? > *time,
Predicate::Empty => {
if entry.file_type().is_dir() {
std::fs::read_dir(entry.path())?.next().is_none()
} else {
entry.file_type().is_file() && entry.metadata()?.len() == 0
}
}
Predicate::Prune => {
prune = true;
true
}
Predicate::Print(null) => {
out.write_all(entry.path().as_os_str().as_encoded_bytes())?;
out.write_all(if *null { b"\0" } else { b"\n" })?;
true
}
Predicate::Exec(argv) => {
out.flush()?;
let argv: Vec<_> = argv
.iter()
.map(|arg| arg.replace("{}", &entry.path().to_string_lossy()))
.collect();
match Command::new(&argv[0]).args(&argv[1..]).status() {
Ok(code) => code == 0,
Err(e) => {
eprintln!("find: {e}");
status = 1;
false
}
}
}
};
if !matched {
break;
}
}
if matched {
matches = true;
break;
}
}
let matched = matches(&predicate, &entry, &mut prune, &mut status, &mut out)?;
if prune && entry.file_type().is_dir() {
walk.skip_current_dir();
}
if matches && !explicit_action {
if matched && !parser.explicit_action {
out.write_all(entry.path().as_os_str().as_encoded_bytes())?;
out.write_all(b"\n")?;
}
+3 -1
View File
@@ -8,10 +8,12 @@ def main [host: path, bundle: path] {
mkdir ($fixture | path join skip/deep) ($fixture | path join keep/deep) ($fixture | path join empty)
'hidden' | save ($fixture | path join skip/deep/file)
'shown' | save ($fixture | path join keep/deep/file)
mkdir ($fixture | path join target)
'artifact' | save ($fixture | path join target output)
'' | save ($fixture | path join zero)
'' | save ($fixture | path join reference)
^touch -d '2001-01-01 UTC' ($fixture | path join reference)
for args in [[-name skip -prune -o -type f -print] [-path './skip' -prune -o -print] [-name skip -prune -o -type f] [-maxdepth 1] [-mindepth 1 -maxdepth 2] [-empty] [-newer reference] [-path './keep/*']] {
for args in [[-name skip -prune -o -type f -print] [-path './skip' -prune -o -print] [-name skip -prune -o -type f] [-maxdepth 1] [-mindepth 1 -maxdepth 2] [-empty] [-newer reference] [-path './keep/*'] [-type f -not -path './target/*'] ['!' -type d] [-not -not -type f] ['(' -name zero -o -name reference ')' -a -type f] [-name zero -o -name reference -a -type d] [-not '(' -name skip -o -name keep ')' -a -type d] [-name skip -prune -o '(' -type f -a '!' -name reference ')' -print] [-name zero -o -name reference -print] ['(' -name zero -o -name reference ')' -print] [-not] ['(' -type f] [-type f -a]] {
let native = (do { cd $fixture; ^find . ...$args | complete })
let command = ($args | each {|arg| $arg | into string | to json -r} | str join ' ')
let guest = (^$host sh $world -c $"cd /fixture; find . ($command)" | complete)