use std::path::{Path, PathBuf};
use std::process::Command;
use ignore::WalkBuilder;
use toml_edit::DocumentMut;
use crate::error::Error;
pub fn prepare_staging(project_root: &Path, staging_dir: &Path) -> Result<(), Error> {
let walker = WalkBuilder::new(project_root)
.hidden(false)
.filter_entry(|entry| {
entry.depth() != 1 || entry.file_name().to_string_lossy() != "target"
})
.build();
for entry in walker {
let entry = entry.map_err(|e| std::io::Error::other(e.to_string()))?;
let source = entry.path();
let relative = source
.strip_prefix(project_root)
.map_err(|e| std::io::Error::other(e.to_string()))?;
let dest = staging_dir.join(relative);
if entry.file_type().is_some_and(|ft| ft.is_dir()) {
std::fs::create_dir_all(&dest)?;
} else if entry.file_type().is_some_and(|ft| ft.is_file()) {
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(source, &dest)?;
}
}
Ok(())
}
pub(crate) enum RuntimeSource<'a> {
Version(&'a str),
Path(&'a Path),
}
pub fn inject_runtime_dependency(staging_dir: &Path, runtime_version: &str) -> Result<(), Error> {
inject_runtime(staging_dir, RuntimeSource::Version(runtime_version))
}
pub fn inject_runtime_path_dependency(
staging_dir: &Path,
runtime_path: &Path,
) -> Result<(), Error> {
inject_runtime(staging_dir, RuntimeSource::Path(runtime_path))
}
fn inject_runtime(staging_dir: &Path, source: RuntimeSource<'_>) -> Result<(), Error> {
let cargo_toml_path = staging_dir.join("Cargo.toml");
let content = std::fs::read_to_string(&cargo_toml_path)?;
let mut doc: DocumentMut = content
.parse::<DocumentMut>()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
if !doc.contains_table("dependencies") {
doc["dependencies"] = toml_edit::Item::Table(toml_edit::Table::new());
}
match source {
RuntimeSource::Version(v) => {
doc["dependencies"]["piano-runtime"] = toml_edit::value(v);
}
RuntimeSource::Path(p) => {
let mut table = toml_edit::InlineTable::new();
table.insert("path", p.to_string_lossy().as_ref().into());
doc["dependencies"]["piano-runtime"] =
toml_edit::Item::Value(toml_edit::Value::InlineTable(table));
}
}
std::fs::write(&cargo_toml_path, doc.to_string())?;
Ok(())
}
fn extract_rendered_errors(json_output: &str) -> Vec<String> {
json_output
.lines()
.filter_map(|line| {
let msg: serde_json::Value = serde_json::from_str(line).ok()?;
if msg.get("reason")?.as_str()? != "compiler-message" {
return None;
}
msg.get("message")?
.get("rendered")?
.as_str()
.map(String::from)
})
.collect()
}
pub fn find_workspace_root(project_dir: &Path) -> Option<PathBuf> {
let project_dir = project_dir.canonicalize().ok()?;
let mut dir = project_dir.parent()?;
loop {
let cargo_toml = dir.join("Cargo.toml");
if cargo_toml.exists() {
let content = std::fs::read_to_string(&cargo_toml).ok()?;
let doc: DocumentMut = content.parse().ok()?;
if doc.get("workspace").is_some() {
return Some(dir.to_path_buf());
}
}
dir = dir.parent()?;
}
}
pub fn find_bin_entry_point(project_dir: &Path) -> Result<PathBuf, Error> {
let cargo_toml_path = project_dir.join("Cargo.toml");
let content = std::fs::read_to_string(&cargo_toml_path)?;
let doc: DocumentMut = content
.parse::<DocumentMut>()
.map_err(|e| Error::BuildFailed(format!("failed to parse Cargo.toml: {e}")))?;
if let Some(bins) = doc.get("bin").and_then(|b| b.as_array_of_tables()) {
for bin in bins {
if let Some(path) = bin.get("path").and_then(|p| p.as_str()) {
return Ok(PathBuf::from(path));
}
}
for bin in bins {
if let Some(name) = bin.get("name").and_then(|n| n.as_str()) {
let single_file = PathBuf::from("src").join("bin").join(format!("{name}.rs"));
if project_dir.join(&single_file).exists() {
return Ok(single_file);
}
let dir_main = PathBuf::from("src").join("bin").join(name).join("main.rs");
if project_dir.join(&dir_main).exists() {
return Ok(dir_main);
}
}
}
}
let default = PathBuf::from("src").join("main.rs");
if project_dir.join(&default).exists() {
return Ok(default);
}
Err(Error::BuildFailed(format!(
"could not find binary entry point: no [[bin]] path in Cargo.toml and {} does not exist",
project_dir.join(&default).display()
)))
}
pub fn build_instrumented(
staging_dir: &Path,
target_dir: &Path,
package: Option<&str>,
) -> Result<PathBuf, Error> {
let mut cmd = Command::new("cargo");
cmd.arg("build")
.arg("--message-format=json")
.env("CARGO_TARGET_DIR", target_dir)
.env_remove("RUSTUP_TOOLCHAIN")
.current_dir(staging_dir);
if let Some(pkg) = package {
cmd.arg("-p").arg(pkg);
}
let output = cmd.output()?;
if !output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let rendered = extract_rendered_errors(&stdout);
if rendered.is_empty() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::BuildFailed(stderr.into_owned()));
}
return Err(Error::BuildFailed(rendered.join("")));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut binary_path = None;
for line in stdout.lines() {
let Ok(msg) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
if msg.get("reason").and_then(|r| r.as_str()) == Some("compiler-artifact")
&& let Some(exe) = msg.get("executable").and_then(|e| e.as_str())
{
binary_path = Some(PathBuf::from(exe));
}
}
binary_path
.ok_or_else(|| Error::BuildFailed("no executable found in cargo build output".into()))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn create_file(base: &Path, relative: &str, content: &str) {
let path = base.join(relative);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, content).unwrap();
}
#[test]
fn staging_copies_project_structure() {
let project = TempDir::new().unwrap();
let staging = TempDir::new().unwrap();
create_file(project.path(), "Cargo.toml", "[package]\nname = \"demo\"");
create_file(project.path(), "src/main.rs", "fn main() {}");
create_file(project.path(), "src/lib.rs", "pub fn lib() {}");
create_file(project.path(), "src/util/helper.rs", "pub fn help() {}");
create_file(project.path(), "target/debug/demo", "binary-content");
prepare_staging(project.path(), staging.path()).unwrap();
assert!(staging.path().join("Cargo.toml").exists());
assert!(staging.path().join("src/main.rs").exists());
assert!(staging.path().join("src/lib.rs").exists());
assert!(staging.path().join("src/util/helper.rs").exists());
assert!(!staging.path().join("target").exists());
let content = std::fs::read_to_string(staging.path().join("Cargo.toml")).unwrap();
assert_eq!(content, "[package]\nname = \"demo\"");
}
#[test]
fn inject_dependency_adds_piano_runtime() {
let staging = TempDir::new().unwrap();
let toml_content = r#"[package]
name = "demo"
version = "0.1.0"
[dependencies]
serde = "1"
"#;
create_file(staging.path(), "Cargo.toml", toml_content);
inject_runtime_dependency(staging.path(), "0.1.0").unwrap();
let result = std::fs::read_to_string(staging.path().join("Cargo.toml")).unwrap();
let doc: DocumentMut = result.parse().unwrap();
assert_eq!(doc["dependencies"]["piano-runtime"].as_str(), Some("0.1.0"),);
assert_eq!(doc["dependencies"]["serde"].as_str(), Some("1"),);
}
#[test]
fn extract_compiler_errors_from_json() {
let json_lines = concat!(
r#"{"reason":"compiler-message","message":{"rendered":"error[E0308]: mismatched types\n --> src/main.rs:2:5\n"}}"#,
"\n",
r#"{"reason":"compiler-message","message":{"rendered":"error: aborting due to previous error\n"}}"#,
"\n",
r#"{"reason":"build-finished","success":false}"#,
);
let errors = extract_rendered_errors(json_lines);
assert_eq!(errors.len(), 2);
assert!(errors[0].contains("mismatched types"));
}
#[test]
fn inject_dependency_creates_section_if_missing() {
let staging = TempDir::new().unwrap();
let toml_content = r#"[package]
name = "demo"
version = "0.1.0"
"#;
create_file(staging.path(), "Cargo.toml", toml_content);
inject_runtime_dependency(staging.path(), "0.2.0").unwrap();
let result = std::fs::read_to_string(staging.path().join("Cargo.toml")).unwrap();
let doc: DocumentMut = result.parse().unwrap();
assert_eq!(doc["dependencies"]["piano-runtime"].as_str(), Some("0.2.0"),);
}
#[test]
fn find_workspace_root_detects_parent_workspace() {
let tmp = TempDir::new().unwrap();
let ws = tmp.path().join("ws");
create_file(&ws, "Cargo.toml", "[workspace]\nmembers = [\"crates/*\"]\n");
create_file(
&ws,
"crates/member/Cargo.toml",
"[package]\nname = \"member\"\nversion = \"0.1.0\"\n",
);
create_file(&ws, "crates/member/src/main.rs", "fn main() {}");
let member_dir = ws.join("crates").join("member");
let result = find_workspace_root(&member_dir);
assert!(result.is_some(), "should find workspace root");
assert_eq!(result.unwrap(), ws.canonicalize().unwrap());
}
#[test]
fn find_workspace_root_returns_none_for_standalone() {
let tmp = TempDir::new().unwrap();
create_file(
tmp.path(),
"Cargo.toml",
"[package]\nname = \"standalone\"\nversion = \"0.1.0\"\n",
);
create_file(tmp.path(), "src/main.rs", "fn main() {}");
let result = find_workspace_root(tmp.path());
assert!(
result.is_none(),
"standalone project should not find workspace root"
);
}
#[test]
fn find_bin_entry_point_with_explicit_path() {
let tmp = TempDir::new().unwrap();
let toml = r#"[package]
name = "demo"
version = "0.1.0"
[[bin]]
name = "demo"
path = "src/custom/app.rs"
"#;
create_file(tmp.path(), "Cargo.toml", toml);
create_file(tmp.path(), "src/custom/app.rs", "fn main() {}");
let result = find_bin_entry_point(tmp.path()).unwrap();
assert_eq!(result, PathBuf::from("src/custom/app.rs"));
}
#[test]
fn find_bin_entry_point_infers_from_name_single_file() {
let tmp = TempDir::new().unwrap();
let toml = r#"[package]
name = "demo"
version = "0.1.0"
[[bin]]
name = "mytool"
"#;
create_file(tmp.path(), "Cargo.toml", toml);
create_file(tmp.path(), "src/bin/mytool.rs", "fn main() {}");
let result = find_bin_entry_point(tmp.path()).unwrap();
assert_eq!(result, PathBuf::from("src/bin/mytool.rs"));
}
#[test]
fn find_bin_entry_point_infers_from_name_dir_main() {
let tmp = TempDir::new().unwrap();
let toml = r#"[package]
name = "demo"
version = "0.1.0"
[[bin]]
name = "mytool"
"#;
create_file(tmp.path(), "Cargo.toml", toml);
create_file(tmp.path(), "src/bin/mytool/main.rs", "fn main() {}");
let result = find_bin_entry_point(tmp.path()).unwrap();
assert_eq!(result, PathBuf::from("src/bin/mytool/main.rs"));
}
#[test]
fn find_bin_entry_point_defaults_to_src_main() {
let tmp = TempDir::new().unwrap();
let toml = r#"[package]
name = "demo"
version = "0.1.0"
"#;
create_file(tmp.path(), "Cargo.toml", toml);
create_file(tmp.path(), "src/main.rs", "fn main() {}");
let result = find_bin_entry_point(tmp.path()).unwrap();
assert_eq!(result, PathBuf::from("src/main.rs"));
}
#[test]
fn find_bin_entry_point_errors_when_no_entry_found() {
let tmp = TempDir::new().unwrap();
let toml = r#"[package]
name = "demo"
version = "0.1.0"
"#;
create_file(tmp.path(), "Cargo.toml", toml);
let result = find_bin_entry_point(tmp.path());
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("could not find binary entry point"),
"unexpected error: {err_msg}"
);
}
}