From 8e06b2074df92999e9e70f56cabc78ff33257d6a Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Wed, 16 Sep 2026 23:59:16 +0200 Subject: [PATCH] new: flag rust-toolchain.toml pins in pkh new --- src/new/questions.rs | 63 ++++++++++--- src/new/templates/mod.rs | 5 ++ src/new/templates/python.rs | 1 + src/new/templates/rust.rs | 174 +++++++++++++++++++++++++++++++++++- 4 files changed, 228 insertions(+), 15 deletions(-) diff --git a/src/new/questions.rs b/src/new/questions.rs index 6ef9096..48c8add 100644 --- a/src/new/questions.rs +++ b/src/new/questions.rs @@ -160,6 +160,22 @@ async fn run_wizard(mut cli: NewCli) -> Result> { } let template = TemplateId::parse(cli.lang.as_deref().unwrap_or_default())?; + // The rust toolchain pin of the packaged project does not travel into + // the chroot build: surface it now so a too-old pin is not a surprise + // when `pkh deb` compiles with the distribution's rustc. + let toolchain_pin = if template == TemplateId::Rust { + probe.as_ref().and_then(|p| p.toolchain_pin.clone()) + } else { + None + }; + if let Some(pin) = &toolchain_pin { + log::info!( + "Project pins rust {pin} via rust-toolchain.toml; the chroot \ + build uses the distribution's rustc and ignores the pin — \ + adjust or remove it if the code needs newer compiler features" + ); + } + // 3. Source location. Skipped (with the inline notice) when a confident // detection already decided to package the current directory; a // --source flag skips it too. @@ -398,7 +414,7 @@ async fn run_wizard(mut cli: NewCli) -> Result> { // Summary screen + final confirmation: Ctrl+C or 'n' abort with // nothing written (generation is all-or-nothing later anyway). - println!("{}", summary_text(&opts)); + println!("{}", summary_text(&opts, toolchain_pin.as_deref())); if !prompt::confirm("Generate?", true)? { return Err("Aborted: nothing was written to disk.".into()); } @@ -714,12 +730,13 @@ pub fn watch_template(homepage: Option<&str>) -> Option { )) } -/// The summary screen shown before the final `Generate?` confirmation -/// (spec transcript): identity line, template/license/maintainer line, the -/// generated-file overview and — for a metapackage — the Depends payload, -/// for a skeleton — the upstream files that will be created, and for rust — -/// a warning when dependencies cannot be vendored on this host. -pub fn summary_text(opts: &NewOptions) -> String { +/// The pre-flight summary screen shown before the final `Generate?` +/// confirmation (spec transcript): identity line, template/license/maintainer +/// line, the generated-file overview and — for a metapackage — the Depends +/// payload, for a skeleton — the upstream files that will be created, and +/// for rust — the probed toolchain pin (when any; the chroot build ignores +/// it) plus a warning when dependencies cannot be vendored on this host. +pub fn summary_text(opts: &NewOptions, toolchain_pin: Option<&str>) -> String { let template = templates::get(opts.template); let mut lines = Vec::new(); @@ -756,6 +773,13 @@ pub fn summary_text(opts: &NewOptions) -> String { " debian/rules cargo build --release --offline (vendored at generation)" .to_string(), ); + // A pinned rust-toolchain.toml does not reach the chroot build: + // flagged here so a too-old pin is no surprise later. + if let Some(pin) = toolchain_pin { + lines.push(format!( + " rust-toolchain {pin} (ignored by the chroot build)" + )); + } } else { lines.push(format!(" debian/rules {}", template.rules_dh_line())); } @@ -923,7 +947,7 @@ mod tests { #[test] fn summary_screen_skeleton() { - let text = summary_text(&opts(Tid::Makefile)); + let text = summary_text(&opts(Tid::Makefile), None); assert!( text.contains("mytool 0.1.0-1 · builds for ubuntu/resolute"), "{text}" @@ -956,7 +980,7 @@ mod tests { let mut o = opts(Tid::Empty); o.depends = vec!["hello".to_string(), "hello-data (>= 1.0)".to_string()]; o.source_dir = options::SourceDir::Here; - let text = summary_text(&o); + let text = summary_text(&o, None); assert!(text.contains("Architecture: all"), "{text}"); assert!( text.contains("Depends hello, hello-data (>= 1.0)"), @@ -973,7 +997,7 @@ mod tests { o.release = true; o.autopkgtest = true; o.watch = Some("version=4\n".to_string()); - let text = summary_text(&o); + let text = summary_text(&o, None); assert!(text.contains("0.1.0-1 resolute, Initial release"), "{text}"); assert!( text.contains("debian/tests autopkgtest smoke test"), @@ -987,7 +1011,7 @@ mod tests { /// yet (regression: it claimed "(vendored)" before generating). #[test] fn summary_screen_rust_does_not_presume_vendoring() { - let text = summary_text(&opts(Tid::Rust)); + let text = summary_text(&opts(Tid::Rust), None); assert!( text.contains("cargo build --release --offline (vendored at generation)"), "{text}" @@ -995,6 +1019,23 @@ mod tests { assert!(!text.contains("(vendored)"), "{text}"); } + /// A probed rust toolchain pin surfaces in the summary as its own row + /// (rust template only), flagged as ignored by the chroot build. + #[test] + fn summary_screen_shows_the_toolchain_pin() { + let text = summary_text(&opts(Tid::Rust), Some("1.98.0")); + assert!( + text.contains("rust-toolchain 1.98.0 (ignored by the chroot build)"), + "{text}" + ); + + // No pin, no row. + assert!(!summary_text(&opts(Tid::Rust), None).contains("rust-toolchain")); + // A pin under a template other than rust is not shown either (the + // pin only matters for a cargo build). + assert!(!summary_text(&opts(Tid::Go), Some("1.98.0")).contains("rust-toolchain")); + } + #[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 8430930..53b5fc8 100644 --- a/src/new/templates/mod.rs +++ b/src/new/templates/mod.rs @@ -81,6 +81,11 @@ pub struct ProbeResult { /// Installed command / binary name (e.g. the first `[[bin]]` target or /// console script). pub command: Option, + /// Rust toolchain channel pinned by the project's `rust-toolchain.toml` + /// (or legacy `rust-toolchain`), e.g. `1.98.0`. Only set by the rust + /// template; the chroot build uses the distribution's rustc and ignores + /// the pin, so the wizard surfaces it as a heads-up instead. + pub toolchain_pin: Option, } /// A package template: one supported ecosystem / build system. diff --git a/src/new/templates/python.rs b/src/new/templates/python.rs index 15b88f3..e70a011 100644 --- a/src/new/templates/python.rs +++ b/src/new/templates/python.rs @@ -208,6 +208,7 @@ impl Template for Python { homepage: project.homepage, license: project.license, command: project.script, + ..Default::default() }; if result.name.is_some() || result.version.is_some() diff --git a/src/new/templates/rust.rs b/src/new/templates/rust.rs index 5ccd1bb..54c2bfd 100644 --- a/src/new/templates/rust.rs +++ b/src/new/templates/rust.rs @@ -118,12 +118,24 @@ impl Template for Rust { /// Name, version, description, homepage, license and first binary from /// `cargo metadata --no-deps` (when host cargo is available), with a - /// minimal line-parse of `Cargo.toml` as fallback. + /// minimal line-parse of `Cargo.toml` as fallback; plus the toolchain + /// channel pinned by `rust-toolchain.toml` / legacy `rust-toolchain`. fn probe(&self, dir: &Path) -> Option { - if let Some(result) = probe_cargo_metadata(dir) { - return Some(result); + match probe_cargo_metadata(dir).or_else(|| probe_cargo_toml(dir)) { + Some(mut result) => { + result.toolchain_pin = toolchain_pin(dir); + Some(result) + } + // A Cargo.toml that resists reading, but a toolchain pin this + // template can still report. + None => { + let pin = toolchain_pin(dir)?; + Some(ProbeResult { + toolchain_pin: Some(pin), + ..Default::default() + }) + } } - probe_cargo_toml(dir) } /// Vendor the Cargo dependencies into the freshly written tree: run @@ -360,6 +372,8 @@ fn probe_cargo_metadata(dir: &Path) -> Option { homepage: field("homepage"), license: field("license"), command, + // Filled in by `probe` from the rust-toolchain file, not metadata. + toolchain_pin: None, }) } @@ -401,6 +415,56 @@ fn probe_cargo_toml(dir: &Path) -> Option { } } +/// The rust toolchain channel pinned in `dir`: `rust-toolchain.toml` +/// (preferred, like rustup does) or the legacy bare `rust-toolchain`. +/// `None` without a pin file or when it names no channel. +fn toolchain_pin(dir: &Path) -> Option { + let content = std::fs::read_to_string(dir.join("rust-toolchain.toml")) + .or_else(|_| std::fs::read_to_string(dir.join("rust-toolchain"))) + .ok()?; + parse_toolchain_channel(&content) +} + +/// Minimal line-parse of the `channel` out of a `rust-toolchain` file (no +/// TOML dependency, matching the `Cargo.toml` fallback above): +/// `channel = "…"` (single quotes work too) under the `[toolchain]` section, +/// comments and unrelated sections tolerated. Without a parseable TOML +/// channel, a legacy bare `rust-toolchain` — just the channel string on a +/// single line, no TOML syntax at all — is taken whole. +fn parse_toolchain_channel(content: &str) -> Option { + let mut in_toolchain = false; + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some(header) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) { + in_toolchain = header.trim() == "toolchain"; + continue; + } + if !in_toolchain { + continue; + } + if let Some((key, value)) = line.split_once('=') + && key.trim() == "channel" + { + let value = value.trim().trim_matches('"').trim_matches('\'').trim(); + if !value.is_empty() { + return Some(value.to_string()); + } + } + } + // Legacy bare format: the whole file is the channel name (never + // comment-only). + let bare = content.trim(); + (!bare.is_empty() + && !bare.starts_with('#') + && !bare.contains('\n') + && !bare.contains('=') + && !bare.contains('[')) + .then(|| bare.to_string()) +} + #[cfg(test)] mod tests { use super::*; @@ -713,4 +777,106 @@ mod tests { None => unsafe { std::env::remove_var("RUSTUP_TOOLCHAIN") }, } } + + /// The `rust-toolchain` channel parser: quoted channel under + /// `[toolchain]` (double or single quotes, with comments and unrelated + /// sections around), the legacy bare single-line format taken whole, and + /// the failure cases (missing key, empty channel, wrong section, empty + /// file). + #[test] + fn parse_toolchain_channel_variants() { + // The canonical spelling. + assert_eq!( + parse_toolchain_channel("[toolchain]\nchannel = \"1.98.0\"\n"), + Some("1.98.0".to_string()) + ); + // Single quotes, no trailing newline, no spaces around `=`. + assert_eq!( + parse_toolchain_channel("[toolchain]\nchannel='nightly'"), + Some("nightly".to_string()) + ); + // Comments, blank lines and unrelated sections tolerated. + assert_eq!( + parse_toolchain_channel( + "# pin for CI\n\ + \n\ + [toolchain]\n\ + # bumped by cargo update\n\ + channel = \"stable\"\n\ + \n\ + [targets.x86_64-unknown-linux-gnu]\n" + ), + Some("stable".to_string()) + ); + // Legacy bare `rust-toolchain`: just the channel string, no TOML. + assert_eq!( + parse_toolchain_channel("1.98.0\n"), + Some("1.98.0".to_string()) + ); + assert_eq!( + parse_toolchain_channel("nightly"), + Some("nightly".to_string()) + ); + // Missing channel key. + assert_eq!( + parse_toolchain_channel("[toolchain]\ncomponents = [\"rustfmt\"]\n"), + None + ); + // Wrong section: the key is not honored (rustup would ignore it too). + assert_eq!( + parse_toolchain_channel("[unstable]\nchannel = \"1.98.0\"\n"), + None + ); + // Empty channel value or file: nothing to report. + assert_eq!( + parse_toolchain_channel("[toolchain]\nchannel = \"\"\n"), + None + ); + assert_eq!(parse_toolchain_channel(""), None); + assert_eq!(parse_toolchain_channel("# only a comment\n"), None); + } + + /// `probe()` carries the pin next to the project metadata: + /// `rust-toolchain.toml` wins over the legacy file (as with rustup), and + /// no pin file leaves the field empty. + #[test] + fn probe_reports_the_toolchain_pin() { + let template = super::super::get(TemplateId::Rust).unwrap(); + let cargo_toml = "[package]\nname = \"pinned\"\nversion = \"1.0.0\"\n"; + + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("Cargo.toml"), cargo_toml).unwrap(); + std::fs::write( + dir.path().join("rust-toolchain.toml"), + "[toolchain]\nchannel = \"1.98.0\"\n", + ) + .unwrap(); + let probe = template.probe(dir.path()).expect("probe result"); + assert_eq!(probe.name.as_deref(), Some("pinned")); + assert_eq!(probe.toolchain_pin.as_deref(), Some("1.98.0")); + + // The legacy bare file is honored when there is no toml ... + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("Cargo.toml"), cargo_toml).unwrap(); + std::fs::write(dir.path().join("rust-toolchain"), "nightly\n").unwrap(); + assert_eq!( + template.probe(dir.path()).unwrap().toolchain_pin, + Some("nightly".to_string()) + ); + // ... but loses once the toml exists. + std::fs::write( + dir.path().join("rust-toolchain.toml"), + "[toolchain]\nchannel = \"1.90.0\"\n", + ) + .unwrap(); + assert_eq!( + template.probe(dir.path()).unwrap().toolchain_pin, + Some("1.90.0".to_string()) + ); + + // No pin file at all. + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("Cargo.toml"), cargo_toml).unwrap(); + assert_eq!(template.probe(dir.path()).unwrap().toolchain_pin, None); + } }