872 lines
34 KiB
Rust
872 lines
34 KiB
Rust
//! The `rust` template: a Cargo project shipped as a **vendored** build.
|
|
//!
|
|
//! Standard Debian practice (debcargo → dh-cargo → registry deps) needs every
|
|
//! Cargo dependency as a `librust-*-dev` archive package — unusable for a
|
|
//! brand-new program. v1 therefore vendors at scaffold time: `cargo vendor`
|
|
//! 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 and reports
|
|
//! [`ScaffoldOutcome::vendoring_failed`] to the flow — the package will not
|
|
//! build until the user vendors manually.
|
|
//!
|
|
//! The metadata and bodies are manifest data
|
|
//! (`data/templates/rust/manifest.yml`: the skeleton `Cargo.toml` /
|
|
//! `src/main.rs` and the `debian/rules` vendored-build overrides), rendered
|
|
//! through the placeholders this module supplies; the logic half here is
|
|
//! the project probe and the vendoring hook.
|
|
|
|
use std::path::Path;
|
|
|
|
use serde_json::Value;
|
|
|
|
use super::{ProbeResult, ScaffoldOutcome, TemplateHooks, find_on_path, source_dir_of};
|
|
use crate::new::options::{NewOptions, SourceDir};
|
|
|
|
/// The logic half of the rust template.
|
|
pub struct Hooks;
|
|
|
|
/// The rust template's hooks, registered in the template registry.
|
|
pub static HOOKS: Hooks = Hooks;
|
|
|
|
/// The source-replacement configuration, used when `cargo vendor` did not
|
|
/// print one itself (old cargo versions, empty output).
|
|
const FALLBACK_VENDOR_CONFIG: &str = "[source.crates-io]\nreplace-with = \"vendored-sources\"\n\n[source.vendored-sources]\ndirectory = \"vendor\"";
|
|
|
|
/// The cargo crate name of the skeleton: dpkg package names may carry `+`
|
|
/// or `.`, which cargo rejects in package names.
|
|
fn crate_name(opts: &NewOptions) -> String {
|
|
opts.name.replace(['+', '.'], "_")
|
|
}
|
|
|
|
impl TemplateHooks for Hooks {
|
|
/// The placeholder values of the manifest bodies: `{crate_name}` names
|
|
/// the skeleton crate (a sanitized package name — see [`crate_name`]);
|
|
/// `{locked}` is ` --locked` only when the packaged tree already
|
|
/// carries a `Cargo.lock` (fresh skeletons have none yet — the
|
|
/// vendoring hook patches the flag in once `cargo vendor` created it);
|
|
/// `{artifact}` is the built binary of the rules install override —
|
|
/// a skeleton's is named after its crate, an existing project's under
|
|
/// the (probed or answered) command.
|
|
fn context(&self, opts: &NewOptions) -> Vec<(String, String)> {
|
|
let locked = if lockfile_present(opts) {
|
|
" --locked"
|
|
} else {
|
|
""
|
|
};
|
|
let artifact = match opts.source_dir {
|
|
SourceDir::Skeleton => crate_name(opts),
|
|
_ => opts.command.clone(),
|
|
};
|
|
vec![
|
|
("crate_name".to_string(), crate_name(opts)),
|
|
("locked".to_string(), locked.to_string()),
|
|
("artifact".to_string(), artifact),
|
|
]
|
|
}
|
|
|
|
/// 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; plus the toolchain
|
|
/// channel pinned by `rust-toolchain.toml` / legacy `rust-toolchain`.
|
|
fn probe(&self, dir: &Path) -> Option<ProbeResult> {
|
|
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()
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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 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<ScaffoldOutcome, Box<dyn std::error::Error>> {
|
|
if !tree.join("Cargo.toml").exists() {
|
|
return Ok(ScaffoldOutcome::default());
|
|
}
|
|
// `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,
|
|
orig_origin: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// `pub(crate)` because the `pkh build` re-vendor retry (see
|
|
/// [`crate::build`]'s `VendorDriftError` hook) reruns exactly this step.
|
|
pub(crate) fn vendor_dependencies(tree: &Path) -> Result<bool, Box<dyn std::error::Error>> {
|
|
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);
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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<String> {
|
|
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<String> {
|
|
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<dyn std::error::Error>> {
|
|
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<String> {
|
|
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())
|
|
}
|
|
|
|
/// Probe through `cargo metadata --no-deps --format-version 1`: silent `None`
|
|
/// when cargo is unavailable or fails.
|
|
fn probe_cargo_metadata(dir: &Path) -> Option<ProbeResult> {
|
|
let cargo = find_on_path("cargo")?;
|
|
let output = std::process::Command::new(cargo)
|
|
.args(["metadata", "--no-deps", "--format-version", "1"])
|
|
.current_dir(dir)
|
|
.output()
|
|
.ok()?;
|
|
if !output.status.success() {
|
|
return None;
|
|
}
|
|
let metadata: Value = serde_json::from_slice(&output.stdout).ok()?;
|
|
let package = metadata.get("packages")?.as_array()?.first()?;
|
|
let command = package
|
|
.get("targets")?
|
|
.as_array()?
|
|
.iter()
|
|
.find(|target| {
|
|
target
|
|
.get("kind")
|
|
.and_then(|kind| kind.as_array())
|
|
.is_some_and(|kinds| kinds.iter().any(|k| k.as_str() == Some("bin")))
|
|
})
|
|
.and_then(|target| target.get("name"))
|
|
.and_then(|name| name.as_str())
|
|
.map(str::to_string);
|
|
let field = |key: &str| {
|
|
package
|
|
.get(key)
|
|
.and_then(Value::as_str)
|
|
.filter(|s| !s.is_empty())
|
|
.map(str::to_string)
|
|
};
|
|
Some(ProbeResult {
|
|
name: field("name"),
|
|
version: field("version"),
|
|
description: field("description"),
|
|
homepage: field("homepage"),
|
|
license: field("license"),
|
|
command,
|
|
// Filled in by `probe` from the rust-toolchain file, not metadata.
|
|
toolchain_pin: None,
|
|
})
|
|
}
|
|
|
|
/// Minimal line-parse fallback for `Cargo.toml` (no TOML dependency): the
|
|
/// `key = value` pairs of the `[package]` section.
|
|
fn probe_cargo_toml(dir: &Path) -> Option<ProbeResult> {
|
|
let content = std::fs::read_to_string(dir.join("Cargo.toml")).ok()?;
|
|
let mut result = ProbeResult::default();
|
|
let mut in_package = 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_package = header.trim() == "package";
|
|
continue;
|
|
}
|
|
if !in_package {
|
|
continue;
|
|
}
|
|
let Some((key, value)) = line.split_once('=') else {
|
|
continue;
|
|
};
|
|
let value = value.trim().trim_matches('"').trim_matches('\'').trim();
|
|
match key.trim() {
|
|
"name" => result.name = Some(value.to_string()),
|
|
"version" => result.version = Some(value.to_string()),
|
|
"description" => result.description = Some(value.to_string()),
|
|
"homepage" => result.homepage = Some(value.to_string()),
|
|
"license" => result.license = Some(value.to_string()),
|
|
_ => {}
|
|
}
|
|
}
|
|
if result.name.is_none() && result.version.is_none() {
|
|
None
|
|
} else {
|
|
Some(result)
|
|
}
|
|
}
|
|
|
|
/// 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<String> {
|
|
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<String> {
|
|
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::*;
|
|
use crate::new::options::{License, SourceDir, TemplateId};
|
|
use serial_test::serial;
|
|
use tempfile::tempdir;
|
|
|
|
fn opts(source_dir: SourceDir) -> NewOptions {
|
|
NewOptions {
|
|
name: "mytool".into(),
|
|
template: TemplateId::RUST,
|
|
source_dir,
|
|
upstream_version: "0.1.0".into(),
|
|
revision: 1,
|
|
summary: "A tool".into(),
|
|
long_description: "A tool".into(),
|
|
homepage: None,
|
|
license: License::Mit,
|
|
command: "mytool".into(),
|
|
maintainer: ("Jane".into(), "jane@example.com".into()),
|
|
dist: "ubuntu".into(),
|
|
series: "resolute".into(),
|
|
release: false,
|
|
depends: Vec::new(),
|
|
source_format: crate::new::options::SourceFormat::Quilt,
|
|
orig: Some(crate::new::options::OrigOrigin::Snapshot),
|
|
git: true,
|
|
autopkgtest: false,
|
|
pkg_config: false,
|
|
watch: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rust_template_shape() {
|
|
let o = opts(SourceDir::Skeleton);
|
|
let template = super::super::get(TemplateId::RUST).unwrap();
|
|
|
|
assert_eq!(template.architecture(&o), "any");
|
|
assert_eq!(
|
|
template.build_depends(&o),
|
|
vec!["cargo:native".to_string(), "rustc:native".to_string()]
|
|
);
|
|
assert_eq!(template.rules_dh_line(), "dh $@");
|
|
assert!(template.debian(&o).is_empty());
|
|
|
|
// Skeleton (manifest data): Cargo.toml + src/main.rs, byte-exact.
|
|
let skeleton = template.skeleton(&o);
|
|
assert_eq!(skeleton.len(), 2);
|
|
let cargo_toml = skeleton
|
|
.iter()
|
|
.find(|f| f.path == "Cargo.toml")
|
|
.expect("Cargo.toml skeleton");
|
|
assert_eq!(
|
|
cargo_toml.contents,
|
|
"[package]\n\
|
|
name = \"mytool\"\n\
|
|
version = \"0.1.0\"\n\
|
|
edition = \"2021\"\n\
|
|
\n\
|
|
[dependencies]\n"
|
|
);
|
|
let main_rs = skeleton
|
|
.iter()
|
|
.find(|f| f.path == "src/main.rs")
|
|
.expect("src/main.rs skeleton");
|
|
assert_eq!(
|
|
main_rs.contents,
|
|
"// Placeholder for mytool, generated by `pkh new`.\n\
|
|
fn main() {\n\
|
|
\tprintln!(\"Hello from mytool!\");\n\
|
|
}\n"
|
|
);
|
|
|
|
// Fresh skeleton: no Cargo.lock, so no --locked flag anywhere.
|
|
let extra = template.rules_extra(&o);
|
|
assert!(extra.contains("override_dh_auto_build:\n\tcargo build --release --offline\n"));
|
|
assert!(
|
|
extra.contains("\tinstall -Dm755 target/release/mytool debian/mytool/usr/bin/mytool")
|
|
);
|
|
assert!(extra.contains("override_dh_auto_test:\n\tcargo test --release --offline\n"));
|
|
assert!(extra.contains("override_dh_auto_clean:\n\tcargo clean"));
|
|
assert!(extra.contains("override_dh_clean:"));
|
|
assert!(extra.contains("\tdh_clean -X .orig"));
|
|
assert!(!extra.contains("--locked"));
|
|
}
|
|
|
|
#[test]
|
|
fn rules_use_locked_only_with_lockfile() {
|
|
let template = super::super::get(TemplateId::RUST).unwrap();
|
|
|
|
let dir = tempdir().unwrap();
|
|
std::fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap();
|
|
let with_lock = opts(SourceDir::Path(dir.path().to_path_buf()));
|
|
assert!(!lockfile_present(&with_lock));
|
|
let extra = template.rules_extra(&with_lock);
|
|
assert!(!extra.contains("--locked"), "{extra}");
|
|
|
|
std::fs::write(dir.path().join("Cargo.lock"), "# generated\n").unwrap();
|
|
assert!(lockfile_present(&with_lock));
|
|
let extra = template.rules_extra(&with_lock);
|
|
assert!(extra.contains("\tcargo build --release --offline --locked\n"));
|
|
assert!(extra.contains("\tcargo test --release --offline --locked\n"));
|
|
// debhelper refreshing embedded autotools files (e.g. `-sys` crates
|
|
// shipping config.sub/config.guess) would break cargo's per-file
|
|
// vendored checksums — the override skips the refresh entirely.
|
|
assert!(extra.contains("override_dh_update_autotools_config:\n"));
|
|
}
|
|
|
|
/// Package names may carry `+`/`.` (legal dpkg, rejected by cargo): the
|
|
/// skeleton crate name is sanitized, and the install override picks the
|
|
/// crate-named artifact and installs it under the command name.
|
|
#[test]
|
|
fn skeleton_sanitizes_the_crate_name() {
|
|
let o = NewOptions {
|
|
name: "my.tool+".into(),
|
|
command: "mytool".into(),
|
|
..opts(SourceDir::Skeleton)
|
|
};
|
|
let template = super::super::get(TemplateId::RUST).unwrap();
|
|
|
|
let skeleton = template.skeleton(&o);
|
|
let cargo_toml = skeleton
|
|
.iter()
|
|
.find(|f| f.path == "Cargo.toml")
|
|
.expect("Cargo.toml skeleton");
|
|
assert_eq!(
|
|
cargo_toml.contents,
|
|
"[package]\n\
|
|
name = \"my_tool_\"\n\
|
|
version = \"0.1.0\"\n\
|
|
edition = \"2021\"\n\
|
|
\n\
|
|
[dependencies]\n"
|
|
);
|
|
|
|
let extra = template.rules_extra(&o);
|
|
assert!(
|
|
extra.contains(
|
|
"\tinstall -Dm755 target/release/my_tool_ debian/my.tool+/usr/bin/mytool\n"
|
|
),
|
|
"{extra}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn probe_reads_cargo_toml_lines() {
|
|
let template = super::super::get(TemplateId::RUST).unwrap();
|
|
|
|
let dir = tempdir().unwrap();
|
|
std::fs::write(
|
|
dir.path().join("Cargo.toml"),
|
|
"# comment\n\
|
|
[package]\n\
|
|
name = \"mytool\"\n\
|
|
version = \"2.3.4\"\n\
|
|
description = \"A cargo tool\"\n\
|
|
homepage = \"https://example.com/mytool\"\n\
|
|
license = \"MIT OR Apache-2.0\"\n\
|
|
\n\
|
|
[dependencies]\n\
|
|
serde = \"1\"\n",
|
|
)
|
|
.unwrap();
|
|
let probe = template.probe(dir.path()).expect("probe result");
|
|
assert_eq!(probe.name.as_deref(), Some("mytool"));
|
|
assert_eq!(probe.version.as_deref(), Some("2.3.4"));
|
|
assert_eq!(probe.description.as_deref(), Some("A cargo tool"));
|
|
assert_eq!(
|
|
probe.homepage.as_deref(),
|
|
Some("https://example.com/mytool")
|
|
);
|
|
assert_eq!(probe.license.as_deref(), Some("MIT OR Apache-2.0"));
|
|
|
|
// Only section headers and comments: nothing to report.
|
|
let dir = tempdir().unwrap();
|
|
std::fs::write(dir.path().join("Cargo.toml"), "[dependencies]\n").unwrap();
|
|
assert!(template.probe(dir.path()).is_none());
|
|
}
|
|
|
|
/// With host cargo available, `cargo metadata` wins and yields the bin
|
|
/// target as the command. (Without cargo on PATH the line-parse fallback
|
|
/// above is exercised.)
|
|
#[test]
|
|
fn probe_prefers_cargo_metadata() {
|
|
if find_on_path("cargo").is_none() {
|
|
// No cargo on this host: metadata probing is silent and the
|
|
// fallback applies (already covered by the line-parse test).
|
|
return;
|
|
}
|
|
let dir = tempdir().unwrap();
|
|
std::fs::write(
|
|
dir.path().join("Cargo.toml"),
|
|
"[package]\nname = \"metaprobe\"\nversion = \"0.9.0\"\nedition = \"2021\"\n",
|
|
)
|
|
.unwrap();
|
|
std::fs::create_dir_all(dir.path().join("src")).unwrap();
|
|
std::fs::write(dir.path().join("src/main.rs"), "fn main() {}\n").unwrap();
|
|
|
|
let template = super::super::get(TemplateId::RUST).unwrap();
|
|
let probe = template.probe(dir.path()).expect("probe result");
|
|
assert_eq!(probe.name.as_deref(), Some("metaprobe"));
|
|
assert_eq!(probe.version.as_deref(), Some("0.9.0"));
|
|
assert_eq!(probe.command.as_deref(), Some("metaprobe"));
|
|
}
|
|
|
|
/// The vendoring hook over a zero-dependency skeleton: offline config
|
|
/// 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);
|
|
let template = super::super::get(TemplateId::RUST).unwrap();
|
|
for file in template.skeleton(&o) {
|
|
let path = dir.path().join(&file.path);
|
|
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
std::fs::write(path, file.contents).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();
|
|
assert!(config.contains("[source.crates-io]"), "{config}");
|
|
assert!(config.contains("replace-with = \"vendored-sources\""));
|
|
assert!(config.contains("[net]\noffline = true"), "{config}");
|
|
}
|
|
// 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() {
|
|
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
|
|
/// (`<name> (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") },
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
}
|