nex-pkg 0.18.0

Package manager UX for nix-darwin + homebrew
Documentation
use std::fmt;
use std::path::{Path, PathBuf};

use anyhow::{bail, Context, Result};
use serde::Deserialize;

pub const PROFILE_FRAGMENT_SCHEMA_V1: &str = "io.styrene.nex.profile-fragment.v1";

#[derive(Debug, Clone, Deserialize)]
pub struct ProfileFragmentDocument {
    pub fragment: ProfileFragment,
}

#[derive(Debug, Clone, Deserialize)]
pub struct ProfileFragment {
    pub schema: String,
    pub id: String,
    pub name: String,
    pub description: Option<String>,
    pub category: ProfileFragmentCategory,
    #[serde(default)]
    pub requires: Vec<String>,
    #[serde(default)]
    pub conflicts: Vec<String>,
    #[serde(default)]
    pub platforms: Vec<ProfileFragmentPlatform>,
    pub visibility: Option<ProfileFragmentVisibility>,
    pub safety: Option<ProfileFragmentSafety>,
}

#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum ProfileFragmentCategory {
    Core,
    Platform,
    Desktop,
    Gpu,
    Audio,
    Shell,
    Role,
}

impl fmt::Display for ProfileFragmentCategory {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(match self {
            Self::Core => "core",
            Self::Platform => "platform",
            Self::Desktop => "desktop",
            Self::Gpu => "gpu",
            Self::Audio => "audio",
            Self::Shell => "shell",
            Self::Role => "role",
        })
    }
}

#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum ProfileFragmentPlatform {
    Any,
    Linux,
    Macos,
}

impl fmt::Display for ProfileFragmentPlatform {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(match self {
            Self::Any => "any",
            Self::Linux => "linux",
            Self::Macos => "macos",
        })
    }
}

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ProfileFragmentVisibility {
    Public,
    Internal,
}

impl fmt::Display for ProfileFragmentVisibility {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(match self {
            Self::Public => "public",
            Self::Internal => "internal",
        })
    }
}

#[derive(Debug, Clone, Deserialize)]
pub struct ProfileFragmentSafety {
    pub mutates_system_services: bool,
    pub mutates_hardware_drivers: bool,
    pub requires_confirmation: bool,
}

impl ProfileFragmentDocument {
    pub fn from_path(path: &Path) -> Result<Self> {
        let content = std::fs::read_to_string(path)
            .with_context(|| format!("reading profile fragment {}", path.display()))?;
        Self::from_str(&content).with_context(|| format!("parsing profile fragment {}", path.display()))
    }

    pub fn from_str(content: &str) -> Result<Self> {
        let document: Self = toml::from_str(content).context("invalid profile fragment TOML")?;
        document.validate()?;
        Ok(document)
    }

    pub fn validate(&self) -> Result<()> {
        let fragment = &self.fragment;
        require_non_empty("fragment.schema", &fragment.schema)?;
        require_non_empty("fragment.id", &fragment.id)?;
        require_non_empty("fragment.name", &fragment.name)?;

        if fragment.schema != PROFILE_FRAGMENT_SCHEMA_V1 {
            bail!(
                "unsupported profile fragment schema '{}'; expected '{}'",
                fragment.schema,
                PROFILE_FRAGMENT_SCHEMA_V1
            );
        }

        validate_fragment_id(&fragment.id)?;
        let category_prefix = format!("{}/", fragment.category);
        if !fragment.id.starts_with(&category_prefix) {
            bail!(
                "fragment id '{}' must start with its category prefix '{}'",
                fragment.id,
                category_prefix
            );
        }

        for required in &fragment.requires {
            validate_fragment_id(required)?;
            if required == &fragment.id {
                bail!("fragment '{}' cannot require itself", fragment.id);
            }
        }

        for conflict in &fragment.conflicts {
            validate_fragment_id(conflict)?;
            if conflict == &fragment.id {
                bail!("fragment '{}' cannot conflict with itself", fragment.id);
            }
        }

        if fragment.platforms.is_empty() {
            bail!("fragment.platforms must contain at least one platform");
        }
        if fragment.platforms.contains(&ProfileFragmentPlatform::Any) && fragment.platforms.len() > 1 {
            bail!("fragment.platforms cannot combine 'any' with specific platforms");
        }

        if matches!(
            fragment.category,
            ProfileFragmentCategory::Platform
                | ProfileFragmentCategory::Desktop
                | ProfileFragmentCategory::Gpu
                | ProfileFragmentCategory::Audio
        ) && fragment.safety.is_none()
        {
            bail!("fragment.safety is required for system-mutating fragment categories");
        }

        if let Some(safety) = &fragment.safety {
            if safety.mutates_hardware_drivers && !safety.requires_confirmation {
                bail!("hardware-driver fragments must require confirmation");
            }
        }

        Ok(())
    }
}

