dkp 0.4.1

dkp — Domain Knowledge Pack management CLI
Documentation
use anyhow::Result;
use clap::Args;
use flate2::{Compression, write::GzEncoder};
use sha2::{Digest, Sha256};
use std::{
    collections::BTreeMap,
    fs,
    io::{BufWriter, Write},
    path::{Path, PathBuf},
};
use xz2::write::XzEncoder;
use zip::{ZipWriter, write::SimpleFileOptions};

use dkp_core::Pack;

use crate::cli::CmdCtx;

#[derive(Args, Debug)]
pub struct BuildArgs {
    /// Path to the DKP bundle directory
    pub pack: PathBuf,

    /// Archive format: zip | tar.gz | tar.xz | dkp
    #[arg(long, default_value = "dkp", value_name = "FMT")]
    pub format: String,

    /// Output directory (default: build/)
    #[arg(long, value_name = "DIR")]
    pub out: Option<PathBuf>,

    /// Exclude human/ assets (machine-only distribution)
    #[arg(long)]
    pub no_human: bool,

    /// Regenerate machine/mcp_manifest.json before packaging
    #[arg(long)]
    pub gen_mcp_manifest: bool,

    /// Render human/handbook.md to handbook.pdf/handbook.epub before
    /// packaging, if missing or older than handbook.md
    #[arg(long)]
    pub render_handbook: bool,
}

pub async fn run(args: BuildArgs, _cli: &CmdCtx) -> Result<()> {
    let pack_dir = args.pack.canonicalize()?;
    let pack = Pack::open(&pack_dir)?;
    let manifest = &pack.manifest;

    let slug = manifest
        .name
        .trim_start_matches('@')
        .replace('/', "-")
        .to_lowercase()
        .replace(' ', "-");
    let version = &manifest.version;
    let base_name = format!("{}-v{}", slug, version);

    let out_dir = args.out.unwrap_or_else(|| pack_dir.join("build"));
    fs::create_dir_all(&out_dir)?;

    maybe_render_handbook(&pack_dir, args.render_handbook)?;

    let files = collect_files(&pack_dir, args.no_human)?;

    // Compute checksums for all included files
    let mut checksums: BTreeMap<String, String> = BTreeMap::new();
    for rel in &files {
        let hash = sha256_file(&pack_dir.join(rel))?;
        checksums.insert(rel.clone(), hash);
    }
    let checksums_json = serde_json::to_string_pretty(&checksums)?;

    // Write checksums.json alongside the archive
    let checksums_path = out_dir.join("checksums.json");
    fs::write(&checksums_path, &checksums_json)?;

    let archive_name = match args.format.as_str() {
        "dkp" => format!("{}.dkp", base_name),
        other => format!("{}.{}", base_name, other),
    };
    let archive_path = out_dir.join(&archive_name);

    eprintln!("Building {}...", archive_name);
    eprintln!("  Including: {}", included_layers(&pack_dir, args.no_human));
    eprintln!("  Excluding: build/");

    match args.format.as_str() {
        "zip" => write_zip(
            &pack_dir,
            &files,
            &checksums_json,
            &base_name,
            &archive_path,
        )?,
        "tar.gz" => write_tar_gz(
            &pack_dir,
            &files,
            &checksums_json,
            &base_name,
            &archive_path,
        )?,
        "tar.xz" => write_tar_xz(
            &pack_dir,
            &files,
            &checksums_json,
            &base_name,
            &archive_path,
            6,
        )?,
        "dkp" => write_tar_xz(
            &pack_dir,
            &files,
            &checksums_json,
            &base_name,
            &archive_path,
            9,
        )?,
        other => anyhow::bail!(
            "unsupported format '{}' — supported: zip, tar.gz, tar.xz, dkp",
            other
        ),
    }

    println!("  Written:  {}", archive_path.display());
    println!("            {}", checksums_path.display());
    println!("  Files:    {}", files.len() + 1); // +1 for checksums.json inside archive
    Ok(())
}

// ── Handbook rendering ───────────────────────────────────────────────────────

/// If `render_handbook` is set and `human/handbook.md` exists, (re)renders
/// `handbook.pdf`/`handbook.epub` when either is missing or older than
/// `handbook.md`. If not set, just warns when the rendered formats look stale
/// — packaging still proceeds either way, since these artifacts are optional
/// per spec §11.2.
fn maybe_render_handbook(pack_dir: &Path, render_handbook: bool) -> Result<()> {
    let human_dir = pack_dir.join("human");
    let md_path = human_dir.join("handbook.md");
    if !md_path.exists() {
        return Ok(());
    }

    if !handbook_formats_stale(&human_dir) {
        return Ok(());
    }

    if render_handbook {
        let markdown = fs::read_to_string(&md_path)?;
        let report = dkp_gen_core::render_handbook_formats(&markdown, &human_dir)?;
        for w in &report.warnings {
            eprintln!("{w}");
        }
    } else {
        eprintln!(
            "  ⚠ human/handbook.pdf or .epub is missing or older than handbook.md — \
             pass --render-handbook or run `dkp render-handbook` to refresh"
        );
    }
    Ok(())
}

