pkgsrc 0.11.0

Rust interface to pkgsrc packages and infrastructure
Documentation
/*
 * Copyright (c) 2026 Jonathan Perkin <jonathan@perkin.org.uk>
 *
 * Permission to use, copy, modify, and distribute this software for any
 * purpose with or without fee is hereby granted, provided that the above
 * copyright notice and this permission notice appear in all copies.
 *
 * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
 * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
 * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
 * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
 * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
 * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
 * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 */

/*!
 * Package path (category/name) parsing and normalization.
 *
 * In pkgsrc, every package resides in a directory structure of the form
 * `category/package`, where `category` groups related packages (such as
 * `devel`, `net`, or `www`) and `package` is the specific package name.
 *
 * This location is referred to as the `PKGPATH` and is stored as metadata
 * in binary packages.  Within pkgsrc itself, packages reference each other
 * using relative paths like `../../category/package` to traverse back to
 * the pkgsrc root before descending into the target package directory.
 *
 * [`PkgPath`] handles both formats:
 *
 * - **Short form**: `category/package` (as stored in `PKGPATH` metadata)
 * - **Full form**: `../../category/package` (as used in Makefiles)
 *
 * Either form can be used as input, and both are accessible after parsing.
 *
 * # Examples
 *
 * ```
 * use pkgsrc::PkgPath;
 * use std::ffi::OsStr;
 *
 * // Parse from short form
 * let p = PkgPath::new("pkgtools/pkg_install")?;
 * assert_eq!(p.as_path(), OsStr::new("pkgtools/pkg_install"));
 * assert_eq!(p.as_full_path(), OsStr::new("../../pkgtools/pkg_install"));
 *
 * // Parse from full form
 * let p = PkgPath::new("../../devel/gmake")?;
 * assert_eq!(p.as_path(), OsStr::new("devel/gmake"));
 * assert_eq!(p.as_full_path(), OsStr::new("../../devel/gmake"));
 *
 * // Invalid paths are rejected
 * assert!(PkgPath::new("../pkg_install").is_err());  // wrong depth
 * assert!(PkgPath::new("pkg_install").is_err());     // missing category
 * # Ok::<(), pkgsrc::PkgPathError>(())
 * ```
 *
 * # Display and Conversion
 *
 * [`PkgPath`] displays as the short form and implements [`AsRef<Path>`] for
 * use with filesystem operations.
 *
 * ```
 * use pkgsrc::PkgPath;
 *
 * let p = PkgPath::new("../../www/nginx")?;
 * assert_eq!(format!("{p}"), "www/nginx");
 * # Ok::<(), pkgsrc::PkgPathError>(())
 * ```
 *
 * [`AsRef<Path>`]: std::path::Path
 */

use std::borrow::Borrow;
use std::path::{Component, Path, PathBuf};
use std::str::FromStr;
use thiserror::Error;

const PREFIX: &str = "../../";

#[cfg(feature = "serde")]
use serde_with::{DeserializeFromStr, SerializeDisplay};

/**
 * An invalid path was specified trying to create a new [`PkgPath`].
 */
#[derive(Debug, Eq, Error, Ord, PartialEq, PartialOrd)]
pub enum PkgPathError {
    /**
     * Contains an invalid path.
     */
    #[error("Invalid path specified")]
    InvalidPath,
}

