xcassets 0.2.0

Parse Xcode .xcassets catalogs into a typed Rust tree with diagnostics.
Documentation
use std::{
    fs,
    path::{Path, PathBuf},
};

use serde::Deserialize;

use crate::{
    diagnostics::{Diagnostic, DiagnosticCode, Severity},
    error::ParseError,
    model::{AssetReference, AssetReferenceIndex, AssetReferenceKind},
};

pub fn index_asset_references(path: impl AsRef<Path>) -> Result<AssetReferenceIndex, ParseError> {
    let source_path = path.as_ref().to_path_buf();

    if !source_path.exists() {
        return Err(ParseError::MissingRoot { path: source_path });
    }
    if !source_path.is_dir() {
        return Err(ParseError::RootNotDirectory { path: source_path });
    }
    if source_path.extension().and_then(|ext| ext.to_str()) != Some("xcassets") {
        return Err(ParseError::InvalidCatalogRoot { path: source_path });
    }

    let mut indexer = ReferenceIndexer::new();
    indexer.walk_directory(&source_path, Path::new(""), &mut Vec::new());

    Ok(AssetReferenceIndex {
        catalog_name: source_path
            .file_stem()
            .and_then(|name| name.to_str())
            .unwrap_or("catalog")
            .to_string(),
        source_path,
        references: indexer.references,
        diagnostics: indexer.diagnostics,
    })
}

#[derive(Debug)]
struct ReferenceIndexer {
    references: Vec<AssetReference>,
    diagnostics: Vec<Diagnostic>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ReferenceFolderKind {
    Group,
    ImageSet,
    ColorSet,
    AppIconSet,
    SpriteAtlas,
    Other,
}

#[derive(Debug, Default, Deserialize)]
struct NamespaceFolderContents {
    #[serde(default)]
    properties: NamespaceFolderProperties,
}

#[derive(Debug, Default, Deserialize)]
struct NamespaceFolderProperties {
    #[serde(rename = "provides-namespace")]
    provides_namespace: Option<bool>,
}

impl ReferenceIndexer {
    fn new() -> Self {
        Self {
            references: Vec::new(),
            diagnostics: Vec::new(),
        }
    }

    fn walk_directory(
        &mut self,
        absolute_path: &Path,
        relative_path: &Path,
        namespace: &mut Vec<String>,
    ) {
        let (folder_name, kind) = classify_reference_folder(relative_path);

        match kind {
            ReferenceFolderKind::ImageSet => {
                self.references.push(AssetReference {
                    kind: AssetReferenceKind::Image,
                    lookup_name: join_lookup_name(namespace, &folder_name),
                    relative_path: relative_path.to_path_buf(),
                });
                return;
            }
            ReferenceFolderKind::ColorSet => {
                self.references.push(AssetReference {
                    kind: AssetReferenceKind::Color,
                    lookup_name: join_lookup_name(namespace, &folder_name),
                    relative_path: relative_path.to_path_buf(),
                });
                return;
            }
            ReferenceFolderKind::AppIconSet => {
                self.references.push(AssetReference {
                    kind: AssetReferenceKind::AppIcon,
                    lookup_name: join_lookup_name(namespace, &folder_name),
                    relative_path: relative_path.to_path_buf(),
                });
                return;
            }
            ReferenceFolderKind::Group
            | ReferenceFolderKind::SpriteAtlas
            | ReferenceFolderKind::Other => {}
        }

        let scan = match self.scan_directory(absolute_path, relative_path) {
            Ok(scan) => scan,
            Err(error) => {
                self.diagnostics.push(Diagnostic::new(
                    DiagnosticCode::UnreadableDirectory,
                    Severity::Error,
                    relative_path.to_path_buf(),
                    format!("failed to read directory: {error}"),
                ));
                return;
            }
        };

        let add_namespace = matches!(
            kind,
            ReferenceFolderKind::Group | ReferenceFolderKind::SpriteAtlas
        ) && !relative_path.as_os_str().is_empty()
            && self.directory_provides_namespace(relative_path, scan.contents_path.as_deref());

        if add_namespace {
            namespace.push(folder_name);
        }

        for child in scan.child_directories {
            self.walk_directory(&child.absolute_path, &child.relative_path, namespace);
        }

        if add_namespace {
            namespace.pop();
        }
    }

