From 84824f61c6a416b7617ed30d41a0c27bf5a4865a Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Wed, 16 Sep 2026 23:24:43 +0200 Subject: [PATCH] new: surface cargo vendor failures and pin the vendoring toolchain --- src/main.rs | 8 +- src/new/mod.rs | 63 +++++-- src/new/questions.rs | 63 ++++++- src/new/templates/mod.rs | 20 +- src/new/templates/rust.rs | 372 ++++++++++++++++++++++++++++++-------- 5 files changed, 415 insertions(+), 111 deletions(-) diff --git a/src/main.rs b/src/main.rs index 805682f..cdde41a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -287,11 +287,13 @@ fn main() { // resolves through the same pipeline; without a TTY the resolve // error lists every missing answer. Afterwards the two // verification builds are offered (`--no-verify` skips them; - // the structural self-checks inside `scaffold` always run). + // the structural self-checks inside `scaffold` always run), with + // the scaffold outcome (e.g. a failed vendoring) shaping the + // offer. if let Err(e) = rt.block_on(async { let opts = pkh::new::questions::run(cli).await?; - pkh::new::scaffold(opts.clone(), &multi)?; - pkh::new::questions::offer_verification(&opts, &multi, no_verify).await; + let outcome = pkh::new::scaffold(opts.clone(), &multi)?; + pkh::new::questions::offer_verification(&opts, &outcome, &multi, no_verify).await; Ok::<(), Box>(()) }) { error!("{}", e); diff --git a/src/new/mod.rs b/src/new/mod.rs index 82571b4..66583a0 100644 --- a/src/new/mod.rs +++ b/src/new/mod.rs @@ -24,7 +24,7 @@ use std::time::Duration; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use options::NewOptions; -use templates::OutputFile; +use templates::{OutputFile, ScaffoldOutcome}; /// Scaffold a full Debian source tree from `opts`. /// @@ -40,7 +40,14 @@ use templates::OutputFile; /// 7. `git init` unless `--no-git` or already inside a repository, /// 8. run the structural verification, /// 9. print the success message with the next steps. -pub fn scaffold(opts: NewOptions, multi: &MultiProgress) -> Result<(), Box> { +/// +/// On success the [`ScaffoldOutcome`] of the post-write hook is returned, so +/// the caller can adapt the end of the flow (e.g. the verification offer) +/// to what the templates managed to do. +pub fn scaffold( + opts: NewOptions, + multi: &MultiProgress, +) -> Result> { let pb = multi.add(ProgressBar::new_spinner()); pb.enable_steady_tick(Duration::from_millis(50)); pb.set_style( @@ -65,7 +72,7 @@ pub fn scaffold(opts: NewOptions, multi: &MultiProgress) -> Result<(), Box Result<(), Box> { +fn scaffold_steps(opts: &NewOptions, pb: &ProgressBar) -> Result> { // 1. Template resolution: an id without a registered template fails // here with the friendly message instead of a parse error. let template = templates::get(opts.template).ok_or_else(|| { @@ -175,9 +182,10 @@ fn scaffold_steps(opts: &NewOptions, pb: &ProgressBar) -> Result<(), Box Result<(), Box ".".to_string(), - display => display, + // itself (Here mode): `Created .` would be cryptic, so spell the + // location out; the skeleton/path modes keep the `` display. + let display = crate::ui::display_path(&target); + let location = if display.is_empty() { + "package in the current directory".to_string() + } else { + display.clone() }; log::info!( - "Created {display} — {} ({}-{}) for {}/{}, template '{}'", + "Created {location} — {} ({}-{}) for {}/{}, template '{}'", opts.name, opts.upstream_version, opts.revision, @@ -216,7 +227,7 @@ fn print_success(opts: &NewOptions) { opts.template ); log::info!("Next steps:"); - log::info!(" cd {display}"); + log::info!(" cd {}", if display.is_empty() { "." } else { &display }); if opts.release { log::info!( " pkh chlog # for later changes; the entry already targets {}", @@ -264,7 +275,10 @@ mod tests { /// Run `scaffold` with the cwd changed to `dir` (restored afterwards); /// must run under `#[serial]` because the cwd is process-global. - fn scaffold_in(dir: &std::path::Path, opts: NewOptions) -> Result<(), Box> { + fn scaffold_in( + dir: &std::path::Path, + opts: NewOptions, + ) -> Result> { let previous = std::env::current_dir()?; std::env::set_current_dir(dir)?; let result = scaffold(opts, &MultiProgress::new()); @@ -558,28 +572,38 @@ mod tests { /// End-to-end rust skeleton: the vendoring hook runs before the orig /// tarball is created, so `.cargo/` (and `vendor/` when dependencies /// exist) travel inside it. The vendoring step needs host cargo; on a - /// cargo-less host the scaffold still succeeds with a warning. + /// cargo-less host the scaffold still succeeds with a warning and a + /// `vendoring_failed` outcome. Keyed against the `RUSTUP_TOOLCHAIN` + /// tests of the rust template: they mutate the process-global + /// environment the cargo shim would pick up mid-vendoring. #[test] #[serial] + #[serial(RUSTUP_TOOLCHAIN)] fn scaffold_rust_skeleton_vendors_before_tarball() { let dir = tempdir().unwrap(); - scaffold_in( + let outcome = scaffold_in( dir.path(), opts(TemplateId::Rust, "mytool", SourceDir::Skeleton), ) .unwrap(); + let has_cargo = crate::new::templates::find_on_path("cargo").is_some(); + assert_eq!(outcome.vendoring_failed, !has_cargo); + let tree = dir.path().join("mytool"); assert!(tree.join("Cargo.toml").exists()); assert!(tree.join("src/main.rs").exists()); + // The vendoring step is what creates Cargo.lock for a skeleton. + assert_eq!(tree.join("Cargo.lock").exists(), has_cargo); - // rules: the vendored build overrides, no --locked on a fresh - // skeleton without Cargo.lock. + // rules: the vendored build overrides, and `--locked` exactly when + // the vendoring step left a lockfile behind (it appears after the + // rules were rendered, so the hook patches it in). let rules = std::fs::read_to_string(tree.join("debian/rules")).unwrap(); assert!(rules.contains("%:\n\tdh $@\n")); - assert!(rules.contains("override_dh_auto_build:\n\tcargo build --release --offline\n")); + assert!(rules.contains("override_dh_auto_build:\n\tcargo build --release --offline")); assert!(rules.contains("override_dh_auto_install:\n\tinstall -Dm755 target/release/mytool debian/mytool/usr/bin/mytool")); - assert!(!rules.contains("--locked")); + assert_eq!(rules.contains("--locked"), has_cargo); // control: Architecture any + the cargo/rustc build-deps. let control = crate::debian::ControlInfo::parse(&tree.join("debian/control")).unwrap(); @@ -591,7 +615,6 @@ mod tests { // The offline config exists when host cargo vendored the skeleton, // and both it and the skeleton land inside the orig tarball. - let has_cargo = crate::new::templates::find_on_path("cargo").is_some(); if has_cargo { let config = std::fs::read_to_string(tree.join(".cargo/config.toml")).unwrap(); assert!(config.contains("[source.crates-io]"), "{config}"); diff --git a/src/new/questions.rs b/src/new/questions.rs index 695f608..6ef9096 100644 --- a/src/new/questions.rs +++ b/src/new/questions.rs @@ -24,7 +24,7 @@ use indicatif::MultiProgress; use crate::new::detect::{self, Detection}; use crate::new::options::{self, NewCli, NewOptions, SourceDir, TemplateId}; -use crate::new::templates::{self, ProbeResult}; +use crate::new::templates::{self, ProbeResult, ScaffoldOutcome}; use crate::ui::prompt; /// Answer of the "where is the source code?" question: fresh skeleton. @@ -411,10 +411,17 @@ async fn run_wizard(mut cli: NewCli) -> Result> { /// (`pkh build`, offered yes) and the binary build (`pkh deb`, offered no — /// it needs network + build deps). A failed verification build never undoes /// the scaffold: the error is printed together with the manual next steps. -pub async fn offer_verification(opts: &NewOptions, multi: &MultiProgress, no_verify: bool) { - if no_verify || !is_interactive() { - return; - } +/// +/// When the scaffold's vendoring step failed (`outcome`), a prominent notice +/// states that the tree will not build until the dependencies are vendored — +/// printed with or without a TTY — and the build offer is reworded with its +/// default flipped to *no*. +pub async fn offer_verification( + opts: &NewOptions, + outcome: &ScaffoldOutcome, + multi: &MultiProgress, + no_verify: bool, +) { let tree = opts.target_dir(&std::env::current_dir().unwrap_or_default()); let display = crate::ui::display_path(&tree); let display = if display.is_empty() { @@ -423,7 +430,30 @@ pub async fn offer_verification(opts: &NewOptions, multi: &MultiProgress, no_ver display }; - let verify_source = match prompt::confirm("Verify with `pkh build` now?", true) { + if outcome.vendoring_failed { + // Set apart from the surrounding success output by blank lines: a + // single warning between two success lines is easy to miss. + println!(); + log::warn!( + "The Cargo dependencies could NOT be vendored: this package will \ + not build until the vendoring is completed by hand:\n\ + \x20 1. `cd {display} && cargo vendor`\n\ + \x20 2. add the printed source replacement to .cargo/config.toml, \ + plus `[net] offline = true`" + ); + println!(); + } + + if no_verify || !is_interactive() { + return; + } + + let build_offer = if outcome.vendoring_failed { + "Verify with `pkh build` now? (it will fail until dependencies are vendored)" + } else { + "Verify with `pkh build` now?" + }; + let verify_source = match prompt::confirm(build_offer, !outcome.vendoring_failed) { Ok(answer) => answer, Err(_) => return, }; @@ -720,8 +750,12 @@ pub fn summary_text(opts: &NewOptions) -> String { lines.push(format!(" Depends {}", opts.depends.join(", "))); } } else if opts.template == TemplateId::Rust { - lines - .push(" debian/rules cargo build --release --offline (vendored)".to_string()); + // Nothing is vendored yet at this point: only announce that the + // generation will attempt it. + lines.push( + " debian/rules cargo build --release --offline (vendored at generation)" + .to_string(), + ); } else { lines.push(format!(" debian/rules {}", template.rules_dh_line())); } @@ -948,6 +982,19 @@ mod tests { assert!(text.contains("debian/watch release watcher"), "{text}"); } + /// The rust summary only announces the vendoring attempt of the + /// generation, it must not assert an outcome that has not been tried + /// yet (regression: it claimed "(vendored)" before generating). + #[test] + fn summary_screen_rust_does_not_presume_vendoring() { + let text = summary_text(&opts(Tid::Rust)); + assert!( + text.contains("cargo build --release --offline (vendored at generation)"), + "{text}" + ); + assert!(!text.contains("(vendored)"), "{text}"); + } + #[test] fn answer_validators() { assert!(validate_revision_answer("1").is_ok()); diff --git a/src/new/templates/mod.rs b/src/new/templates/mod.rs index 8c924fa..8430930 100644 --- a/src/new/templates/mod.rs +++ b/src/new/templates/mod.rs @@ -51,6 +51,17 @@ impl OutputFile { } } +/// What the template post-write hook did to the freshly written tree, +/// threaded through [`super::scaffold`] so the flow can react (e.g. word +/// the post-scaffold verification offer differently when vendoring failed). +#[derive(Debug, Clone, Copy, Default)] +pub struct ScaffoldOutcome { + /// The vendoring step did not complete (host `cargo` missing, `cargo + /// vendor` failed, or the offline config could not be written): the + /// package will not build until the dependencies are vendored manually. + pub vendoring_failed: bool, +} + /// Metadata extracted from an existing project by [`Template::probe`], used /// by the interactive wizard to pre-fill its answers (explicit flags always /// win). Every field is optional; probe failures are silent and the generic @@ -126,13 +137,16 @@ pub trait Template: Sync { /// Hook run after the generated files have been written to `tree` and /// before the orig tarball is created, for templates that need to run /// host tooling over the freshly written tree (e.g. `cargo vendor`, so - /// the vendored sources land inside the tarball). + /// the vendored sources land inside the tarball). Returns the outcome + /// the flow should know about ([`ScaffoldOutcome`]); failures that leave + /// the tree in place but not buildable are reported through it instead + /// of failing the scaffold. fn post_write( &self, _opts: &NewOptions, _tree: &Path, - ) -> Result<(), Box> { - Ok(()) + ) -> Result> { + Ok(ScaffoldOutcome::default()) } } diff --git a/src/new/templates/rust.rs b/src/new/templates/rust.rs index 4f76730..5ccd1bb 100644 --- a/src/new/templates/rust.rs +++ b/src/new/templates/rust.rs @@ -6,14 +6,15 @@ //! runs over the freshly written tree (before the orig tarball is created, so //! `vendor/` travels inside it), and `debian/rules` builds offline with the //! source replacement. When host `cargo` is missing or vendoring fails, the -//! scaffold continues with a loud warning — the package will not build until -//! the user vendors manually. +//! scaffold continues with a loud warning and reports +//! [`ScaffoldOutcome::vendoring_failed`] to the flow — the package will not +//! build until the user vendors manually. use std::path::Path; use serde_json::Value; -use super::{OutputFile, ProbeResult, Template, find_on_path, source_dir_of}; +use super::{OutputFile, ProbeResult, ScaffoldOutcome, Template, find_on_path, source_dir_of}; use crate::new::options::{NewOptions, SourceDir, TemplateId}; /// Rust project (`Cargo.toml`). @@ -80,9 +81,10 @@ impl Template for Rust { /// The vendored build overrides. `--locked` is used only when the /// packaged tree already carries a `Cargo.lock` (fresh skeletons have - /// none yet); omitting it is always safe. The built artifact of a - /// skeleton is named after its crate (a sanitized package name) and - /// installed under the command name. + /// none yet — the vendoring hook patches the flag in once `cargo vendor` + /// created it, see [`patch_rules_locked`]); omitting it is always safe. + /// The built artifact of a skeleton is named after its crate (a + /// sanitized package name) and installed under the command name. fn rules_extra(&self, opts: &NewOptions) -> String { let locked = if lockfile_present(opts) { " --locked" @@ -127,86 +129,191 @@ impl Template for Rust { /// Vendor the Cargo dependencies into the freshly written tree: run /// `cargo vendor` in it and write `.cargo/config.toml` with the printed /// source replacement plus `offline = true`, so the build never touches - /// the network. Failures warn loudly and continue: the scaffold stays in - /// place, the package just will not build until vendored manually. + /// the network. Failures warn loudly and come back as + /// [`ScaffoldOutcome::vendoring_failed`] instead of failing the scaffold: + /// the tree stays in place, the package just will not build until the + /// vendoring is completed manually. fn post_write( &self, _opts: &NewOptions, tree: &Path, - ) -> Result<(), Box> { + ) -> Result> { if !tree.join("Cargo.toml").exists() { - return Ok(()); + return Ok(ScaffoldOutcome::default()); } - let Some(cargo) = find_on_path("cargo") else { - log::warn!( - "cargo was not found on PATH: the Rust package will NOT build \ - until its dependencies are vendored. Run `cargo vendor` in the \ - tree and add the printed source replacement to \ - .cargo/config.toml (with `[net] offline = true`)." - ); - return Ok(()); - }; + // `vendor_dependencies` reports whether the step completed; the + // outcome carries the flipped, failure-shaped flag. + let vendoring_failed = !vendor_dependencies(tree)?; + // The vendoring step is what creates `Cargo.lock` for a fresh + // skeleton, but `debian/rules` was rendered before it ran: add + // `--locked` now that the outcome is known, so the final rules use + // it exactly when the lockfile exists. + patch_rules_locked(tree)?; + Ok(ScaffoldOutcome { vendoring_failed }) + } +} - log::info!("Vendoring Cargo dependencies (`cargo vendor`) — needs one network sync"); - match std::process::Command::new(&cargo) - .arg("vendor") - .current_dir(tree) - .output() - { - Ok(output) if output.status.success() => { - let printed = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let snippet = if printed.contains("[source.") { - printed - } else { - FALLBACK_VENDOR_CONFIG.to_string() - }; - let config_path = tree.join(".cargo/config.toml"); - if config_path.exists() { - log::warn!( - "'{}' already exists: the vendored-source replacement \ - printed by `cargo vendor` was NOT written there; add it \ - manually (plus `[net] offline = true`).", - config_path.display() - ); - return Ok(()); - } - std::fs::create_dir_all(config_path.parent().unwrap_or(tree))?; - std::fs::write( - &config_path, - format!("{snippet}\n\n[net]\noffline = true\n"), - )?; - log::info!( - "Vendored sources and '{}' written; the package builds \ - fully offline", +/// The vendoring step proper: run `cargo vendor` over `tree` and write the +/// offline source replacement. Returns whether the step completed; failures +/// warn loudly and leave the tree for manual vendoring. I/O errors on the +/// freshly written tree are the exception: they fail the scaffold. +fn vendor_dependencies(tree: &Path) -> Result> { + let Some(cargo) = find_on_path("cargo") else { + log::warn!( + "cargo was not found on PATH: the Rust package will NOT build \ + until its dependencies are vendored. Run `cargo vendor` in the \ + tree and add the printed source replacement to \ + .cargo/config.toml (with `[net] offline = true`)." + ); + return Ok(false); + }; + + log::info!("Vendoring Cargo dependencies (`cargo vendor`) — needs one network sync"); + let mut command = std::process::Command::new(&cargo); + command.arg("vendor").current_dir(tree); + // Pin the run to the host's *default* rustup toolchain: `cargo` usually + // is a rustup shim, so a `rust-toolchain.toml` pin of the scaffolded + // project would otherwise download that toolchain (or fail on broken + // rustup state) mid-scaffold. Vendoring only fetches crate sources and + // is toolchain-insensitive. + if let Some(toolchain) = vendoring_toolchain() { + command.env("RUSTUP_TOOLCHAIN", toolchain); + } + match command.output() { + Ok(output) if output.status.success() => { + let printed = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let snippet = if printed.contains("[source.") { + printed + } else { + FALLBACK_VENDOR_CONFIG.to_string() + }; + let config_path = tree.join(".cargo/config.toml"); + if config_path.exists() { + log::warn!( + "'{}' already exists: the vendored-source replacement \ + printed by `cargo vendor` was NOT written there; add it \ + manually (plus `[net] offline = true`).", config_path.display() ); + return Ok(false); } - Ok(output) => { - log::warn!( - "`cargo vendor` failed ({}): the package will NOT build until \ - its dependencies are vendored. Run `cargo vendor` in the tree \ - and add the printed source replacement to .cargo/config.toml. \ - Last stderr line: {}", - output.status, - String::from_utf8_lossy(&output.stderr) - .lines() - .last() - .unwrap_or("(no output)") - ); - } - Err(e) => { - log::warn!( - "could not run `cargo vendor` ({e}): the package will NOT \ - build until its dependencies are vendored. Run \ - `cargo vendor` in the tree and add the printed source \ - replacement to .cargo/config.toml." - ); - } + std::fs::create_dir_all(config_path.parent().unwrap_or(tree))?; + std::fs::write( + &config_path, + format!("{snippet}\n\n[net]\noffline = true\n"), + )?; + log::info!( + "Vendored sources and '{}' written; the package builds \ + fully offline", + config_path.display() + ); + Ok(true) + } + Ok(output) => { + log::warn!( + "`cargo vendor` failed ({}): the package will NOT build until \ + its dependencies are vendored. Run `cargo vendor` in the tree \ + and add the printed source replacement to .cargo/config.toml. \ + Last stderr line: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + .lines() + .last() + .unwrap_or("(no output)") + ); + Ok(false) + } + Err(e) => { + log::warn!( + "could not run `cargo vendor` ({e}): the package will NOT \ + build until its dependencies are vendored. Run \ + `cargo vendor` in the tree and add the printed source \ + replacement to .cargo/config.toml." + ); + Ok(false) } - Ok(()) } } +/// The `RUSTUP_TOOLCHAIN` value for the vendoring run: the host's *default* +/// rustup toolchain. `None` — run with the inherited environment — when +/// rustup is not on `PATH`, is broken, prints nothing parseable, or when the +/// user already set `RUSTUP_TOOLCHAIN` (never overridden). +fn vendoring_toolchain() -> Option { + if std::env::var_os("RUSTUP_TOOLCHAIN").is_some() { + return None; + } + let rustup = find_on_path("rustup")?; + let output = std::process::Command::new(rustup) + .arg("default") + .output() + .ok()?; + if !output.status.success() { + return None; + } + parse_default_toolchain(&String::from_utf8_lossy(&output.stdout)) +} + +/// Parse the default toolchain name out of `rustup default` output, loosely: +/// the first token after `default toolchain:` on its line (older rustup, +/// e.g. `default toolchain: stable (override)`), or the token before +/// `(default)` (e.g. `stable-x86_64-unknown-linux-gnu (default)`). +fn parse_default_toolchain(output: &str) -> Option { + for line in output.lines() { + let line = line.trim(); + let name = if let Some((_, rest)) = line.split_once("default toolchain:") { + rest.split_whitespace().next() + } else if let Some((name, _)) = line.split_once(" (default)") { + Some(name.trim()) + } else { + None + }; + if let Some(name) = name.filter(|name| !name.is_empty()) { + return Some(name.to_string()); + } + } + None +} + +/// Add `--locked` to the offline cargo commands of the rendered rules when +/// the tree carries a `Cargo.lock` they were not rendered for: a fresh +/// skeleton renders `debian/rules` before the vendoring step, and it is +/// exactly that step which creates the lockfile. No-op without a lockfile, +/// without rules to patch, or when the rules already carry the flag. +fn patch_rules_locked(tree: &Path) -> Result<(), Box> { + if !tree.join("Cargo.lock").exists() { + return Ok(()); + } + let path = tree.join("debian/rules"); + let Ok(rules) = std::fs::read_to_string(&path) else { + return Ok(()); + }; + let Some(patched) = with_locked(&rules) else { + return Ok(()); + }; + std::fs::write(&path, patched)?; + Ok(()) +} + +/// `rules` contents with `--locked` added to the `cargo build`/`cargo test` +/// offline commands; `None` when there is nothing to patch (the flag is +/// already there, e.g. because the lockfile existed at render time). +fn with_locked(rules: &str) -> Option { + if rules.contains("--locked") { + return None; + } + let patched = rules + .replace( + "\tcargo build --release --offline\n", + "\tcargo build --release --offline --locked\n", + ) + .replace( + "\tcargo test --release --offline\n", + "\tcargo test --release --offline --locked\n", + ); + (patched != rules).then_some(patched) +} + /// Whether the packaged tree carries a `Cargo.lock` (skeletons do not yet). fn lockfile_present(opts: &NewOptions) -> bool { source_dir_of(opts).is_some_and(|dir| dir.join("Cargo.lock").exists()) @@ -298,6 +405,7 @@ fn probe_cargo_toml(dir: &Path) -> Option { mod tests { use super::*; use crate::new::options::{License, SourceDir}; + use serial_test::serial; use tempfile::tempdir; fn opts(source_dir: SourceDir) -> NewOptions { @@ -466,9 +574,12 @@ mod tests { } /// The vendoring hook over a zero-dependency skeleton: offline config - /// written, no failure (needs host cargo; without it the warning path - /// keeps the tree intact). + /// written, outcome reports success (needs host cargo; without it the + /// warning path keeps the tree intact and reports the failure). Keyed + /// against `vendoring_respects_the_users_toolchain`, which mutates the + /// process-global `RUSTUP_TOOLCHAIN` the cargo shim would pick up. #[test] + #[serial(RUSTUP_TOOLCHAIN)] fn post_write_vendors_skeleton() { let dir = tempdir().unwrap(); let o = opts(SourceDir::Skeleton); @@ -479,7 +590,8 @@ mod tests { std::fs::write(path, file.contents).unwrap(); } - template.post_write(&o, dir.path()).unwrap(); + let outcome = template.post_write(&o, dir.path()).unwrap(); + assert_eq!(outcome.vendoring_failed, find_on_path("cargo").is_none()); if find_on_path("cargo").is_some() { let config = std::fs::read_to_string(dir.path().join(".cargo/config.toml")).unwrap(); @@ -487,12 +599,118 @@ mod tests { assert!(config.contains("replace-with = \"vendored-sources\"")); assert!(config.contains("[net]\noffline = true"), "{config}"); } - // An existing .cargo/config.toml is never overwritten. + // An existing .cargo/config.toml is never overwritten (and the + // unwriteable config counts as an incomplete vendoring). let existing = dir.path().join(".cargo/config.toml"); if existing.exists() { - template.post_write(&o, dir.path()).unwrap(); + let outcome = template.post_write(&o, dir.path()).unwrap(); let config = std::fs::read_to_string(&existing).unwrap(); assert!(config.contains("[source.crates-io]")); + assert_eq!(outcome.vendoring_failed, find_on_path("cargo").is_some()); + } + } + + /// The vendored cargo commands gain `--locked` only when the rules do + /// not carry it yet (i.e. no lockfile existed at render time). + #[test] + fn with_locked_patches_only_unlocked_rules() { + let unlocked = "#!/usr/bin/make -f\n%:\n\tdh $@\n\n\ + override_dh_auto_build:\n\ + \tcargo build --release --offline\n\ + \n\ + override_dh_auto_test:\n\ + \tcargo test --release --offline\n"; + let patched = with_locked(unlocked).expect("unlockable rules"); + assert!( + patched.contains("\tcargo build --release --offline --locked\n"), + "{patched}" + ); + assert!( + patched.contains("\tcargo test --release --offline --locked\n"), + "{patched}" + ); + // Idempotent: a second pass has nothing left to do. + assert!(with_locked(&patched).is_none()); + // Rules already carrying the flag (lockfile present at render time) + // are left alone, and so are rules without cargo commands. + assert!( + with_locked("override_dh_auto_build:\n\tcargo build --release --offline --locked\n") + .is_none() + ); + assert!(with_locked("%:\n\tdh $@\n").is_none()); + } + + /// The post-vendoring rules patch follows the lockfile: no `Cargo.lock` + /// in the tree, no patch; once the vendoring step created it (what it + /// does for a fresh skeleton), the offline commands become `--locked`. + #[test] + fn patch_rules_locked_follows_the_lockfile() { + let dir = tempdir().unwrap(); + let rules_path = dir.path().join("debian/rules"); + std::fs::create_dir_all(rules_path.parent().unwrap()).unwrap(); + std::fs::write( + &rules_path, + "override_dh_auto_build:\n\tcargo build --release --offline\n", + ) + .unwrap(); + + patch_rules_locked(dir.path()).unwrap(); + assert!( + !std::fs::read_to_string(&rules_path) + .unwrap() + .contains("--locked") + ); + + std::fs::write(dir.path().join("Cargo.lock"), "").unwrap(); + patch_rules_locked(dir.path()).unwrap(); + let rules = std::fs::read_to_string(&rules_path).unwrap(); + assert!( + rules.contains("\tcargo build --release --offline --locked\n"), + "{rules}" + ); + } + + /// `rustup default` output parsing: the toolchain name, loosely, from + /// both the historical (`default toolchain: …`) and the current + /// (` (default)`) spellings; anything else fails soft. + #[test] + fn parse_default_toolchain_variants() { + assert_eq!( + parse_default_toolchain("stable-x86_64-unknown-linux-gnu (default)\n"), + Some("stable-x86_64-unknown-linux-gnu".to_string()) + ); + assert_eq!( + parse_default_toolchain("default toolchain: stable (override)\n"), + Some("stable".to_string()) + ); + assert_eq!( + parse_default_toolchain("default toolchain: 1.75.0-x86_64-unknown-linux-gnu\n"), + Some("1.75.0-x86_64-unknown-linux-gnu".to_string()) + ); + // The line can sit among unrelated output. + assert_eq!( + parse_default_toolchain("info: syncing channel updates\nnightly (default)\n"), + Some("nightly".to_string()) + ); + // Nothing parseable: run without the override then. + assert_eq!(parse_default_toolchain(""), None); + assert_eq!( + parse_default_toolchain("error: no default toolchain configured\n"), + None + ); + } + + /// A `RUSTUP_TOOLCHAIN` set by the user is never overridden: the + /// vendoring run keeps the inherited environment. + #[test] + #[serial(RUSTUP_TOOLCHAIN)] + fn vendoring_respects_the_users_toolchain() { + let saved = std::env::var_os("RUSTUP_TOOLCHAIN"); + unsafe { std::env::set_var("RUSTUP_TOOLCHAIN", "nightly-custom") }; + assert_eq!(vendoring_toolchain(), None); + match saved { + Some(value) => unsafe { std::env::set_var("RUSTUP_TOOLCHAIN", value) }, + None => unsafe { std::env::remove_var("RUSTUP_TOOLCHAIN") }, } } }