mirror of
https://codeberg.org/Toasterson/ips.git
synced 2026-04-10 13:20:42 +00:00
1061 lines
37 KiB
Rust
1061 lines
37 KiB
Rust
// This Source Code Form is subject to the terms of
|
|
// the Mozilla Public License, v. 2.0. If a copy of the
|
|
// MPL was not distributed with this file, You can
|
|
// obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
|
//! High-level, struct-first APIs for forge/pkgdev integration.
|
|
//!
|
|
//! These facades wrap existing libips modules to provide a stable API surface
|
|
//! for building, transforming, linting, resolving, and publishing IPS packages
|
|
//! entirely in memory using typed structures.
|
|
//!
|
|
//! See doc/forge_docs/ips_integration.md for an overview of the end-to-end flow.
|
|
//!
|
|
//! Quickstart (ignore): Build, lint, resolve, and publish
|
|
//! ```ignore
|
|
//! use libips::api as ips;
|
|
//! use std::path::Path;
|
|
//!
|
|
//! // 1) Build a Manifest from a prototype directory and base metadata
|
|
//! let proto = Path::new("/path/to/proto");
|
|
//! let mut manifest = ips::ManifestBuilder::from_prototype_dir(proto)?
|
|
//! .with_base_metadata(ips::BaseMeta {
|
|
//! fmri: Some(ips::Fmri::parse("pkg://pub/example@1.0")?),
|
|
//! summary: Some("Example package".into()),
|
|
//! classification: Some("org.opensolaris.category.2008:Applications/Other".into()),
|
|
//! upstream_url: Some("https://example.com".into()),
|
|
//! source_url: Some("https://example.com/src.tar.gz".into()),
|
|
//! license: Some("MIT".into()),
|
|
//! })
|
|
//! .build();
|
|
//!
|
|
//! // 2) Generate dependencies with a repository (for FMRI mapping)
|
|
//! let mut backend = libips::repository::file_backend::FileBackend::open(Path::new("/repo"))?;
|
|
//! manifest = ips::DependencyGenerator::generate_with_repo(&mut backend, Some("pub"), proto, &manifest, ips::DependGenerateOptions::default())?;
|
|
//!
|
|
//! // 3) Lint and optionally filter rules
|
|
//! let mut lint_cfg = ips::LintConfig::default();
|
|
//! lint_cfg.disabled_rules = vec!["manifest.summary".into()];
|
|
//! let diags = ips::lint::lint_manifest(&manifest, &lint_cfg)?;
|
|
//! assert!(diags.is_empty(), "Diagnostics: {:?}", diags);
|
|
//!
|
|
//! // 4) Publish
|
|
//! let repo = ips::Repository::open(Path::new("/repo"))?;
|
|
//! if !repo.has_publisher("pub")? { repo.add_publisher("pub")?; }
|
|
//! let client = ips::PublisherClient::new(repo, "pub");
|
|
//! let mut txn = client.begin()?;
|
|
//! txn.add_payload_dir(proto)?;
|
|
//! txn.add_manifest(&manifest);
|
|
//! txn.commit()?;
|
|
//! # Ok::<(), libips::api::IpsError>(())
|
|
//! ```
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use miette::Diagnostic;
|
|
use thiserror::Error;
|
|
use walkdir::WalkDir;
|
|
|
|
pub use crate::actions::Manifest;
|
|
// Core typed manifest
|
|
use crate::actions::{
|
|
Attr, Dependency as DependAction, File as FileAction, License as LicenseAction,
|
|
Link as LinkAction, Property,
|
|
};
|
|
pub use crate::depend::{FileDep, GenerateOptions as DependGenerateOptions};
|
|
pub use crate::fmri::Fmri;
|
|
// For BaseMeta
|
|
use crate::repository::file_backend::{FileBackend, Transaction};
|
|
use crate::repository::{
|
|
ReadableRepository, RepositoryError, RepositoryVersion, WritableRepository,
|
|
};
|
|
use crate::transformer;
|
|
pub use crate::transformer::TransformRule;
|
|
|
|
/// Unified error type for API-level operations
|
|
#[allow(clippy::result_large_err)]
|
|
#[derive(Debug, Error, Diagnostic)]
|
|
pub enum IpsError {
|
|
#[error(transparent)]
|
|
#[diagnostic(transparent)]
|
|
Repository(Box<RepositoryError>),
|
|
|
|
#[error(transparent)]
|
|
#[diagnostic(transparent)]
|
|
Transform(#[from] transformer::TransformError),
|
|
|
|
#[error(transparent)]
|
|
#[diagnostic(transparent)]
|
|
Depend(#[from] crate::depend::DependError),
|
|
|
|
#[error("I/O error: {0}")]
|
|
#[diagnostic(code(ips::api_error::io), help("Check file paths and permissions"))]
|
|
Io(String),
|
|
|
|
#[error("Unimplemented feature: {feature}")]
|
|
#[diagnostic(
|
|
code(ips::api_error::unimplemented),
|
|
help("See doc/forge_docs/ips_integration.md for roadmap.")
|
|
)]
|
|
Unimplemented { feature: &'static str },
|
|
}
|
|
|
|
/// Base package metadata used by ManifestBuilder.
|
|
///
|
|
/// Fields are optional to support incremental construction. At minimum,
|
|
/// providing `fmri` and `summary` is recommended.
|
|
///
|
|
/// Example:
|
|
/// ```
|
|
/// use libips::api::{BaseMeta, Fmri};
|
|
/// let meta = BaseMeta {
|
|
/// fmri: Some(Fmri::parse("pkg://pub/example@1.0").unwrap()),
|
|
/// summary: Some("Example".into()),
|
|
/// classification: Some("org.opensolaris.category.2008:Applications/Other".into()),
|
|
/// upstream_url: Some("https://example.com".into()),
|
|
/// source_url: Some("https://example.com/src.tar.gz".into()),
|
|
/// license: Some("MIT".into()),
|
|
/// };
|
|
/// ```
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct BaseMeta {
|
|
pub fmri: Option<Fmri>,
|
|
pub summary: Option<String>,
|
|
pub classification: Option<String>,
|
|
pub upstream_url: Option<String>,
|
|
pub source_url: Option<String>,
|
|
pub license: Option<String>,
|
|
}
|
|
|
|
/// Build or enrich typed manifests using a fluent builder.
|
|
///
|
|
/// Example (no_run):
|
|
/// ```no_run
|
|
/// use libips::api as ips;
|
|
/// let mut builder = ips::ManifestBuilder::new();
|
|
/// let fmri = ips::Fmri::parse("pkg://pub/name@1.0").unwrap();
|
|
/// let summary = String::from("A summary");
|
|
/// let classification = "Applications/Other";
|
|
/// let project_url = String::from("https://example.com");
|
|
/// let source_url = String::from("https://example.com/src.tar.gz");
|
|
/// let license_file_name = "license.txt";
|
|
/// let license_name = "MIT";
|
|
/// builder.add_set("pkg.fmri", &fmri.to_string());
|
|
/// builder.add_set("pkg.summary", &summary);
|
|
/// builder.add_set(
|
|
/// "info.classification",
|
|
/// &format!("org.opensolaris.category.2008:{}", classification),
|
|
/// );
|
|
/// builder.add_set("info.upstream-url", &project_url);
|
|
/// builder.add_set("info.source-url", &source_url);
|
|
/// builder.add_license(&license_file_name, &license_name);
|
|
/// let manifest = builder.build();
|
|
/// # Ok::<(), ips::IpsError>(())
|
|
/// ```
|
|
///
|
|
/// Another style using with_base_metadata:
|
|
/// Example (no_run):
|
|
/// ```no_run
|
|
/// use libips::api as ips;
|
|
/// use std::path::Path;
|
|
/// let proto = Path::new("/proto");
|
|
/// let mut manifest = ips::ManifestBuilder::new()
|
|
/// .with_base_metadata(ips::BaseMeta {
|
|
/// fmri: Some(ips::Fmri::parse("pkg://pub/name@1.0").unwrap()),
|
|
/// summary: Some("Summary".into()),
|
|
/// classification: None,
|
|
/// upstream_url: None,
|
|
/// source_url: None,
|
|
/// license: None,
|
|
/// })
|
|
/// .build();
|
|
/// # Ok::<(), ips::IpsError>(())
|
|
/// ```
|
|
pub struct ManifestBuilder {
|
|
manifest: Manifest,
|
|
}
|
|
|
|
impl ManifestBuilder {
|
|
/// Add a simple set (attribute) action: set name=<key> value=<value>
|
|
/// Returns self for chaining.
|
|
pub fn add_set<K: Into<String>, V: ToString>(&mut self, key: K, value: V) -> &mut Self {
|
|
self.manifest.attributes.push(Attr {
|
|
key: key.into(),
|
|
values: vec![value.to_string()],
|
|
properties: Default::default(),
|
|
});
|
|
self
|
|
}
|
|
|
|
/// Add a license action, equivalent to: license path=<path> license=<license_name>
|
|
pub fn add_license(&mut self, path: &str, license_name: &str) -> &mut Self {
|
|
let mut props = std::collections::HashMap::new();
|
|
props.insert(
|
|
"path".to_string(),
|
|
Property {
|
|
key: "path".to_string(),
|
|
value: path.to_string(),
|
|
},
|
|
);
|
|
props.insert(
|
|
"license".to_string(),
|
|
Property {
|
|
key: "license".to_string(),
|
|
value: license_name.to_string(),
|
|
},
|
|
);
|
|
self.manifest.licenses.push(LicenseAction {
|
|
payload: String::new(),
|
|
properties: props,
|
|
});
|
|
self
|
|
}
|
|
|
|
/// Add a link action
|
|
pub fn add_link(&mut self, path: &str, target: &str) -> &mut Self {
|
|
self.manifest.links.push(LinkAction {
|
|
path: path.to_string(),
|
|
target: target.to_string(),
|
|
properties: Default::default(),
|
|
});
|
|
self
|
|
}
|
|
|
|
/// Add a dependency action with a type and an FMRI string (name or full FMRI).
|
|
/// If FMRI parsing fails, the dependency is added without an fmri (will be flagged by lint).
|
|
pub fn add_depend(&mut self, dep_type: &str, fmri_str: &str) -> &mut Self {
|
|
let fmri = Fmri::parse(fmri_str).ok();
|
|
let mut d = DependAction::default();
|
|
d.dependency_type = dep_type.to_string();
|
|
d.fmri = fmri;
|
|
self.manifest.dependencies.push(d);
|
|
self
|
|
}
|
|
/// Start a new empty builder
|
|
pub fn new() -> Self {
|
|
Self {
|
|
manifest: Manifest::new(),
|
|
}
|
|
}
|
|
|
|
/// Convenience: construct a Manifest directly by scanning a prototype directory.
|
|
/// Paths in the manifest are stored relative to `proto`.
|
|
pub fn from_prototype_dir(proto: &Path) -> Result<Manifest, IpsError> {
|
|
if !proto.exists() {
|
|
return Err(IpsError::Io(format!(
|
|
"prototype directory does not exist: {}",
|
|
proto.display()
|
|
)));
|
|
}
|
|
let root = proto.canonicalize().map_err(|e| {
|
|
IpsError::Io(format!("failed to canonicalize {}: {}", proto.display(), e))
|
|
})?;
|
|
|
|
let mut m = Manifest::new();
|
|
for entry in WalkDir::new(&root).into_iter().filter_map(|e| e.ok()) {
|
|
let p = entry.path();
|
|
if p.is_file() {
|
|
// Build File action from absolute path
|
|
let mut f = FileAction::read_from_path(p).map_err(RepositoryError::from)?;
|
|
// Store path relative to root
|
|
let rel = p
|
|
.strip_prefix(&root)
|
|
.map_err(RepositoryError::from)?
|
|
.to_string_lossy()
|
|
.to_string();
|
|
f.path = rel;
|
|
m.add_file(f);
|
|
}
|
|
}
|
|
Ok(m)
|
|
}
|
|
|
|
/// Add base metadata to the manifest using typed fields.
|
|
pub fn with_base_metadata(mut self, meta: BaseMeta) -> Self {
|
|
// Helper to push an attribute set action
|
|
let mut push_attr = |key: &str, val: String| {
|
|
self.manifest.attributes.push(Attr {
|
|
key: key.to_string(),
|
|
values: vec![val],
|
|
properties: Default::default(),
|
|
});
|
|
};
|
|
|
|
if let Some(fmri) = meta.fmri {
|
|
push_attr("pkg.fmri", fmri.to_string());
|
|
}
|
|
if let Some(s) = meta.summary {
|
|
push_attr("pkg.summary", s);
|
|
}
|
|
if let Some(c) = meta.classification {
|
|
push_attr("info.classification", c);
|
|
}
|
|
if let Some(u) = meta.upstream_url {
|
|
push_attr("info.upstream-url", u);
|
|
}
|
|
if let Some(su) = meta.source_url {
|
|
push_attr("info.source-url", su);
|
|
}
|
|
if let Some(l) = meta.license {
|
|
// Represent base license via an attribute named 'license'; callers may add dedicated license actions separately
|
|
self.manifest.attributes.push(Attr {
|
|
key: "license".to_string(),
|
|
values: vec![l],
|
|
properties: Default::default(),
|
|
});
|
|
}
|
|
self
|
|
}
|
|
|
|
/// Apply typed transform rules to the manifest (in place)
|
|
pub fn apply_rules(mut self, rules: &[TransformRule]) -> Result<Self, IpsError> {
|
|
let rules: Vec<crate::actions::Transform> = rules.iter().cloned().map(Into::into).collect();
|
|
transformer::apply(&mut self.manifest, &rules)?;
|
|
Ok(self)
|
|
}
|
|
|
|
/// Finalize and return the Manifest
|
|
pub fn build(self) -> Manifest {
|
|
self.manifest
|
|
}
|
|
}
|
|
|
|
/// Minimal repository facade backed by an on-disk file repository.
|
|
///
|
|
/// Example (no_run):
|
|
/// ```no_run
|
|
/// use libips::api::Repository;
|
|
/// use std::path::Path;
|
|
/// let repo_path = Path::new("/repo");
|
|
/// // Create if needed
|
|
/// let _ = Repository::create(repo_path);
|
|
/// // Open and ensure publisher
|
|
/// let repo = Repository::open(repo_path)?;
|
|
/// if !repo.has_publisher("pub")? { repo.add_publisher("pub")?; }
|
|
/// # Ok::<(), libips::api::IpsError>(())
|
|
/// ```
|
|
pub struct Repository {
|
|
path: PathBuf,
|
|
}
|
|
|
|
impl Repository {
|
|
pub fn open(path: &Path) -> Result<Self, IpsError> {
|
|
// Validate by opening backend
|
|
let _ = FileBackend::open(path)?;
|
|
Ok(Self {
|
|
path: path.to_path_buf(),
|
|
})
|
|
}
|
|
|
|
pub fn create(path: &Path) -> Result<Self, IpsError> {
|
|
let _ = FileBackend::create(path, RepositoryVersion::default())?;
|
|
Ok(Self {
|
|
path: path.to_path_buf(),
|
|
})
|
|
}
|
|
|
|
pub fn has_publisher(&self, name: &str) -> Result<bool, IpsError> {
|
|
let backend = FileBackend::open(&self.path)?;
|
|
let info = backend.get_info()?;
|
|
Ok(info.publishers.iter().any(|p| p.name == name))
|
|
}
|
|
|
|
pub fn add_publisher(&self, name: &str) -> Result<(), IpsError> {
|
|
let mut backend = FileBackend::open(&self.path)?;
|
|
backend.add_publisher(name)?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn path(&self) -> &Path {
|
|
&self.path
|
|
}
|
|
}
|
|
|
|
/// High-level publishing client for starting repository transactions.
|
|
///
|
|
/// Example (no_run):
|
|
/// ```no_run
|
|
/// use libips::api as ips;
|
|
/// use std::path::Path;
|
|
/// let repo = ips::Repository::open(Path::new("/repo"))?;
|
|
/// let client = ips::PublisherClient::new(repo, "pub");
|
|
/// let mut tx = client.begin()?;
|
|
/// // Add payloads and manifests, then commit
|
|
/// # Ok::<(), ips::IpsError>(())
|
|
/// ```
|
|
pub struct PublisherClient {
|
|
repo: Repository,
|
|
publisher: String,
|
|
}
|
|
|
|
impl PublisherClient {
|
|
pub fn new(repo: Repository, publisher: impl Into<String>) -> Self {
|
|
Self {
|
|
repo,
|
|
publisher: publisher.into(),
|
|
}
|
|
}
|
|
|
|
/// Begin a new transaction
|
|
pub fn begin(&self) -> Result<Txn, IpsError> {
|
|
let backend = FileBackend::open(self.repo.path())?;
|
|
let tx = backend.begin_transaction()?; // returns Transaction bound to repo path
|
|
Ok(Txn {
|
|
backend_path: self.repo.path().to_path_buf(),
|
|
tx,
|
|
publisher: self.publisher.clone(),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Transaction wrapper exposing add_payload_dir/add_manifest/commit.
|
|
///
|
|
/// Start a transaction via PublisherClient::begin, add payload directories and manifests,
|
|
/// then commit to publish.
|
|
///
|
|
/// Example (no_run):
|
|
/// ```no_run
|
|
/// use libips::api as ips;
|
|
/// use std::path::Path;
|
|
/// let repo = ips::Repository::open(Path::new("/repo"))?;
|
|
/// let client = ips::PublisherClient::new(repo, "pub");
|
|
/// let mut tx = client.begin()?;
|
|
/// tx.add_payload_dir(Path::new("/proto"))?;
|
|
/// tx.add_manifest(&ips::Manifest::new());
|
|
/// tx.commit()?;
|
|
/// # Ok::<(), ips::IpsError>(())
|
|
/// ```
|
|
pub struct Txn {
|
|
backend_path: PathBuf,
|
|
tx: Transaction,
|
|
publisher: String,
|
|
}
|
|
|
|
impl Txn {
|
|
/// Add all files from the given payload/prototype directory
|
|
pub fn add_payload_dir(&mut self, dir: &Path) -> Result<(), IpsError> {
|
|
let root = dir.canonicalize().map_err(|e| {
|
|
IpsError::Io(format!("failed to canonicalize {}: {}", dir.display(), e))
|
|
})?;
|
|
for entry in WalkDir::new(&root).into_iter().filter_map(|e| e.ok()) {
|
|
let p = entry.path();
|
|
if p.is_file() {
|
|
let mut f = FileAction::read_from_path(p).map_err(RepositoryError::from)?;
|
|
let rel = p
|
|
.strip_prefix(&root)
|
|
.map_err(RepositoryError::from)?
|
|
.to_string_lossy()
|
|
.to_string();
|
|
f.path = rel;
|
|
self.tx.add_file(f, p)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Merge the provided manifest into the transaction's manifest
|
|
pub fn add_manifest(&mut self, manifest: &Manifest) {
|
|
self.tx.update_manifest(manifest.clone());
|
|
}
|
|
|
|
/// Commit the transaction to the repository for the preselected publisher
|
|
pub fn commit(mut self) -> Result<(), IpsError> {
|
|
self.tx.set_publisher(&self.publisher);
|
|
self.tx.commit()?;
|
|
// Rebuild metadata (catalog and index)
|
|
let backend = FileBackend::open(&self.backend_path)?;
|
|
backend.rebuild(Some(&self.publisher), false, false)?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Dependency generation facade.
|
|
///
|
|
/// Use this to compute file-level dependencies and resolve them to package
|
|
/// FMRIs using a repository.
|
|
///
|
|
/// Example: generate dependencies with a repository (no_run)
|
|
/// ```no_run
|
|
/// use libips::api as ips;
|
|
/// use std::path::Path;
|
|
/// use libips::repository::{FileBackend, ReadableRepository};
|
|
/// let proto = Path::new("/proto");
|
|
/// let mut backend = FileBackend::open(Path::new("/repo"))?;
|
|
/// let manifest = ips::Manifest::new();
|
|
/// let manifest = ips::DependencyGenerator::generate_with_repo(
|
|
/// &mut backend,
|
|
/// Some("pub"),
|
|
/// proto,
|
|
/// &manifest,
|
|
/// ips::DependGenerateOptions::default(),
|
|
/// )?;
|
|
/// # Ok::<(), ips::IpsError>(())
|
|
/// ```
|
|
pub struct DependencyGenerator;
|
|
|
|
impl DependencyGenerator {
|
|
/// Compute file-level dependencies for the given manifest, using `proto` as base for local file resolution.
|
|
/// This is a helper for callers that want to inspect raw file deps before mapping them to package FMRIs.
|
|
pub fn file_deps(
|
|
proto: &Path,
|
|
manifest: &Manifest,
|
|
mut opts: DependGenerateOptions,
|
|
) -> Result<Vec<FileDep>, IpsError> {
|
|
if opts.proto_dir.is_none() {
|
|
opts.proto_dir = Some(proto.to_path_buf());
|
|
}
|
|
let deps = crate::depend::generate_file_dependencies_from_manifest(manifest, &opts)?;
|
|
Ok(deps)
|
|
}
|
|
|
|
/// Generate dependencies and return a new manifest with Depend actions injected.
|
|
/// Intentionally not implemented in this facade: mapping raw file dependencies to package FMRIs
|
|
/// requires repository/catalog context. Call `generate_with_repo` instead.
|
|
pub fn generate(_proto: &Path, _manifest: &Manifest) -> Result<Manifest, IpsError> {
|
|
Err(IpsError::Unimplemented {
|
|
feature: "DependencyGenerator::generate (use generate_with_repo)",
|
|
})
|
|
}
|
|
|
|
/// Generate dependencies using a repository to resolve file-level deps into package FMRIs.
|
|
pub fn generate_with_repo<R: ReadableRepository>(
|
|
repo: &mut R,
|
|
publisher: Option<&str>,
|
|
proto: &Path,
|
|
manifest: &Manifest,
|
|
mut opts: DependGenerateOptions,
|
|
) -> Result<Manifest, IpsError> {
|
|
if opts.proto_dir.is_none() {
|
|
opts.proto_dir = Some(proto.to_path_buf());
|
|
}
|
|
let file_deps = crate::depend::generate_file_dependencies_from_manifest(manifest, &opts)?;
|
|
let deps = crate::depend::resolve_dependencies(repo, publisher, &file_deps)?;
|
|
let mut out = manifest.clone();
|
|
out.dependencies.extend(deps);
|
|
Ok(out)
|
|
}
|
|
}
|
|
|
|
/// Cross-manifest dependency resolver.
|
|
///
|
|
/// This helper fills missing publisher/version on dependency FMRIs either by
|
|
/// inspecting peer manifests in-memory or by querying a repository.
|
|
///
|
|
/// Examples (no_run):
|
|
/// ```no_run
|
|
/// use libips::api as ips;
|
|
/// // Peer-manifest resolve
|
|
/// let mut manifests: Vec<ips::Manifest> = vec![]; // populate with manifests that depend on each other
|
|
/// ips::Resolver::resolve(&mut manifests)?;
|
|
/// # Ok::<(), ips::IpsError>(())
|
|
/// ```
|
|
/// ```no_run
|
|
/// use libips::api as ips;
|
|
/// use std::path::Path;
|
|
/// // Repository-backed resolve
|
|
/// use libips::repository::{FileBackend, ReadableRepository};
|
|
/// let backend = FileBackend::open(Path::new("/repo"))?;
|
|
/// let mut manifests: Vec<ips::Manifest> = vec![]; // populate
|
|
/// ips::Resolver::resolve_with_repo(&backend, Some("pub"), &mut manifests)?;
|
|
/// # Ok::<(), ips::IpsError>(())
|
|
/// ```
|
|
pub struct Resolver;
|
|
|
|
impl Resolver {
|
|
/// Best-effort peer-manifest resolver.
|
|
/// Note: For production resolution against published packages, prefer resolve_with_repo().
|
|
pub fn resolve(manifests: &mut [Manifest]) -> Result<(), IpsError> {
|
|
// Build a map from package name (stem) to full FMRI from the provided manifests
|
|
use std::collections::HashMap;
|
|
let mut providers: HashMap<String, Fmri> = HashMap::new();
|
|
for m in manifests.iter() {
|
|
if let Some(f) = manifest_fmri(m) {
|
|
providers.insert(f.stem().to_string(), f);
|
|
}
|
|
}
|
|
|
|
// For each manifest dependency that has an FMRI with missing publisher/version,
|
|
// fill in from providers if there is a matching manifest by name.
|
|
for m in manifests.iter_mut() {
|
|
for dep in &mut m.dependencies {
|
|
if let Some(ref mut f) = dep.fmri {
|
|
// Only attempt if version is missing
|
|
if f.version.is_none() {
|
|
if let Some(p) = providers.get(f.stem()) {
|
|
// Fill publisher if missing and version from provider
|
|
if f.publisher.is_none() {
|
|
f.publisher = p.publisher.clone();
|
|
}
|
|
if f.version.is_none() {
|
|
f.version = p.version.clone();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Resolve dependency FMRIs using a repository of already-published packages.
|
|
/// For each dependency with a name-only FMRI (missing version), if exactly one
|
|
/// package with that name exists in the given publisher, fill in publisher and version.
|
|
/// If multiple or zero matches are found, the dependency is left unchanged.
|
|
pub fn resolve_with_repo<R: ReadableRepository>(
|
|
repo: &R,
|
|
publisher: Option<&str>,
|
|
manifests: &mut [Manifest],
|
|
) -> Result<(), IpsError> {
|
|
for m in manifests.iter_mut() {
|
|
for dep in &mut m.dependencies {
|
|
if let Some(ref mut f) = dep.fmri {
|
|
if f.version.is_none() {
|
|
// Query repository for this package name
|
|
let pkgs = repo.list_packages(publisher, Some(&f.name))?;
|
|
let matches: Vec<&crate::repository::PackageInfo> =
|
|
pkgs.iter().filter(|pi| pi.fmri.name == f.name).collect();
|
|
if matches.len() == 1 {
|
|
let fmri = &matches[0].fmri;
|
|
if f.publisher.is_none() {
|
|
f.publisher = fmri.publisher.clone();
|
|
}
|
|
if f.version.is_none() {
|
|
f.version = fmri.version.clone();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// Helper: extract the package FMRI from a manifest's attributes
|
|
fn manifest_fmri(manifest: &Manifest) -> Option<Fmri> {
|
|
for attr in &manifest.attributes {
|
|
if attr.key == "pkg.fmri"
|
|
&& let Some(val) = attr.values.first()
|
|
&& let Ok(f) = Fmri::parse(val)
|
|
{
|
|
return Some(f);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Lint facade providing a typed, extensible rule engine with enable/disable controls.
|
|
///
|
|
/// Configure which rules to run, override severities, and pass rule-specific parameters.
|
|
///
|
|
/// Example: disable a rule and run lint (no_run)
|
|
/// ```no_run
|
|
/// use libips::api as ips;
|
|
/// let mut cfg = ips::LintConfig::default();
|
|
/// cfg.disabled_rules = vec!["manifest.summary".into()];
|
|
/// let mut m = ips::Manifest::new();
|
|
/// m.attributes.push(libips::actions::Attr{ key: "pkg.fmri".into(), values: vec!["pkg://pub/name@1.0".into()], properties: Default::default() });
|
|
/// let diags = ips::lint::lint_manifest(&m, &cfg)?;
|
|
/// assert!(diags.is_empty());
|
|
/// # Ok::<(), ips::IpsError>(())
|
|
/// ```
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct LintConfig {
|
|
pub reference_repos: Vec<PathBuf>,
|
|
pub rulesets: Vec<String>,
|
|
// Rule configurability
|
|
pub disabled_rules: Vec<String>, // rule IDs to disable
|
|
pub enabled_only: Option<Vec<String>>, // if Some, only these rule IDs run
|
|
pub severity_overrides: std::collections::HashMap<String, lint::LintSeverity>,
|
|
pub rule_params: std::collections::HashMap<String, std::collections::HashMap<String, String>>, // rule_id -> (key->val)
|
|
}
|
|
|
|
pub mod lint {
|
|
use super::*;
|
|
use miette::Diagnostic;
|
|
use thiserror::Error;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum LintSeverity {
|
|
Error,
|
|
Warning,
|
|
Info,
|
|
}
|
|
|
|
#[derive(Debug, Error, Diagnostic)]
|
|
pub enum LintIssue {
|
|
#[error("Manifest is missing pkg.fmri or it is invalid")]
|
|
#[diagnostic(
|
|
code(ips::lint_error::missing_fmri),
|
|
help("Add a valid set name=pkg.fmri value=... attribute")
|
|
)]
|
|
MissingOrInvalidFmri,
|
|
|
|
#[error("Manifest has multiple pkg.fmri attributes")]
|
|
#[diagnostic(
|
|
code(ips::lint_error::duplicate_fmri),
|
|
help("Ensure only one pkg.fmri set action is present")
|
|
)]
|
|
DuplicateFmri,
|
|
|
|
#[error("Manifest is missing pkg.summary")]
|
|
#[diagnostic(
|
|
code(ips::lint_error::missing_summary),
|
|
help("Add a set name=pkg.summary value=... attribute")
|
|
)]
|
|
MissingSummary,
|
|
|
|
#[error("Dependency is missing FMRI or name")]
|
|
#[diagnostic(
|
|
code(ips::lint_error::dependency_missing_fmri),
|
|
help("Each depend action should include a valid fmri (name or full fmri)")
|
|
)]
|
|
DependencyMissingFmri,
|
|
|
|
#[error("Dependency type is missing")]
|
|
#[diagnostic(
|
|
code(ips::lint_error::dependency_missing_type),
|
|
help("Set depend type (e.g., require, incorporate, optional)")
|
|
)]
|
|
DependencyMissingType,
|
|
}
|
|
|
|
pub trait LintRule {
|
|
fn id(&self) -> &'static str;
|
|
fn description(&self) -> &'static str;
|
|
fn default_severity(&self) -> LintSeverity {
|
|
LintSeverity::Error
|
|
}
|
|
/// Run this rule against the manifest. Implementors may ignore `config` (prefix with `_`) if not needed.
|
|
/// The config carries enable/disable lists, severity overrides and rule-specific parameters for extensibility.
|
|
fn check(&self, manifest: &Manifest, config: &LintConfig) -> Vec<miette::Report>;
|
|
}
|
|
|
|
struct RuleManifestFmri;
|
|
impl LintRule for RuleManifestFmri {
|
|
fn id(&self) -> &'static str {
|
|
"manifest.fmri"
|
|
}
|
|
fn description(&self) -> &'static str {
|
|
"Validate pkg.fmri presence/uniqueness/parse"
|
|
}
|
|
fn check(&self, manifest: &Manifest, _config: &LintConfig) -> Vec<miette::Report> {
|
|
let mut diags = Vec::new();
|
|
let mut fmri_attr_count = 0usize;
|
|
let mut fmri_text: Option<String> = None;
|
|
for attr in &manifest.attributes {
|
|
if attr.key == "pkg.fmri" {
|
|
fmri_attr_count += 1;
|
|
if let Some(v) = attr.values.first() {
|
|
fmri_text = Some(v.clone());
|
|
}
|
|
}
|
|
}
|
|
if fmri_attr_count > 1 {
|
|
diags.push(miette::Report::new(LintIssue::DuplicateFmri));
|
|
}
|
|
match (fmri_attr_count, fmri_text) {
|
|
(0, _) => diags.push(miette::Report::new(LintIssue::MissingOrInvalidFmri)),
|
|
(_, Some(txt)) => {
|
|
if crate::fmri::Fmri::parse(&txt).is_err() {
|
|
diags.push(miette::Report::new(LintIssue::MissingOrInvalidFmri));
|
|
}
|
|
}
|
|
(_, None) => diags.push(miette::Report::new(LintIssue::MissingOrInvalidFmri)),
|
|
}
|
|
diags
|
|
}
|
|
}
|
|
|
|
struct RuleManifestSummary;
|
|
impl LintRule for RuleManifestSummary {
|
|
fn id(&self) -> &'static str {
|
|
"manifest.summary"
|
|
}
|
|
fn description(&self) -> &'static str {
|
|
"Validate pkg.summary presence"
|
|
}
|
|
fn check(&self, manifest: &Manifest, _config: &LintConfig) -> Vec<miette::Report> {
|
|
let mut diags = Vec::new();
|
|
let has_summary = manifest
|
|
.attributes
|
|
.iter()
|
|
.any(|a| a.key == "pkg.summary" && a.values.iter().any(|v| !v.trim().is_empty()));
|
|
if !has_summary {
|
|
diags.push(miette::Report::new(LintIssue::MissingSummary));
|
|
}
|
|
diags
|
|
}
|
|
}
|
|
|
|
struct RuleDependencyFields;
|
|
impl LintRule for RuleDependencyFields {
|
|
fn id(&self) -> &'static str {
|
|
"depend.fields"
|
|
}
|
|
fn description(&self) -> &'static str {
|
|
"Validate basic dependency fields"
|
|
}
|
|
fn check(&self, manifest: &Manifest, _config: &LintConfig) -> Vec<miette::Report> {
|
|
let mut diags = Vec::new();
|
|
for dep in &manifest.dependencies {
|
|
let fmri_ok = dep
|
|
.fmri
|
|
.as_ref()
|
|
.map(|f| !f.name.trim().is_empty())
|
|
.unwrap_or(false);
|
|
if !fmri_ok {
|
|
diags.push(miette::Report::new(LintIssue::DependencyMissingFmri));
|
|
}
|
|
if dep.dependency_type.trim().is_empty() {
|
|
diags.push(miette::Report::new(LintIssue::DependencyMissingType));
|
|
}
|
|
}
|
|
diags
|
|
}
|
|
}
|
|
|
|
fn default_rules() -> Vec<Box<dyn LintRule>> {
|
|
vec![
|
|
Box::new(RuleManifestFmri),
|
|
Box::new(RuleManifestSummary),
|
|
Box::new(RuleDependencyFields),
|
|
]
|
|
}
|
|
|
|
fn rule_enabled(rule_id: &str, cfg: &LintConfig) -> bool {
|
|
if let Some(only) = &cfg.enabled_only {
|
|
let set: std::collections::HashSet<&str> = only.iter().map(|s| s.as_str()).collect();
|
|
return set.contains(rule_id);
|
|
}
|
|
let disabled: std::collections::HashSet<&str> =
|
|
cfg.disabled_rules.iter().map(|s| s.as_str()).collect();
|
|
!disabled.contains(rule_id)
|
|
}
|
|
|
|
/// Lint a manifest and return diagnostics. Does not fail the call; diagnostics are returned as reports.
|
|
///
|
|
/// Example (no_run):
|
|
/// ```no_run
|
|
/// use libips::api as ips;
|
|
/// let mut m = ips::Manifest::new();
|
|
/// m.attributes.push(libips::actions::Attr{ key: "pkg.fmri".into(), values: vec!["pkg://pub/name@1.0".into()], properties: Default::default() });
|
|
/// let cfg = ips::LintConfig::default();
|
|
/// let diags = ips::lint::lint_manifest(&m, &cfg)?;
|
|
/// assert!(diags.is_empty());
|
|
/// # Ok::<(), ips::IpsError>(())
|
|
/// ```
|
|
pub fn lint_manifest(
|
|
manifest: &Manifest,
|
|
config: &LintConfig,
|
|
) -> Result<Vec<miette::Report>, IpsError> {
|
|
let mut diags: Vec<miette::Report> = Vec::new();
|
|
for rule in default_rules().into_iter() {
|
|
if rule_enabled(rule.id(), config) {
|
|
diags.extend(rule.check(manifest, config).into_iter());
|
|
}
|
|
}
|
|
Ok(diags)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::actions::{Attr, Dependency as ManifestDependency};
|
|
|
|
fn make_manifest_with_fmri(fmri_str: &str) -> Manifest {
|
|
let mut m = Manifest::new();
|
|
m.attributes.push(Attr {
|
|
key: "pkg.fmri".into(),
|
|
values: vec![fmri_str.to_string()],
|
|
properties: Default::default(),
|
|
});
|
|
m
|
|
}
|
|
|
|
#[test]
|
|
fn resolver_fills_version_and_publisher_from_peer_manifest() {
|
|
// Provider manifest: pkgA with publisher and version
|
|
let provider = make_manifest_with_fmri("pkg://pub/pkgA@1.0");
|
|
|
|
// Consumer manifest with dependency on pkgA without version/publisher
|
|
let mut consumer = make_manifest_with_fmri("pkg://pub/consumer@0.1");
|
|
let dep_fmri = Fmri::parse("pkgA").unwrap();
|
|
consumer.dependencies.push(ManifestDependency {
|
|
fmri: Some(dep_fmri),
|
|
dependency_type: "require".to_string(),
|
|
predicate: None,
|
|
root_image: String::new(),
|
|
optional: Vec::new(),
|
|
facets: Default::default(),
|
|
});
|
|
|
|
let mut manifests = vec![provider, consumer];
|
|
Resolver::resolve(&mut manifests).unwrap();
|
|
|
|
// After resolve, the consumer's first dependency should have version and publisher set
|
|
let consumer_after = &manifests[1];
|
|
let dep = &consumer_after.dependencies[0];
|
|
let fmri = dep.fmri.as_ref().unwrap();
|
|
assert_eq!(fmri.name, "pkgA");
|
|
assert_eq!(fmri.publisher.as_deref(), Some("pub"));
|
|
assert!(
|
|
fmri.version.is_some(),
|
|
"expected version to be filled from provider"
|
|
);
|
|
assert_eq!(fmri.version.as_ref().unwrap().to_string(), "1.0");
|
|
}
|
|
|
|
#[test]
|
|
fn resolver_uses_repository_for_provider() {
|
|
use crate::repository::RepositoryVersion;
|
|
use crate::repository::file_backend::FileBackend;
|
|
|
|
// Create a temporary repository and add a publisher
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo_path = tmp.path().join("repo");
|
|
let mut backend = FileBackend::create(&repo_path, RepositoryVersion::default()).unwrap();
|
|
backend.add_publisher("pub").unwrap();
|
|
|
|
// Publish provider package pkgA@1.0
|
|
let mut provider = Manifest::new();
|
|
provider.attributes.push(Attr {
|
|
key: "pkg.fmri".into(),
|
|
values: vec!["pkg://pub/pkgA@1.0".to_string()],
|
|
properties: Default::default(),
|
|
});
|
|
let mut tx = backend.begin_transaction().unwrap();
|
|
tx.update_manifest(provider);
|
|
tx.set_publisher("pub");
|
|
tx.commit().unwrap();
|
|
backend.rebuild(Some("pub"), false, false).unwrap();
|
|
|
|
// Build consumer with name-only dependency
|
|
let mut consumer = make_manifest_with_fmri("pkg://pub/consumer@0.1");
|
|
let dep_fmri = Fmri::parse("pkgA").unwrap();
|
|
consumer.dependencies.push(ManifestDependency {
|
|
fmri: Some(dep_fmri),
|
|
dependency_type: "require".to_string(),
|
|
predicate: None,
|
|
root_image: String::new(),
|
|
optional: Vec::new(),
|
|
facets: Default::default(),
|
|
});
|
|
|
|
let mut manifests = vec![consumer];
|
|
Resolver::resolve_with_repo(&backend, Some("pub"), &mut manifests).unwrap();
|
|
let dep = &manifests[0].dependencies[0];
|
|
let fmri = dep.fmri.as_ref().unwrap();
|
|
assert_eq!(fmri.publisher.as_deref(), Some("pub"));
|
|
assert_eq!(fmri.version.as_ref().unwrap().to_string(), "1.0");
|
|
}
|
|
|
|
#[test]
|
|
fn lint_reports_missing_fmri_and_summary() {
|
|
let m = Manifest::new();
|
|
let cfg = LintConfig::default();
|
|
let diags = lint::lint_manifest(&m, &cfg).unwrap();
|
|
assert!(!diags.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn lint_accepts_valid_manifest() {
|
|
let mut m = Manifest::new();
|
|
m.attributes.push(Attr {
|
|
key: "pkg.fmri".into(),
|
|
values: vec!["pkg://pub/name@1.0".to_string()],
|
|
properties: Default::default(),
|
|
});
|
|
m.attributes.push(Attr {
|
|
key: "pkg.summary".into(),
|
|
values: vec!["A package".to_string()],
|
|
properties: Default::default(),
|
|
});
|
|
let cfg = LintConfig::default();
|
|
let diags = lint::lint_manifest(&m, &cfg).unwrap();
|
|
assert!(diags.is_empty(), "unexpected diags: {:?}", diags);
|
|
}
|
|
|
|
#[test]
|
|
fn lint_disable_summary_rule() {
|
|
// Manifest with valid fmri but missing summary
|
|
let mut m = Manifest::new();
|
|
m.attributes.push(Attr {
|
|
key: "pkg.fmri".into(),
|
|
values: vec!["pkg://pub/name@1.0".to_string()],
|
|
properties: Default::default(),
|
|
});
|
|
|
|
// Disable the summary rule; expect no diagnostics
|
|
let mut cfg = LintConfig::default();
|
|
cfg.disabled_rules = vec!["manifest.summary".to_string()];
|
|
let diags = lint::lint_manifest(&m, &cfg).unwrap();
|
|
// fmri is valid, dependencies empty, summary rule disabled => no diags
|
|
assert!(
|
|
diags.is_empty(),
|
|
"expected no diagnostics when summary rule disabled, got: {:?}",
|
|
diags
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn builder_add_set_license_link_depend() {
|
|
// add_set with Fmri and strings
|
|
let fmri = Fmri::parse("pkg://pub/example@1.0").unwrap();
|
|
let mut b = ManifestBuilder::new();
|
|
b.add_set("pkg.fmri", &fmri);
|
|
b.add_set("pkg.summary", "Summary");
|
|
b.add_set("info.upstream-url", "https://example.com");
|
|
b.add_license("LICENSE", "MIT");
|
|
b.add_link("usr/bin/foo", "../libexec/foo");
|
|
b.add_depend("require", "pkg://pub/dep@1.2");
|
|
let m = b.build();
|
|
|
|
// Validate attributes include fmri and summary
|
|
assert!(m.attributes.iter().any(|a| {
|
|
a.key == "pkg.fmri"
|
|
&& a.values
|
|
.first()
|
|
.map(|v| v == &fmri.to_string())
|
|
.unwrap_or(false)
|
|
}));
|
|
assert!(
|
|
m.attributes.iter().any(|a| a.key == "pkg.summary"
|
|
&& a.values.first().map(|v| v == "Summary").unwrap_or(false))
|
|
);
|
|
|
|
// Validate license
|
|
assert_eq!(m.licenses.len(), 1);
|
|
let lic = &m.licenses[0];
|
|
assert_eq!(
|
|
lic.properties.get("path").map(|p| p.value.as_str()),
|
|
Some("LICENSE")
|
|
);
|
|
assert_eq!(
|
|
lic.properties.get("license").map(|p| p.value.as_str()),
|
|
Some("MIT")
|
|
);
|
|
|
|
// Validate link
|
|
assert_eq!(m.links.len(), 1);
|
|
let ln = &m.links[0];
|
|
assert_eq!(ln.path, "usr/bin/foo");
|
|
assert_eq!(ln.target, "../libexec/foo");
|
|
|
|
// Validate dependency
|
|
assert_eq!(m.dependencies.len(), 1);
|
|
let dep = &m.dependencies[0];
|
|
assert_eq!(dep.dependency_type, "require");
|
|
let df = dep.fmri.as_ref().expect("dep fmri parsed");
|
|
assert_eq!(df.publisher.as_deref(), Some("pub"));
|
|
assert_eq!(df.version.as_ref().unwrap().to_string(), "1.2");
|
|
}
|
|
}
|
|
|
|
impl From<RepositoryError> for IpsError {
|
|
fn from(err: RepositoryError) -> Self {
|
|
Self::Repository(Box::new(err))
|
|
}
|
|
}
|