    fn scan_directory(
        &self,
        absolute_path: &Path,
        relative_path: &Path,
    ) -> Result<DirectoryScan, std::io::Error> {
        let mut child_directories = Vec::new();
        let mut contents_path = None;

        for entry in fs::read_dir(absolute_path)? {
            let entry = entry?;
            let file_type = entry.file_type()?;
            let file_name = entry.file_name();
            let file_name = file_name.to_string_lossy().to_string();

            if file_type.is_dir() {
                child_directories.push(ChildDirectory {
                    absolute_path: entry.path(),
                    relative_path: join_relative(relative_path, &file_name),
                });
            } else if file_type.is_file() && file_name == "Contents.json" {
                contents_path = Some(entry.path());
            }
        }

        Ok(DirectoryScan {
            child_directories,
            contents_path,
        })
    }

    fn directory_provides_namespace(
        &mut self,
        relative_path: &Path,
        contents_path: Option<&Path>,
    ) -> bool {
        let Some(contents_path) = contents_path else {
            return false;
        };

        let raw_text = match fs::read_to_string(contents_path) {
            Ok(raw_text) => raw_text,
            Err(error) => {
                self.diagnostics.push(Diagnostic::new(
                    DiagnosticCode::UnreadableFile,
                    Severity::Error,
                    relative_path.to_path_buf(),
                    format!("failed to read Contents.json: {error}"),
                ));
                return false;
            }
        };

        match serde_json::from_str::<NamespaceFolderContents>(&raw_text) {
            Ok(contents) => contents.properties.provides_namespace.unwrap_or(false),
            Err(error) => {
                let code = if error.is_data() {
                    DiagnosticCode::InvalidContentsSchema
                } else {
                    DiagnosticCode::InvalidContentsJson
                };
                let prefix = if error.is_data() {
                    "unsupported or malformed Contents.json schema"
                } else {
                    "invalid Contents.json"
                };
                self.diagnostics.push(Diagnostic::new(
                    code,
                    Severity::Error,
                    relative_path.to_path_buf(),
                    format!("{prefix}: {error}"),
                ));
                false
            }
        }
    }
}

#[derive(Debug)]
struct DirectoryScan {
    child_directories: Vec<ChildDirectory>,
    contents_path: Option<PathBuf>,
}

#[derive(Debug)]
struct ChildDirectory {
    absolute_path: PathBuf,
    relative_path: PathBuf,
}

fn classify_reference_folder(relative_path: &Path) -> (String, ReferenceFolderKind) {
    let folder_name = relative_path
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or_default();

    let extension = Path::new(folder_name)
        .extension()
        .and_then(|extension| extension.to_str());

    let stem = Path::new(folder_name)
        .file_stem()
        .and_then(|name| name.to_str())
        .unwrap_or(folder_name)
        .to_string();

    let kind = match extension {
        None => ReferenceFolderKind::Group,
        Some("imageset") => ReferenceFolderKind::ImageSet,
        Some("colorset") => ReferenceFolderKind::ColorSet,
        Some("appiconset") => ReferenceFolderKind::AppIconSet,
        Some("spriteatlas") => ReferenceFolderKind::SpriteAtlas,
        Some(_) => ReferenceFolderKind::Other,
    };

    (stem, kind)
}

fn join_lookup_name(namespace: &[String], name: &str) -> String {
    if namespace.is_empty() {
        name.to_string()
    } else {
        let mut full_name = namespace.join("/");
        full_name.push('/');
        full_name.push_str(name);
        full_name
    }
}

fn join_relative(base: &Path, child: &str) -> PathBuf {
    if base.as_os_str().is_empty() {
        PathBuf::from(child)
    } else {
        base.join(child)
    }
}