/// True if either rendered format is missing or older than `handbook.md`.
fn handbook_formats_stale(human_dir: &Path) -> bool {
    let md_mtime = match fs::metadata(human_dir.join("handbook.md")).and_then(|m| m.modified()) {
        Ok(t) => t,
        Err(_) => return false,
    };
    for name in ["handbook.pdf", "handbook.epub"] {
        let stale = match fs::metadata(human_dir.join(name)).and_then(|m| m.modified()) {
            Ok(t) => t < md_mtime,
            Err(_) => true, // missing counts as stale
        };
        if stale {
            return true;
        }
    }
    false
}

// ── File collection ──────────────────────────────────────────────────────────

const EXCLUDE_DIRS: &[&str] = &["build", "__pycache__", ".git", "node_modules", "target"];

fn collect_files(pack_dir: &Path, no_human: bool) -> Result<Vec<String>> {
    let mut files: Vec<String> = Vec::new();
    collect_recursive(pack_dir, pack_dir, no_human, &mut files)?;
    files.sort();
    Ok(files)
}

fn collect_recursive(root: &Path, dir: &Path, no_human: bool, out: &mut Vec<String>) -> Result<()> {
    for entry in fs::read_dir(dir)? {
        let entry = entry?;
        let path = entry.path();
        let name = path
            .file_name()
            .and_then(|n| n.to_str())
            .unwrap_or("")
            .to_string();

        if path.is_dir() {
            if EXCLUDE_DIRS.contains(&name.as_str()) {
                continue;
            }
            if no_human && name == "human" {
                continue;
            }
            collect_recursive(root, &path, no_human, out)?;
        } else {
            let rel = path
                .strip_prefix(root)
                .map(|p| p.to_string_lossy().replace('\\', "/"))
                .unwrap_or_default();
            out.push(rel);
        }
    }
    Ok(())
}

fn included_layers(pack_dir: &Path, no_human: bool) -> String {
    let mut layers = vec!["machine/", "evidence/", "manifest.json", "checksums.json"];
    if !no_human && pack_dir.join("human").exists() {
        layers.push("human/");
    }
    if pack_dir.join("okf").exists() {
        layers.push("okf/");
    }
    layers.join(", ")
}

// ── SHA-256 ──────────────────────────────────────────────────────────────────

fn sha256_file(path: &Path) -> Result<String> {
    let data = fs::read(path)?;
    let hash = Sha256::digest(&data);
    Ok(format!("{:x}", hash))
}

// ── Archive writers ──────────────────────────────────────────────────────────

fn write_zip(
    pack_dir: &Path,
    files: &[String],
    checksums_json: &str,
    base_name: &str,
    out: &Path,
) -> Result<()> {
    let file = fs::File::create(out)?;
    let mut zip = ZipWriter::new(BufWriter::new(file));
    let opts = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);

    for rel in files {
        let archive_path = format!("{}/{}", base_name, rel);
        zip.start_file(&archive_path, opts)?;
        zip.write_all(&fs::read(pack_dir.join(rel))?)?;
    }

    // checksums.json at the archive root
    zip.start_file(format!("{}/checksums.json", base_name), opts)?;
    zip.write_all(checksums_json.as_bytes())?;

    zip.finish()?;
    Ok(())
}

fn write_tar_gz(
    pack_dir: &Path,
    files: &[String],
    checksums_json: &str,
    base_name: &str,
    out: &Path,
) -> Result<()> {
    let file = fs::File::create(out)?;
    let enc = GzEncoder::new(BufWriter::new(file), Compression::best());
    let mut tar = tar::Builder::new(enc);

    for rel in files {
        let abs = pack_dir.join(rel);
        let archive_path = format!("{}/{}", base_name, rel);
        let mut f = fs::File::open(&abs)?;
        tar.append_file(&archive_path, &mut f)?;
    }

    // checksums.json
    let ck_bytes = checksums_json.as_bytes();
    let mut header = tar::Header::new_gnu();
    header.set_size(ck_bytes.len() as u64);
    header.set_mode(0o644);
    header.set_cksum();
    tar.append_data(
        &mut header,
        format!("{}/checksums.json", base_name),
        ck_bytes,
    )?;

    tar.finish()?;
    Ok(())
}

fn write_tar_xz(
    pack_dir: &Path,
    files: &[String],
    checksums_json: &str,
    base_name: &str,
    out: &Path,
    compression_level: u32,
) -> Result<()> {
    let file = fs::File::create(out)?;
    let enc = XzEncoder::new(BufWriter::new(file), compression_level);
    let mut tar = tar::Builder::new(enc);

    for rel in files {
        let abs = pack_dir.join(rel);
        let archive_path = format!("{}/{}", base_name, rel);
        let mut f = fs::File::open(&abs)?;
        tar.append_file(&archive_path, &mut f)?;
    }

    let ck_bytes = checksums_json.as_bytes();
    let mut header = tar::Header::new_gnu();
    header.set_size(ck_bytes.len() as u64);
    header.set_mode(0o644);
    header.set_cksum();
    tar.append_data(
        &mut header,
        format!("{}/checksums.json", base_name),
        ck_bytes,
    )?;

    tar.finish()?;
    Ok(())
}