diff --git a/.config/jp/tools/src/cargo.rs b/.config/jp/tools/src/cargo.rs index a5e23c31e..e822c3542 100644 --- a/.config/jp/tools/src/cargo.rs +++ b/.config/jp/tools/src/cargo.rs @@ -1,3 +1,5 @@ +use std::time::{Duration, Instant}; + use camino::{Utf8Path, Utf8PathBuf}; use jp_tool::{AccessPolicy, Capability, Outcome}; use serde_json::Value; @@ -5,7 +7,7 @@ use serde_json::Value; use crate::{ Context, Tool, fs::utils::{authorize, resolve_workspace_path}, - util::{ToolResult, error, unknown_tool}, + util::{OneOrMany, ToolResult, error, unknown_tool}, }; mod check; @@ -65,14 +67,39 @@ pub async fn run(ctx: Context, t: Tool) -> ToolResult { Err(message) => return error(message), }; + // Flags to append to `RUSTFLAGS`, set via `options.rustflags`. Malformed + // values are refused for the same reason as `root`: silently ignoring them + // would compile with flags the caller believes are in effect. + let rustflags = match t.options.get("rustflags") { + None | Some(Value::Null) => rustflags(&[]), + Some(value) => match serde_json::from_value::>(value.clone()) { + Ok(flags) => rustflags(&flags.into_vec()), + Err(_) => { + return error(format!( + "The `rustflags` tool option must be a string or an array of strings, got \ + `{value}`." + )); + } + }, + }; + + let started = Instant::now(); let outcome = match subcommand { - "check" => cargo_check(&root, t.opt("package")?, checksum_freshness).await, + "check" => cargo_check(&root, &rustflags, t.opt("package")?, checksum_freshness).await, "expand" => { - cargo_expand(&root, t.req("item")?, t.opt("package")?, checksum_freshness).await + cargo_expand( + &root, + &rustflags, + t.req("item")?, + t.opt("package")?, + checksum_freshness, + ) + .await } "test" => { cargo_test( &root, + &rustflags, t.opt("package")?, t.opt("testname")?, t.opt("backtrace")?, @@ -80,12 +107,14 @@ pub async fn run(ctx: Context, t: Tool) -> ToolResult { ) .await } - "format" => cargo_format(&root, t.opt("package")?).await, + "format" => cargo_format(&root, &rustflags, t.opt("package")?).await, "install_tools" => cargo_install_tools(&root).await, "update" => cargo_update(&root, t.req("packages")?).await, _ => return unknown_tool(t), }; + let outcome = note_duration(outcome, started.elapsed()); + if root == ctx.root { return outcome; } @@ -93,6 +122,71 @@ pub async fn run(ctx: Context, t: Tool) -> ToolResult { note_root(outcome, &root) } +/// Append how long the cargo invocation took. +/// +/// Wall-clock duration is the whole signal when tuning compile times, and a +/// caller reading only the tool's text cannot otherwise tell a warm cache from +/// a full rebuild — the two are indistinguishable when both end in "Check +/// succeeded". +/// Failures are timed too: a three-second failure and a three-minute one call +/// for different responses. +fn note_duration(outcome: ToolResult, elapsed: Duration) -> ToolResult { + let note = format!("(took {})", format_duration(elapsed)); + + match outcome { + Ok(Outcome::Success { content }) => Ok(Outcome::Success { + content: format!("{content}\n\n{note}"), + }), + Ok(Outcome::Error { + message, + trace, + transient, + }) => Ok(Outcome::Error { + message: format!("{message}\n\n{note}"), + trace, + transient, + }), + Err(error) => Err(format!("{error}\n\n{note}").into()), + other => other, + } +} + +/// Render a duration at a precision that matches how it will be read. +/// +/// Sub-minute builds are compared against each other, where a tenth of a second +/// distinguishes a warm cache from a small rebuild; past a minute nobody cares +/// about the fraction. +fn format_duration(elapsed: Duration) -> String { + let seconds = elapsed.as_secs(); + + if seconds >= 60 { + return format!("{}m {}s", seconds / 60, seconds % 60); + } + + format!("{:.1}s", elapsed.as_secs_f64()) +} + +/// Warnings are reported, not fatal: these tools surface diagnostics rather +/// than failing on them, and CI runs its own `-D warnings` pass. +const BASE_RUSTFLAGS: &str = "-W warnings"; + +/// Build the `RUSTFLAGS` value, appending any configured flags to the base. +/// +/// Setting `RUSTFLAGS` at all overrides `rustflags` from `.cargo/config.toml` +/// wholesale, so a workspace that relies on those (a linker choice, extra `-Z` +/// flags) has to restate them through `options.rustflags`. +/// +/// Every compiling cargo tool sets the variable, so they agree on the flag set +/// and a shared target directory stays warm when alternating between them. +/// Configured flags come last, so they win over the base. +fn rustflags(extra: &[String]) -> String { + if extra.is_empty() { + return BASE_RUSTFLAGS.to_owned(); + } + + format!("{BASE_RUSTFLAGS} {}", extra.join(" ")) +} + /// Capabilities a cargo subcommand needs on the directory it runs in. /// /// These gate whether cargo is spawned at all; they cannot bound what it does diff --git a/.config/jp/tools/src/cargo/check.rs b/.config/jp/tools/src/cargo/check.rs index 22c48f8e6..e3843479c 100644 --- a/.config/jp/tools/src/cargo/check.rs +++ b/.config/jp/tools/src/cargo/check.rs @@ -11,11 +11,13 @@ use crate::util::{ pub(crate) async fn cargo_check( root: &Utf8Path, + rustflags: &str, package: Option, checksum_freshness: bool, ) -> ToolResult { cargo_check_impl( root, + rustflags, package.as_deref(), checksum_freshness, &DuctProcessRunner, @@ -24,14 +26,14 @@ pub(crate) async fn cargo_check( fn cargo_check_impl( root: &Utf8Path, + rustflags: &str, package: Option<&str>, checksum_freshness: bool, runner: &R, ) -> ToolResult { let clippy_scope = package.map_or("--workspace".to_owned(), |v| format!("--package={v}")); - // Prevent warnings from being treated as errors, e.g. on CI. - let mut env = vec![("RUSTFLAGS", "-W warnings")]; + let mut env = vec![("RUSTFLAGS", rustflags)]; if checksum_freshness { // Use content checksums instead of file mtimes for cargo's freshness // checks, so that sibling checkouts (git worktrees) sharing a target diff --git a/.config/jp/tools/src/cargo/check_tests.rs b/.config/jp/tools/src/cargo/check_tests.rs index 07e78cbb4..b9b494a68 100644 --- a/.config/jp/tools/src/cargo/check_tests.rs +++ b/.config/jp/tools/src/cargo/check_tests.rs @@ -50,7 +50,7 @@ fn test_cargo_check_with_warnings() { .expect("comfort") .returns_success(""); - let result = cargo_check_impl(&ctx.root, None, false, &runner).unwrap(); + let result = cargo_check_impl(&ctx.root, "-W warnings", None, false, &runner).unwrap(); assert_eq!(result.into_content().unwrap(), indoc::indoc! {r#" ``` @@ -80,7 +80,7 @@ fn test_cargo_check_no_warnings() { .expect("comfort") .returns_success(""); - let result = cargo_check_impl(&ctx.root, None, false, &runner).unwrap(); + let result = cargo_check_impl(&ctx.root, "-W warnings", None, false, &runner).unwrap(); assert_eq!( result.into_content().unwrap(), @@ -103,7 +103,7 @@ fn clean_clippy_with_comfort_drift_appends_note() { status: ExitCode::from_code(1), }); - let result = cargo_check_impl(&ctx.root, None, false, &runner).unwrap(); + let result = cargo_check_impl(&ctx.root, "-W warnings", None, false, &runner).unwrap(); // The header is clippy-scoped, not a blanket "Check succeeded", so it does // not contradict the drift note below it. @@ -134,7 +134,7 @@ fn clippy_warnings_and_comfort_drift_are_both_reported() { status: ExitCode::from_code(1), }); - let result = cargo_check_impl(&ctx.root, None, false, &runner).unwrap(); + let result = cargo_check_impl(&ctx.root, "-W warnings", None, false, &runner).unwrap(); assert_eq!(result.into_content().unwrap(), indoc::indoc! {" ``` @@ -163,7 +163,7 @@ fn comfort_drift_listing_is_bounded() { status: ExitCode::from_code(1), }); - let content = cargo_check_impl(&ctx.root, None, false, &runner) + let content = cargo_check_impl(&ctx.root, "-W warnings", None, false, &runner) .unwrap() .unwrap_content(); @@ -193,7 +193,7 @@ fn comfort_real_failure_is_reported_as_error() { status: ExitCode::from_code(2), }); - let result = cargo_check_impl(&ctx.root, None, false, &runner).unwrap(); + let result = cargo_check_impl(&ctx.root, "-W warnings", None, false, &runner).unwrap(); match result { Outcome::Error { message, .. } => { assert_eq!(message, "comfort failed: comfort: parse error"); @@ -214,7 +214,7 @@ fn clippy_failure_short_circuits_before_running_comfort() { status: ExitCode::from_code(101), }); - let result = cargo_check_impl(&ctx.root, None, false, &runner).unwrap(); + let result = cargo_check_impl(&ctx.root, "-W warnings", None, false, &runner).unwrap(); match result { Outcome::Error { message, .. } => { assert_eq!(message, "Cargo command failed: error: build failed"); @@ -252,7 +252,8 @@ fn package_scope_is_passed_through_to_both_tools() { ]) .returns_success(""); - let result = cargo_check_impl(&ctx.root, Some("my_pkg"), false, &runner).unwrap(); + let result = + cargo_check_impl(&ctx.root, "-W warnings", Some("my_pkg"), false, &runner).unwrap(); assert_eq!( result.into_content().unwrap(), "Check succeeded. No warnings or errors found." @@ -277,7 +278,7 @@ fn checksum_freshness_reaches_cargo() { .returns_success("") .into(); - cargo_check_impl(&ctx.root, None, true, &runner).unwrap(); + cargo_check_impl(&ctx.root, "-W warnings", None, true, &runner).unwrap(); let call = runner .call_with_arg("clippy") @@ -304,7 +305,7 @@ fn checksum_freshness_is_absent_unless_opted_into() { .returns_success("") .into(); - cargo_check_impl(&ctx.root, None, false, &runner).unwrap(); + cargo_check_impl(&ctx.root, "-W warnings", None, false, &runner).unwrap(); let call = runner .call_with_arg("clippy") diff --git a/.config/jp/tools/src/cargo/expand.rs b/.config/jp/tools/src/cargo/expand.rs index 6d8a78bfe..256a940d5 100644 --- a/.config/jp/tools/src/cargo/expand.rs +++ b/.config/jp/tools/src/cargo/expand.rs @@ -15,15 +15,24 @@ const MAX_EXPANDED_BYTES: usize = 100_000; pub(crate) async fn cargo_expand( root: &Utf8Path, + rustflags: &str, item: String, package: Option, checksum_freshness: bool, ) -> ToolResult { - cargo_expand_impl(root, &item, package, checksum_freshness, &DuctProcessRunner) + cargo_expand_impl( + root, + rustflags, + &item, + package, + checksum_freshness, + &DuctProcessRunner, + ) } fn cargo_expand_impl( root: &Utf8Path, + rustflags: &str, item: &str, package: Option, checksum_freshness: bool, @@ -36,7 +45,7 @@ fn cargo_expand_impl( } args.push(item); - let mut env = vec![("RUST_BACKTRACE", "1")]; + let mut env = vec![("RUST_BACKTRACE", "1"), ("RUSTFLAGS", rustflags)]; if checksum_freshness { // Use content checksums instead of file mtimes for cargo's freshness // checks, so that sibling checkouts (git worktrees) sharing a target diff --git a/.config/jp/tools/src/cargo/expand_tests.rs b/.config/jp/tools/src/cargo/expand_tests.rs index a1d8f87d1..7c2d7ad84 100644 --- a/.config/jp/tools/src/cargo/expand_tests.rs +++ b/.config/jp/tools/src/cargo/expand_tests.rs @@ -25,7 +25,7 @@ fn test_cargo_expand_success() { let runner = MockProcessRunner::success(stdout); - let result = cargo_expand_impl(&ctx.root, "main", None, false, &runner).unwrap(); + let result = cargo_expand_impl(&ctx.root, "-W warnings", "main", None, false, &runner).unwrap(); assert_eq!(result.into_content().unwrap(), indoc::indoc! {r#" ```rust diff --git a/.config/jp/tools/src/cargo/format.rs b/.config/jp/tools/src/cargo/format.rs index be1d123a2..bb08a789a 100644 --- a/.config/jp/tools/src/cargo/format.rs +++ b/.config/jp/tools/src/cargo/format.rs @@ -9,12 +9,17 @@ use crate::util::{ truncate, }; -pub(crate) async fn cargo_format(root: &Utf8Path, package: Option) -> ToolResult { - cargo_format_impl(root, package.as_deref(), &DuctProcessRunner) +pub(crate) async fn cargo_format( + root: &Utf8Path, + rustflags: &str, + package: Option, +) -> ToolResult { + cargo_format_impl(root, rustflags, package.as_deref(), &DuctProcessRunner) } fn cargo_format_impl( root: &Utf8Path, + rustflags: &str, package: Option<&str>, runner: &R, ) -> ToolResult { @@ -35,8 +40,7 @@ fn cargo_format_impl( "--files-with-diff", ], root, - // Prevent warnings from being treated as errors, e.g. on CI. - &[("RUSTFLAGS", "-W warnings")], + &[("RUSTFLAGS", rustflags)], )?; if !cargo_status.is_success() { diff --git a/.config/jp/tools/src/cargo/format_tests.rs b/.config/jp/tools/src/cargo/format_tests.rs index 901c8a0a3..490067083 100644 --- a/.config/jp/tools/src/cargo/format_tests.rs +++ b/.config/jp/tools/src/cargo/format_tests.rs @@ -27,7 +27,7 @@ fn no_changes_anywhere_reports_nothing_to_format() { .expect("comfort") .returns_success(""); - let result = cargo_format_impl(&ctx.root, None, &runner).unwrap(); + let result = cargo_format_impl(&ctx.root, "-W warnings", None, &runner).unwrap(); assert_eq!(result.unwrap_content(), "No files to format."); } @@ -41,7 +41,7 @@ fn rustfmt_changes_only_lists_those_files() { .expect("comfort") .returns_success(""); - let result = cargo_format_impl(&ctx.root, None, &runner).unwrap(); + let result = cargo_format_impl(&ctx.root, "-W warnings", None, &runner).unwrap(); assert_eq!( result.unwrap_content(), "Formatted files:\n- src/lib.rs\n- src/main.rs" @@ -61,7 +61,7 @@ fn formatted_file_listing_is_bounded() { .expect("comfort") .returns_success(""); - let content = cargo_format_impl(&ctx.root, None, &runner) + let content = cargo_format_impl(&ctx.root, "-W warnings", None, &runner) .unwrap() .unwrap_content(); @@ -87,7 +87,7 @@ fn comfort_changes_only_lists_those_files() { .expect("comfort") .returns_success(comfort_stdout); - let result = cargo_format_impl(&ctx.root, None, &runner).unwrap(); + let result = cargo_format_impl(&ctx.root, "-W warnings", None, &runner).unwrap(); assert_eq!( result.unwrap_content(), "Formatted files:\n- crates/foo/src/lib.rs" @@ -105,7 +105,7 @@ fn overlapping_changes_are_deduplicated_and_sorted() { .expect("comfort") .returns_success(comfort_stdout); - let result = cargo_format_impl(&ctx.root, None, &runner).unwrap(); + let result = cargo_format_impl(&ctx.root, "-W warnings", None, &runner).unwrap(); assert_eq!( result.unwrap_content(), "Formatted files:\n- src/a.rs\n- src/b.rs\n- src/c.rs" @@ -138,7 +138,7 @@ fn with_package_argument_is_passed_through_to_both_tools() { ]) .returns_success(""); - let result = cargo_format_impl(&ctx.root, Some("my_pkg"), &runner).unwrap(); + let result = cargo_format_impl(&ctx.root, "-W warnings", Some("my_pkg"), &runner).unwrap(); assert_eq!(result.unwrap_content(), "No files to format."); } @@ -161,7 +161,7 @@ fn without_package_uses_workspace_scope_on_both_tools() { ]) .returns_success(""); - let result = cargo_format_impl(&ctx.root, None, &runner).unwrap(); + let result = cargo_format_impl(&ctx.root, "-W warnings", None, &runner).unwrap(); assert_eq!(result.unwrap_content(), "No files to format."); } @@ -177,7 +177,7 @@ fn rustfmt_failure_short_circuits_before_running_comfort() { status: ExitCode::from_code(1), }); - let result = cargo_format_impl(&ctx.root, None, &runner).unwrap(); + let result = cargo_format_impl(&ctx.root, "-W warnings", None, &runner).unwrap(); match result { Outcome::Error { message, .. } => { assert_eq!(message, "cargo fmt failed: error: could not format files"); @@ -199,7 +199,7 @@ fn comfort_failure_is_reported_even_when_rustfmt_succeeded() { status: ExitCode::from_code(2), }); - let result = cargo_format_impl(&ctx.root, None, &runner).unwrap(); + let result = cargo_format_impl(&ctx.root, "-W warnings", None, &runner).unwrap(); match result { Outcome::Error { message, .. } => { assert_eq!(message, "comfort failed: comfort: parse error"); @@ -219,7 +219,7 @@ fn trailing_newlines_in_output_are_tolerated() { .expect("comfort") .returns_success(comfort_stdout); - let result = cargo_format_impl(&ctx.root, None, &runner).unwrap(); + let result = cargo_format_impl(&ctx.root, "-W warnings", None, &runner).unwrap(); assert_eq!( result.unwrap_content(), "Formatted files:\n- src/lib.rs\n- src/main.rs" diff --git a/.config/jp/tools/src/cargo/test.rs b/.config/jp/tools/src/cargo/test.rs index d24cfdfc8..d8eddeac0 100644 --- a/.config/jp/tools/src/cargo/test.rs +++ b/.config/jp/tools/src/cargo/test.rs @@ -44,6 +44,7 @@ struct TestFailure { pub(crate) async fn cargo_test( root: &Utf8Path, + rustflags: &str, package: Option, testname: Option, backtrace: Option, @@ -51,6 +52,7 @@ pub(crate) async fn cargo_test( ) -> ToolResult { cargo_test_impl( root, + rustflags, package, testname, backtrace.unwrap_or(false), @@ -61,6 +63,7 @@ pub(crate) async fn cargo_test( fn cargo_test_impl( root: &Utf8Path, + rustflags: &str, package: Option, testname: Option, backtrace: bool, @@ -73,6 +76,7 @@ fn cargo_test_impl( let mut env = vec![ ("NEXTEST_EXPERIMENTAL_LIBTEST_JSON", "1"), ("RUST_BACKTRACE", if backtrace { "1" } else { "0" }), + ("RUSTFLAGS", rustflags), ]; if checksum_freshness { // Use content checksums instead of file mtimes for cargo's freshness diff --git a/.config/jp/tools/src/cargo/test_tests.rs b/.config/jp/tools/src/cargo/test_tests.rs index 39cf07785..70a1636a8 100644 --- a/.config/jp/tools/src/cargo/test_tests.rs +++ b/.config/jp/tools/src/cargo/test_tests.rs @@ -21,7 +21,7 @@ fn test_cargo_test_success() { let stdout = r#"{"type":"test","event":"ok","name":"my_test","stdout":""}"#; let runner = MockProcessRunner::success(stdout); - let result = cargo_test_impl(&ctx.root, None, None, false, false, &runner) + let result = cargo_test_impl(&ctx.root, "-W warnings", None, None, false, false, &runner) .unwrap() .into_content() .unwrap(); @@ -43,7 +43,7 @@ fn test_cargo_test_with_failure() { let stdout = r#"{"type":"test","event":"failed","name":"my_crate$tests::my_test","stdout":"assertion failed"}"#; let runner = MockProcessRunner::success(stdout); - let result = cargo_test_impl(&ctx.root, None, None, false, false, &runner) + let result = cargo_test_impl(&ctx.root, "-W warnings", None, None, false, false, &runner) .unwrap() .into_content() .unwrap(); @@ -82,7 +82,7 @@ fn no_tests_ran_error_is_bounded() { .expect_any() .returns_error(&stderr); - let error = cargo_test_impl(&ctx.root, None, None, false, false, &runner) + let error = cargo_test_impl(&ctx.root, "-W warnings", None, None, false, false, &runner) .expect_err("a run with zero tests is an error") .to_string(); @@ -125,7 +125,7 @@ fn failure_output_is_bounded_across_the_whole_run() { .join("\n"); let runner = MockProcessRunner::success(stdout); - let content = cargo_test_impl(&ctx.root, None, None, false, false, &runner) + let content = cargo_test_impl(&ctx.root, "-W warnings", None, None, false, false, &runner) .unwrap() .unwrap_content(); @@ -174,7 +174,7 @@ fn failure_blocks_are_bounded_when_captured_output_is_empty() { .join("\n"); let runner = MockProcessRunner::success(stdout); - let content = cargo_test_impl(&ctx.root, None, None, false, false, &runner) + let content = cargo_test_impl(&ctx.root, "-W warnings", None, None, false, false, &runner) .unwrap() .unwrap_content(); @@ -235,6 +235,46 @@ impl ProcessRunner for EnvCapturingRunner { } } +/// `cargo test` was the one compiling tool that never set `RUSTFLAGS`, so it +/// inherited `rustflags` from `.cargo/config.toml` while its siblings overrode +/// them. +/// That both thrashed a shared target directory and, in one workspace, applied +/// a flag that broke proc-macro crates outright. +#[test] +fn test_rustflags_reaches_cargo() { + let dir = tempdir().unwrap(); + let ctx = Context { + root: dir.path().to_owned(), + action: Action::Run, + access: None, + workspace_id: "test".into(), + conversation_id: "test".into(), + }; + + let stdout = r#"{"type":"test","event":"ok","name":"my_test","stdout":""}"#; + let runner: EnvCapturingRunner = MockProcessRunner::success(stdout).into(); + let _result = cargo_test_impl( + &ctx.root, + "-W warnings -Zthreads=0", + None, + None, + false, + false, + &runner, + ) + .unwrap(); + + assert_eq!( + runner + .captured_env() + .iter() + .find(|(k, _)| k == "RUSTFLAGS") + .map(|(_, v)| v.as_str()), + Some("-W warnings -Zthreads=0"), + "the merged flags must reach cargo, or `.cargo/config.toml` silently wins", + ); +} + #[test] fn test_backtrace_disabled_by_default() { let dir = tempdir().unwrap(); @@ -248,7 +288,8 @@ fn test_backtrace_disabled_by_default() { let stdout = r#"{"type":"test","event":"ok","name":"my_test","stdout":""}"#; let runner: EnvCapturingRunner = MockProcessRunner::success(stdout).into(); - let _result = cargo_test_impl(&ctx.root, None, None, false, false, &runner).unwrap(); + let _result = + cargo_test_impl(&ctx.root, "-W warnings", None, None, false, false, &runner).unwrap(); assert_eq!( runner @@ -273,7 +314,8 @@ fn test_checksum_freshness_disabled_by_default() { let stdout = r#"{"type":"test","event":"ok","name":"my_test","stdout":""}"#; let runner: EnvCapturingRunner = MockProcessRunner::success(stdout).into(); - let _result = cargo_test_impl(&ctx.root, None, None, false, false, &runner).unwrap(); + let _result = + cargo_test_impl(&ctx.root, "-W warnings", None, None, false, false, &runner).unwrap(); assert!( !runner @@ -297,7 +339,8 @@ fn test_checksum_freshness_enabled() { let stdout = r#"{"type":"test","event":"ok","name":"my_test","stdout":""}"#; let runner: EnvCapturingRunner = MockProcessRunner::success(stdout).into(); - let _result = cargo_test_impl(&ctx.root, None, None, false, true, &runner).unwrap(); + let _result = + cargo_test_impl(&ctx.root, "-W warnings", None, None, false, true, &runner).unwrap(); assert_eq!( runner @@ -322,7 +365,8 @@ fn test_backtrace_enabled() { let stdout = r#"{"type":"test","event":"ok","name":"my_test","stdout":""}"#; let runner: EnvCapturingRunner = MockProcessRunner::success(stdout).into(); - let _result = cargo_test_impl(&ctx.root, None, None, true, false, &runner).unwrap(); + let _result = + cargo_test_impl(&ctx.root, "-W warnings", None, None, true, false, &runner).unwrap(); assert_eq!( runner diff --git a/.config/jp/tools/src/cargo_tests.rs b/.config/jp/tools/src/cargo_tests.rs index 82d2bf2cb..7fa63c28d 100644 --- a/.config/jp/tools/src/cargo_tests.rs +++ b/.config/jp/tools/src/cargo_tests.rs @@ -1,4 +1,4 @@ -use std::fs; +use std::{fs, time::Duration}; use camino::{Utf8Path, Utf8PathBuf}; use camino_tempfile::tempdir; @@ -6,7 +6,10 @@ use jp_tool::{AccessPolicy, Action, Capability, Context, FsRule, Outcome}; use pretty_assertions::assert_eq; use serde_json::{Map, json}; -use super::{Tool, cargo_root, note_root, required_capabilities, run}; +use super::{ + Tool, cargo_root, format_duration, note_duration, note_root, required_capabilities, run, + rustflags, +}; /// Capabilities for a building subcommand, which is the demanding case. const BUILDS: &[Capability] = &[ @@ -271,6 +274,111 @@ fn a_directory_without_a_manifest_is_rejected() { assert!(error.contains("enclosing workspace"), "got: {error}"); } +#[test] +fn no_configured_flags_is_just_the_base() { + assert_eq!(rustflags(&[]), "-W warnings"); +} + +/// Configured flags come last so they can override the base. +#[test] +fn configured_flags_are_appended_to_the_base() { + let flags = rustflags(&[ + "-Zthreads=0".to_owned(), + "-Clink-arg=-fuse-ld=lld".to_owned(), + ]); + + assert_eq!(flags, "-W warnings -Zthreads=0 -Clink-arg=-fuse-ld=lld"); +} + +/// An array and a bare string are both accepted, so a single flag needs no +/// brackets. +#[test] +fn a_bare_string_is_accepted_as_one_flag() { + assert_eq!( + rustflags(&["-Zthreads=0".to_owned()]), + "-W warnings -Zthreads=0" + ); +} + +/// A malformed value is refused rather than dropped, for the same reason as +/// `root`: compiling with flags the caller believes are in effect is worse than +/// refusing to compile. +#[tokio::test] +async fn a_non_string_rustflags_option_fails_the_invocation() { + let dir = tempdir().unwrap(); + let ctx = Context { + root: dir.path().to_owned(), + action: Action::Run, + access: None, + workspace_id: "test".into(), + conversation_id: "test".into(), + }; + let tool = Tool { + name: "cargo_check".to_owned(), + arguments: Map::new(), + answers: Map::new(), + options: Map::from_iter([("rustflags".to_owned(), json!({ "flag": true }))]), + }; + + let Outcome::Error { message, .. } = run(ctx, tool).await.unwrap() else { + panic!("expected an error outcome"); + }; + + assert!( + message.contains("must be a string or an array of strings"), + "got: {message}" + ); +} + +/// A tenth of a second is what separates a warm cache from a small rebuild, so +/// sub-minute durations keep the fraction. +#[test] +fn short_durations_keep_a_fraction() { + assert_eq!(format_duration(Duration::from_millis(1_240)), "1.2s"); + assert_eq!(format_duration(Duration::from_millis(230)), "0.2s"); + assert_eq!(format_duration(Duration::from_secs(59)), "59.0s"); +} + +#[test] +fn long_durations_are_minutes_and_seconds() { + assert_eq!(format_duration(Duration::from_mins(1)), "1m 0s"); + assert_eq!(format_duration(Duration::from_secs(230)), "3m 50s"); +} + +/// "Check succeeded" reads the same after a warm cache and a full rebuild; the +/// duration is the only thing that tells them apart. +#[test] +fn a_success_carries_its_duration() { + let outcome = Ok(Outcome::Success { + content: "Check succeeded. No warnings or errors found.".to_owned(), + }); + + let noted = note_duration(outcome, Duration::from_secs(230)).unwrap(); + + assert_eq!(noted, Outcome::Success { + content: "Check succeeded. No warnings or errors found.\n\n(took 3m 50s)".to_owned(), + }); +} + +/// A failure that took three minutes and one that took three seconds call for +/// different responses. +#[test] +fn a_failure_carries_its_duration() { + let outcome = Ok(Outcome::Error { + message: "error: could not compile `bevy`".to_owned(), + trace: vec![], + transient: false, + }); + + let Outcome::Error { message, .. } = note_duration(outcome, Duration::from_secs(95)).unwrap() + else { + panic!("expected an error outcome"); + }; + + assert!(message.contains("could not compile"), "got: {message}"); + assert!(message.contains("(took 1m 35s)"), "got: {message}"); +} + /// `Tool::option_or` reports a malformed value as an absent one, which would /// silently run against the host workspace while reporting success. /// Every other test here calls `cargo_root` directly, so only driving `run`