codexa 0.0.3

A Git-native content compiler for building typed public and private knowledge artifacts.
Documentation
use std::{
    collections::{BTreeMap, BTreeSet},
    fs, io,
    path::{Path, PathBuf},
    process::Command,
};

use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use walkdir::WalkDir;

use crate::{
    model::{DocumentLink, SourceDocument},
    parser::{parse_document_links, parse_source_document},
};

#[derive(Debug, Clone)]
pub struct SourceRoot {
    pub path: PathBuf,
    pub repository: String,
}

#[derive(Debug, Clone)]
pub struct CompiledDocument {
    pub source: SourceDocument,
    pub repository: String,
    pub source_root: PathBuf,
    pub source_path: String,
    pub commit: String,
    pub content_hash: String,
    pub links: Vec<DocumentLink>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Bundle {
    pub schema: String,
    pub producer: String,
    pub producer_version: String,
    pub documents: BTreeMap<String, BundleDocument>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BundleDocument {
    pub title: String,
    pub source_repository: String,
    pub source_path: String,
    pub root: String,
    pub product: String,
    pub section: String,
    pub order: i64,
    pub web_slug: Option<String>,
    pub links: Vec<DocumentLink>,
}

pub fn compile_roots(roots: &[SourceRoot]) -> Result<Vec<CompiledDocument>, CompileError> {
    let mut documents = Vec::new();
    let mut ids = BTreeSet::new();

    for root in roots {
        let commit = git_commit(&root.path).unwrap_or_else(|| "unknown".into());
        for entry in WalkDir::new(&root.path)
            .into_iter()
            .filter_entry(|entry| !is_ignored(entry.path()))
        {
            let entry = entry.map_err(CompileError::Walk)?;
            if !entry.file_type().is_file()
                || entry.path().extension().and_then(|v| v.to_str()) != Some("md")
            {
                continue;
            }

            let markdown = fs::read_to_string(entry.path())?;
            if !markdown.starts_with("---\n") {
                continue;
            }

            let source = parse_source_document(&markdown)?;
            if !ids.insert(source.metadata.id.clone()) {
                return Err(CompileError::DuplicateId(source.metadata.id));
            }
            let links = parse_document_links(&source.body)?;
            let relative = entry
                .path()
                .strip_prefix(&root.path)
                .map_err(|_| CompileError::Path(entry.path().display().to_string()))?;

            documents.push(CompiledDocument {
                source,
                repository: root.repository.clone(),
                source_root: root.path.clone(),
                source_path: relative.to_string_lossy().replace('\\', "/"),
                commit: commit.clone(),
                content_hash: sha256(&markdown),
                links,
            });
        }
    }

    for document in &documents {
        for link in &document.links {
            if !ids.contains(&link.target_id) {
                return Err(CompileError::MissingLink {
                    source_id: document.source.metadata.id.clone(),
                    target_id: link.target_id.clone(),
                });
            }
        }
    }

    documents.sort_by(|a, b| a.source.metadata.id.cmp(&b.source.metadata.id));
    Ok(documents)
}

pub fn bundle(documents: &[CompiledDocument]) -> Bundle {
    let documents = documents
        .iter()
        .map(|compiled| {
            let metadata = &compiled.source.metadata;
            (
                metadata.id.clone(),
                BundleDocument {
                    title: metadata.title.clone(),
                    source_repository: compiled.repository.clone(),
                    source_path: compiled.source_path.clone(),
                    root: metadata.navigation.root.clone(),
                    product: metadata.navigation.product.clone(),
                    section: metadata.navigation.section.clone(),
                    order: metadata.navigation.order,
                    web_slug: metadata.web.as_ref().map(|web| web.slug.clone()),
                    links: compiled.links.clone(),
                },
            )
        })
        .collect();

    Bundle {
        schema: "codexa.bundle@1".into(),
        producer: "codexa".into(),
        producer_version: crate::VERSION.into(),
        documents,
    }
}

fn is_ignored(path: &Path) -> bool {
    path.components().any(|component| {
        matches!(
            component.as_os_str().to_str(),
            Some(".git" | "target" | "dist" | "node_modules")
        )
    })
}

fn git_commit(root: &Path) -> Option<String> {
    let output = Command::new("git")
        .arg("-C")
        .arg(root)
        .arg("rev-parse")
        .arg("HEAD")
        .output()
        .ok()?;
    output
        .status
        .success()
        .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
}

fn sha256(source: &str) -> String {
    let digest = Sha256::digest(source.as_bytes());
    format!("sha256:{digest:x}")
}

#[derive(Debug)]
pub enum CompileError {
    Io(io::Error),
    Walk(walkdir::Error),
    Frontmatter(crate::parser::FrontmatterError),
    Link(crate::parser::LinkError),
    DuplicateId(String),
    MissingLink {
        source_id: String,
        target_id: String,
    },
    Path(String),
}

impl std::fmt::Display for CompileError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Io(error) => write!(f, "I/O error: {error}"),
            Self::Walk(error) => write!(f, "repository walk error: {error}"),
            Self::Frontmatter(error) => write!(f, "frontmatter error: {error}"),
            Self::Link(error) => write!(f, "link error: {error}"),
            Self::DuplicateId(id) => write!(f, "duplicate document id `{id}`"),
            Self::MissingLink {
                source_id,
                target_id,
            } => write!(
                f,
                "document `{source_id}` links to missing document `{target_id}`"
            ),
            Self::Path(path) => write!(f, "source path is outside source root: {path}"),
        }
    }
}

impl std::error::Error for CompileError {}
impl From<io::Error> for CompileError {
    fn from(v: io::Error) -> Self {
        Self::Io(v)
    }
}
impl From<walkdir::Error> for CompileError {
    fn from(v: walkdir::Error) -> Self {
        Self::Walk(v)
    }
}
impl From<crate::parser::FrontmatterError> for CompileError {
    fn from(v: crate::parser::FrontmatterError) -> Self {
        Self::Frontmatter(v)
    }
}
impl From<crate::parser::LinkError> for CompileError {
    fn from(v: crate::parser::LinkError) -> Self {
        Self::Link(v)
    }
}