From 240f3261717a04492ba36b57ae29350fe62c3f71 Mon Sep 17 00:00:00 2001 From: Elijah Date: Thu, 3 Sep 2026 04:23:28 +0000 Subject: [PATCH 1/5] cot-cli: discover, build, and cache project binary metadata --- Cargo.lock | 37 +++ Cargo.toml | 2 + cot-cli/Cargo.toml | 4 + cot-cli/src/args.rs | 4 + cot-cli/src/lib.rs | 3 +- cot-cli/src/project.rs | 458 +++++++++++++++++++++++++++++++ cot-cli/src/project/build.rs | 37 +++ cot-cli/src/project/cache.rs | 358 ++++++++++++++++++++++++ cot-cli/src/project/discovery.rs | 209 ++++++++++++++ 9 files changed, 1111 insertions(+), 1 deletion(-) create mode 100644 cot-cli/src/project.rs create mode 100644 cot-cli/src/project/build.rs create mode 100644 cot-cli/src/project/cache.rs create mode 100644 cot-cli/src/project/discovery.rs diff --git a/Cargo.lock b/Cargo.lock index 17f81f3bb..d5172fc32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -599,6 +599,39 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "camino" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "cargo_metadata" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.20", +] + [[package]] name = "cargo_toml" version = "1.0.0" @@ -979,6 +1012,7 @@ version = "0.7.0" dependencies = [ "anyhow", "assert_cmd", + "cargo_metadata", "cargo_toml", "chrono", "clap", @@ -999,11 +1033,14 @@ dependencies = [ "proc-macro2", "quote", "rand 0.10.2", + "serde", + "serde_json", "syn 3.0.4", "tempfile", "tracing", "tracing-subscriber", "trybuild", + "wait-timeout", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b61a99523..faa4be14c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -76,6 +76,7 @@ backtrace = "0.3.76" blake3 = "1.8.5" bytes = "1.12" cargo_toml = "1.0" +cargo_metadata = "0.23.1" chrono = { version = "0.4.45", default-features = false } chrono-tz = { version = "0.10", default-features = false } clap = { version = "4.6", features = ["deprecated"] } @@ -157,6 +158,7 @@ tracing-subscriber = "0.3" tracing-test = "0.2" trybuild = { version = "1", features = ["diff"] } url = "2" +wait-timeout = { version = "0.2", default-features = false } [profile.dev.package] insta.opt-level = 3 diff --git a/cot-cli/Cargo.toml b/cot-cli/Cargo.toml index 341b564bd..ac94f4931 100644 --- a/cot-cli/Cargo.toml +++ b/cot-cli/Cargo.toml @@ -22,6 +22,7 @@ workspace = true [dependencies] anyhow.workspace = true cargo_toml.workspace = true +cargo_metadata.workspace = true chrono.workspace = true clap = { workspace = true, features = ["derive", "env", "wrap_help", "string"] } clap_complete.workspace = true @@ -41,6 +42,9 @@ quote.workspace = true syn.workspace = true tracing.workspace = true tracing-subscriber = { workspace = true, features = ["env-filter"] } +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +wait-timeout.workspace = true [dev-dependencies] cot-cli = { path = ".", features = ["test_utils"] } diff --git a/cot-cli/src/args.rs b/cot-cli/src/args.rs index 1e35ceec8..67713fb28 100644 --- a/cot-cli/src/args.rs +++ b/cot-cli/src/args.rs @@ -3,6 +3,10 @@ use std::path::PathBuf; use clap::{Args, Parser, Subcommand}; use clap_verbosity_flag::Verbosity; +pub const PACKAGE_SHORT_FLAG: &str = "-p"; +pub const RELEASE_FLAG: &str = "--release"; +pub const BINARY_FLAG: &str = "--bin"; + #[derive(Debug, Parser)] #[command( name = "cot", diff --git a/cot-cli/src/lib.rs b/cot-cli/src/lib.rs index 5c23e7383..fe181c1cd 100644 --- a/cot-cli/src/lib.rs +++ b/cot-cli/src/lib.rs @@ -4,6 +4,7 @@ pub mod args; pub mod handlers; pub mod migration_generator; pub mod new_project; +pub mod project; #[cfg(feature = "test_utils")] pub mod test_utils; -mod utils; +mod utils; \ No newline at end of file diff --git a/cot-cli/src/project.rs b/cot-cli/src/project.rs new file mode 100644 index 000000000..b34951445 --- /dev/null +++ b/cot-cli/src/project.rs @@ -0,0 +1,458 @@ +//! Functionality to locate, build (only if necessary), and query a Cot-compiled +//! binary. +mod build; +mod cache; +mod discovery; + +use std::path::{Path, PathBuf}; + +use anyhow::bail; +use cot::metadata::ProjectMetadata; +use cot::utils::cli::{StatusType, print_status_msg}; + +use crate::project::discovery::ResolvedBinary; + +const RELEASE_PROFILE: &str = "release"; +const DEBUG_PROFILE: &str = "debug"; + +#[derive(Debug)] +pub struct ProjectBinary { + pub path: PathBuf, + pub metadata: Option, +} + +/// Find and load the project binary and its metadata. +/// +/// `package` corresponds to `cot -p ...` or `--package `. +/// It's required when run from a workspace root +/// (or any directory that doesn't unambiguously belong to one package) and +/// the workspace has more than one member. +pub fn load( + path: &Path, + release: bool, + package: Option<&str>, + build: bool, +) -> anyhow::Result> { + let Some(resolved) = discovery::resolve(path, release, package)? else { + return Ok(None); + }; + + let ResolvedBinary { + binary_path, + project_dir, + package_name, + binary_name, + } = resolved; + + if !binary_path.exists() { + if !build { + return Ok(None); + } + + build::build_binary(&package_name, &binary_name, release)?; + if !binary_path.exists() { + bail!( + "`cargo build` succeeded but `{}` still wasn't found at the expected path, \ + this may mean the binary name `cot` resolved doesn't match what cargo built.", + binary_path.display(), + ); + } + } + + // Guard against the `cot` CLI resolving to itself. This can happen when + // running from within the `cot-cli` package or a workspace package whose + // binary is the current executable. Querying it for `--metadata` would + // either recurse or fail: only cot application binaries implement that + // flag, not the CLI proxy. + if discovery::is_current_executable(&binary_path) { + return Ok(None); + } + + let cache_path = cache::command_cache_path(&project_dir); + let metadata = match cache::load_or_refresh(&binary_path, &cache_path) { + Ok(meta) => meta, + Err(e) => { + print_status_msg( + StatusType::Warning, + &format!( + "could not determine `{}`'s cli commands, so they won't be \ + listed when you run `cot --help`: {e:#}", + binary_path.display(), + ), + ); + None + } + }; + + Ok(Some(ProjectBinary { + path: binary_path, + metadata, + })) +} + +#[cfg(test)] +mod tests { + use std::fs; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + + use cot::metadata::CommandMeta; + use tempfile::TempDir; + + use super::*; + use crate::project::cache::command_cache_path; + + pub(crate) fn canonical_temp_dir() -> (TempDir, PathBuf) { + let temp_dir = TempDir::new().unwrap(); + let tmp_path = temp_dir.path().canonicalize().unwrap(); + (temp_dir, tmp_path) + } + + pub(crate) fn write_package_manifest(package_dir: &Path, package_name: &str, extra: &str) { + fs::create_dir_all(package_dir).unwrap(); + fs::write( + package_dir.join("Cargo.toml"), + format!( + r#"[package] +name = "{package_name}" +version = "0.1.0" +edition = "2024" + +{extra}"# + ), + ) + .unwrap(); + + if !extra.contains("[[bin]]") { + let src_dir = package_dir.join("src"); + fs::create_dir_all(&src_dir).unwrap(); + fs::write(src_dir.join("main.rs"), "fn main() {}\n").unwrap(); + } + } + + pub(crate) fn write_workspace_manifest(workspace_dir: &Path, members: &[&str]) { + fs::write( + workspace_dir.join("Cargo.toml"), + format!( + "[workspace]\nresolver = \"3\"\nmembers = [{}]\n", + members + .iter() + .map(|member| format!("\"{member}\"")) + .collect::>() + .join(", ") + ), + ) + .unwrap(); + } + + fn command(name: &str) -> CommandMeta { + CommandMeta { + name: name.to_string(), + about: None, + aliases: vec![], + subcommands: vec![], + args: vec![], + } + } + + pub(crate) fn metadata(binary_name: &str, command_names: &[&str]) -> ProjectMetadata { + ProjectMetadata { + version: cot::metadata::METADATA_SCHEMA_VERSION, + binary_name: binary_name.to_string(), + commands: command_names.iter().map(|name| command(name)).collect(), + } + } + + #[cfg(unix)] + pub(crate) fn write_metadata_script(path: &Path, metadata: &ProjectMetadata) { + let json = serde_json::to_string(metadata).unwrap(); + write_shell_script(path, &format!("printf '%s\\n' '{json}'\n")); + } + + #[cfg(unix)] + pub(crate) fn write_shell_script(path: &Path, body: &str) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, format!("#!/bin/sh\n{body}")).unwrap(); + let mut permissions = fs::metadata(path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).unwrap(); + } + + #[test] + fn load_returns_none_without_cargo_manifest() { + let (_guard, temp_dir) = canonical_temp_dir(); + + let result = load(&temp_dir, false, None, true).unwrap(); + + assert!(result.is_none()); + } + + #[test] + fn load_errors_when_start_path_does_not_exist() { + let (_guard, temp_dir) = canonical_temp_dir(); + + let result = load(&temp_dir.join("missing"), false, None, true); + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("path does not exist") + ); + } + + #[test] + fn load_returns_none_when_expected_binary_is_missing() { + let (_guard, temp_dir) = canonical_temp_dir(); + + write_package_manifest(&temp_dir, "demo", ""); + + let result = load(&temp_dir, false, None, false).unwrap(); + + assert!(result.is_none()); + } + + #[test] + #[cfg_attr( + miri, + ignore = "can't call foreign function `posix_spawnattr_init` on OS `linux`" + )] + #[cfg(unix)] + fn load_reads_debug_binary_metadata_and_writes_cache() { + let (_guard, temp_dir) = canonical_temp_dir(); + + write_package_manifest(&temp_dir, "demo", ""); + let binary_path = temp_dir.join("target/debug/demo"); + write_metadata_script(&binary_path, &metadata("demo", &["serve"])); + + let project = load(&temp_dir, false, None, true).unwrap().unwrap(); + + assert_eq!(project.path, binary_path); + assert!(project.metadata.is_some()); + + let metadata = project.metadata.unwrap(); + + assert_eq!(metadata.binary_name, "demo"); + assert_eq!(metadata.commands[0].name, "serve"); + assert!(command_cache_path(&temp_dir).exists()); + } + + #[test] + #[cfg_attr( + miri, + ignore = "can't call foreign function `posix_spawnattr_init` on OS `linux`" + )] + #[cfg(unix)] + fn load_uses_release_profile_when_requested() { + let (_guard, temp_dir) = canonical_temp_dir(); + + write_package_manifest(&temp_dir, "demo", ""); + let binary_path = temp_dir.join("target/release/demo"); + write_metadata_script(&binary_path, &metadata("demo", &["serve"])); + + let project = load(&temp_dir, true, None, true).unwrap().unwrap(); + + assert_eq!(project.path, binary_path); + } + + #[test] + #[cfg_attr( + miri, + ignore = "can't call foreign function `posix_spawnattr_init` on OS `linux`" + )] + #[cfg(unix)] + fn load_uses_single_named_bin_target() { + let (_guard, temp_dir) = canonical_temp_dir(); + + write_package_manifest( + &temp_dir, + "demo", + r#"[[bin]] +name = "server" +path = "src/server.rs" +"#, + ); + let binary_path = temp_dir.join("target/debug/server"); + write_metadata_script(&binary_path, &metadata("server", &["serve"])); + + let project = load(&temp_dir, false, None, true).unwrap().unwrap(); + + assert_eq!(project.path, binary_path); + assert!(project.metadata.is_some()); + assert_eq!(project.metadata.unwrap().binary_name, "server"); + } + + #[test] + #[cfg_attr( + miri, + ignore = "can't call foreign function `posix_spawnattr_init` on OS `linux`" + )] + #[cfg(unix)] + fn load_uses_metadata_binary_override_before_bin_targets() { + let (_guard, temp_dir) = canonical_temp_dir(); + + write_package_manifest( + &temp_dir, + "demo", + r#"[package.metadata.cot] +binary = "api" + +[[bin]] +name = "api" +path = "src/api.rs" + +[[bin]] +name = "worker" +path = "src/worker.rs" +"#, + ); + let binary_path = temp_dir.join("target/debug/api"); + write_metadata_script(&binary_path, &metadata("api", &["serve"])); + + let project = load(&temp_dir, false, None, true).unwrap().unwrap(); + + assert_eq!(project.path, binary_path); + assert!(project.metadata.is_some()); + assert_eq!(project.metadata.unwrap().binary_name, "api"); + } + + #[test] + fn load_errors_on_multiple_bin_targets_without_override() { + let (_guard, temp_dir) = canonical_temp_dir(); + + write_package_manifest( + &temp_dir, + "demo", + r#"[[bin]] +name = "api" +path = "src/api.rs" + +[[bin]] +name = "worker" +path = "src/worker.rs" +"#, + ); + + let result = load(&temp_dir, false, None, true); + + assert!(result.is_err()); + let message = result.unwrap_err().to_string(); + assert!(message.contains("multiple [[bin]] targets")); + assert!(message.contains("[package.metadata.cot]")); + } + + #[test] + #[cfg_attr( + miri, + ignore = "can't call foreign function `posix_spawnattr_init` on OS `linux`" + )] + #[cfg(unix)] + fn load_falls_back_to_no_metadata_on_command_failure() { + let (_guard, temp_dir) = canonical_temp_dir(); + write_package_manifest(&temp_dir, "demo", ""); + let binary_path = temp_dir.join("target/debug/demo"); + write_shell_script( + &binary_path, + "echo stdout message\necho stderr message >&2\nexit 42\n", + ); + + let project = load(&temp_dir, false, None, true).unwrap().unwrap(); + + assert!(project.metadata.is_none()); + } + + #[test] + #[cfg_attr( + miri, + ignore = "can't call foreign function `posix_spawnattr_init` on OS `linux`" + )] + #[cfg(unix)] + fn load_falls_back_to_no_metadata_on_invalid_json() { + let (_guard, temp_dir) = canonical_temp_dir(); + write_package_manifest(&temp_dir, "demo", ""); + let binary_path = temp_dir.join("target/debug/demo"); + write_shell_script(&binary_path, "echo 'not json'\n"); + + let project = load(&temp_dir, false, None, true).unwrap().unwrap(); + + assert!(project.metadata.is_none()); + } + + #[test] + fn workspace_root_requires_package_when_ambiguous() { + let (_guard, temp_dir) = canonical_temp_dir(); + write_workspace_manifest(&temp_dir, &["api", "web"]); + write_package_manifest(&temp_dir.join("api"), "api", ""); + write_package_manifest(&temp_dir.join("web"), "web", ""); + + let result = load(&temp_dir, false, None, true); + + assert!(result.is_err()); + let message = result.unwrap_err().to_string(); + assert!(message.contains("multiple packages found")); + assert!(message.contains("api")); + assert!(message.contains("web")); + } + + #[test] + fn workspace_package_flag_must_match_member() { + let (_guard, temp_dir) = canonical_temp_dir(); + write_workspace_manifest(&temp_dir, &["api", "web"]); + write_package_manifest(&temp_dir.join("api"), "api", ""); + write_package_manifest(&temp_dir.join("web"), "web", ""); + + let result = load(&temp_dir, false, Some("missing"), true); + + assert!(result.is_err()); + let message = result.unwrap_err().to_string(); + assert!(message.contains("package `missing` not found")); + assert!(message.contains("api")); + assert!(message.contains("web")); + } + + #[test] + #[cfg_attr( + miri, + ignore = "can't call foreign function `posix_spawnattr_init` on OS `linux`" + )] + #[cfg(unix)] + fn workspace_root_uses_selected_package_and_workspace_target_dir() { + let (_guard, temp_dir) = canonical_temp_dir(); + write_workspace_manifest(&temp_dir, &["api", "web"]); + write_package_manifest(&temp_dir.join("api"), "api", ""); + write_package_manifest(&temp_dir.join("web"), "web", ""); + let binary_path = temp_dir.join("target/debug/api"); + write_metadata_script(&binary_path, &metadata("api", &["check"])); + + let project = load(&temp_dir, false, Some("api"), true).unwrap().unwrap(); + + assert_eq!(project.path, binary_path); + assert!(&temp_dir.join("api").exists()); + } + + #[test] + #[cfg_attr( + miri, + ignore = "can't call foreign function `posix_spawnattr_init` on OS `linux`" + )] + #[cfg(unix)] + fn workspace_member_directory_uses_current_package_without_flag() { + let (_guard, temp_dir) = canonical_temp_dir(); + write_workspace_manifest(&temp_dir, &["api", "web"]); + write_package_manifest(&temp_dir.join("api"), "api", ""); + write_package_manifest(&temp_dir.join("web"), "web", ""); + let binary_path = temp_dir.join("target/debug/web"); + write_metadata_script(&binary_path, &metadata("web", &["check"])); + + let project = load(&temp_dir.join("web"), false, None, true) + .unwrap() + .unwrap(); + + assert_eq!(project.path, binary_path); + assert!(project.metadata.is_some()); + assert_eq!(project.metadata.unwrap().binary_name, "web"); + } +} diff --git a/cot-cli/src/project/build.rs b/cot-cli/src/project/build.rs new file mode 100644 index 000000000..1f897f352 --- /dev/null +++ b/cot-cli/src/project/build.rs @@ -0,0 +1,37 @@ +//! Contains functionality to build a target project's binary. + +use anyhow::Context; +use cot::utils::cli::{StatusType, print_status_msg}; + +use crate::args::{BINARY_FLAG, PACKAGE_SHORT_FLAG, RELEASE_FLAG}; + +pub(crate) fn build_binary( + package_name: &str, + binary_name: &str, + release: bool, +) -> anyhow::Result<()> { + print_status_msg( + StatusType::Notice, + &format!("no existing binary found for `{binary_name}`, building it now"), + ); + + let mut cmd = std::process::Command::new("cargo"); + cmd.args([ + "build", + PACKAGE_SHORT_FLAG, + package_name, + BINARY_FLAG, + binary_name, + ]); + if release { + cmd.arg(RELEASE_FLAG); + } + + let status = cmd.status().context("failed to spawn `cargo build`")?; + + anyhow::ensure!( + status.success(), + "`cargo build` failed for `{package_name}`" + ); + Ok(()) +} diff --git a/cot-cli/src/project/cache.rs b/cot-cli/src/project/cache.rs new file mode 100644 index 000000000..ffdc7b4ff --- /dev/null +++ b/cot-cli/src/project/cache.rs @@ -0,0 +1,358 @@ +//! Contains functionality to manage the caching mechanism of running +//! cot-compiled binaries. Metadata information is retrieved from the binary and +//! stored in a cache file located in the `.cot` directory in the root dir of +//! the project. When the cache is stale or unavailable, we query the binary and +//! populate the cache. + +use std::fmt::Write; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::SystemTime; + +use anyhow::{Context, bail}; +use cot::metadata::{METADATA_FLAG, ProjectMetadata}; +use cot::utils::cli::{StatusType, print_status_msg}; +use serde::{Deserialize, Serialize}; +use wait_timeout::ChildExt; + +const METADATA_TIMEOUT: core::time::Duration = core::time::Duration::from_secs(5); +const COT_DIR_NAME: &str = ".cot"; +const CACHE_FILE_NAME: &str = "command-cache.json"; +#[derive(Serialize, Deserialize)] +pub(crate) struct Cache { + binary_mtime_secs: u64, + metadata: ProjectMetadata, +} + +pub(crate) fn command_cache_path(project_dir: &Path) -> PathBuf { + project_dir.join(COT_DIR_NAME).join(CACHE_FILE_NAME) +} + +pub(crate) fn load_or_refresh( + binary_path: &Path, + cache_path: &Path, +) -> anyhow::Result> { + let current_mtime_secs = mtime_secs(binary_path)?; + + // Fast path if we hit the cache + if let Ok(bytes) = std::fs::read(cache_path) + && let Ok(cache) = serde_json::from_slice::(&bytes) + && cache.binary_mtime_secs == current_mtime_secs + { + return Ok(Some(cache.metadata)); + } + + // slow path + // stdout/stderr are piped and drained on separate threads to avoid a + // deadlock. Pipe buffers are OS-bounded, so if the child fills one + // while we're blocked waiting to timeout in `wait_timeout` or reading the other + // output, its write blocks and deadlocks. + // https://doc.rust-lang.org/std/process/index.html#handling-io + // https://docs.rs/os_pipe/latest/os_pipe/#common-deadlocks-related-to-pipes + let mut child = std::process::Command::new(binary_path) + .arg(METADATA_FLAG) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .with_context(|| format!("Failed to spawn {}", binary_path.display()))?; + + let mut std_err_piped = child.stderr.take().expect("Stderr should be piped"); + let mut std_out_piped = child.stdout.take().expect("Stdout should be piped"); + + let std_err_thread = std::thread::spawn(move || { + let mut buf = Vec::new(); + std_err_piped + .read_to_end(&mut buf) + .expect("reading to buffer should not fail"); + buf + }); + + let std_out_thread = std::thread::spawn(move || { + let mut buf = Vec::new(); + std_out_piped + .read_to_end(&mut buf) + .expect("reading to buffer should not fail"); + buf + }); + + let Some(status) = child + .wait_timeout(METADATA_TIMEOUT) + .with_context(|| format!("Failed to wait on {}", binary_path.display()))? + else { + let _ = child.kill(); + let _ = child.wait(); + bail!( + "the `{}` binary did not respond within {:?} when queried for metadata.", + binary_path.display(), + METADATA_TIMEOUT + ); + }; + + let stdout = std_out_thread + .join() + .expect("joining thread handle should not fail"); + let stderr = std_err_thread + .join() + .expect("joining stderr thread should not fail"); + + if !status.success() { + let stderr_str = String::from_utf8_lossy(&stderr); + + // check for previous cot versions(<=0.7.0) without metadata support. + let is_legacy_binary = status.code() == Some(2) + && stderr_str.contains(&format!("unexpected argument '{METADATA_FLAG}'")); + + if is_legacy_binary { + print_status_msg( + StatusType::Warning, + &format!( + "the `{}` binary doesn't recognize a flag `cot` uses to discover the binary's cli commands, \ + so they won't be listed when you run `cot --help`. This usually means the binary \ + was built against an older version of `cot`. To fix this, update your `cot`version", + binary_path.display(), + ), + ); + return Ok(None); + } + + let mut msg = format!( + "the `{}` binary exited unexpectedly while `cot` was trying to determine the binary's cli commands.", + binary_path.display(), + ); + if !stderr_str.trim().is_empty() { + let _ = write!(msg, "\n\nstderr:\n{}", stderr_str.trim()); + } + let stdout_str = String::from_utf8_lossy(&stdout); + if !stdout_str.trim().is_empty() { + let _ = write!(msg, "\n\nstdout:\n{}", stdout_str.trim()); + } + bail!(msg); + } + + if stdout.is_empty() { + // The binary ran but the metadata flag was ignored + bail!( + "the `{}` binary produced no output for {METADATA_FLAG}", + binary_path.display(), + ); + } + + let metadata = parse_metadata(&stdout, binary_path)?; + + write_cache( + cache_path, + &Cache { + binary_mtime_secs: current_mtime_secs, + metadata: metadata.clone(), + }, + )?; + + Ok(Some(metadata)) +} + +pub(crate) fn mtime_secs(path: &Path) -> anyhow::Result { + let metadata = path.metadata()?; + Ok(metadata + .modified()? + .duration_since(SystemTime::UNIX_EPOCH)? + .as_secs()) +} + +pub(crate) fn write_cache(cache_path: &Path, cache: &Cache) -> anyhow::Result<()> { + if let Some(parent) = cache_path.parent() { + std::fs::create_dir_all(parent)?; + ensure_cachedir_tag(parent)?; + } + std::fs::write(cache_path, serde_json::to_string(cache)?)?; + Ok(()) +} + +const CACHEDIR_TAG_CONTENT: &str = "Signature: 8a477f597d28d172789f06886806bc55\n\ + # This file is a cache directory tag created by cot.\n\ + # For information about cache directory tags see https://bford.info/cachedir/\n"; + +fn ensure_cachedir_tag(cot_dir: &Path) -> anyhow::Result<()> { + let tag_path = cot_dir.join("CACHEDIR.TAG"); + if !tag_path.exists() { + std::fs::write(tag_path, CACHEDIR_TAG_CONTENT)?; + } + Ok(()) +} + +#[derive(Deserialize)] +struct MetadataVersionProbe { + version: u32, +} + +pub(crate) fn parse_metadata(bytes: &[u8], binary_path: &Path) -> anyhow::Result { + // check the version first before attempting to deserialize so we can show a + // clearer error message instead of the generic serde error message + let probe: MetadataVersionProbe = serde_json::from_slice(bytes).with_context(|| { + format!( + "the `{}` binary returned metadata with no readable version field.", + binary_path.display() + ) + })?; + + anyhow::ensure!( + probe.version == cot::metadata::METADATA_SCHEMA_VERSION, + "the `{}` binary was built against a `cot` version with metadata schema v{}, \ + but this `cot-cli` expects v{}. Try updating cot-cli (`cargo install --locked cot-cli`) \ + or rebuilding the project.", + binary_path.display(), + probe.version, + cot::metadata::METADATA_SCHEMA_VERSION, + ); + + serde_json::from_slice(bytes).with_context(|| { + format!( + "Binary `{}` returned invalid JSON for {METADATA_FLAG}\n\nstdout:\n{}", + binary_path.display(), + String::from_utf8_lossy(bytes).trim(), + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::project::load; + use crate::project::tests::{canonical_temp_dir, metadata, write_package_manifest}; + #[cfg(unix)] + use crate::project::tests::{write_metadata_script, write_shell_script}; + + #[test] + #[cfg_attr( + miri, + ignore = "can't call foreign function `posix_spawnattr_init` on OS `linux`" + )] + #[cfg(unix)] + fn load_reuses_valid_cache_without_spawning_binary() { + let (_guard, temp_dir) = canonical_temp_dir(); + write_package_manifest(&temp_dir, "demo", ""); + let binary_path = temp_dir.join("target/debug/demo"); + write_shell_script( + &binary_path, + "echo 'binary should not be queried' >&2\nexit 42\n", + ); + let cache = Cache { + binary_mtime_secs: mtime_secs(&binary_path).unwrap(), + metadata: metadata("demo", &["cached"]), + }; + write_cache(&command_cache_path(&temp_dir), &cache).unwrap(); + + let project = load(&temp_dir, false, None, true).unwrap().unwrap(); + + assert!(project.metadata.is_some()); + assert_eq!(project.metadata.unwrap().commands[0].name, "cached"); + } + + #[test] + #[cfg_attr( + miri, + ignore = "can't call foreign function `posix_spawnattr_init` on OS `linux`" + )] + #[cfg(unix)] + fn load_refreshes_stale_cache() { + let (_guard, temp_dir) = canonical_temp_dir(); + write_package_manifest(&temp_dir, "demo", ""); + let binary_path = temp_dir.join("target/debug/demo"); + write_metadata_script(&binary_path, &metadata("demo", &["fresh"])); + let cache = Cache { + binary_mtime_secs: 0, + metadata: metadata("demo", &["stale"]), + }; + write_cache(&command_cache_path(&temp_dir), &cache).unwrap(); + + let project = load(&temp_dir, false, None, true).unwrap().unwrap(); + + assert!(project.metadata.is_some()); + assert_eq!(project.metadata.unwrap().commands[0].name, "fresh"); + } + + #[test] + #[cfg_attr( + miri, + ignore = "can't call foreign function `posix_spawnattr_init` on OS `linux`" + )] + #[cfg(unix)] + fn load_or_refresh_reports_command_failure_with_output() { + let (_guard, temp_dir) = canonical_temp_dir(); + let binary_path = temp_dir.join("demo"); + write_shell_script( + &binary_path, + "echo stdout message\necho stderr message >&2\nexit 42\n", + ); + let cache_path = command_cache_path(&temp_dir); + + let result = load_or_refresh(&binary_path, &cache_path); + + assert!(result.is_err()); + let message = format!("{:#}", result.unwrap_err()); + assert!(message.contains("exited unexpectedly")); + assert!(message.contains("stdout message")); + assert!(message.contains("stderr message")); + } + + #[test] + #[cfg_attr( + miri, + ignore = "can't call foreign function `posix_spawnattr_init` on OS `linux`" + )] + #[cfg(unix)] + fn load_or_refresh_reports_invalid_json() { + let (_guard, temp_dir) = canonical_temp_dir(); + let binary_path = temp_dir.join("demo"); + write_shell_script(&binary_path, "echo 'not json'\n"); + let cache_path = command_cache_path(&temp_dir); + + let result = load_or_refresh(&binary_path, &cache_path); + + assert!(result.is_err()); + let message = format!("{:#}", result.unwrap_err()); + assert!(message.contains("no readable version field")); + } + + #[test] + #[cfg_attr( + miri, + ignore = "can't call foreign function `posix_spawnattr_init` on OS `linux`" + )] + #[cfg(unix)] + fn load_or_refresh_returns_none_for_legacy_binary() { + let (_guard, temp_dir) = canonical_temp_dir(); + let binary_path = temp_dir.join("demo"); + write_shell_script( + &binary_path, + &format!("echo \"error: unexpected argument '{METADATA_FLAG}'\" >&2\nexit 2\n"), + ); + let cache_path = command_cache_path(&temp_dir); + + let result = load_or_refresh(&binary_path, &cache_path).unwrap(); + + assert!(result.is_none()); + } + + #[test] + fn parse_metadata_reports_schema_version_mismatch() { + let bytes = br#"{"version":999,"binary_name":"demo","commands":[]}"#; + + let result = parse_metadata(bytes, &PathBuf::from("target/debug/demo")); + + assert!(result.is_err()); + let message = result.unwrap_err().to_string(); + assert!(message.contains("metadata schema v999")); + assert!(message.contains("cargo install --locked cot-cli")); + } + + #[test] + fn parse_metadata_succeeds_on_matching_shape() { + let meta = metadata("demo", &["serve"]); + let bytes = serde_json::to_vec(&meta).unwrap(); + + let result = parse_metadata(&bytes, &PathBuf::from("target/debug/demo")); + + assert!(result.is_ok()); + } +} diff --git a/cot-cli/src/project/discovery.rs b/cot-cli/src/project/discovery.rs new file mode 100644 index 000000000..3701a3d28 --- /dev/null +++ b/cot-cli/src/project/discovery.rs @@ -0,0 +1,209 @@ +//! Contains functionality to discover the cot-compiled binary path and its +//! target dir. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, bail}; +use cargo_metadata::{Metadata, MetadataCommand, Package, Target}; + +use crate::project::{DEBUG_PROFILE, RELEASE_PROFILE}; + +#[derive(Debug)] +pub(crate) struct ResolvedBinary { + pub(crate) binary_path: PathBuf, + pub(crate) project_dir: PathBuf, + pub(crate) package_name: String, + pub(crate) binary_name: String, +} + +pub(crate) fn resolve( + path: &Path, + release: bool, + package: Option<&str>, +) -> anyhow::Result> { + let Some(workspace_metadata) = load_cargo_metadata(path)? else { + return Ok(None); + }; + + let resolved_package = resolve_package(&workspace_metadata, path, package)?; + let binary_name = resolve_binary_name(resolved_package)?; + let target_dir = workspace_metadata.target_directory.as_std_path(); + let profile = if release { + RELEASE_PROFILE + } else { + DEBUG_PROFILE + }; + + #[cfg(target_os = "windows")] + let binary_name = format!("{binary_name}.exe"); + + let binary_path = target_dir.join(profile).join(&binary_name); + + let project_dir = resolved_package + .manifest_path + .parent() + .context("package manifest path unexpectedly has no parent directory")? + .as_std_path() + .to_path_buf(); + + Ok(Some(ResolvedBinary { + binary_path, + project_dir, + package_name: resolved_package.name.to_string(), + binary_name, + })) +} + +pub(crate) fn resolve_package<'a>( + metadata: &'a Metadata, + path: &Path, + package: Option<&str>, +) -> anyhow::Result<&'a Package> { + if let Some(name) = package { + return metadata + .packages + .iter() + .find(|p| p.name.as_str() == name) + .with_context(|| { + format!( + "package `{name}` not found in workspace.\nAvailable packages: {}", + available_packages(metadata) + ) + }); + } + + if metadata.packages.len() == 1 { + return Ok(&metadata.packages[0]); + } + + current_package(metadata, path).ok_or_else(|| { + anyhow::anyhow!( + "multiple packages found in the workspace; specify which one to use with `-p `.\n\n\ + Available packages: {}", + available_packages(metadata) + ) + }) +} + +/// Resolve the binary name for a package: +/// +/// 1. If the package has a `[package.metadata.cot.binary]` entry, use that. +/// 2. If the package has a single `[[bin]]` target, use that. +/// 3. If it has multiple, use `default-run` if set. +/// 4. Otherwise, error out and ask the user to disambiguate. +pub(crate) fn resolve_binary_name(package: &Package) -> anyhow::Result { + if let Some(name) = package + .metadata + .get("cot") + .and_then(|c| c.get("binary")) + .and_then(|b| b.as_str()) + { + return Ok(name.to_string()); + } + + let bin_targets: Vec<&Target> = package.targets.iter().filter(|t| t.is_bin()).collect(); + + match bin_targets.len() { + 0 => bail!( + "package `{}` has no binary ([[bin]]) targets for `cot` to run.", + package.name, + ), + 1 => Ok(bin_targets[0].name.clone()), + _ => { + // if a default-run field exists lets use that + // https://doc.rust-lang.org/cargo/reference/manifest.html#the-default-run-field + if let Some(default_run) = &package.default_run { + return Ok(default_run.clone()); + } + + bail!( + "package `{}` has multiple [[bin]] targets.\n\ + Specify which one `cot` should use by adding to its Cargo.toml:\n\ + \n\ + [package.metadata.cot]\n\ + binary = \"your-binary-name\"", + package.name, + ) + } + } +} + +pub(crate) fn is_current_executable(binary_path: &Path) -> bool { + let Ok(current_exe) = std::env::current_exe() else { + return false; + }; + + let Ok(binary_path) = binary_path.canonicalize() else { + return false; + }; + let Ok(current_exe) = current_exe.canonicalize() else { + return false; + }; + + binary_path == current_exe +} + +/// Runs `cargo metadata --no-deps` rooted at `path`. +/// +/// `--no-deps` means this never touches the network or reads/writes +/// `Cargo.lock`: it only needs to parse the workspace's own manifests, so +/// it's safe to run on every `cot` invocation. +pub(crate) fn load_cargo_metadata(path: &Path) -> anyhow::Result> { + if !path.exists() { + bail!("path does not exist: {}", path.display()) + } + + match MetadataCommand::new().no_deps().current_dir(path).exec() { + Ok(metadata) => Ok(Some(metadata)), + Err(cargo_metadata::Error::CargoMetadata { stderr }) + if stderr.contains("could not find `Cargo.toml`") => + { + Ok(None) + } + Err(e) => Err(e).context("failed to run `cargo metadata`"), + } +} + +fn available_packages(metadata: &Metadata) -> String { + metadata + .packages + .iter() + .map(|p| p.name.as_str()) + .collect::>() + .join(", ") +} + +/// Finds the workspace member `path` is inside of, preferring the most +/// specific (deepest) match — mirrors how cargo resolves the "current +/// package" from the nearest enclosing manifest. +fn current_package<'a>(metadata: &'a Metadata, path: &Path) -> Option<&'a Package> { + let path = path.canonicalize().ok()?; + + metadata + .packages + .iter() + .filter(|pkg| { + pkg.manifest_path + .parent() + .is_some_and(|dir| path.starts_with(dir.as_std_path())) + }) + .max_by_key(|pkg| pkg.manifest_path.as_str().len()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn current_executable_matches_current_process() { + let current_exe = std::env::current_exe().unwrap(); + + assert!(is_current_executable(¤t_exe)); + } + + #[test] + fn current_executable_does_not_match_missing_path() { + let missing = std::env::temp_dir().join("cot-cli-missing-test-binary"); + + assert!(!is_current_executable(&missing)); + } +} From fcfafaa8c1b5911c345d0d6e916522c41d4f7142 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 05:03:20 +0000 Subject: [PATCH 2/5] chore(pre-commit.ci): auto fixes from pre-commit hooks --- cot-cli/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cot-cli/src/lib.rs b/cot-cli/src/lib.rs index fe181c1cd..c76021490 100644 --- a/cot-cli/src/lib.rs +++ b/cot-cli/src/lib.rs @@ -7,4 +7,4 @@ pub mod new_project; pub mod project; #[cfg(feature = "test_utils")] pub mod test_utils; -mod utils; \ No newline at end of file +mod utils; From a08946ac98698217e58238f61237b2b39acdc800 Mon Sep 17 00:00:00 2001 From: Elijah Date: Thu, 3 Sep 2026 21:40:34 +0000 Subject: [PATCH 3/5] lint --- cot-cli/src/project/cache.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cot-cli/src/project/cache.rs b/cot-cli/src/project/cache.rs index ffdc7b4ff..77d969e9c 100644 --- a/cot-cli/src/project/cache.rs +++ b/cot-cli/src/project/cache.rs @@ -46,8 +46,8 @@ pub(crate) fn load_or_refresh( // slow path // stdout/stderr are piped and drained on separate threads to avoid a // deadlock. Pipe buffers are OS-bounded, so if the child fills one - // while we're blocked waiting to timeout in `wait_timeout` or reading the other - // output, its write blocks and deadlocks. + // while we're blocked waiting to timeout in `wait_timeout` or reading the + // other output, its write blocks and deadlocks. // https://doc.rust-lang.org/std/process/index.html#handling-io // https://docs.rs/os_pipe/latest/os_pipe/#common-deadlocks-related-to-pipes let mut child = std::process::Command::new(binary_path) From b6eddeac04e426fa007ca23d73edd5d5de305ab5 Mon Sep 17 00:00:00 2001 From: Elijah Date: Mon, 7 Sep 2026 19:28:50 +0000 Subject: [PATCH 4/5] address PR comments --- cot-cli/src/project.rs | 5 +++-- cot-cli/src/project/build.rs | 5 ++++- cot-cli/src/project/cache.rs | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/cot-cli/src/project.rs b/cot-cli/src/project.rs index b34951445..69a8c53e1 100644 --- a/cot-cli/src/project.rs +++ b/cot-cli/src/project.rs @@ -53,7 +53,8 @@ pub fn load( if !binary_path.exists() { bail!( "`cargo build` succeeded but `{}` still wasn't found at the expected path, \ - this may mean the binary name `cot` resolved doesn't match what cargo built.", + this may mean the binary name `cot` resolved doesn't match what cargo built.\ + Please report this at https://github.com/cot-lang/cot/issues/new", binary_path.display(), ); } @@ -61,7 +62,7 @@ pub fn load( // Guard against the `cot` CLI resolving to itself. This can happen when // running from within the `cot-cli` package or a workspace package whose - // binary is the current executable. Querying it for `--metadata` would + // binary is the current executable. Querying it for metadata would // either recurse or fail: only cot application binaries implement that // flag, not the CLI proxy. if discovery::is_current_executable(&binary_path) { diff --git a/cot-cli/src/project/build.rs b/cot-cli/src/project/build.rs index 1f897f352..86624605a 100644 --- a/cot-cli/src/project/build.rs +++ b/cot-cli/src/project/build.rs @@ -5,6 +5,7 @@ use cot::utils::cli::{StatusType, print_status_msg}; use crate::args::{BINARY_FLAG, PACKAGE_SHORT_FLAG, RELEASE_FLAG}; +const CARGO_ENV: &str = "CARGO"; pub(crate) fn build_binary( package_name: &str, binary_name: &str, @@ -15,7 +16,9 @@ pub(crate) fn build_binary( &format!("no existing binary found for `{binary_name}`, building it now"), ); - let mut cmd = std::process::Command::new("cargo"); + let mut cmd = std::process::Command::new( + std::env::var_os(CARGO_ENV).unwrap_or_else(|| "cargo".into()), + ); cmd.args([ "build", PACKAGE_SHORT_FLAG, diff --git a/cot-cli/src/project/cache.rs b/cot-cli/src/project/cache.rs index 77d969e9c..d141edb35 100644 --- a/cot-cli/src/project/cache.rs +++ b/cot-cli/src/project/cache.rs @@ -109,7 +109,7 @@ pub(crate) fn load_or_refresh( &format!( "the `{}` binary doesn't recognize a flag `cot` uses to discover the binary's cli commands, \ so they won't be listed when you run `cot --help`. This usually means the binary \ - was built against an older version of `cot`. To fix this, update your `cot`version", + was built against an older version of `cot`. To fix this, update your `cot` version", binary_path.display(), ), ); From 2bb90b56992282bbf656829322d1c833f1cf1c39 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:29:19 +0000 Subject: [PATCH 5/5] chore(pre-commit.ci): auto fixes from pre-commit hooks --- cot-cli/src/project/build.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cot-cli/src/project/build.rs b/cot-cli/src/project/build.rs index 86624605a..41e734228 100644 --- a/cot-cli/src/project/build.rs +++ b/cot-cli/src/project/build.rs @@ -16,9 +16,8 @@ pub(crate) fn build_binary( &format!("no existing binary found for `{binary_name}`, building it now"), ); - let mut cmd = std::process::Command::new( - std::env::var_os(CARGO_ENV).unwrap_or_else(|| "cargo".into()), - ); + let mut cmd = + std::process::Command::new(std::env::var_os(CARGO_ENV).unwrap_or_else(|| "cargo".into())); cmd.args([ "build", PACKAGE_SHORT_FLAG,