/**
 * Handling for `PKGPATH` metadata and relative package directory locations.
 *
 * [`PkgPath`] is a struct for storing the path to a package within pkgsrc.
 *
 * Binary packages contain the `PKGPATH` metadata, for example
 * `pkgtools/pkg_install`, while across pkgsrc dependencies are referred to by
 * their relative location, for example `../../pkgtools/pkg_install`.
 *
 * [`PkgPath`] takes either format as input, validates it for correctness,
 * and normalises it internally to the full `../../category/package` form.
 *
 * Both short and full forms are available as zero-cost string slices:
 * [`as_str`] returns `category/package`, while [`as_full_str`] returns
 * `../../category/package`.  [`as_path`] and [`as_full_path`] return the
 * same as [`Path`] references.
 *
 * A small amount of normalisation is performed on input, for example
 * trailing or double slashes, but otherwise input strings are expected to
 * be precisely formatted, and a [`PkgPathError`] is raised otherwise.
 *
 * ## Examples
 *
 * ```
 * use pkgsrc::PkgPath;
 * use std::ffi::OsStr;
 *
 * let p = PkgPath::new("pkgtools/pkg_install")?;
 * assert_eq!(p.as_path(), OsStr::new("pkgtools/pkg_install"));
 * assert_eq!(p.as_full_path(), OsStr::new("../../pkgtools/pkg_install"));
 *
 * let p = PkgPath::new("../../pkgtools/pkg_install")?;
 * assert_eq!(p.as_path(), OsStr::new("pkgtools/pkg_install"));
 * assert_eq!(p.as_full_path(), OsStr::new("../../pkgtools/pkg_install"));
 *
 * // Missing category path.
 * assert!(PkgPath::new("../../pkg_install").is_err());
 *
 * // Must traverse back to the pkgsrc root directory.
 * assert!(PkgPath::new("../pkg_install").is_err());
 *
 * // Not fully formed.
 * assert!(PkgPath::new("/pkgtools/pkg_install").is_err());;
 * # Ok::<(), pkgsrc::PkgPathError>(())
 * ```
 *
 * [`as_full_path`]: PkgPath::as_full_path
 * [`as_full_str`]: PkgPath::as_full_str
 * [`as_path`]: PkgPath::as_path
 * [`as_str`]: PkgPath::as_str
 */
#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[cfg_attr(feature = "serde", derive(SerializeDisplay, DeserializeFromStr))]
pub struct PkgPath {
    full: String,
}

impl PkgPath {
    /**
     * Create a new PkgPath
     */
    pub fn new(path: &str) -> Result<Self, PkgPathError> {
        let p = PathBuf::from(path);
        let c: Vec<_> = p.components().collect();

        let (cat, pkg) = match c.len() {
            //
            // Handle the "category/package" case.
            //
            2 => match (c[0], c[1]) {
                (Component::Normal(cat), Component::Normal(pkg)) => (cat, pkg),
                _ => return Err(PkgPathError::InvalidPath),
            },
            //
            // Handle the "../../category/package" case, extracting
            // just the "category/package" portion.
            //
            4 => match (c[0], c[1], c[2], c[3]) {
                (
                    Component::ParentDir,
                    Component::ParentDir,
                    Component::Normal(cat),
                    Component::Normal(pkg),
                ) => (cat, pkg),
                _ => return Err(PkgPathError::InvalidPath),
            },
            //
            // All other forms of input are invalid.
            //
            _ => return Err(PkgPathError::InvalidPath),
        };

        let cat = cat.to_str().ok_or(PkgPathError::InvalidPath)?;
        let pkg = pkg.to_str().ok_or(PkgPathError::InvalidPath)?;
        Ok(PkgPath {
            full: format!("{PREFIX}{cat}/{pkg}"),
        })
    }

    /**
     * Return the short path as a string slice, for example
     * `pkgtools/pkg_install`.
     */
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.full[PREFIX.len()..]
    }

    /**
     * Return a [`Path`] reference containing the short version of a PkgPath,
     * for example `pkgtools/pkg_install`.
     */
    pub fn as_path(&self) -> &Path {
        Path::new(self.as_str())
    }

    /**
     * Return the full path as a string slice, for example
     * `../../pkgtools/pkg_install`.
     */
    #[must_use]
    pub fn as_full_str(&self) -> &str {
        &self.full
    }

    /**
     * Return a [`Path`] reference containing the full version of a PkgPath,
     * for example `../../pkgtools/pkg_install`.
     */
    pub fn as_full_path(&self) -> &Path {
        Path::new(&self.full)
    }
}

impl std::fmt::Display for PkgPath {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

impl FromStr for PkgPath {
    type Err = PkgPathError;

