chromasync-core 0.5.3

Core generation pipeline and configuration loading for Chromasync
Documentation
use std::{
    collections::BTreeSet,
    fs,
    path::{Component, Path, PathBuf},
};

use chromasync_types::ThemePack;
use directories::ProjectDirs;
use serde::Deserialize;
use thiserror::Error;

#[derive(Debug, Clone, Default)]
pub struct PackRegistry {
    packs: Vec<ThemePack>,
}

impl PackRegistry {
    pub fn discover() -> Result<Self, PackError> {
        Self::discover_in(&pack_search_roots())
    }

    pub fn discover_in(search_roots: &[PathBuf]) -> Result<Self, PackError> {
        let mut packs = Vec::new();
        let mut seen_names = std::collections::BTreeMap::new();

        for root in search_roots {
            if !root.exists() {
                continue;
            }

            let mut candidates = Vec::new();

            for entry in fs::read_dir(root).map_err(|source| PackError::ReadPacksDir {
                path: root.to_path_buf(),
                source,
            })? {
                let entry = entry.map_err(|source| PackError::ReadPacksDir {
                    path: root.to_path_buf(),
                    source,
                })?;
                let path = entry.path();

                if path.is_dir() && path.join("pack.toml").is_file() {
                    candidates.push(path);
                }
            }

            candidates.sort();

            for candidate in candidates {
                let pack = load_pack(&candidate)?;

                if let Some(previous) =
                    seen_names.insert(pack.name.clone(), pack.root_dir.display().to_string())
                {
                    return Err(PackError::DuplicatePackName {
                        name: pack.name,
                        first_source: previous,
                        second_source: candidate.display().to_string(),
                    });
                }

                packs.push(pack);
            }
        }

        packs.sort_by(|left, right| left.name.cmp(&right.name));

        Ok(Self { packs })
    }

    pub fn packs(&self) -> &[ThemePack] {
        &self.packs
    }

    pub fn get(&self, name: &str) -> Option<&ThemePack> {
        self.packs.iter().find(|pack| pack.name == name)
    }
}

