katana-markdown-engine 0.1.0

Renderer-neutral Markdown document model for the KatanA ecosystem
Documentation
use katana_markdown_engine::{
    ContextAnchor, KmeNodeKind, MarkdownInput, MetadataDocument, MetadataEntry,
    MetadataReconcileRequest, MetadataTarget, TargetResolutionKind, parse_markdown,
    reconcile_metadata, reconcile_metadata_targets,
};
use serde_json::json;
use std::path::PathBuf;

#[test]
fn resolves_metadata_target_by_stable_node_id() {
    let document = sample_document("# Title\n\nBody text\n");
    let heading = document
        .nodes_by_kind(|kind| matches!(kind, KmeNodeKind::Heading(_)))
        .remove(0);
    let metadata = metadata_for_node("title-note", &document.path, heading);

    let resolutions = reconcile_metadata_targets(&document, &document, &metadata);

    assert!(matches!(
        &resolutions[0].kind,
        TargetResolutionKind::Resolved { node_id } if node_id == &heading.id
    ));
}

#[test]
fn unresolved_metadata_is_returned_without_deletion() {
    let old_document = sample_document("# Title\n\nBody text\n");
    let new_document = sample_document("# Other\n\nBody text\n");
    let heading = old_document
        .nodes_by_kind(|kind| matches!(kind, KmeNodeKind::Heading(_)))
        .remove(0);
    let metadata = metadata_for_node("title-note", &old_document.path, heading);

    let resolutions = reconcile_metadata_targets(&old_document, &new_document, &metadata);

    assert!(matches!(
        &resolutions[0].kind,
        TargetResolutionKind::Unresolved(unresolved) if unresolved.node_id == heading.id
    ));
}

#[test]
fn resolves_metadata_target_by_fingerprint_when_node_id_changes() {
    let old_document = sample_document("# Title\n\nBody text\n");
    let new_document = sample_document("# Title\n\nBody text\n");
    let heading = old_document
        .nodes_by_kind(|kind| matches!(kind, KmeNodeKind::Heading(_)))
        .remove(0);
    let mut metadata = metadata_for_node("title-note", &old_document.path, heading);
    metadata.entries[0].target.node_id.0 = "kme-old-id".to_string();

    let resolutions = reconcile_metadata_targets(&old_document, &new_document, &metadata);

    assert!(matches!(
        &resolutions[0].kind,
        TargetResolutionKind::Moved { previous_node_id, .. } if previous_node_id.0 == "kme-old-id"
    ));
}

#[test]
fn returns_conflict_when_fingerprint_matches_multiple_new_nodes() {
    let old_document = sample_document("# Title\n\nBody text\n");
    let new_document = sample_document("# Title\n\n# Title\n");
    let heading = old_document
        .nodes_by_kind(|kind| matches!(kind, KmeNodeKind::Heading(_)))
        .remove(0);
    let mut metadata = metadata_for_node("title-note", &old_document.path, heading);
    metadata.entries[0].target.node_id.0 = "kme-old-id".to_string();

    let resolutions = reconcile_metadata_targets(&old_document, &new_document, &metadata);

    assert!(matches!(
        &resolutions[0].kind,
        TargetResolutionKind::Conflict(conflict)
            if conflict.previous_node_id.0 == "kme-old-id" && conflict.candidate_node_ids.len() == 2
    ));
}

#[test]
fn reconciles_save_time_metadata_request_without_deleting_entries() {
    let old_document = sample_document("# Title\n\nBody text\n");
    let new_document = sample_document("# Other\n\nBody text\n");
    let heading = old_document
        .nodes_by_kind(|kind| matches!(kind, KmeNodeKind::Heading(_)))
        .remove(0);
    let heading_id = heading.id.clone();
    let metadata = metadata_for_node("title-note", &old_document.path, heading);

    let result = reconcile_metadata(MetadataReconcileRequest {
        old_document,
        new_document,
        metadata,
    });

    assert_eq!(result.metadata.entries.len(), 1);
    assert_eq!(result.resolutions.len(), 1);
    assert!(matches!(
        &result.resolutions[0].kind,
        TargetResolutionKind::Unresolved(unresolved) if unresolved.node_id == heading_id
    ));
}

#[test]
fn metadata_use_fixture_covers_v0_use_cases() {
    let metadata: MetadataDocument =
        serde_json::from_str(include_str!("fixtures/metadata_uses.json")).unwrap();
    let payload_kinds = metadata
        .entries
        .iter()
        .map(|entry| entry.payload["kind"].as_str().unwrap())
        .collect::<Vec<&str>>();

    assert_eq!(
        payload_kinds,
        vec!["pdf-page", "llm-annotation", "ast-edit"]
    );
}

fn sample_document(content: &str) -> katana_markdown_engine::KmeDocument {
    parse_markdown(MarkdownInput::from_content("README.md", content)).unwrap()
}

fn metadata_for_node(
    key: &str,
    path: &std::path::Path,
    node: &katana_markdown_engine::KmeNode,
) -> MetadataDocument {
    MetadataDocument {
        markdown_path: path.to_path_buf(),
        entries: vec![MetadataEntry {
            key: key.to_string(),
            target: MetadataTarget {
                file_path: PathBuf::from(path),
                node_id: node.id.clone(),
                byte_range: node.source.byte_range,
                line_column_range: node.source.line_column_range,
                text_fingerprint: node.source.raw.fingerprint(),
                context: ContextAnchor {
                    before: String::new(),
                    after: String::new(),
                },
            },
            payload: json!({ "kind": "note" }),
        }],
    }
}