minco-workbench 1.2.1

Optional local dashboard and deterministic exports for bounded Minco project views
Documentation
use minco_project_view::load_project_view;
use minco_workbench::{ExportFormat, ExportRequest, export_project_view};
use serde_json::Value;
use std::{fs, path::Path};

fn packaged_project_fixture() -> std::path::PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests/fixtures/project")
        .canonicalize()
        .expect("canonical packaged project fixture")
}

#[test]
fn json_export_publishes_a_complete_view_to_a_new_relative_destination() {
    let root = packaged_project_fixture();
    let view = load_project_view(&root).expect("packaged fixture ProjectView");
    let target = root.join("target");
    fs::create_dir_all(&target).expect("target directory");
    let sandbox = tempfile::tempdir_in(&target).expect("export sandbox");
    let destination = sandbox
        .path()
        .join("workbench-json")
        .strip_prefix(&root)
        .expect("project-relative destination")
        .to_path_buf();
    let canonical_inputs = view
        .provenance
        .iter()
        .map(|source| source.path.clone())
        .collect::<Vec<_>>();

    let report = export_project_view(
        &view,
        ExportRequest {
            root: &root,
            destination: &destination,
            canonical_inputs: &canonical_inputs,
            format: ExportFormat::Json,
        },
    )
    .expect("safe JSON export");

    assert_eq!(report.destination, destination);
    assert_eq!(report.files, vec!["project-view.json"]);
    let exported: Value = serde_json::from_slice(
        &fs::read(root.join(&destination).join("project-view.json"))
            .expect("exported project view"),
    )
    .expect("valid exported JSON");
    assert_eq!(exported["schema_version"], 1);
    assert_eq!(
        exported["project"]["source_digest"],
        view.project.source_digest
    );
}

#[test]
fn mermaid_export_is_deterministic_and_uses_opaque_node_identifiers() {
    let root = packaged_project_fixture();
    let view = load_project_view(&root).expect("packaged fixture ProjectView");
    let target = root.join("target");
    fs::create_dir_all(&target).expect("target directory");
    let sandbox = tempfile::tempdir_in(&target).expect("export sandbox");
    let canonical_inputs = view
        .provenance
        .iter()
        .map(|source| source.path.clone())
        .collect::<Vec<_>>();
    let export = |name: &str| {
        let destination = sandbox
            .path()
            .join(name)
            .strip_prefix(&root)
            .expect("project-relative destination")
            .to_path_buf();
        export_project_view(
            &view,
            ExportRequest {
                root: &root,
                destination: &destination,
                canonical_inputs: &canonical_inputs,
                format: ExportFormat::Mermaid,
            },
        )
        .expect("safe Mermaid export");
        fs::read_to_string(root.join(destination).join("project-view.mmd"))
            .expect("exported Mermaid")
    };

    let first = export("workbench-mermaid-a");
    let second = export("workbench-mermaid-b");
    assert_eq!(first, second);
    assert!(first.starts_with("flowchart TD\n"));
    assert!(first.contains("n0["));
    assert!(first.contains("-->|contains|"));
    assert!(!first.contains("project:root["));
}

#[test]
fn static_export_contains_accessible_local_only_assets_and_the_same_project_view() {
    let root = packaged_project_fixture();
    let view = load_project_view(&root).expect("packaged fixture ProjectView");
    let target = root.join("target");
    fs::create_dir_all(&target).expect("target directory");
    let sandbox = tempfile::tempdir_in(&target).expect("export sandbox");
    let destination = sandbox
        .path()
        .join("workbench-static")
        .strip_prefix(&root)
        .expect("project-relative destination")
        .to_path_buf();
    let canonical_inputs = view
        .provenance
        .iter()
        .map(|source| source.path.clone())
        .collect::<Vec<_>>();

    let report = export_project_view(
        &view,
        ExportRequest {
            root: &root,
            destination: &destination,
            canonical_inputs: &canonical_inputs,
            format: ExportFormat::Static,
        },
    )
    .expect("safe static export");

    assert_eq!(
        report.files,
        vec![
            "index.html",
            "project-view.json",
            "project-view.mmd",
            "workbench.css",
            "workbench.js",
        ]
    );
    let output = root.join(destination);
    let html = fs::read_to_string(output.join("index.html")).expect("workbench HTML");
    let css = fs::read_to_string(output.join("workbench.css")).expect("workbench CSS");
    let javascript = fs::read_to_string(output.join("workbench.js")).expect("workbench JavaScript");
    let exported: Value = serde_json::from_slice(
        &fs::read(output.join("project-view.json")).expect("exported ProjectView"),
    )
    .expect("valid ProjectView JSON");

    assert!(html.contains("<main id=\"main-content\""));
    assert!(html.contains("aria-label=\"Workbench views\""));
    assert!(html.contains("Read aloud"));
    assert!(css.contains("@media (max-width: 720px)"));
    assert!(javascript.contains("speechSynthesis"));
    assert!(javascript.contains("textContent"));
    assert!(!javascript.contains("innerHTML"));
    assert_eq!(
        exported["project"]["source_digest"],
        view.project.source_digest
    );
}

#[cfg(unix)]
#[test]
fn export_rejects_a_symlinked_destination_ancestor_without_writing_through_it() {
    use std::os::unix::fs::symlink;

    let root = packaged_project_fixture();
    let view = load_project_view(&root).expect("packaged fixture ProjectView");
    let target = root.join("target");
    fs::create_dir_all(&target).expect("target directory");
    let sandbox = tempfile::tempdir_in(&target).expect("export sandbox");
    let real_parent = sandbox.path().join("real-parent");
    fs::create_dir(&real_parent).expect("real destination parent");
    let linked_parent = sandbox.path().join("linked-parent");
    symlink(&real_parent, &linked_parent).expect("symlinked destination ancestor");
    let destination = linked_parent
        .join("workbench")
        .strip_prefix(&root)
        .expect("project-relative destination")
        .to_path_buf();
    let canonical_inputs = view
        .provenance
        .iter()
        .map(|source| source.path.clone())
        .collect::<Vec<_>>();

    let error = export_project_view(
        &view,
        ExportRequest {
            root: &root,
            destination: &destination,
            canonical_inputs: &canonical_inputs,
            format: ExportFormat::Json,
        },
    )
    .expect_err("symlinked ancestor must fail closed");

    assert!(error.to_string().contains("without symlinks"));
    assert!(!real_parent.join("workbench").exists());
    assert!(
        fs::read_dir(&real_parent)
            .expect("real parent entries")
            .next()
            .is_none()
    );
}

#[test]
fn export_rejects_a_destination_inside_a_canonical_input_root() {
    let root = packaged_project_fixture();
    let view = load_project_view(&root).expect("packaged fixture ProjectView");
    let destination = Path::new("tasks/workbench-export-test");
    let canonical_inputs = vec![Path::new("tasks").to_path_buf()];

    let error = export_project_view(
        &view,
        ExportRequest {
            root: &root,
            destination,
            canonical_inputs: &canonical_inputs,
            format: ExportFormat::Json,
        },
    )
    .expect_err("canonical input overlap must fail closed");

    assert!(error.to_string().contains("overlaps canonical input tasks"));
    assert!(!root.join(destination).exists());
}