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
20 changes: 20 additions & 0 deletions src/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,26 @@ pub fn find_workspace_root(project_dir: &Path) -> Option<PathBuf> {
}
}

/// Find the project root by walking up from `start_dir` looking for Cargo.toml.
///
/// Returns the canonicalized directory containing the nearest Cargo.toml.
/// Starts checking `start_dir` itself, then walks up through parents.
pub fn find_project_root(start_dir: &Path) -> Result<PathBuf, Error> {
let start = start_dir
.canonicalize()
.map_err(|_| Error::NoProjectFound(start_dir.to_path_buf()))?;
let mut dir = start.as_path();
loop {
if dir.join("Cargo.toml").exists() {
return Ok(dir.to_path_buf());
}
match dir.parent() {
Some(parent) => dir = parent,
None => return Err(Error::NoProjectFound(start_dir.to_path_buf())),
}
}
}

/// Find the binary entry point for a Cargo project.
///
/// Reads `Cargo.toml` and resolves the entry point using Cargo's rules:
Expand Down
3 changes: 3 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ pub enum Error {
#[error("no run found for tag '{tag}' -- run piano tag to see available tags")]
RunNotFound { tag: String },

#[error("no Cargo.toml found in {0} or any parent directory")]
NoProjectFound(PathBuf),

#[error("failed to read run file {}: {source}", path.display())]
RunReadError {
path: PathBuf,
Expand Down
45 changes: 28 additions & 17 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ use std::process;
use clap::{Parser, Subcommand};

use piano::build::{
build_instrumented, find_bin_entry_point, find_workspace_root, inject_runtime_dependency,
inject_runtime_path_dependency, prepare_staging,
build_instrumented, find_bin_entry_point, find_project_root, find_workspace_root,
inject_runtime_dependency, inject_runtime_path_dependency, prepare_staging,
};
use piano::error::Error;
use piano::report::{
Expand Down Expand Up @@ -50,9 +50,9 @@ enum Commands {
#[arg(long = "mod", value_name = "NAME")]
mod_patterns: Vec<String>,

/// Project root (defaults to current directory).
#[arg(long, default_value = ".")]
project: PathBuf,
/// Project root (auto-detected from Cargo.toml).
#[arg(long)]
project: Option<PathBuf>,

/// Path to piano-runtime source (for development before publishing).
#[arg(long)]
Expand Down Expand Up @@ -85,9 +85,9 @@ enum Commands {
#[arg(long = "mod", value_name = "NAME")]
mod_patterns: Vec<String>,

/// Project root (defaults to current directory).
#[arg(long, default_value = ".")]
project: PathBuf,
/// Project root (auto-detected from Cargo.toml).
#[arg(long)]
project: Option<PathBuf>,

/// Path to piano-runtime source (for development before publishing).
#[arg(long)]
Expand Down Expand Up @@ -392,10 +392,14 @@ fn cmd_build(
fn_patterns: Vec<String>,
file_patterns: Vec<PathBuf>,
mod_patterns: Vec<String>,
project: PathBuf,
project: Option<PathBuf>,
runtime_path: Option<PathBuf>,
cpu_time: bool,
) -> Result<(), Error> {
let project = match project {
Some(p) => p,
None => find_project_root(&std::env::current_dir()?)?,
};
let (binary, _runs_dir) = build_project(
fn_patterns,
file_patterns,
Expand All @@ -417,7 +421,8 @@ fn cmd_build(
}

fn find_latest_binary() -> Result<PathBuf, Error> {
let dir = PathBuf::from("target/piano/debug");
let project = find_project_root(&std::env::current_dir()?).map_err(|_| Error::NoBinary)?;
let dir = project.join("target/piano/debug");
if !dir.is_dir() {
return Err(Error::NoBinary);
}
Expand Down Expand Up @@ -464,14 +469,18 @@ fn cmd_profile(
fn_patterns: Vec<String>,
file_patterns: Vec<PathBuf>,
mod_patterns: Vec<String>,
project: PathBuf,
project: Option<PathBuf>,
runtime_path: Option<PathBuf>,
cpu_time: bool,
show_all: bool,
frames: bool,
ignore_exit_code: bool,
args: Vec<String>,
) -> Result<(), Error> {
let project = match project {
Some(p) => p,
None => find_project_root(&std::env::current_dir()?)?,
};
let (binary, runs_dir) = build_project(
fn_patterns,
file_patterns,
Expand Down Expand Up @@ -642,9 +651,10 @@ fn default_runs_dir() -> Result<PathBuf, Error> {
if let Ok(dir) = std::env::var("PIANO_RUNS_DIR") {
return Ok(PathBuf::from(dir));
}
let local = PathBuf::from("target/piano/runs");
let project = find_project_root(&std::env::current_dir()?).map_err(|_| Error::NoRuns)?;
let local = project.join("target/piano/runs");
if local.is_dir() {
return Ok(std::fs::canonicalize(local)?);
return Ok(local);
}
Err(Error::NoRuns)
}
Expand All @@ -653,15 +663,16 @@ fn default_tags_dir() -> Result<PathBuf, Error> {
if let Ok(dir) = std::env::var("PIANO_TAGS_DIR") {
return Ok(PathBuf::from(dir));
}
let local = PathBuf::from("target/piano/tags");
let project = find_project_root(&std::env::current_dir()?).map_err(|_| Error::NoRuns)?;
let local = project.join("target/piano/tags");
if local.is_dir() {
return Ok(std::fs::canonicalize(local)?);
return Ok(local);
}
// Auto-create tags dir if runs exist (tags live alongside runs)
let runs_local = PathBuf::from("target/piano/runs");
let runs_local = project.join("target/piano/runs");
if runs_local.is_dir() {
std::fs::create_dir_all(&local)?;
return Ok(std::fs::canonicalize(local)?);
return Ok(local);
}
Err(Error::NoRuns)
}
176 changes: 176 additions & 0 deletions tests/project_root.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
//! Tests for project root auto-detection.

use std::fs;
use std::path::Path;
use std::process::Command;

fn create_mini_project(dir: &Path) {
fs::create_dir_all(dir.join("src")).unwrap();
fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"mini\"\nversion = \"0.1.0\"\nedition = \"2024\"\n",
)
.unwrap();
fs::write(dir.join("src/main.rs"), "fn main() {}\n").unwrap();
}

#[test]
fn finds_root_from_project_dir() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("myproject");
create_mini_project(&project);

let root = piano::build::find_project_root(&project).unwrap();
assert_eq!(root, project.canonicalize().unwrap());
}

#[test]
fn finds_root_from_subdirectory() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("myproject");
create_mini_project(&project);

let subdir = project.join("src");
let root = piano::build::find_project_root(&subdir).unwrap();
assert_eq!(root, project.canonicalize().unwrap());
}

#[test]
fn finds_root_from_nested_subdirectory() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("myproject");
create_mini_project(&project);

let deep = project.join("src").join("nested");
fs::create_dir_all(&deep).unwrap();

let root = piano::build::find_project_root(&deep).unwrap();
assert_eq!(root, project.canonicalize().unwrap());
}

#[test]
fn errors_when_no_cargo_toml() {
let tmp = tempfile::tempdir().unwrap();
let empty = tmp.path().join("empty");
fs::create_dir_all(&empty).unwrap();

let result = piano::build::find_project_root(&empty);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("no Cargo.toml found"),
"error should mention Cargo.toml, got: {msg}"
);
}

#[test]
fn build_auto_detects_project_from_subdirectory() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("myproject");
create_mini_project(&project);

let piano_bin = env!("CARGO_BIN_EXE_piano");
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let runtime_path = manifest_dir.join("piano-runtime");

// Run piano build from src/ subdirectory, without --project
let output = Command::new(piano_bin)
.args(["build", "--fn", "main", "--runtime-path"])
.arg(&runtime_path)
.current_dir(project.join("src"))
.output()
.expect("failed to run piano build");

let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);

assert!(
output.status.success(),
"piano build from subdirectory failed:\nstderr: {stderr}\nstdout: {stdout}"
);
}

#[test]
fn report_works_from_subdirectory() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("myproject");
create_mini_project(&project);

let piano_bin = env!("CARGO_BIN_EXE_piano");
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let runtime_path = manifest_dir.join("piano-runtime");

// Build and run to generate profiling data
let output = Command::new(piano_bin)
.args(["profile", "--fn", "main", "--project"])
.arg(&project)
.arg("--runtime-path")
.arg(&runtime_path)
.output()
.expect("failed to run piano profile");

assert!(
output.status.success(),
"piano profile failed: {}",
String::from_utf8_lossy(&output.stderr)
);

// Now run piano report from src/ subdirectory, without PIANO_RUNS_DIR
let report_output = Command::new(piano_bin)
.args(["report"])
.current_dir(project.join("src"))
.output()
.expect("failed to run piano report");

let stdout = String::from_utf8_lossy(&report_output.stdout);
let stderr = String::from_utf8_lossy(&report_output.stderr);

assert!(
report_output.status.success(),
"piano report from subdirectory failed:\nstderr: {stderr}\nstdout: {stdout}"
);
assert!(
stdout.contains("main"),
"report should show 'main' function, got: {stdout}"
);
}

#[test]
fn run_works_from_subdirectory() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("myproject");
create_mini_project(&project);

let piano_bin = env!("CARGO_BIN_EXE_piano");
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let runtime_path = manifest_dir.join("piano-runtime");

// Build first
let build_output = Command::new(piano_bin)
.args(["build", "--project"])
.arg(&project)
.arg("--runtime-path")
.arg(&runtime_path)
.output()
.expect("failed to run piano build");

assert!(
build_output.status.success(),
"piano build failed: {}",
String::from_utf8_lossy(&build_output.stderr)
);

// Run from src/ subdirectory
let run_output = Command::new(piano_bin)
.arg("run")
.current_dir(project.join("src"))
.output()
.expect("failed to run piano run");

let stderr = String::from_utf8_lossy(&run_output.stderr);

assert!(
run_output.status.success(),
"piano run from subdirectory failed:\nstderr: {stderr}"
);
}