use crate::cli::{ListArgs, PullArgs, RemoveArgs, SearchArgs, UpdateArgs};
use crate::package::{
self, ArtifactEntry, ArtifactType, LockedArtifact, LockedPackage, PackageManifest,
PackageProvenance, PackageRef, PullResponse,
};
use crate::registry_client::{resolve_registry_url, RegistryClient};
use chrono::{DateTime, Local, Utc};
use nono::{NonoError, Result, SignerIdentity};
use semver::Version;
use std::cmp::Ordering;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
pub fn run_pull(args: PullArgs) -> Result<()> {
let package_ref = package::parse_package_ref(&args.package_ref)?;
let registry_url = resolve_registry_url(args.registry.as_deref());
let client = RegistryClient::new(registry_url.clone());
let requested_version = package_ref.version.as_deref().unwrap_or("latest");
let pull = client.fetch_pull_response(&package_ref, requested_version)?;
validate_pull_response(&package_ref, &pull)?;
let lockfile = package::read_lockfile()?;
if let Some(existing) = lockfile.packages.get(&package_ref.key()) {
if existing.version == pull.version && !args.force {
eprintln!(
" {} is already at {} (use --force to reinstall)",
package_ref.key(),
pull.version
);
return Ok(());
}
}
let printer = crate::pull_ui::ProgressPrinter::new(&pull);
printer.header(&package_ref);
let downloads = download_and_verify_artifacts(&client, &package_ref, &pull, Some(&printer))?;
let manifest = load_manifest(&downloads.artifacts)?;
validate_manifest(&manifest)?;
let signer_identity = signer_identity_uri(&downloads.signer_identity)?;
enforce_signer_pinning(
lockfile.packages.get(&package_ref.key()),
&signer_identity,
args.force,
)?;
if let Some(prior_pkg) = lockfile.packages.get(&package_ref.key()) {
if !prior_pkg.wiring_record.is_empty() {
let failures = crate::wiring::reverse(&prior_pkg.wiring_record);
if !failures.is_empty() {
for f in &failures {
eprintln!(" failed: {} — {}", f.record_summary, f.error);
}
return Err(NonoError::PackageInstall(format!(
"re-pull of {} aborted — {} prior wiring directive(s) failed to reverse. \
Resolve the failures above (or `nono remove --force` first) before retrying.",
package_ref.key(),
failures.len()
)));
}
}
}
let pack_owned_files = pack_owned_write_file_paths(&lockfile, &package_ref);
let install = install_package(
&package_ref,
&manifest,
&downloads,
args.init,
&pack_owned_files,
)?;
update_lockfile(
&package_ref,
®istry_url,
&pull,
&signer_identity,
&downloads.artifacts,
&install.wiring_record,
)?;
let install_dir = package::package_install_dir(&package_ref.namespace, &package_ref.name)?;
crate::pull_ui::render_summary(
&package_ref,
&pull,
&install_dir,
install.installed_artifacts,
install.copied_to_project,
);
Ok(())
}
pub fn run_remove(args: RemoveArgs) -> Result<()> {
let package_ref = package::parse_package_ref(&args.package_ref)?;
let lockfile = package::read_lockfile()?;
let locked_pkg = lockfile.packages.get(&package_ref.key());
let install_dir = package::package_install_dir(&package_ref.namespace, &package_ref.name)?;
let install_dir_existed = install_dir.exists();
if locked_pkg.is_none() && !install_dir_existed {
return Err(NonoError::PackageInstall(format!(
"package {} is not installed",
package_ref.key()
)));
}
if let Some(pkg) = locked_pkg {
if !pkg.wiring_record.is_empty() {
let failures = crate::wiring::reverse(&pkg.wiring_record);
let total = pkg.wiring_record.len();
let succeeded = total.saturating_sub(failures.len());
eprintln!(" reversed {succeeded}/{total} wiring directive(s)",);
if !failures.is_empty() {
for f in &failures {
eprintln!(" failed: {} — {}", f.record_summary, f.error);
}
if !args.force {
return Err(NonoError::PackageInstall(format!(
"remove of {} aborted — {} wiring directive(s) failed to reverse. \
The lockfile entry has been preserved so you can retry. \
Inspect the failures above and either resolve them and re-run, \
or pass --force to drop the lockfile entry and accept any \
orphaned wiring.",
package_ref.key(),
failures.len()
)));
}
eprintln!(
" --force: dropping lockfile entry despite {} failed reversal(s)",
failures.len()
);
}
}
}
if install_dir.exists() {
fs::remove_dir_all(&install_dir).map_err(NonoError::Io)?;
}
if let Some(ns_dir) = install_dir.parent() {
if ns_dir.exists() && is_dir_empty(ns_dir) {
let _ = fs::remove_dir(ns_dir);
}
}
package::remove_package_from_lockfile(&package_ref)?;
eprintln!("Removed {}", package_ref.key());
Ok(())
}
fn pack_owned_write_file_paths(
lockfile: &package::Lockfile,
package_ref: &PackageRef,
) -> HashMap<PathBuf, String> {
let mut owned = HashMap::new();
if let Some(pkg) = lockfile.packages.get(&package_ref.key()) {
for record in &pkg.wiring_record {
if let crate::wiring::WiringRecord::WriteFile { dest, sha256 } = record {
owned.insert(PathBuf::from(dest), sha256.clone());
}
}
}
owned
}
fn is_dir_empty(path: &Path) -> bool {
fs::read_dir(path)
.map(|mut entries| entries.next().is_none())
.unwrap_or(false)
}
pub fn run_update(args: UpdateArgs) -> Result<()> {
if let Some(package_ref) = args.package_ref {
let package_ref = package::parse_package_ref(&package_ref)?;
return Err(NonoError::PackageInstall(format!(
"package update flow for {} is not implemented yet",
package_ref.key()
)));
}
Err(NonoError::PackageInstall(
"bulk package update is not implemented yet".to_string(),
))
}
pub fn run_search(args: SearchArgs) -> Result<()> {
let registry_url = resolve_registry_url(args.registry.as_deref());
let client = RegistryClient::new(registry_url);
let results = client.search_packages(&args.query)?;
if args.json {
let json = serde_json::to_string_pretty(&results).map_err(|e| {
NonoError::ConfigParse(format!("failed to serialize search results: {e}"))
})?;
println!("{json}");
return Ok(());
}
if results.is_empty() {
println!("No nono packs found.");
return Ok(());
}
for result in results {
let version = result.latest_version.unwrap_or_else(|| "-".to_string());
let description = result.description.unwrap_or_default();
println!(
"{}\t{}\t{}",
format_args!("{}/{}", result.namespace, result.name),
version,
description
);
}
Ok(())
}
pub fn run_list(args: ListArgs) -> Result<()> {
let lockfile = package::read_lockfile()?;
if args.installed {
if args.json {
let json = serde_json::to_string_pretty(&lockfile).map_err(|e| {
NonoError::ConfigParse(format!("failed to serialize lockfile: {e}"))
})?;
println!("{json}");
return Ok(());
}
if lockfile.packages.is_empty() {
println!("No installed nono packs.");
return Ok(());
}
for (name, pkg) in lockfile.packages {
let installed_at = format_timestamp(&pkg.installed_at);
println!("{name}\t{}\t{installed_at}", pkg.version);
}
return Ok(());
}
Err(NonoError::PackageInstall(
"only `nono list --installed` is currently supported".to_string(),
))
}
struct DownloadedArtifact {
filename: String,
path: PathBuf,
sha256_digest: String,
}
struct VerifiedDownloads {
_tempdir: TempDir,
bundle_json: String,
signer_identity: SignerIdentity,
artifacts: Vec<DownloadedArtifact>,
}
struct InstallSummary {
installed_artifacts: usize,
copied_to_project: usize,
wiring_record: Vec<crate::wiring::WiringRecord>,
}
fn validate_pull_response(package_ref: &PackageRef, pull: &PullResponse) -> Result<()> {
if pull.namespace != package_ref.namespace || pull.name != package_ref.name {
return Err(NonoError::PackageVerification {
package: package_ref.key(),
reason: format!(
"registry returned {} / {} for requested package {}",
pull.namespace,
pull.name,
package_ref.key()
),
});
}
if pull.artifacts.is_empty() {
return Err(NonoError::PackageVerification {
package: package_ref.key(),
reason: "pull response did not include any artifacts".to_string(),
});
}
let mut filenames = HashSet::with_capacity(pull.artifacts.len());
for artifact in &pull.artifacts {
validate_relative_path(&artifact.filename)?;
if !filenames.insert(artifact.filename.as_str()) {
return Err(NonoError::PackageVerification {
package: package_ref.key(),
reason: format!(
"pull response includes duplicate artifact '{}'",
artifact.filename
),
});
}
}
Ok(())
}
fn download_and_verify_artifacts(
client: &RegistryClient,
package_ref: &PackageRef,
pull: &PullResponse,
printer: Option<&crate::pull_ui::ProgressPrinter>,
) -> Result<VerifiedDownloads> {
let trusted_root = nono::trust::load_production_trusted_root()?;
let policy = nono::trust::VerificationPolicy::default();
let bundle_path = Path::new(".nono-trust.bundle");
let tempdir = TempDir::new().map_err(NonoError::Io)?;
let bundle_json = client.download_bundle(&pull.bundle_url)?;
let bundle = nono::trust::load_bundle_from_str(&bundle_json, bundle_path)?;
let subjects = nono::trust::extract_all_subjects(&bundle, bundle_path)?;
let subject_digests: std::collections::HashMap<&str, &str> = subjects
.iter()
.map(|(name, digest)| (digest.as_str(), name.as_str()))
.collect();
if let Some((_, first_digest)) = subjects.first() {
nono::trust::verify_bundle_with_digest(
first_digest,
&bundle,
&trusted_root,
&policy,
bundle_path,
)?;
} else {
return Err(NonoError::PackageVerification {
package: package_ref.key(),
reason: "bundle contains no subjects".to_string(),
});
}
let signer_identity = nono::trust::extract_signer_identity(&bundle, bundle_path)?;
enforce_namespace_assertion(package_ref, &signer_identity)?;
let mut downloads = Vec::with_capacity(pull.artifacts.len());
for artifact in &pull.artifacts {
let path = tempdir.path().join(&artifact.filename);
let digest = client.download_artifact_to_path(&artifact.download_url, &path)?;
if digest != artifact.sha256_digest {
return Err(NonoError::PackageVerification {
package: package_ref.key(),
reason: format!(
"artifact {} digest mismatch: registry={}, local={}",
artifact.filename, artifact.sha256_digest, digest
),
});
}
if !subject_digests.contains_key(digest.as_str()) {
return Err(NonoError::PackageVerification {
package: package_ref.key(),
reason: format!(
"artifact {} digest not found in bundle subjects",
artifact.filename
),
});
}
let bytes = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
if let Some(p) = printer {
p.finished(&artifact.filename, bytes);
}
downloads.push(DownloadedArtifact {
filename: artifact.filename.clone(),
path,
sha256_digest: digest,
});
}
Ok(VerifiedDownloads {
_tempdir: tempdir,
bundle_json,
signer_identity,
artifacts: downloads,
})
}
fn load_manifest(downloads: &[DownloadedArtifact]) -> Result<PackageManifest> {
let manifest = downloads
.iter()
.find(|artifact| artifact.filename == "package.json")
.ok_or_else(|| NonoError::PackageInstall("package is missing package.json".to_string()))?;
let bytes = fs::read(&manifest.path).map_err(NonoError::Io)?;
serde_json::from_slice::<PackageManifest>(&bytes).map_err(|e| {
NonoError::PackageInstall(format!("failed to parse package.json manifest: {e}"))
})
}
fn validate_manifest(manifest: &PackageManifest) -> Result<()> {
if !manifest.platforms.is_empty()
&& !manifest
.platforms
.iter()
.any(|platform| platform == current_platform())
{
return Err(NonoError::PackageInstall(format!(
"package does not support {}",
current_platform()
)));
}
if let Some(min_version) = &manifest.min_nono_version {
if compare_versions(env!("CARGO_PKG_VERSION"), min_version)?.is_lt() {
return Err(NonoError::PackageInstall(format!(
"package requires nono >= {}, current version is {}",
min_version,
env!("CARGO_PKG_VERSION")
)));
}
}
Ok(())
}
fn install_package(
package_ref: &PackageRef,
manifest: &PackageManifest,
downloads: &VerifiedDownloads,
init: bool,
pack_owned_files: &HashMap<PathBuf, String>,
) -> Result<InstallSummary> {
let staging_parent = package::package_store_dir()?
.join(".staging")
.join(&package_ref.namespace);
fs::create_dir_all(&staging_parent).map_err(NonoError::Io)?;
let tempdir = TempDir::new_in(&staging_parent).map_err(NonoError::Io)?;
let staging_root = tempdir.path().join(&package_ref.name);
fs::create_dir_all(&staging_root).map_err(NonoError::Io)?;
let mut downloaded_by_name: HashMap<&str, &DownloadedArtifact> =
HashMap::with_capacity(downloads.artifacts.len());
for artifact in &downloads.artifacts {
downloaded_by_name.insert(artifact.filename.as_str(), artifact);
}
write_supporting_artifacts(&staging_root, downloads)?;
let mut copied_to_project = 0usize;
for artifact in &manifest.artifacts {
let downloaded = downloaded_by_name
.get(artifact.path.as_str())
.ok_or_else(|| {
NonoError::PackageInstall(format!(
"manifest references missing artifact '{}'",
artifact.path
))
})?;
install_manifest_artifact(&staging_root, artifact, &downloaded.path)?;
if init
&& artifact.artifact_type == ArtifactType::Instruction
&& artifact.placement.as_deref() == Some("project")
{
copy_instruction_to_project(artifact, &downloaded.path)?;
copied_to_project = copied_to_project.saturating_add(1);
}
}
let final_root = package::package_install_dir(&package_ref.namespace, &package_ref.name)?;
if let Some(parent) = final_root.parent() {
fs::create_dir_all(parent).map_err(NonoError::Io)?;
}
if final_root.exists() {
fs::remove_dir_all(&final_root).map_err(NonoError::Io)?;
}
fs::rename(&staging_root, &final_root).map_err(NonoError::Io)?;
tempdir.close().map_err(NonoError::Io)?;
let wiring_record = if manifest.wiring.is_empty() {
Vec::new()
} else {
let ctx = crate::wiring::WiringContext {
pack_dir: final_root.clone(),
namespace: package_ref.namespace.clone(),
pack_name: package_ref.name.clone(),
};
let report = crate::wiring::execute(&manifest.wiring, &ctx, pack_owned_files)?;
for conflict in &report.conflicts {
eprintln!(" warning: {conflict}");
}
report.records
};
Ok(InstallSummary {
installed_artifacts: manifest.artifacts.len(),
copied_to_project,
wiring_record,
})
}
fn write_supporting_artifacts(staging_root: &Path, downloads: &VerifiedDownloads) -> Result<()> {
for artifact in &downloads.artifacts {
if artifact.filename == "package.json" {
let path = staging_root.join("package.json");
copy_path(&artifact.path, &path)?;
}
}
let bundle =
serde_json::from_str::<serde_json::Value>(&downloads.bundle_json).map_err(|e| {
NonoError::PackageInstall(format!("failed to parse trust bundle from registry: {e}"))
})?;
let bundles: Vec<serde_json::Value> = downloads
.artifacts
.iter()
.map(|artifact| {
serde_json::json!({
"artifact": artifact.filename,
"digest": artifact.sha256_digest,
"bundle": bundle.clone()
})
})
.collect();
if !bundles.is_empty() {
let bundle_path = staging_root.join(".nono-trust.bundle");
let json = serde_json::to_string_pretty(&bundles).map_err(|e| {
NonoError::PackageInstall(format!("failed to serialize trust bundle: {e}"))
})?;
fs::write(&bundle_path, json).map_err(NonoError::Io)?;
}
Ok(())
}
fn install_manifest_artifact(
staging_root: &Path,
artifact: &ArtifactEntry,
source_path: &Path,
) -> Result<()> {
match artifact.artifact_type {
ArtifactType::Profile => {
let install_name = artifact.install_as.as_deref().ok_or_else(|| {
NonoError::PackageInstall(format!(
"profile artifact '{}' is missing install_as",
artifact.path
))
})?;
validate_safe_name(install_name, "install_as")?;
let path = staging_root
.join("profiles")
.join(format!("{install_name}.json"));
copy_path(source_path, &path)?;
parse_json::<crate::profile::Profile>(&path)?;
}
ArtifactType::Instruction => {
let path = staging_root
.join("instructions")
.join(file_name(&artifact.path)?);
copy_path(source_path, &path)?;
}
ArtifactType::TrustPolicy => {
let path = staging_root.join("trust-policy.json");
copy_path(source_path, &path)?;
let content = fs::read_to_string(&path).map_err(NonoError::Io)?;
nono::trust::load_policy_from_str(&content)?;
}
ArtifactType::Groups => {
let prefix = artifact.prefix.as_deref().ok_or_else(|| {
NonoError::PackageInstall(format!(
"groups artifact '{}' is missing prefix",
artifact.path
))
})?;
let path = staging_root.join("groups.json");
copy_path(source_path, &path)?;
let bytes = fs::read(&path).map_err(NonoError::Io)?;
validate_groups(&bytes, prefix)?;
}
ArtifactType::Plugin => {
validate_relative_path(&artifact.path)?;
let path = staging_root.join(&artifact.path);
copy_path(source_path, &path)?;
if artifact.path.contains("/bin/") || artifact.path.ends_with(".sh") {
ensure_executable(&path)?;
}
}
}
Ok(())
}
fn copy_instruction_to_project(artifact: &ArtifactEntry, source_path: &Path) -> Result<()> {
let cwd = std::env::current_dir().map_err(NonoError::Io)?;
let path = cwd.join(file_name(&artifact.path)?);
if path.exists() {
return Ok(());
}
copy_path(source_path, &path)
}
fn validate_groups(bytes: &[u8], prefix: &str) -> Result<()> {
let groups: HashMap<String, crate::policy::Group> = serde_json::from_slice(bytes)
.map_err(|e| NonoError::PackageInstall(format!("failed to parse groups.json: {e}")))?;
let embedded = crate::policy::load_policy(crate::config::embedded::embedded_policy_json())?;
for name in groups.keys() {
if !name.starts_with(prefix) {
return Err(NonoError::PackageInstall(format!(
"group '{}' does not start with required prefix '{}'",
name, prefix
)));
}
if embedded.groups.contains_key(name) {
return Err(NonoError::PackageInstall(format!(
"group '{}' collides with an embedded policy group",
name
)));
}
}
Ok(())
}
fn update_lockfile(
package_ref: &PackageRef,
registry_url: &str,
pull: &PullResponse,
signer_identity: &str,
downloads: &[DownloadedArtifact],
wiring_record: &[crate::wiring::WiringRecord],
) -> Result<()> {
let mut lockfile = package::read_lockfile()?;
lockfile.lockfile_version = package::LOCKFILE_VERSION;
lockfile.registry = registry_url.to_string();
let artifacts = downloads
.iter()
.filter(|artifact| artifact.filename != "package.json")
.map(|artifact| {
(
artifact.filename.clone(),
LockedArtifact {
sha256: artifact.sha256_digest.clone(),
artifact_type: infer_artifact_type(&artifact.filename),
},
)
})
.collect::<BTreeMap<_, _>>();
lockfile.packages.insert(
package_ref.key(),
LockedPackage {
version: pull.version.clone(),
installed_at: Utc::now().to_rfc3339(),
provenance: Some(PackageProvenance {
signer_identity: signer_identity.to_string(),
repository: pull.provenance.repository.clone(),
workflow: pull.provenance.workflow.clone(),
git_ref: pull.provenance.git_ref.clone(),
rekor_log_index: pull.provenance.rekor_log_index.unwrap_or_default() as u64,
signed_at: pull
.provenance
.signed_at
.map(|dt| dt.to_rfc3339())
.unwrap_or_else(|| Utc::now().to_rfc3339()),
}),
artifacts,
wiring_record: wiring_record.to_vec(),
},
);
package::write_lockfile(&lockfile)
}
fn enforce_namespace_assertion(
package_ref: &PackageRef,
signer_identity: &SignerIdentity,
) -> Result<()> {
match signer_identity {
SignerIdentity::Keyless { repository, .. } => {
let signer_namespace = repository.split('/').next().unwrap_or_default();
if signer_namespace != package_ref.namespace {
return Err(NonoError::PackageVerification {
package: package_ref.key(),
reason: format!(
"signer namespace '{}' does not match requested namespace '{}'",
signer_namespace, package_ref.namespace
),
});
}
Ok(())
}
SignerIdentity::Keyed { .. } => Err(NonoError::PackageVerification {
package: package_ref.key(),
reason: "registry packages must use keyless Sigstore signing".to_string(),
}),
}
}
fn enforce_signer_pinning(
existing: Option<&LockedPackage>,
signer_identity: &str,
force: bool,
) -> Result<()> {
if force {
return Ok(());
}
if let Some(existing) = existing {
if let Some(provenance) = &existing.provenance {
if canonical_signer_identity(&provenance.signer_identity)
!= canonical_signer_identity(signer_identity)
{
return Err(NonoError::PackageVerification {
package: provenance.repository.clone(),
reason: format!(
"signer identity changed from '{}' to '{}'",
provenance.signer_identity, signer_identity
),
});
}
}
}
Ok(())
}
fn canonical_signer_identity(uri: &str) -> &str {
uri.rsplit_once('@')
.map(|(prefix, _)| prefix)
.unwrap_or(uri)
}
fn signer_identity_uri(identity: &SignerIdentity) -> Result<String> {
match identity {
SignerIdentity::Keyless {
repository,
workflow,
git_ref,
..
} => Ok(format!(
"https://github.com/{repository}/{workflow}@{git_ref}"
)),
SignerIdentity::Keyed { key_id } => Ok(format!("keyed:{key_id}")),
}
}
fn infer_artifact_type(filename: &str) -> ArtifactType {
match filename {
"groups.json" => ArtifactType::Groups,
"trust-policy.json" => ArtifactType::TrustPolicy,
name if name.ends_with(".profile.json") => ArtifactType::Profile,
name if name.ends_with(".md") => ArtifactType::Instruction,
_ => ArtifactType::Plugin,
}
}
fn parse_json<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T> {
let content = fs::read_to_string(path).map_err(NonoError::Io)?;
serde_json::from_str(&content)
.map_err(|e| NonoError::PackageInstall(format!("failed to parse {}: {e}", path.display())))
}
fn copy_path(source: &Path, dest: &Path) -> Result<()> {
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).map_err(NonoError::Io)?;
}
fs::copy(source, dest).map_err(NonoError::Io)?;
Ok(())
}
fn ensure_executable(path: &Path) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path).map_err(NonoError::Io)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(path, perms).map_err(NonoError::Io)?;
}
Ok(())
}
fn file_name(path: &str) -> Result<&str> {
Path::new(path)
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| NonoError::PackageInstall(format!("invalid artifact path '{}'", path)))
}
fn validate_safe_name(name: &str, field: &str) -> Result<()> {
if name.is_empty()
|| name.contains('/')
|| name.contains('\\')
|| name == "."
|| name == ".."
|| name.contains("..")
{
return Err(NonoError::PackageInstall(format!(
"{field} contains unsafe path component: '{name}'"
)));
}
Ok(())
}
fn validate_relative_path(path: &str) -> Result<()> {
let p = Path::new(path);
if p.is_absolute() {
return Err(NonoError::PackageInstall(format!(
"artifact path must be relative, got '{path}'"
)));
}
for component in p.components() {
match component {
std::path::Component::ParentDir => {
return Err(NonoError::PackageInstall(format!(
"artifact path contains '..': '{path}'"
)));
}
std::path::Component::RootDir | std::path::Component::Prefix(_) => {
return Err(NonoError::PackageInstall(format!(
"artifact path must be relative, got '{path}'"
)));
}
_ => {}
}
}
Ok(())
}
fn current_platform() -> &'static str {
if cfg!(target_os = "macos") {
"macos"
} else if cfg!(target_os = "linux") {
"linux"
} else {
"unknown"
}
}
fn compare_versions(left: &str, right: &str) -> Result<Ordering> {
let left = parse_version(left, "current nono version")?;
let right = parse_version(right, "min_nono_version")?;
Ok(left.cmp(&right))
}
fn parse_version(value: &str, field: &str) -> Result<Version> {
let normalized = value.trim().strip_prefix('v').unwrap_or(value.trim());
Version::parse(normalized)
.map_err(|error| NonoError::PackageInstall(format!("invalid {field} '{value}': {error}")))
}
fn format_timestamp(value: &str) -> String {
DateTime::parse_from_rfc3339(value)
.map(|dt| {
dt.with_timezone(&Local)
.format("%Y-%m-%d %H:%M")
.to_string()
})
.unwrap_or_else(|_| value.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compare_versions_honors_prerelease_ordering() {
let prerelease_vs_stable = compare_versions("1.0.0-alpha.1", "1.0.0")
.unwrap_or_else(|err| panic!("version compare failed: {err}"));
let stable_vs_prerelease = compare_versions("1.0.0", "1.0.0-alpha.1")
.unwrap_or_else(|err| panic!("version compare failed: {err}"));
assert_eq!(prerelease_vs_stable, Ordering::Less);
assert_eq!(stable_vs_prerelease, Ordering::Greater);
}
}