pub fn validate_fragment_id(id: &str) -> Result<()> {
    require_non_empty("fragment id", id)?;
    let parts: Vec<&str> = id.split('/').collect();
    if parts.len() != 2 || parts.iter().any(|part| part.is_empty()) {
        bail!("fragment id '{}' must use '<category>/<slug>' format", id);
    }
    for part in parts {
        if !part
            .chars()
            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
        {
            bail!(
                "fragment id '{}' must use lowercase slugs with [a-z0-9-] characters",
                id
            );
        }
    }
    Ok(())
}

pub fn infer_fragment_path_id(path: &Path) -> Option<String> {
    let stem = path.file_stem()?.to_str()?;
    let parent = path.parent()?.file_name()?.to_str()?;
    Some(format!("{parent}/{stem}"))
}

pub fn find_fragment_files(root: &Path) -> Result<Vec<PathBuf>> {
    let mut files = Vec::new();
    visit_fragment_files(root, &mut files)?;
    files.sort();
    Ok(files)
}

fn visit_fragment_files(path: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
    for entry in std::fs::read_dir(path).with_context(|| format!("reading {}", path.display()))? {
        let entry = entry?;
        let path = entry.path();
        if path.is_dir() {
            visit_fragment_files(&path, files)?;
        } else if path.extension().and_then(|ext| ext.to_str()) == Some("toml") {
            files.push(path);
        }
    }
    Ok(())
}

fn require_non_empty(field: &str, value: &str) -> Result<()> {
    if value.trim().is_empty() {
        bail!("{field} is required");
    }
    Ok(())
}

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

    fn valid_fragment() -> &'static str {
        r#"
[fragment]
schema = "io.styrene.nex.profile-fragment.v1"
id = "gpu/amd"
name = "amd"
description = "AMD GPU"
category = "gpu"
requires = ["platform/linux"]
conflicts = ["gpu/nvidia", "gpu/intel"]
platforms = ["linux"]
visibility = "public"

[fragment.safety]
mutates_system_services = false
mutates_hardware_drivers = true
requires_confirmation = true
"#
    }

    #[test]
    fn valid_profile_fragment_passes() {
        ProfileFragmentDocument::from_str(valid_fragment()).expect("valid fragment");
    }

    #[test]
    fn rejects_category_mismatch() {
        let manifest = valid_fragment().replace("id = \"gpu/amd\"", "id = \"audio/amd\"");
        let error = ProfileFragmentDocument::from_str(&manifest).expect_err("category mismatch");
        assert!(format!("{error:#}").contains("must start with its category prefix"));
    }

    #[test]
    fn rejects_hardware_mutation_without_confirmation() {
        let manifest = valid_fragment().replace("requires_confirmation = true", "requires_confirmation = false");
        let error = ProfileFragmentDocument::from_str(&manifest).expect_err("confirmation required");
        assert!(format!("{error:#}").contains("hardware-driver fragments must require confirmation"));
    }
}