// 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 #[derive(Debug, Error, Diagnostic)] pub enum IpsError { #[error(transparent)] #[diagnostic(transparent)] Repository(#[from] 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, pub summary: Option, pub classification: Option, pub upstream_url: Option, pub source_url: Option, pub license: Option, } /// 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= value= /// Returns self for chaining. pub fn add_set, 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= license= 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 { 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 { let rules: Vec = 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 { // Validate by opening backend let _ = FileBackend::open(path)?; Ok(Self { path: path.to_path_buf(), }) } pub fn create(path: &Path) -> Result { let _ = FileBackend::create(path, RepositoryVersion::default())?; Ok(Self { path: path.to_path_buf(), }) } pub fn has_publisher(&self, name: &str) -> Result { 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) -> Self { Self { repo, publisher: publisher.into(), } } /// Begin a new transaction pub fn begin(&self) -> Result { 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, 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 { 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( repo: &mut R, publisher: Option<&str>, proto: &Path, manifest: &Manifest, mut opts: DependGenerateOptions, ) -> Result { 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 = 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 = 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 = 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( 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 { for attr in &manifest.attributes { if attr.key == "pkg.fmri" { if let Some(val) = attr.values.get(0) { if 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, pub rulesets: Vec, // Rule configurability pub disabled_rules: Vec, // rule IDs to disable pub enabled_only: Option>, // if Some, only these rule IDs run pub severity_overrides: std::collections::HashMap, pub rule_params: std::collections::HashMap>, // 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; } 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 { let mut diags = Vec::new(); let mut fmri_attr_count = 0usize; let mut fmri_text: Option = None; for attr in &manifest.attributes { if attr.key == "pkg.fmri" { fmri_attr_count += 1; if let Some(v) = attr.values.get(0) { 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 { 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 { 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> { 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, IpsError> { let mut diags: Vec = 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 .get(0) .map(|v| v == &fmri.to_string()) .unwrap_or(false) })); assert!( m.attributes.iter().any(|a| a.key == "pkg.summary" && a.values.get(0).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"); } }