    fn from_str(s: &str) -> Result<Self, PkgPathError> {
        PkgPath::new(s)
    }
}

impl AsRef<Path> for PkgPath {
    fn as_ref(&self) -> &Path {
        self.as_path()
    }
}

impl crate::kv::FromKv for PkgPath {
    fn from_kv(value: &str, span: crate::kv::Span) -> crate::kv::Result<Self> {
        Self::new(value).map_err(|e| crate::kv::KvError::Parse {
            message: e.to_string(),
            span,
        })
    }
}

impl Borrow<Path> for PkgPath {
    fn borrow(&self) -> &Path {
        self.as_path()
    }
}

impl TryFrom<&str> for PkgPath {
    type Error = PkgPathError;

    fn try_from(s: &str) -> Result<Self, Self::Error> {
        Self::new(s)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::ffi::OsStr;

    fn assert_valid_foobar(s: &str) -> Result<(), PkgPathError> {
        let p = PkgPath::new(s)?;
        assert_eq!(p.as_path(), OsStr::new("foo/bar"));
        assert_eq!(p.as_full_path(), OsStr::new("../../foo/bar"));
        Ok(())
    }

    #[test]
    fn pkgpath_test_good_input() -> Result<(), PkgPathError> {
        assert_valid_foobar("foo/bar")?;
        assert_valid_foobar("foo//bar")?;
        assert_valid_foobar("foo//bar//")?;
        assert_valid_foobar("../../foo/bar")?;
        assert_valid_foobar("../../foo/bar/")?;
        assert_valid_foobar("..//..//foo//bar//")?;
        Ok(())
    }

    #[test]
    fn pkgpath_test_bad_input() {
        let err = Err(PkgPathError::InvalidPath);
        assert_eq!(PkgPath::new(""), err);
        assert_eq!(PkgPath::new("\0"), err);
        assert_eq!(PkgPath::new("foo"), err);
        assert_eq!(PkgPath::new("foo/"), err);
        assert_eq!(PkgPath::new("./foo"), err);
        assert_eq!(PkgPath::new("./foo/"), err);
        assert_eq!(PkgPath::new("../foo"), err);
        assert_eq!(PkgPath::new("../foo/"), err);
        assert_eq!(PkgPath::new("../foo/bar"), err);
        assert_eq!(PkgPath::new("../foo/bar/"), err);
        assert_eq!(PkgPath::new("../foo/bar/ojnk"), err);
        assert_eq!(PkgPath::new("../foo/bar/ojnk/"), err);
        assert_eq!(PkgPath::new("../.."), err);
        assert_eq!(PkgPath::new("../../"), err);
        assert_eq!(PkgPath::new("../../foo"), err);
        assert_eq!(PkgPath::new("../../foo/"), err);
        assert_eq!(PkgPath::new("../../foo/bar/ojnk"), err);
        assert_eq!(PkgPath::new("../../foo/bar/ojnk/"), err);
        // ".. /" gets parsed as a Normal file named ".. ".
        assert_eq!(PkgPath::new(".. /../foo/bar"), err);
    }

    #[test]
    fn pkgpath_as_ref() -> Result<(), PkgPathError> {
        let p = PkgPath::new("pkgtools/pkg_install")?;

        // AsRef<Path> returns the short path
        let path: &Path = p.as_ref();
        assert_eq!(path, Path::new("pkgtools/pkg_install"));

        // Test that it works with generic functions expecting AsRef<Path>
        fn takes_asref(p: impl AsRef<Path>) -> bool {
            p.as_ref().starts_with("pkgtools")
        }
        assert!(takes_asref(&p));

        // Borrow<Path> returns the short path
        use std::borrow::Borrow;
        let path: &Path = p.borrow();
        assert_eq!(path, Path::new("pkgtools/pkg_install"));

        // TryFrom<&str>
        let p: PkgPath = "devel/gmake".try_into()?;
        assert_eq!(p.as_path(), OsStr::new("devel/gmake"));
        Ok(())
    }
}