pagesift-diff 0.71.0

Block-level Markdown snapshot diff that separates noise changes (price, date, count) from substantive ones.
Documentation
use pagesift_diff::*;

fn kinds(r: &DiffReport) -> Vec<ChangeType> {
    r.blocks.iter().map(|b| b.change).collect()
}

#[test]
fn identical() {
    let a = "# T\n\nhello\n\n- a\n- b\n";
    let r = diff(a, a);
    assert!(!r.changed && !r.substantive && r.blocks.is_empty());
    assert_eq!(r.stats.unchanged, 4);
}

#[test]
fn whitespace_only_is_unchanged() {
    let r = diff("hello   world\n\nfoo", "hello world\n\n\n\nfoo\n");
    assert!(!r.changed);
}

#[test]
fn pure_addition() {
    let r = diff("one\n\ntwo", "one\n\nnew para\n\ntwo");
    assert_eq!(kinds(&r), [ChangeType::Added]);
    assert!(r.substantive);
    assert_eq!(r.blocks[0].new_index, Some(1));
}

#[test]
fn pure_removal() {
    let r = diff("one\n\nold para\n\ntwo", "one\n\ntwo");
    assert_eq!(kinds(&r), [ChangeType::Removed]);
    assert_eq!(r.blocks[0].old_text.as_deref(), Some("old para"));
}

#[test]
fn moved_paragraph() {
    let a = "alpha paragraph here\n\nbeta paragraph there\n\ngamma paragraph again\n\ndelta ending";
    let b = "beta paragraph there\n\ngamma paragraph again\n\ndelta ending\n\nalpha paragraph here";
    let r = diff(a, b);
    assert_eq!(kinds(&r), [ChangeType::Moved]);
    assert_eq!(r.blocks[0].old_index, Some(0));
    assert_eq!(r.blocks[0].new_index, Some(3));
    let r = diff_with(
        a,
        b,
        &DiffOptions {
            moved_is_noise: true,
            ..Default::default()
        },
    );
    assert!(!r.substantive && r.changed);
}

#[test]
fn price_only_is_noise() {
    let r = diff(
        "# Shop\n\nWidget now $1,299.00 while stocks last",
        "# Shop\n\nWidget now $1,199.50 while stocks last",
    );
    assert_eq!(kinds(&r), [ChangeType::Modified]);
    assert_eq!(r.blocks[0].significance, Significance::Noise);
    assert_eq!(r.blocks[0].noise_categories, [NoiseCategory::Price]);
    assert!(r.changed && !r.substantive);
}

#[test]
fn price_configurable_as_substantive() {
    let opts = DiffOptions {
        noise_categories: vec![
            NoiseCategory::Date,
            NoiseCategory::RelativeTime,
            NoiseCategory::Count,
        ],
        ..Default::default()
    };
    let r = diff_with("Widget now $10 only", "Widget now $12 only", &opts);
    assert!(r.substantive);
    assert_eq!(r.blocks[0].significance, Significance::Substantive);
}

#[test]
fn relative_time_only_is_noise() {
    let r = diff("Posted 3 hours ago by admin", "Posted 5 hours ago by admin");
    assert_eq!(r.blocks[0].noise_categories, [NoiseCategory::RelativeTime]);
    assert!(!r.substantive);
    let r = diff("发布于 3 小时前", "发布于 4 小时前");
    assert_eq!(r.blocks[0].noise_categories, [NoiseCategory::RelativeTime]);
}

#[test]
fn count_and_date_noise() {
    let r = diff("1,234 次阅读 · 12 条评论", "1,301 次阅读 · 15 条评论");
    assert_eq!(r.blocks[0].noise_categories, [NoiseCategory::Count]);
    let r = diff("更新于 2024年1月5日", "更新于 2024年3月9日");
    assert_eq!(r.blocks[0].noise_categories, [NoiseCategory::Date]);
}

#[test]
fn chinese_single_char_edit() {
    let r = diff(
        "这家餐厅的服务态度非常好,推荐大家来试试。",
        "这家餐厅的服务态度非常差,推荐大家来试试。",
    );
    assert_eq!(kinds(&r), [ChangeType::Modified]);
    let b = &r.blocks[0];
    assert_eq!(b.significance, Significance::Substantive);
    let del: Vec<_> = b
        .inline
        .iter()
        .filter(|s| s.op == InlineOp::Delete)
        .collect();
    let ins: Vec<_> = b
        .inline
        .iter()
        .filter(|s| s.op == InlineOp::Insert)
        .collect();
    assert_eq!((del[0].text.as_str(), ins[0].text.as_str()), ("好", "差"));
}

#[test]
fn table_row_edit() {
    let a = "| name | stock |\n|---|---|\n| apple | in stock |\n| pear | in stock |\n| plum | in stock |";
    let b = "| name | stock |\n|---|---|\n| apple | in stock |\n| pear | sold out |\n| plum | in stock |";
    let r = diff(a, b);
    assert_eq!(kinds(&r), [ChangeType::Modified]);
    assert_eq!(r.blocks[0].block_type, BlockKind::TableRow);
    assert_eq!(r.blocks[0].significance, Significance::Substantive);
}

#[test]
fn completely_different() {
    let r = diff("aaa one\n\nbbb two\n\nccc three", "xxx uno\n\nyyy dos");
    assert_eq!(r.stats.added, 2);
    assert_eq!(r.stats.removed, 3);
    assert_eq!(r.stats.modified, 0);
    assert!(r.substantive);
}

#[test]
fn serializes_camel_case() {
    let r = diff("Price $5 today", "Price $6 today");
    let v = serde_json::to_value(&r).unwrap();
    assert_eq!(v["changed"], true);
    assert_eq!(v["substantive"], false);
    assert_eq!(v["blocks"][0]["type"], "modified");
    assert_eq!(v["blocks"][0]["blockType"], "paragraph");
    assert_eq!(v["blocks"][0]["significance"], "noise");
    assert_eq!(v["blocks"][0]["noiseCategories"][0], "price");
    assert_eq!(v["stats"]["oldBlocks"], 1);
}

#[test]
fn options_deserialize_partial() {
    let o: DiffOptions = serde_json::from_str(r#"{"noiseCategories":["date"]}"#).unwrap();
    assert_eq!(o.noise_categories, [NoiseCategory::Date]);
    assert_eq!(o.similarity_threshold, 0.5);
}