The leaf validators of the wizard (source name, command, upstream version, maintainer, homepage, Depends, orig path, template id, file collisions) now return a shared options::ValidationError carrying the structured pieces (the rejected string, the wrapped DebianVersion or Deps parse failure) instead of pre-formatted Strings; the wizard resolve flow keeps its aggregate String errors and renders at the boundary, and the Prompter validator closures adapt with to_string() since the port's Validator deliberately exchanges re-ask explanations as plain strings. Display texts are unchanged, as are the tests that assert on them.
1745 lines
65 KiB
Rust
1745 lines
65 KiB
Rust
//! 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/<id>/` (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<TemplateId, ValidationError> {
|
|
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<TemplateId> {
|
|
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 `./<name>/`.
|
|
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<PathBuf> {
|
|
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<License> {
|
|
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<String>,
|
|
/// 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<String>,
|
|
/// 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<OrigOrigin>,
|
|
/// Initialize a git repository (gitignores are written regardless).
|
|
pub git: bool,
|
|
/// Write the autopkgtest smoke test (`debian/tests/control` +
|
|
/// `debian/tests/smoke`) running `<command> --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<String>,
|
|
}
|
|
|
|
impl NewOptions {
|
|
/// Directory the package tree lives in: `./<name>/` 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: `<upstream>-<revision>`.
|
|
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 `<name>`.
|
|
pub name: Option<String>,
|
|
/// `--lang` value (parsed by [`resolve`]).
|
|
pub lang: Option<String>,
|
|
/// `--source <path>`.
|
|
pub source: Option<PathBuf>,
|
|
/// `--upstream-version <v>`.
|
|
pub upstream_version: Option<String>,
|
|
/// `--revision <n>`.
|
|
pub revision: Option<u32>,
|
|
/// `--description <one-liner>`.
|
|
pub description: Option<String>,
|
|
/// `--homepage <url>`.
|
|
pub homepage: Option<String>,
|
|
/// `--license <SPDX>`.
|
|
pub license: Option<String>,
|
|
/// `--command <cmd>`.
|
|
pub command: Option<String>,
|
|
/// `--maintainer "Name <email>"`.
|
|
pub maintainer: Option<String>,
|
|
/// `--depends` values; each entry may itself be a comma-separated list.
|
|
pub depends: Vec<String>,
|
|
/// `--dist <dist>`.
|
|
pub dist: Option<String>,
|
|
/// `--series <s>`.
|
|
pub series: Option<String>,
|
|
/// `--release`.
|
|
pub release: bool,
|
|
/// `--native`.
|
|
pub native: bool,
|
|
/// `--quilt` (mutually exclusive with `--native`).
|
|
pub quilt: bool,
|
|
/// `--orig-from <release|git|path|snapshot>`.
|
|
pub orig_from: Option<String>,
|
|
/// `--orig-path <file|url>` (required by `--orig-from path`).
|
|
pub orig_path: Option<String>,
|
|
/// 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 <email>`.
|
|
#[error("'{s}' is not a valid maintainer: expected the form 'Name <email>'")]
|
|
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::<Vec<_>>().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<Regex> = 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<String> {
|
|
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<Regex> = 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 <email>` 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<Regex> = 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<Vec<String>, 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::<Vec<_>>()
|
|
.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<NewOptions, String> {
|
|
let mut missing: Vec<String> = 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::<Vec<_>>()
|
|
.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::<Vec<_>>()
|
|
.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 <id> (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 <one-liner>".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 `<lasttag>+git<YYYYMMDD>.<hash>`, 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 <email>\"."
|
|
)
|
|
})?,
|
|
};
|
|
|
|
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<Option<OrigOrigin>, 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 <tarball file or URL>".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 <jane@example.com>").unwrap(),
|
|
("Jane Doe".to_string(), "jane@example.com".to_string())
|
|
);
|
|
assert_eq!(
|
|
parse_maintainer(" X <x@y.z> ").unwrap(),
|
|
("X".to_string(), "x@y.z".to_string())
|
|
);
|
|
// Bad forms.
|
|
assert!(parse_maintainer("Jane Doe").is_err()); // no email
|
|
assert!(parse_maintainer("<jane@example.com>").is_err()); // no name
|
|
assert!(parse_maintainer("Jane <jane example.com>").is_err()); // no @
|
|
assert!(parse_maintainer("Jane <a@b@c>").is_err()); // two @s
|
|
assert!(parse_maintainer("Jane <jane@ example.com>").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 <jane@example.com>".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 <jane@example.com>".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 <jane@example.com>".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 <jane@example.com>".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 <jane@example.com>".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 <jane@example.com>".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 <jane@example.com>".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}");
|
|
}
|
|
}
|