orchestral-cli 0.1.0

Command line interface for running Orchestral workflows
use std::collections::{BTreeMap, BTreeSet};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;

use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
struct BaselineEntry {
    modified_nanos: u128,
    content: String,
}

#[derive(Debug, Serialize, Deserialize)]
struct BaselineSnapshot {
    files: BTreeMap<String, BaselineEntry>,
    changed_files: Vec<String>,
}

fn main() -> Result<()> {
    let mut args = env::args().skip(1);
    let Some(command) = args.next() else {
        bail!("expected subcommand: prepare | verify");
    };
    let Some(baseline_path) = args.next() else {
        bail!("expected baseline path");
    };
    let Some(docs_root) = args.next() else {
        bail!("expected docs root");
    };

    let baseline_path = PathBuf::from(baseline_path);
    let docs_root = PathBuf::from(docs_root);
    let changed_files = args.collect::<Vec<_>>();

    match command.as_str() {
        "prepare" => prepare(&baseline_path, &docs_root, &changed_files),
        "verify" => verify(&baseline_path, &docs_root, &changed_files),
        other => bail!("unsupported subcommand '{}'", other),
    }
}

fn prepare(baseline_path: &Path, docs_root: &Path, changed_files: &[String]) -> Result<()> {
    let mut files = BTreeMap::new();
    for path in collect_markdown_files(docs_root)? {
        let relative = path
            .strip_prefix(docs_root)
            .with_context(|| format!("strip prefix '{}' failed", docs_root.display()))?
            .to_string_lossy()
            .replace('\\', "/");
        files.insert(
            relative,
            BaselineEntry {
                modified_nanos: modified_nanos(&path)?,
                content: fs::read_to_string(&path)
                    .with_context(|| format!("read '{}' failed", path.display()))?,
            },
        );
    }

    if let Some(parent) = baseline_path.parent() {
        fs::create_dir_all(parent)
            .with_context(|| format!("create '{}' failed", parent.display()))?;
    }
    fs::write(
        baseline_path,
        serde_json::to_vec_pretty(&BaselineSnapshot {
            files,
            changed_files: changed_files.to_vec(),
        })?,
    )
    .with_context(|| format!("write '{}' failed", baseline_path.display()))?;

    for changed in changed_files {
        strip_leading_title(&docs_root.join(changed))?;
    }

    Ok(())
}

fn verify(baseline_path: &Path, docs_root: &Path, changed_files: &[String]) -> Result<()> {
    let baseline: BaselineSnapshot = serde_json::from_slice(
        &fs::read(baseline_path)
            .with_context(|| format!("read '{}' failed", baseline_path.display()))?,
    )
    .with_context(|| format!("parse '{}' failed", baseline_path.display()))?;

    let changed_set = if changed_files.is_empty() {
        baseline
            .changed_files
            .iter()
            .cloned()
            .collect::<BTreeSet<_>>()
    } else {
        changed_files.iter().cloned().collect::<BTreeSet<_>>()
    };

    for (relative, entry) in &baseline.files {
        let path = docs_root.join(relative);
        let content = fs::read_to_string(&path)
            .with_context(|| format!("read '{}' failed", path.display()))?;
        let current_modified = modified_nanos(&path)?;

        if !content.starts_with("# ") {
            bail!(
                "expected '{}' to have a top-level title after rerun",
                relative
            );
        }
        if content.contains("TODO") {
            bail!(
                "expected '{}' to have no TODO placeholders after rerun",
                relative
            );
        }
        if content != entry.content {
            bail!(
                "expected '{}' content to match baseline after rerun",
                relative
            );
        }

        let changed = changed_set.contains(relative);
        if changed && current_modified <= entry.modified_nanos {
            bail!(
                "expected changed file '{}' to be rewritten after baseline",
                relative
            );
        }
        if !changed && current_modified != entry.modified_nanos {
            bail!(
                "expected untouched file '{}' to keep baseline mtime",
                relative
            );
        }
    }

    println!("incremental document patch verification passed");
    Ok(())
}

fn collect_markdown_files(root: &Path) -> Result<Vec<PathBuf>> {
    let mut files = Vec::new();
    collect_markdown_files_recursive(root, &mut files)?;
    files.sort();
    Ok(files)
}

fn collect_markdown_files_recursive(root: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
    for entry in fs::read_dir(root).with_context(|| format!("read '{}' failed", root.display()))? {
        let entry = entry.with_context(|| format!("read entry in '{}' failed", root.display()))?;
        let path = entry.path();
        if path.is_dir() {
            collect_markdown_files_recursive(&path, out)?;
        } else if path
            .extension()
            .and_then(|value| value.to_str())
            .is_some_and(|ext| matches!(ext, "md" | "markdown" | "mdx" | "txt"))
        {
            out.push(path);
        }
    }
    Ok(())
}

fn modified_nanos(path: &Path) -> Result<u128> {
    Ok(fs::metadata(path)
        .with_context(|| format!("metadata '{}' failed", path.display()))?
        .modified()
        .with_context(|| format!("modified time '{}' failed", path.display()))?
        .duration_since(UNIX_EPOCH)
        .with_context(|| format!("system time before epoch '{}'", path.display()))?
        .as_nanos())
}

fn strip_leading_title(path: &Path) -> Result<()> {
    let content =
        fs::read_to_string(path).with_context(|| format!("read '{}' failed", path.display()))?;
    let Some(first) = content.lines().next() else {
        bail!("expected '{}' to be non-empty", path.display());
    };
    let remaining = if first.starts_with("# ") {
        if let Some(first_newline) = content.find('\n') {
            let mut rest = &content[first_newline + 1..];
            if rest.starts_with('\n') {
                rest = &rest[1..];
            }
            rest.to_string()
        } else {
            String::new()
        }
    } else {
        content
    };
    fs::write(path, remaining.as_bytes())
        .with_context(|| format!("write '{}' failed", path.display()))?;
    Ok(())
}