#[derive(Debug, Error)]
pub enum PackError {
    #[error("failed to read pack directory '{path}': {source}")]
    ReadPacksDir {
        path: PathBuf,
        #[source]
        source: std::io::Error,
    },
    #[error("failed to read pack manifest '{path}': {source}")]
    ReadPackManifest {
        path: PathBuf,
        #[source]
        source: std::io::Error,
    },
    #[error("failed to parse pack manifest '{path}': {error}")]
    ParsePackManifest {
        path: PathBuf,
        #[source]
        error: Box<toml::de::Error>,
    },
    #[error("pack manifest '{path}' uses an invalid pack name '{name}'")]
    InvalidPackName { path: PathBuf, name: String },
    #[error(
        "pack '{name}' is defined multiple times: first at {first_source}, second at {second_source}"
    )]
    DuplicatePackName {
        name: String,
        first_source: String,
        second_source: String,
    },
    #[error("pack '{pack}' {kind} path '{path}' is invalid: {reason}")]
    InvalidAssetPath {
        pack: String,
        kind: &'static str,
        path: String,
        reason: String,
    },
    #[error("pack '{pack}' {kind} path '{path}' does not exist or is not a directory")]
    MissingAssetDir {
        pack: String,
        kind: &'static str,
        path: PathBuf,
    },
    #[error("pack '{pack}' does not define any templates or targets")]
    PackHasNoAssets { pack: String },
    #[error("pack '{pack}' was not found")]
    PackNotFound { pack: String },
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawPackManifest {
    name: String,
    version: String,
    description: Option<String>,
    author: Option<String>,
    license: Option<String>,
    homepage: Option<String>,
    templates: Option<RawPackAssets>,
    targets: Option<RawPackAssets>,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawPackAssets {
    #[serde(default)]
    paths: Vec<PathBuf>,
}

pub fn pack_search_roots() -> Vec<PathBuf> {
    let mut roots = Vec::new();

    if let Some(dirs) = ProjectDirs::from("io", "chromasync", "chromasync") {
        roots.push(dirs.config_dir().join("packs"));
        roots.push(dirs.data_local_dir().join("packs"));
    }

    if let Ok(current_dir) = std::env::current_dir() {
        roots.push(current_dir.join(".chromasync").join("packs"));
    }

    let mut seen = BTreeSet::new();
    roots.retain(|path| seen.insert(path.clone()));

    roots
}

fn load_pack(path: &Path) -> Result<ThemePack, PackError> {
    let manifest_path = path.join("pack.toml");
    let content =
        fs::read_to_string(&manifest_path).map_err(|source| PackError::ReadPackManifest {
            path: manifest_path.clone(),
            source,
        })?;
    let manifest: RawPackManifest =
        toml::from_str(&content).map_err(|error| PackError::ParsePackManifest {
            path: manifest_path.clone(),
            error: Box::new(error),
        })?;

    validate_pack_name(&manifest_path, &manifest.name)?;

    let template_dirs = resolve_asset_dirs(
        path,
        &manifest.name,
        "template",
        &manifest.templates,
        "templates",
    )?;
    let target_dirs =
        resolve_asset_dirs(path, &manifest.name, "target", &manifest.targets, "targets")?;

    if template_dirs.is_empty() && target_dirs.is_empty() {
        return Err(PackError::PackHasNoAssets {
            pack: manifest.name.clone(),
        });
    }

    Ok(ThemePack {
        name: manifest.name,
        version: manifest.version,
        description: manifest.description,
        author: manifest.author,
        license: manifest.license,
        homepage: manifest.homepage,
        root_dir: path.to_path_buf(),
        template_dirs,
        target_dirs,
    })
}

fn validate_pack_name(path: &Path, name: &str) -> Result<(), PackError> {
    if !name.is_empty()
        && name
            .chars()
            .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
    {
        Ok(())
    } else {
        Err(PackError::InvalidPackName {
            path: path.to_path_buf(),
            name: name.to_owned(),
        })
    }
}

fn resolve_asset_dirs(
    pack_root: &Path,
    pack_name: &str,
    kind: &'static str,
    manifest_paths: &Option<RawPackAssets>,
    default_dir: &'static str,
) -> Result<Vec<PathBuf>, PackError> {
    let declared = match manifest_paths {
        Some(paths) => paths.paths.clone(),
        None => {
            let default_path = pack_root.join(default_dir);

            if default_path.is_dir() {
                vec![PathBuf::from(default_dir)]
            } else {
                Vec::new()
            }
        }
    };
    let mut resolved = Vec::new();
    let mut seen = BTreeSet::new();

    for relative in declared {
        validate_relative_asset_path(pack_name, kind, &relative)?;
        let absolute = pack_root.join(&relative);

        if !absolute.is_dir() {
            return Err(PackError::MissingAssetDir {
                pack: pack_name.to_owned(),
                kind,
                path: absolute,
            });
        }

        if seen.insert(absolute.clone()) {
            resolved.push(absolute);
        }
    }

    Ok(resolved)
}

fn validate_relative_asset_path(
    pack_name: &str,
    kind: &'static str,
    path: &Path,
) -> Result<(), PackError> {
    if path.as_os_str().is_empty() {
        return Err(PackError::InvalidAssetPath {
            pack: pack_name.to_owned(),
            kind,
            path: path.display().to_string(),
            reason: "expected a non-empty relative directory".to_owned(),
        });
    }

    if path.is_absolute() {
        return Err(PackError::InvalidAssetPath {
            pack: pack_name.to_owned(),
            kind,
            path: path.display().to_string(),
            reason: "absolute paths are not allowed".to_owned(),
        });
    }

    for component in path.components() {
        match component {
            Component::Normal(_) | Component::CurDir => {}
            Component::ParentDir => {
                return Err(PackError::InvalidAssetPath {
                    pack: pack_name.to_owned(),
                    kind,
                    path: path.display().to_string(),
                    reason: "parent path components are not allowed".to_owned(),
                });
            }
            Component::RootDir | Component::Prefix(_) => {
                return Err(PackError::InvalidAssetPath {
                    pack: pack_name.to_owned(),
                    kind,
                    path: path.display().to_string(),
                    reason: "only relative subdirectories are allowed".to_owned(),
                });
            }
        }
    }

    Ok(())
}