//! Answers, validators and flag/default resolution for `pkh new`. //! //! [`NewCli`] carries the raw command-line answers (every flag optional), //! [`resolve`] merges them with the built-in defaults and the project //! detection (see [`crate::new::detect`]) into a fully-specified //! [`NewOptions`], erroring with the list of every missing required answer //! when running without an interactive wizard. use std::collections::HashSet; use std::path::{Path, PathBuf}; use regex::Regex; use crate::debian::DebianVersion; use crate::debian::deps::{Deps, ParseOpts}; use crate::distro_info; use crate::new::detect::{self, Detection}; use crate::new::licenses; use crate::new::origin::{Forge, GitOrigin}; use crate::new::templates; /// Build systems / project kinds `pkh new` knows about: a lightweight id /// wrapping the stable CLI string (the `--lang` value). /// /// The templates themselves are defined by the per-template manifests /// under `data/templates//` (see [`crate::new::templates`]): label, /// detection markers, policy metadata and file bodies all live there, and /// every id below must have a manifest registered — the registry is built /// from exactly these constants and a consistency test keeps the two in /// lockstep. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct TemplateId(&'static str); impl TemplateId { /// Rust project (`Cargo.toml`). pub const RUST: TemplateId = TemplateId("rust"); /// Python project (`pyproject.toml` / `setup.py` / `setup.cfg`). pub const PYTHON: TemplateId = TemplateId("python"); /// C/C++ with Meson (`meson.build`). pub const MESON: TemplateId = TemplateId("meson"); /// C/C++ with CMake (`CMakeLists.txt`). pub const CMAKE: TemplateId = TemplateId("cmake"); /// C/C++ with Autotools (`configure.ac`). pub const AUTOTOOLS: TemplateId = TemplateId("autotools"); /// Go module (`go.mod`). pub const GO: TemplateId = TemplateId("go"); /// Shell script / single interpreted file. pub const SHELL: TemplateId = TemplateId("shell"); /// Generic Makefile-based project. pub const MAKEFILE: TemplateId = TemplateId("makefile"); /// Metapackage / empty base (no build system). pub const EMPTY: TemplateId = TemplateId("empty"); /// Every template id, in the order offered by the wizard language menu /// (the registry order of the manifests). pub fn all() -> &'static [TemplateId] { templates::ids() } /// Canonical CLI identifier of this template. pub fn as_str(&self) -> &'static str { self.0 } /// Parse a CLI identifier, accepting exactly the registered spellings. pub fn parse(s: &str) -> Result { Self::all() .iter() .copied() .find(|id| id.as_str() == s) .ok_or_else(|| ValidationError::UnknownTemplate { id: s.to_string() }) } /// Human-readable menu label of this template, as offered by the wizard /// language question (and reused in the summary screen): the /// manifest's `label`. pub fn display_name(&self) -> &'static str { templates::get(*self).map(|t| t.label()).unwrap_or(self.0) } /// The template whose menu label (or CLI identifier) is `label`. pub fn from_label(label: &str) -> Option { templates::from_label(label) } } impl std::fmt::Display for TemplateId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.as_str()) } } /// Where the upstream sources come from, and where the package tree lives. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SourceDir { /// Generate a fresh project skeleton in `.//`. Skeleton, /// Package the sources already sitting in the current directory. Here, /// Package the sources of another directory. Path(PathBuf), } impl SourceDir { /// The directory being packaged, when it already exists on disk (`None` /// for the skeleton mode, whose sources are rendered in memory). pub fn existing_dir(&self, cwd: &Path) -> Option { match self { SourceDir::Skeleton => None, SourceDir::Here => Some(cwd.to_path_buf()), SourceDir::Path(path) => Some(path.clone()), } } } /// Debian source format of the scaffolded package: an explicit, derived /// choice rather than a boolean afterthought. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SourceFormat { /// `3.0 (native)`: no orig tarball at all. The default for a fresh, /// self-authored skeleton. Native, /// `3.0 (quilt)`: upstream sources in the orig tarball, packaging in /// `debian/`. The default when packaging an existing project. Quilt, } impl SourceFormat { /// The value written to `debian/source/format`. pub fn deb_string(&self) -> &'static str { match self { SourceFormat::Native => super::debian::SOURCE_FORMAT_NATIVE, SourceFormat::Quilt => super::debian::SOURCE_FORMAT_QUILT, } } } /// How the orig tarball of a quilt package is produced. The right answer /// depends on where the upstream sources come from, so the wizard offers /// them pre-ordered by what git origin detection found (release download /// and `git archive` only exist when HEAD sits exactly on a tag). #[derive(Debug, Clone, PartialEq, Eq)] pub enum OrigOrigin { /// Download the upstream release tarball of `tag` from the forge of the /// `origin` remote and repack it (explicit network consent). Release { /// Release tag the tarball is downloaded for. tag: String, /// Forge the tarball is downloaded from. forge: Forge, }, /// `git archive` the tag HEAD sits on: offline and deterministic. GitArchive { /// Release tag archived into the tarball. tag: String, }, /// Repack a tarball the user provides (a local path or an http(s) URL). Provided { /// Path or URL of the user-provided tarball. source: String, }, /// Snapshot the working tree into the orig tarball (the only option for /// a skeleton, and the fallback when the upstream history is unknown). Snapshot, } impl OrigOrigin { /// Human-readable description used in the wizard summary and the /// post-scaffold report. pub fn label(&self) -> String { match self { OrigOrigin::Release { tag, .. } => format!("release download ({tag})"), OrigOrigin::GitArchive { tag } => format!("git archive ({tag})"), OrigOrigin::Provided { source } => format!("user tarball ({source})"), OrigOrigin::Snapshot => "working tree snapshot".to_string(), } } } /// Upstream license of the package: a curated SPDX list plus a free-text /// fallback for anything else (including "unknown" until the user picks one). /// The curated identifiers, their accepted spellings and the menu labels /// come from the bundled license table (`data/licenses.yml`); this enum /// stays code because [`NewOptions`] and the template rendering match on /// its variants — a consistency test keeps the two in lockstep. #[derive(Debug, Clone, PartialEq, Eq)] pub enum License { /// MIT Mit, /// Apache-2.0 Apache2, /// GPL-2.0+ Gpl2Plus, /// GPL-3.0+ Gpl3Plus, /// LGPL-2.1+ Lgpl21Plus, /// LGPL-3.0+ Lgpl3Plus, /// BSD-2-Clause Bsd2Clause, /// BSD-3-Clause Bsd3Clause, /// ISC Isc, /// Any other license, spelled verbatim (also the fallback for /// unrecognized `--license` values). Custom(String), } impl License { /// Map a license string to a [`License`]: the curated SPDX identifiers /// of the bundled license table (`data/licenses.yml`, matched through /// their spellings, case-insensitively) become their variant, /// anything else becomes [`License::Custom`] verbatim (lowercased). pub fn parse(s: &str) -> License { match licenses::entry_for_spelling(s) { Some(entry) => Self::from_spdx(&entry.id) .expect("licenses.yml ids and License variants are kept in sync by a test"), None => License::Custom(s.to_ascii_lowercase()), } } /// The variant of a curated license's SPDX identifier — the inverse of /// [`License::spdx`] for the non-`Custom` variants, `None` for any /// other string. The ids accepted here are locked to the bundled /// license table by a consistency test. fn from_spdx(id: &str) -> Option { match id { "MIT" => Some(License::Mit), "Apache-2.0" => Some(License::Apache2), "GPL-2.0+" => Some(License::Gpl2Plus), "GPL-3.0+" => Some(License::Gpl3Plus), "LGPL-2.1+" => Some(License::Lgpl21Plus), "LGPL-3.0+" => Some(License::Lgpl3Plus), "BSD-2-Clause" => Some(License::Bsd2Clause), "BSD-3-Clause" => Some(License::Bsd3Clause), "ISC" => Some(License::Isc), _ => None, } } /// SPDX identifier used in `debian/copyright`. pub fn spdx(&self) -> &str { match self { License::Mit => "MIT", License::Apache2 => "Apache-2.0", License::Gpl2Plus => "GPL-2.0+", License::Gpl3Plus => "GPL-3.0+", License::Lgpl21Plus => "LGPL-2.1+", License::Lgpl3Plus => "LGPL-3.0+", License::Bsd2Clause => "BSD-2-Clause", License::Bsd3Clause => "BSD-3-Clause", License::Isc => "ISC", License::Custom(s) => s, } } /// SPDX license data page URL (without the trailing `+` of the /// "or later" spellings), for the copyright reference paragraph: the /// identifier substituted into the URL template of the bundled /// license table. pub fn spdx_url(&self) -> String { licenses::url_template().replace("{id}", self.spdx().trim_end_matches('+')) } } /// Fully-specified options of a `pkh new` run: the CLI answers after /// defaults resolution and validation. #[derive(Debug, Clone)] pub struct NewOptions { /// Source package name (validated). pub name: String, /// Template (language / build system) to scaffold. pub template: TemplateId, /// Where the upstream sources come from, and where the tree lives. pub source_dir: SourceDir, /// Upstream version (validated with [`DebianVersion`]). pub upstream_version: String, /// Debian revision, default 1. pub revision: u32, /// One-line description (the control `Description` synopsis). pub summary: String, /// Long description; defaults to the summary. pub long_description: String, /// Upstream homepage, when known. pub homepage: Option, /// Upstream license. pub license: License, /// Installed command name, default = name (ignored by the empty /// template). pub command: String, /// Maintainer (name, email). pub maintainer: (String, String), /// Target distribution (e.g. `ubuntu`, `debian`). pub dist: String, /// Target series: drives the printed next steps (and, with /// [`NewOptions::release`], the changelog distribution). pub series: String, /// Write `series` into the changelog instead of UNRELEASED. pub release: bool, /// Runtime Depends clauses of the metapackage flavor (canonically /// rendered; empty for every other flavor). pub depends: Vec, /// Debian source format: `3.0 (native)` for a fresh skeleton by /// default, `3.0 (quilt)` when packaging an existing project; either /// can be forced with `--native` / `--quilt`. pub source_format: SourceFormat, /// How the orig tarball is produced (quilt only; `None` with the native /// format, which has no orig tarball). pub orig: Option, /// Initialize a git repository (gitignores are written regardless). pub git: bool, /// Write the autopkgtest smoke test (`debian/tests/control` + /// `debian/tests/smoke`) running ` --help`/`--version`. /// Wizard-only extra, off by default. pub autopkgtest: bool, /// Add `pkg-config` to Build-Depends (the meson/cmake opt-in question; /// most such projects resolve their dependencies through it). /// Wizard-only extra, off by default. pub pkg_config: bool, /// Contents of a `debian/watch` release watcher (GitHub/GitLab tarball /// template). Wizard-only extra, off by default. pub watch: Option, } impl NewOptions { /// Directory the package tree lives in: `.//` for the skeleton /// mode, the given directory otherwise. pub fn target_dir(&self, cwd: &Path) -> PathBuf { match &self.source_dir { SourceDir::Skeleton => cwd.join(&self.name), SourceDir::Here => cwd.to_path_buf(), SourceDir::Path(p) => p.clone(), } } /// Full Debian version of the initial entry: `-`. pub fn full_version(&self) -> String { format!("{}-{}", self.upstream_version, self.revision) } /// Upstream version as used in artifact file names (dpkg drops the /// epoch from file names). pub fn upstream_version_no_epoch(&self) -> String { DebianVersion::parse(&self.full_version()) .map(|v| v.upstream) .unwrap_or_else(|_| self.upstream_version.clone()) } } /// Raw command-line answers of `pkh new`, before defaults resolution. /// /// Every field is optional so the interactive wizard (follow-up work) can /// fill the same structure from its questions. #[derive(Debug, Clone, Default)] pub struct NewCli { /// Positional ``. pub name: Option, /// `--lang` value (parsed by [`resolve`]). pub lang: Option, /// `--source `. pub source: Option, /// `--upstream-version `. pub upstream_version: Option, /// `--revision `. pub revision: Option, /// `--description `. pub description: Option, /// `--homepage `. pub homepage: Option, /// `--license `. pub license: Option, /// `--command `. pub command: Option, /// `--maintainer "Name "`. pub maintainer: Option, /// `--depends` values; each entry may itself be a comma-separated list. pub depends: Vec, /// `--dist `. pub dist: Option, /// `--series `. pub series: Option, /// `--release`. pub release: bool, /// `--native`. pub native: bool, /// `--quilt` (mutually exclusive with `--native`). pub quilt: bool, /// `--orig-from `. pub orig_from: Option, /// `--orig-path ` (required by `--orig-from path`). pub orig_path: Option, /// True unless `--no-git`. pub git: bool, /// `--defaults`. pub defaults: bool, } /// Failure of one of the scaffold option validators ([`validate_source_name`], /// [`validate_command`], [`validate_upstream_version`], [`parse_maintainer`], /// [`validate_homepage`], [`validate_depends`], [`validate_orig_path`], /// [`check_file_collisions`], [`TemplateId::parse`]): what the wizard re-asks /// about, carrying the pieces callers may want to match on. #[derive(Debug, thiserror::Error)] pub enum ValidationError { /// The source package name is shorter than dpkg's 2-character minimum. #[error("'{name}' is too short: package names need at least 2 characters")] SourceNameTooShort { /// The rejected name. name: String, }, /// The source package name is outside dpkg's charset or shape. #[error( "'{}' is not a valid package name: names must be lowercase, start with a letter or digit and may only contain [a-z0-9+.-]", name )] InvalidSourceName { /// The rejected name. name: String, }, /// No installed command was given. #[error("the command name must not be empty")] EmptyCommand, /// The command name is not a lowercase identifier. #[error( "'{}' is not a valid command name: commands must be a lowercase identifier (letters, digits, + - . _), e.g. 'mytool'", command )] InvalidCommand { /// The rejected command name. command: String, }, /// Upstream versions should start with a digit (dpkg recommendation, /// enforced here). #[error("upstream versions should start with a digit")] UpstreamNotDigit, /// `-` is the Debian revision separator. #[error( "upstream versions must not contain '-': the Debian revision is appended automatically" )] UpstreamContainsDash, /// The upstream version composed with the Debian revision does not /// parse as a Debian version. #[error("'{version}' is not a valid Debian version: {source}")] InvalidVersion { /// The composed `upstream-revision` string. version: String, /// The version parse failure. source: crate::debian::version::VersionParseError, }, /// The maintainer is not of the form `Name `. #[error("'{s}' is not a valid maintainer: expected the form 'Name '")] MaintainerShape { /// The rejected maintainer string. s: String, }, /// The maintainer name (before the angle brackets) is empty. #[error("'{s}': the maintainer name must not be empty")] MaintainerNameEmpty { /// The rejected maintainer string. s: String, }, /// The maintainer email does not carry exactly one `@`. #[error("'{s}': the maintainer email must contain exactly one '@'")] MaintainerEmailAt { /// The rejected maintainer string. s: String, }, /// The maintainer email carries whitespace. #[error("'{s}': the maintainer email must not contain whitespace")] MaintainerEmailWhitespace { /// The rejected maintainer string. s: String, }, /// The homepage is not an http(s) URL. #[error("'{url}' is not a valid homepage: expected an http:// or https:// URL")] InvalidHomepage { /// The rejected URL. url: String, }, /// The Depends list does not parse as package relations. #[error("Invalid Depends list '{list}': {source}")] InvalidDepends { /// The rejected list. list: String, /// The relation parse failure. source: crate::debian::deps::ParseError, }, /// The orig tarball is neither an http(s) URL nor an existing file. #[error( "'{path}' is not an http(s) URL and not an existing file: --orig-path must point at a tarball to repack" )] InvalidOrigPath { /// The rejected path or URL. path: String, }, /// The language/template id is not one of the registered spellings. #[error("Unknown language/template '{}'. Supported values are: {}.", id, TemplateId::all().iter().map(|id| id.as_str()).collect::>().join(", "))] UnknownTemplate { /// The unknown id. id: String, }, /// A template generated the same file twice. #[error("internal error: file '{path}' was generated more than once")] GeneratedTwice { /// The duplicated path. path: String, }, } /// Validate a Debian source package name: `^[a-z0-9][a-z0-9+.\-]+$` with a /// minimum length of 2 (dpkg rules). pub fn validate_source_name(name: &str) -> Result<(), ValidationError> { static NAME_REGEX: std::sync::OnceLock = std::sync::OnceLock::new(); let regex = NAME_REGEX.get_or_init(|| Regex::new(r"^[a-z0-9][a-z0-9+.\-]+$").unwrap()); if name.len() < 2 { return Err(ValidationError::SourceNameTooShort { name: name.to_string(), }); } if !regex.is_match(name) { return Err(ValidationError::InvalidSourceName { name: name.to_string(), }); } Ok(()) } /// Try to turn an arbitrary string (e.g. a directory name like `My Tool`) /// into a valid package name: lowercased, whitespace/underscores turned into /// `-`, leading/trailing `-` trimmed and repeated `-` collapsed. `None` when /// nothing valid remains. pub fn sanitize_name(input: &str) -> Option { let mut out = String::with_capacity(input.len()); let mut last_dash = false; for c in input.chars() { let c = c.to_ascii_lowercase(); if c.is_ascii_lowercase() || c.is_ascii_digit() { out.push(c); last_dash = false; } else if c == '.' || c == '+' { // Dpkg-valid characters kept verbatim. out.push(c); last_dash = false; } else if c.is_whitespace() || c == '_' || c == '-' { // Collapse whitespace/underscore runs into a single dash. if !out.is_empty() && !last_dash { out.push('-'); last_dash = true; } } // Characters outside the dpkg name charset (everything but // [a-z0-9+.-]) are dropped: a directory name is not a controlled // input, so stay conservative there. } while out.ends_with('-') { out.pop(); } while out.starts_with('-') { out.remove(0); } // A leading digit would be valid for dpkg but a name made only of // digits/dots is rejected by the validator below anyway. validate_source_name(&out).ok()?; Some(out) } /// Validate the installed command (binary) name: a non-empty, ASCII-only, /// lowercase identifier (`^[a-z0-9][a-z0-9+.\-_]*$`), matching what /// dpkg/devscripts accept for executable names in practice. The name is /// interpolated verbatim into `debian/install`, `debian/rules`, the /// autopkgtest smoke test, automake variables (`{command}_SOURCES`), /// `meson.build` and the `[project.scripts]` table, so uppercase letters, /// whitespace, quotes and shell metacharacters are rejected here instead of /// generating broken install lines and build files. pub fn validate_command(command: &str) -> Result<(), ValidationError> { static COMMAND_REGEX: std::sync::OnceLock = std::sync::OnceLock::new(); let regex = COMMAND_REGEX.get_or_init(|| Regex::new(r"^[a-z0-9][a-z0-9+.\-_]*$").unwrap()); if command.is_empty() { return Err(ValidationError::EmptyCommand); } if !regex.is_match(command) { return Err(ValidationError::InvalidCommand { command: command.to_string(), }); } Ok(()) } /// Validate an upstream version: it must start with a digit (dpkg /// recommendation, enforced here) and survive [`DebianVersion::parse`] once /// composed with the Debian revision. It must not contain `-` (the revision /// separator). pub fn validate_upstream_version(upstream: &str, revision: u32) -> Result<(), ValidationError> { if !upstream.starts_with(|c: char| c.is_ascii_digit()) { return Err(ValidationError::UpstreamNotDigit); } if upstream.contains('-') { return Err(ValidationError::UpstreamContainsDash); } let composed = format!("{}-{}", upstream, revision); DebianVersion::parse(&composed) .map(|_| ()) .map_err(|source| ValidationError::InvalidVersion { version: composed.clone(), source, }) } /// Parse a `Name ` maintainer string: the name must be non-empty and /// the email must contain exactly one `@` with no whitespace inside the /// angle brackets. pub fn parse_maintainer(s: &str) -> Result<(String, String), ValidationError> { static MAINTAINER_REGEX: std::sync::OnceLock = std::sync::OnceLock::new(); let regex = MAINTAINER_REGEX.get_or_init(|| Regex::new(r"^(.+?)\s*<([^<>]*)>\s*$").unwrap()); let caps = regex .captures(s.trim()) .ok_or_else(|| ValidationError::MaintainerShape { s: s.to_string() })?; let name = caps[1].trim().to_string(); let email = caps[2].trim().to_string(); if name.is_empty() { return Err(ValidationError::MaintainerNameEmpty { s: s.to_string() }); } if !email.contains('@') || email.matches('@').count() > 1 { return Err(ValidationError::MaintainerEmailAt { s: s.to_string() }); } if email.chars().any(char::is_whitespace) { return Err(ValidationError::MaintainerEmailWhitespace { s: s.to_string() }); } Ok((name, email)) } /// Validate a homepage URL: it must carry an `http(s)://` prefix. pub fn validate_homepage(url: &str) -> Result<(), ValidationError> { if url.starts_with("http://") || url.starts_with("https://") { Ok(()) } else { Err(ValidationError::InvalidHomepage { url: url.to_string(), }) } } /// Validate a comma-separated runtime Depends list (the metapackage flavor) /// by parsing it with [`Deps::parse`] and re-rendering every clause /// canonically. pub fn validate_depends(list: &str) -> Result, ValidationError> { let opts = ParseOpts { host_arch: crate::get_current_arch(), build_arch: crate::get_current_arch(), build_profiles: Vec::new(), reduce_restrictions: false, union: false, build_dep: false, }; let deps = Deps::parse(list, &opts).map_err(|source| ValidationError::InvalidDepends { list: list.to_string(), source, })?; Ok(deps .clauses() .map(|clause| { clause .iter() .map(|rel| rel.output()) .collect::>() .join(" | ") }) .collect()) } /// Resolve the CLI answers into fully-specified [`NewOptions`]: explicit /// flags win, then detection (when `--lang` is not given), then defaults. /// /// Missing required answers (name, template, description) are all reported /// in a single error so non-interactive callers can fix everything at once. pub async fn resolve(cli: NewCli) -> Result { let mut missing: Vec = Vec::new(); // Where do the sources come from? let source_dir = match &cli.source { Some(p) => SourceDir::Path(p.clone()), None if cli.name.is_some() => SourceDir::Skeleton, None => SourceDir::Here, }; // Name: positional argument, else (with --defaults) the sanitized // basename of the current directory. let name = match &cli.name { Some(n) => n.clone(), None if cli.defaults => { let cwd = std::env::current_dir() .map_err(|e| format!("Could not determine the current directory: {e}"))?; let base = cwd .file_name() .and_then(|s| s.to_str()) .ok_or_else(|| "Could not derive a package name from the current directory; pass a name argument".to_string())?; sanitize_name(base).ok_or_else(|| { format!( "Cannot derive a valid package name from the directory name \ '{base}'; pass a name argument" ) })? } None => { missing.push( "package name (pass it as a positional argument, or use \ --defaults to derive it from the current directory name)" .to_string(), ); String::new() } }; // Template: --lang wins, else project detection in the source directory // (nothing to detect for a fresh skeleton), else --defaults picks the // empty template. let template = match &cli.lang { Some(lang) => Some(TemplateId::parse(lang).map_err(|e| e.to_string())?), None => match &source_dir { SourceDir::Skeleton => { if cli.defaults { log::info!("No language given, --defaults picks the 'empty' template"); Some(TemplateId::EMPTY) } else { missing.push(format!( "--lang <{}|...> (no language given and there is nothing \ to detect for a new project skeleton)", TemplateId::all() .iter() .map(|id| id.as_str()) .collect::>() .join("|") )); None } } other => { let dir = match other { SourceDir::Here => std::env::current_dir() .map_err(|e| format!("Could not determine the current directory: {e}"))?, SourceDir::Path(p) => p.clone(), SourceDir::Skeleton => unreachable!("handled above"), }; match detect::detect(&dir) { Detection::Single(id) => { log::info!("Detected: {} project in {}", id, dir.display()); Some(id) } Detection::Ambiguous(candidates) => { return Err(format!( "Ambiguous project detection in '{}': multiple build \ systems found ({}). Pass --lang explicitly.", dir.display(), candidates .iter() .map(|id| id.as_str()) .collect::>() .join(", ") )); } Detection::Empty => { if cli.defaults { log::info!( "No recognized project in {}, --defaults picks \ the 'empty' template", dir.display() ); Some(TemplateId::EMPTY) } else { missing.push( "--lang (could not detect a build system; \ run inside a project directory or pick one)" .to_string(), ); None } } } } }, }; // One-line description: required without the wizard. The long // description defaults to the summary. let long_description = cli.description.clone().unwrap_or_default(); let summary = match &cli.description { Some(d) => d.clone(), None => { missing.push("--description ".to_string()); String::new() } }; if !missing.is_empty() { return Err(format!( "Missing required answers (use --defaults to take every default, \ or re-run pkh new on an interactive terminal to answer the \ wizard):\n - {}", missing.join("\n - ") )); } // Everything below has a default and is validated as it is resolved. validate_source_name(&name).map_err(|e| e.to_string())?; // Source format: explicit flags win (--native / --quilt, mutually // exclusive), otherwise the mode decides — a fresh, self-authored // skeleton is native by default, an existing project is quilt. let source_format = match (cli.native, cli.quilt) { (true, true) => { return Err("--native and --quilt are mutually exclusive".to_string()); } (true, false) => SourceFormat::Native, (false, true) => SourceFormat::Quilt, (false, false) => match source_dir { SourceDir::Skeleton => SourceFormat::Native, SourceDir::Here | SourceDir::Path(_) => SourceFormat::Quilt, }, }; // Git origin state of the packaged directory: needed for the version // default (tag / +git scheme) and for the orig-tarball plan. Probing is // purely local (git config and refs, no network). let existing_dir = source_dir.existing_dir(&std::env::current_dir().unwrap_or_default()); let needs_origin = cli.upstream_version.is_none() || (source_format == SourceFormat::Quilt && (cli.orig_from.is_some() || existing_dir.is_some())); let origin = if needs_origin { existing_dir.as_deref().and_then(GitOrigin::detect) } else { None }; // Upstream version default: probed project version, else the tag HEAD // sits on, else `+git.`, else 0.1.0. let upstream_version = match cli.upstream_version { Some(ref version) => version.clone(), None => { let probed = template .and_then(templates::get) .and_then(|t| t.probe(existing_dir.as_deref()?)) .and_then(|probe| probe.version) .filter(|version| { validate_upstream_version(version, cli.revision.unwrap_or(1)).is_ok() }); let derived = probed .or_else(|| origin.as_ref().and_then(GitOrigin::head_tag_version)) .or_else(|| origin.as_ref().and_then(GitOrigin::git_version)) .unwrap_or_else(|| "0.1.0".to_string()); if derived != "0.1.0" { log::info!("No --upstream-version given, defaulting to '{derived}'"); } derived } }; let revision = cli.revision.unwrap_or(1); validate_upstream_version(&upstream_version, revision) .map_err(|e| format!("Invalid upstream version: {e}"))?; // Orig-tarball plan (quilt only). Without an explicit --orig-from the // non-interactive default never touches the network: `git archive` of // the tag when HEAD sits on one, a working-tree snapshot otherwise. let orig = resolve_orig_origin(&cli, &source_dir, source_format, origin.as_ref())?; let homepage = match &cli.homepage { Some(h) => { validate_homepage(h).map_err(|e| e.to_string())?; Some(h.clone()) } None => None, }; let license = License::parse(cli.license.as_deref().unwrap_or("unknown")); let command = cli.command.unwrap_or_else(|| name.clone()); // The default is the already-validated package name (a subset of the // command charset), so only an explicit --command/wizard answer can fail. validate_command(&command).map_err(|e| e.to_string())?; let maintainer = match &cli.maintainer { Some(m) => parse_maintainer(m).map_err(|e| e.to_string())?, None => crate::changelog::get_maintainer_info().map_err(|e| { format!( "Could not determine the maintainer ({e}). \ Pass --maintainer \"Name \"." ) })?, }; let dist = cli .dist .unwrap_or_else(|| crate::build::env::current_vendor().to_lowercase()); let series = match &cli.series { Some(s) => s.clone(), None if cli.release => { return Err( "--release requires --series: there is no series to release to".to_string(), ); } None => distro_info::effective_series(distro_info::UNRELEASED, &dist) .await .map_err(|e| { format!("Could not resolve the default target series for '{dist}': {e}") })?, }; // The metapackage Depends list: comma-joined, parsed and re-rendered // canonically. let joined = cli.depends.join(", "); let depends = if joined.trim().is_empty() { Vec::new() } else { validate_depends(&joined).map_err(|e| e.to_string())? }; Ok(NewOptions { name, template: template.unwrap_or(TemplateId::EMPTY), source_dir, upstream_version, revision, summary, long_description, homepage, license, command, maintainer, dist, series, release: cli.release, depends, source_format, orig, git: cli.git, autopkgtest: false, pkg_config: false, watch: None, }) } /// Resolve the orig-tarball plan of a quilt scaffold from the CLI answers /// and the detected git origin. Fails early with a pointed message when an /// explicit `--orig-from` mode cannot apply to this tree; the implicit, /// non-interactive default never touches the network (`git archive` of the /// tag when HEAD sits exactly on one, a working-tree snapshot otherwise). fn resolve_orig_origin( cli: &NewCli, source_dir: &SourceDir, source_format: SourceFormat, origin: Option<&GitOrigin>, ) -> Result, String> { if source_format == SourceFormat::Native { if cli.orig_from.is_some() || cli.orig_path.is_some() { return Err( "--orig-from/--orig-path need a quilt package (drop --native \ or pass --quilt to build with an orig tarball)" .to_string(), ); } return Ok(None); } let skeleton = matches!(source_dir, SourceDir::Skeleton); let head_tag = || origin.and_then(|o| o.head_tag.clone()); let plan = match cli.orig_from.as_deref() { Some("snapshot") => OrigOrigin::Snapshot, Some("path") => { let source = cli.orig_path.clone().ok_or_else(|| { "--orig-from path requires --orig-path ".to_string() })?; validate_orig_path(&source).map_err(|e| e.to_string())?; OrigOrigin::Provided { source } } Some("git") => { if skeleton { return Err("a fresh skeleton has no upstream git history; use \ --orig-from snapshot (or drop --quilt)" .to_string()); } match head_tag() { Some(tag) => OrigOrigin::GitArchive { tag }, None => { return Err("--orig-from git requires HEAD to be exactly on a \ release tag" .to_string()); } } } Some("release") => { if skeleton { return Err("a fresh skeleton has no upstream release to download; \ use --orig-from snapshot (or drop --quilt)" .to_string()); } let Some(tag) = head_tag() else { return Err("--orig-from release requires HEAD to be exactly on a \ release tag" .to_string()); }; let forge = origin.and_then(|o| o.forge.clone()).ok_or_else(|| { "--orig-from release needs a github.com or gitlab.com origin \ remote (self-hosted forges are not recognized)" .to_string() })?; OrigOrigin::Release { tag, forge } } Some(other) => { return Err(format!( "Unknown --orig-from value '{other}'. Supported: release, \ git, path, snapshot." )); } None => { // Skeletons (and anything without an upstream history) can only // be snapshotted; on a tag, git archive is the offline default. match (skeleton, head_tag()) { (false, Some(tag)) => OrigOrigin::GitArchive { tag }, _ => OrigOrigin::Snapshot, } } }; if cli.orig_path.is_some() && !matches!(plan, OrigOrigin::Provided { .. }) { return Err("--orig-path is only valid together with --orig-from path".to_string()); } Ok(Some(plan)) } /// Validate a `--orig-path` value (and the wizard's tarball answer): either /// an http(s) URL (downloaded at scaffold time) or an existing local /// tarball. pub fn validate_orig_path(source: &str) -> Result<(), ValidationError> { if source.starts_with("http://") || source.starts_with("https://") { return Ok(()); } if Path::new(source).is_file() { return Ok(()); } Err(ValidationError::InvalidOrigPath { path: source.to_string(), }) } /// Set of names no template may produce twice (collision check). pub(crate) fn check_file_collisions(paths: &[String]) -> Result<(), ValidationError> { let mut seen: HashSet<&String> = HashSet::new(); for path in paths { if !seen.insert(path) { return Err(ValidationError::GeneratedTwice { path: path.to_string(), }); } } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn template_ids_roundtrip() { for id in TemplateId::all().iter().copied() { assert_eq!(TemplateId::parse(id.as_str()).unwrap(), id); // Every id resolves from its menu label too, and labels are // unique. assert_eq!(TemplateId::from_label(id.display_name()), Some(id)); } assert!(TemplateId::parse("cobol").is_err()); assert_eq!( TemplateId::from_label("Rust (Cargo.toml)"), Some(TemplateId::RUST) ); assert_eq!(TemplateId::from_label("rust"), Some(TemplateId::RUST)); assert_eq!(TemplateId::from_label("nope"), None); } #[test] fn source_name_validator() { for valid in ["mytool", "my-tool", "my.tool", "my+tool", "a1", "pkh9x"] { assert!(validate_source_name(valid).is_ok(), "{valid} must pass"); } for invalid in [ "A", "a", // too short "UPPER", "Mixed", // uppercase "-lead", // bad first char "sp ace", "under_score", "bang!", "", "é", // bad charset ] { assert!( validate_source_name(invalid).is_err(), "{invalid} must fail" ); } } /// The command name is interpolated verbatim into debian/install, /// debian/rules, the smoke test, automake variables, meson.build and /// [project.scripts], so only the safe identifier charset passes. #[test] fn command_validator() { for valid in [ "mytool", "my.tool", // automake/TOML-friendly spellings in use in Debian "my+tool", "my_tool", "my-tool", "2ping", // leading digit "a", // single character "a1.b+c-d_e", ] { assert!(validate_command(valid).is_ok(), "{valid} must pass"); } for invalid in [ "my tool", // whitespace breaks install lines "a\"b", // quotes break shell snippets "MyTool", // uppercase "-lead", // bad first char "_lead", // bad first char ".dot", // bad first char "café", // non-ASCII "a:b", // shell metacharacter "a/b", // path separator "", // empty ] { assert!(validate_command(invalid).is_err(), "{invalid} must fail"); } } #[test] fn sanitize_name_derives_valid_names() { assert_eq!(sanitize_name("My Tool"), Some("my-tool".to_string())); assert_eq!(sanitize_name("My_Tool"), Some("my-tool".to_string())); assert_eq!( sanitize_name(" spaced out "), Some("spaced-out".to_string()) ); assert_eq!( sanitize_name("trailing---dashes--"), Some("trailing-dashes".to_string()) ); assert_eq!(sanitize_name("v1.2_beta"), Some("v1.2-beta".to_string())); // Nothing sane remains. assert_eq!(sanitize_name("---"), None); assert_eq!(sanitize_name("A"), None); assert_eq!(sanitize_name(""), None); } #[test] fn upstream_version_validator() { assert!(validate_upstream_version("0.1.0", 1).is_ok()); assert!(validate_upstream_version("1.0~rc1", 1).is_ok()); assert!(validate_upstream_version("20260101", 1).is_ok()); // Not starting with a digit. assert!(validate_upstream_version("v1.0", 1).is_err()); // Revision separator inside the upstream version. assert!(validate_upstream_version("1.0-2", 1).is_err()); // Invalid characters survive composition. assert!(validate_upstream_version("1.0_0", 1).is_err()); // Epochs are accepted and compose fine. assert!(validate_upstream_version("1:2.0", 1).is_ok()); // The full version round-trips through DebianVersion. let opts = NewOptions { name: "t".into(), template: TemplateId::EMPTY, source_dir: SourceDir::Here, upstream_version: "0.1.0".into(), revision: 1, summary: "s".into(), long_description: "s".into(), homepage: None, license: License::Mit, command: "t".into(), maintainer: ("A".into(), "a@b.c".into()), dist: "debian".into(), series: "sid".into(), release: false, depends: Vec::new(), source_format: SourceFormat::Quilt, orig: None, git: false, autopkgtest: false, pkg_config: false, watch: None, }; assert_eq!(opts.full_version(), "0.1.0-1"); assert_eq!( DebianVersion::parse(&opts.full_version()).unwrap().full(), "0.1.0-1" ); // Epochs never leak into file names. let epochy = NewOptions { upstream_version: "1:2.0".into(), ..opts.clone() }; assert_eq!(epochy.upstream_version_no_epoch(), "2.0"); assert_eq!( DebianVersion::parse(&epochy.full_version()).unwrap().epoch, Some(1) ); } #[test] fn maintainer_parser() { assert_eq!( parse_maintainer("Jane Doe ").unwrap(), ("Jane Doe".to_string(), "jane@example.com".to_string()) ); assert_eq!( parse_maintainer(" X ").unwrap(), ("X".to_string(), "x@y.z".to_string()) ); // Bad forms. assert!(parse_maintainer("Jane Doe").is_err()); // no email assert!(parse_maintainer("").is_err()); // no name assert!(parse_maintainer("Jane ").is_err()); // no @ assert!(parse_maintainer("Jane ").is_err()); // two @s assert!(parse_maintainer("Jane ").is_err()); // space assert!(parse_maintainer("Jane <>").is_err()); // empty email } #[test] fn homepage_validator() { assert!(validate_homepage("https://example.com").is_ok()); assert!(validate_homepage("http://example.com/x").is_ok()); assert!(validate_homepage("ftp://example.com").is_err()); assert!(validate_homepage("example.com").is_err()); assert!(validate_homepage("").is_err()); } #[test] fn depends_parse_and_canonical_rerender() { let clauses = validate_depends("foo (>= 1.0), bar").unwrap(); assert_eq!(clauses, vec!["foo (>= 1.0)", "bar"]); // Canonicalization: legacy relations and odd spacing normalize. let clauses = validate_depends("a(> 1), b | c").unwrap(); assert_eq!(clauses, vec!["a (>= 1)", "b | c"]); // Invalid clauses are rejected. assert!(validate_depends("foo (>= ), bar").is_err()); assert!(validate_depends("a, ,@!").is_err()); // Empty list. assert!(validate_depends("").unwrap().is_empty()); } #[test] fn license_parsing() { assert_eq!(License::parse("MIT"), License::Mit); assert_eq!(License::parse("mit"), License::Mit); assert_eq!(License::parse("GPL-3.0+"), License::Gpl3Plus); assert_eq!(License::parse("bsd-3-clause"), License::Bsd3Clause); assert_eq!(License::parse("Zlib"), License::Custom("zlib".to_string())); assert_eq!(License::Mit.spdx(), "MIT"); assert_eq!(License::Gpl2Plus.spdx(), "GPL-2.0+"); assert_eq!( License::Gpl2Plus.spdx_url(), "https://spdx.org/licenses/GPL-2.0.html" ); } /// The bundled license table and the `License` enum are two halves of /// one list and must never drift apart: every entry id must map to a /// variant (and the entry must accept its own id as a spelling, so /// the menu answers parse back), every variant must have an entry, /// and the counts must agree. The hardcoded variant list here is the /// lock: adding a variant or a YAML entry without the other half /// fails this test. #[test] fn licenses_table_and_enum_are_in_sync() { let variants = [ License::Mit, License::Apache2, License::Gpl2Plus, License::Gpl3Plus, License::Lgpl21Plus, License::Lgpl3Plus, License::Bsd2Clause, License::Bsd3Clause, License::Isc, ]; assert_eq!( licenses::entries().len(), variants.len(), "licenses.yml and the License enum carry a different number of licenses" ); for variant in variants { let entry = licenses::entries() .iter() .find(|entry| entry.id == variant.spdx()) .unwrap_or_else(|| panic!("no licenses.yml entry for '{}'", variant.spdx())); assert_eq!(entry.id, variant.spdx()); assert!( entry .spellings .iter() .any(|s| s.eq_ignore_ascii_case(&entry.id)), "entry '{}' must accept its own id as a spelling", entry.id ); // The menu label parses back into the same variant. assert_eq!(License::parse(&entry.menu), variant); } for entry in licenses::entries() { let variant = License::from_spdx(&entry.id).unwrap_or_else(|| { panic!("licenses.yml entry '{}' has no License variant", entry.id) }); assert_eq!(variant.spdx(), entry.id); } } #[test] fn collision_detection() { assert!(check_file_collisions(&["a".into(), "b".into()]).is_ok()); let err = check_file_collisions(&["a".into(), "b".into(), "a".into()]) .unwrap_err() .to_string(); assert!(err.contains('a')); } #[tokio::test] async fn resolve_reports_every_missing_answer() { // --source an empty directory: detection finds nothing, so the // language joins the missing answers (and all of them are listed // at once). let dir = tempfile::tempdir().unwrap(); let cli = NewCli { source: Some(dir.path().to_path_buf()), ..Default::default() }; let err = resolve(cli).await.unwrap_err(); assert!(err.contains("package name"), "{err}"); assert!(err.contains("--lang"), "{err}"); assert!(err.contains("--description"), "{err}"); } #[tokio::test] async fn resolve_defaults_fill_everything_but_description() { // With --defaults the language defaults to 'empty' when detection // finds nothing; the description stays required without a wizard. let dir = tempfile::tempdir().unwrap(); let cli = NewCli { name: Some("mytool".into()), source: Some(dir.path().to_path_buf()), defaults: true, git: true, ..Default::default() }; let err = resolve(cli).await.unwrap_err(); assert!(err.contains("--description"), "{err}"); assert!(!err.contains("--lang"), "{err}"); } #[tokio::test] async fn resolve_merges_flags_defaults_and_detection() { // Detection against an empty --source directory with --defaults // picks the empty template. let dir = tempfile::tempdir().unwrap(); let cli = NewCli { name: Some("my-tool".into()), source: Some(dir.path().to_path_buf()), defaults: true, description: Some("Does things".into()), depends: vec!["hello (>= 1.0), hello-data".into()], git: true, ..Default::default() }; let opts = resolve(cli).await.unwrap(); assert_eq!(opts.name, "my-tool"); assert_eq!(opts.template, TemplateId::EMPTY); assert!(matches!(opts.source_dir, SourceDir::Path(_))); assert_eq!(opts.upstream_version, "0.1.0"); assert_eq!(opts.revision, 1); assert_eq!(opts.summary, "Does things"); assert_eq!(opts.long_description, "Does things"); assert_eq!(opts.homepage, None); assert_eq!(opts.license, License::Custom("unknown".into())); assert_eq!(opts.command, "my-tool"); assert_eq!(opts.depends, vec!["hello (>= 1.0)", "hello-data"]); assert!(opts.git); assert!(!opts.release); // An existing project (Path mode) is quilt by default; an empty, // non-git directory has no better orig plan than a tree snapshot. assert_eq!(opts.source_format, SourceFormat::Quilt); assert_eq!(opts.orig, Some(OrigOrigin::Snapshot)); // Series: the development series of the current vendor (lowercased, // matching the distro-info keys). let dist = crate::build::env::current_vendor().to_lowercase(); assert_eq!(opts.dist, dist); assert_eq!( opts.series, crate::distro_info::effective_series(crate::distro_info::UNRELEASED, &dist) .await .unwrap() ); } #[tokio::test] async fn resolve_detects_the_project_in_source_dir() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("go.mod"), "module example.com/x\n").unwrap(); let cli = NewCli { name: Some("xtool".into()), source: Some(dir.path().to_path_buf()), description: Some("An X".into()), maintainer: Some("Jane ".into()), git: true, ..Default::default() }; let opts = resolve(cli).await.unwrap(); assert_eq!(opts.template, TemplateId::GO); } #[tokio::test] async fn release_requires_series() { let cli = NewCli { name: Some("mytool".into()), lang: Some("empty".into()), description: Some("A tool".into()), release: true, maintainer: Some("Jane ".into()), git: true, ..Default::default() }; let err = resolve(cli).await.unwrap_err(); assert!(err.contains("--release requires --series"), "{err}"); } /// Minimal CLI answers for a scripted source directory. fn cli_for(dir: &std::path::Path) -> NewCli { NewCli { name: Some("mytool".into()), source: Some(dir.to_path_buf()), lang: Some("empty".into()), description: Some("A tool".into()), maintainer: Some("Jane ".into()), git: true, ..Default::default() } } /// A git repo fixture: init, commit, optional tag, optional extra /// commit (needs the git CLI; returns (repo dir, HEAD short hash)). fn git_repo(tag: Option<&str>, second_commit: bool) -> (tempfile::TempDir, String) { let run = |dir: &std::path::Path, args: &[&str]| { let status = std::process::Command::new("git") .args([ "-c", "user.name=T", "-c", "user.email=t@example.invalid", "-c", "commit.gpgsign=false", ]) .args(args) .current_dir(dir) .status() .expect("git should be runnable"); assert!(status.success(), "git {args:?} failed"); }; let dir = tempfile::tempdir().unwrap(); run(dir.path(), &["init", "-q"]); std::fs::write(dir.path().join("f"), "one\n").unwrap(); run(dir.path(), &["add", "f"]); run(dir.path(), &["commit", "-q", "-m", "first"]); if let Some(tag) = tag { run(dir.path(), &["tag", tag]); } if second_commit { std::fs::write(dir.path().join("f"), "two\n").unwrap(); run(dir.path(), &["add", "f"]); run(dir.path(), &["commit", "-q", "-m", "second"]); } let hash = std::process::Command::new("git") .args(["rev-parse", "--short", "HEAD"]) .current_dir(dir.path()) .output() .unwrap(); let hash = String::from_utf8_lossy(&hash.stdout).trim().to_string(); (dir, hash) } #[tokio::test] async fn resolve_skeleton_is_native_without_orig_tarball() { // A self-authored skeleton defaults to 3.0 (native): no orig // tarball, no orig plan. let cli = NewCli { name: Some("mytool".into()), lang: Some("shell".into()), description: Some("A tool".into()), maintainer: Some("Jane ".into()), git: true, ..Default::default() }; let opts = resolve(cli).await.unwrap(); assert_eq!(opts.source_format, SourceFormat::Native); assert_eq!(opts.orig, None); } #[tokio::test] async fn resolve_skeleton_forced_quilt_snapshots_the_tree() { let cli = NewCli { name: Some("mytool".into()), lang: Some("shell".into()), description: Some("A tool".into()), maintainer: Some("Jane ".into()), quilt: true, git: true, ..Default::default() }; let opts = resolve(cli).await.unwrap(); assert_eq!(opts.source_format, SourceFormat::Quilt); assert_eq!(opts.orig, Some(OrigOrigin::Snapshot)); } #[tokio::test] async fn resolve_skeleton_rejects_history_dependent_orig_modes() { // A skeleton has no upstream git history: only the snapshot mode // makes sense. for mode in ["git", "release"] { let cli = NewCli { name: Some("mytool".into()), lang: Some("shell".into()), description: Some("A tool".into()), maintainer: Some("Jane ".into()), quilt: true, orig_from: Some(mode.to_string()), git: true, ..Default::default() }; let err = resolve(cli).await.unwrap_err(); assert!(err.contains("no upstream"), "{mode}: {err}"); } } #[tokio::test] async fn resolve_existing_project_forced_native() { let dir = tempfile::tempdir().unwrap(); let mut cli = cli_for(dir.path()); cli.native = true; let opts = resolve(cli).await.unwrap(); assert_eq!(opts.source_format, SourceFormat::Native); assert_eq!(opts.orig, None); } #[tokio::test] async fn resolve_native_and_quilt_conflict() { let cli = NewCli { name: Some("mytool".into()), lang: Some("shell".into()), description: Some("A tool".into()), maintainer: Some("Jane ".into()), native: true, quilt: true, ..Default::default() }; let err = resolve(cli).await.unwrap_err(); assert!(err.contains("mutually exclusive"), "{err}"); } #[tokio::test] async fn resolve_head_on_tag_defaults_to_git_archive_and_tag_version() { if std::process::Command::new("git") .arg("--version") .output() .is_err() { return; } let (dir, _hash) = git_repo(Some("v1.2.3"), false); let opts = resolve(cli_for(dir.path())).await.unwrap(); assert_eq!(opts.source_format, SourceFormat::Quilt); // Version default: the tag version, not 0.1.0. assert_eq!(opts.upstream_version, "1.2.3"); // Non-interactive orig default on a tag: git archive (no network). assert_eq!( opts.orig, Some(OrigOrigin::GitArchive { tag: "v1.2.3".to_string() }) ); } #[tokio::test] async fn resolve_between_releases_snapshots_with_git_version() { if std::process::Command::new("git") .arg("--version") .output() .is_err() { return; } let (dir, _hash) = git_repo(Some("v1.2.3"), true); let opts = resolve(cli_for(dir.path())).await.unwrap(); let expected = crate::new::origin::GitOrigin::detect(dir.path()) .unwrap() .git_version() .unwrap(); assert_eq!(opts.upstream_version, expected); assert!(opts.upstream_version.starts_with("1.2.3+git")); assert_eq!(opts.orig, Some(OrigOrigin::Snapshot)); } #[tokio::test] async fn resolve_probed_project_version_beats_the_tag() { if std::process::Command::new("git") .arg("--version") .output() .is_err() { return; } let (dir, _) = git_repo(Some("v9.9.9"), false); // A Cargo.toml without src/ makes `cargo metadata` fail, so the // template probe falls back to its deterministic line parse. std::fs::write( dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\nversion = \"3.3.3\"\n", ) .unwrap(); let mut cli = cli_for(dir.path()); cli.lang = Some("rust".into()); let opts = resolve(cli).await.unwrap(); assert_eq!(opts.upstream_version, "3.3.3"); // The orig plan still comes from the tag. assert_eq!( opts.orig, Some(OrigOrigin::GitArchive { tag: "v9.9.9".to_string() }) ); } #[tokio::test] async fn resolve_orig_from_modes() { if std::process::Command::new("git") .arg("--version") .output() .is_err() { return; } let (dir, _) = git_repo(Some("v1.2.3"), false); // snapshot is always allowed. let mut cli = cli_for(dir.path()); cli.orig_from = Some("snapshot".into()); assert_eq!(resolve(cli).await.unwrap().orig, Some(OrigOrigin::Snapshot)); // release requires a recognized forge remote. let mut cli = cli_for(dir.path()); cli.orig_from = Some("release".into()); let err = resolve(cli).await.unwrap_err(); assert!(err.contains("github.com or gitlab.com"), "{err}"); // After adding a recognized remote the release plan resolves. std::process::Command::new("git") .args(["remote", "add", "origin", "https://github.com/foo/bar.git"]) .current_dir(dir.path()) .status() .unwrap() .success() .then_some(()) .unwrap(); let mut cli = cli_for(dir.path()); cli.orig_from = Some("release".into()); assert_eq!( resolve(cli).await.unwrap().orig, Some(OrigOrigin::Release { tag: "v1.2.3".to_string(), forge: crate::new::origin::Forge::parse("https://github.com/foo/bar.git").unwrap(), }) ); // path requires --orig-path, and an existing file or a URL. let mut cli = cli_for(dir.path()); cli.orig_from = Some("path".into()); let err = resolve(cli).await.unwrap_err(); assert!(err.contains("--orig-path"), "{err}"); let tarball = dir.path().join("upstream.tar.gz"); std::fs::write(&tarball, b"not really a tarball").unwrap(); let mut cli = cli_for(dir.path()); cli.orig_from = Some("path".into()); cli.orig_path = Some(tarball.to_string_lossy().into_owned()); assert_eq!( resolve(cli).await.unwrap().orig, Some(OrigOrigin::Provided { source: tarball.to_string_lossy().into_owned() }) ); let mut cli = cli_for(dir.path()); cli.orig_from = Some("path".into()); cli.orig_path = Some("https://example.com/upstream.tar.gz".into()); assert_eq!( resolve(cli).await.unwrap().orig, Some(OrigOrigin::Provided { source: "https://example.com/upstream.tar.gz".to_string() }) ); // A missing local path is rejected early. let mut cli = cli_for(dir.path()); cli.orig_from = Some("path".into()); cli.orig_path = Some( dir.path() .join("missing.tar.gz") .to_string_lossy() .into_owned(), ); let err = resolve(cli).await.unwrap_err(); assert!(err.contains("not an http(s) URL"), "{err}"); // --orig-path without --orig-from path is a misuse. let mut cli = cli_for(dir.path()); cli.orig_path = Some(tarball.to_string_lossy().into_owned()); let err = resolve(cli).await.unwrap_err(); assert!(err.contains("only valid together"), "{err}"); // git requires HEAD exactly on a tag. let (off, _) = git_repo(Some("v1.0.0"), true); let mut cli = cli_for(off.path()); cli.orig_from = Some("git".into()); let err = resolve(cli).await.unwrap_err(); assert!(err.contains("exactly on a release tag"), "{err}"); // ... and the orig flags need quilt. let mut cli = cli_for(dir.path()); cli.native = true; cli.orig_from = Some("git".into()); let err = resolve(cli).await.unwrap_err(); assert!(err.contains("need a quilt package"), "{err}"); } }