use std::fmt;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use semver::Version;
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 version: 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,
Hardware,
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::Hardware => "hardware",
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 loaded = crate::document::load_document::<Self>(path, "profile fragment")?;
if !loaded.is_canonical() {
tracing::debug!(path = %loaded.path.display(), format = ?loaded.format, "loaded compatibility profile fragment");
}
loaded
.value
.validate()
.with_context(|| format!("validating profile fragment {}", path.display()))?;
Ok(loaded.value)
}
#[cfg(test)]
pub fn parse(content: &str) -> Result<Self> {
let document: Self =
toml::from_str(content).context("invalid compatibility 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)?;
validate_fragment_version(&fragment.version)?;
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
| ProfileFragmentCategory::Hardware
) && 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_version(version: &str) -> Result<()> {
require_non_empty("fragment.version", version)?;
Version::parse(version)
.with_context(|| format!("fragment.version '{version}' must be valid SemVer"))?;
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 matches!(
path.extension().and_then(|ext| ext.to_str()),
Some("pkl" | "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"
version = "0.1.0"
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::parse(valid_fragment()).expect("valid fragment");
}
#[test]
fn rejects_missing_version() {
let manifest = valid_fragment().replace("version = \"0.1.0\"\n", "");
let error = ProfileFragmentDocument::parse(&manifest).expect_err("missing version");
assert!(format!("{error:#}").contains("missing field `version`"));
}
#[test]
fn rejects_invalid_version() {
let manifest = valid_fragment().replace("version = \"0.1.0\"", "version = \"latest\"");
let error = ProfileFragmentDocument::parse(&manifest).expect_err("invalid version");
assert!(format!("{error:#}").contains("must be valid SemVer"));
}
#[test]
fn rejects_category_mismatch() {
let manifest = valid_fragment().replace("id = \"gpu/amd\"", "id = \"audio/amd\"");
let error = ProfileFragmentDocument::parse(&manifest).expect_err("category mismatch");
assert!(format!("{error:#}").contains("must start with its category prefix"));
}
#[test]
fn hardware_profile_fragment_passes_with_safety() {
let manifest = valid_fragment()
.replace("id = \"gpu/amd\"", "id = \"hardware/rpi4\"")
.replace("name = \"amd\"", "name = \"rpi4\"")
.replace("category = \"gpu\"", "category = \"hardware\"")
.replace(
"description = \"AMD GPU\"",
"description = \"Raspberry Pi 4 hardware\"",
);
ProfileFragmentDocument::parse(&manifest).expect("valid hardware fragment");
}
#[test]
fn rejects_hardware_fragment_without_safety() {
let manifest = r#"
[fragment]
schema = "io.styrene.nex.profile-fragment.v1"
id = "hardware/rpi4"
name = "rpi4"
version = "0.1.0"
description = "Raspberry Pi 4 hardware"
category = "hardware"
platforms = ["linux"]
visibility = "public"
"#;
let error = ProfileFragmentDocument::parse(manifest).expect_err("safety required");
assert!(format!("{error:#}").contains("fragment.safety is required"));
}
#[test]
fn rejects_hardware_mutation_without_confirmation() {
let manifest = valid_fragment().replace(
"requires_confirmation = true",
"requires_confirmation = false",
);
let error = ProfileFragmentDocument::parse(&manifest).expect_err("confirmation required");
assert!(
format!("{error:#}").contains("hardware-driver fragments must require confirmation")
);
}
}