diffo 0.2.0

Semantic diffing for Rust structs via serde
Documentation
use diffo::*;
use serde::Serialize;
use std::rc::Rc;

#[test]
fn test_comparator_ignores_timestamps() {
    #[derive(Serialize)]
    struct Event {
        id: u64,
        name: String,
        timestamp: u64,
    }

    let old = Event {
        id: 1,
        name: "login".into(),
        timestamp: 100,
    };

    let new = Event {
        id: 1,
        name: "login".into(),
        timestamp: 200, // Changed timestamp
    };

    // Without comparator - should show timestamp diff
    let diff = diff(&old, &new).unwrap();
    assert!(!diff.is_empty());
    assert!(diff.get("timestamp").is_some());

    // With comparator that ignores timestamp
    let config = DiffConfig::new().comparator(
        "",
        Rc::new(|old, new| {
            // Compare only id and name
            old.get_field("id") == new.get_field("id")
                && old.get_field("name") == new.get_field("name")
        }),
    );

    let diff = diff_with(&old, &new, &config).unwrap();
    assert!(diff.is_empty());
}

#[test]
fn test_comparator_by_id_only() {
    #[derive(Serialize)]
    struct User {
        id: u64,
        name: String,
        email: String,
    }

    let old = vec![
        User {
            id: 1,
            name: "Alice".into(),
            email: "alice@old.com".into(),
        },
        User {
            id: 2,
            name: "Bob".into(),
            email: "bob@old.com".into(),
        },
    ];

    let new = vec![
        User {
            id: 1,
            name: "Alice Updated".into(),
            email: "alice@new.com".into(),
        },
        User {
            id: 2,
            name: "Bob Updated".into(),
            email: "bob@new.com".into(),
        },
    ];

    // Compare users by ID only
    // NOTE: Glob patterns treat [] as character classes, so [0] doesn't match literally.
    // Workaround for known-size arrays: Use explicit wildcard patterns.
    // ?0? matches [0] (3 characters), ?1? matches [1] (3 characters)
    // Limitation: Doesn't scale to dynamic arrays.
    let config = DiffConfig::new()
        .comparator(
            "?0?",
            Rc::new(|old, new| old.get_field("id") == new.get_field("id")),
        )
        .comparator(
            "?1?",
            Rc::new(|old, new| old.get_field("id") == new.get_field("id")),
        );

    let diff = diff_with(&old, &new, &config).unwrap();
    assert!(diff.is_empty(), "Users should be equal when IDs match");
}

#[test]
fn test_comparator_case_insensitive() {
    #[derive(Serialize)]
    struct Config {
        name: String,
        value: String,
    }

    let old = Config {
        name: "database".into(),
        value: "PostgreSQL".into(),
    };

    let new = Config {
        name: "database".into(),
        value: "postgresql".into(), // Different case
    };

    // Case-sensitive comparison (default)
    let diff = diff(&old, &new).unwrap();
    assert!(!diff.is_empty());

    // Case-insensitive comparison
    let config = DiffConfig::new().comparator(
        "value",
        Rc::new(|old, new| {
            if let (Some(old_str), Some(new_str)) = (old.as_string(), new.as_string()) {
                old_str.to_lowercase() == new_str.to_lowercase()
            } else {
                old == new
            }
        }),
    );

    let diff = diff_with(&old, &new, &config).unwrap();
    assert!(
        diff.is_empty(),
        "Should be equal with case-insensitive comparison"
    );
}

#[test]
fn test_comparator_with_pattern_matching() {
    #[derive(Serialize)]
    struct Data {
        stable: String,
        temp1: String,
        temp2: String,
    }

    let old = Data {
        stable: "same".into(),
        temp1: "old_temp1".into(),
        temp2: "old_temp2".into(),
    };

    let new = Data {
        stable: "same".into(),
        temp1: "new_temp1".into(),
        temp2: "new_temp2".into(),
    };

    // Ignore all fields starting with "temp"
    let config = DiffConfig::new().comparator(
        "temp*",
        Rc::new(|_old, _new| {
            // Always consider temp fields equal
            true
        }),
    );

    let diff = diff_with(&old, &new, &config).unwrap();
    assert!(diff.is_empty(), "Should ignore temp fields");
}

#[test]
fn test_comparator_returns_false_continues_diffing() {
    #[derive(Serialize)]
    struct Item {
        value: i32,
    }

    let old = Item { value: 10 };
    let new = Item { value: 20 };

    // Comparator that always returns false (forces diffing to continue)
    let config = DiffConfig::new().comparator(
        "",
        Rc::new(|_old, _new| {
            false // Always consider different
        }),
    );

    let diff = diff_with(&old, &new, &config).unwrap();
    assert!(
        !diff.is_empty(),
        "Should show diff when comparator returns false"
    );
    assert!(diff.get("value").is_some());
}

#[test]
fn test_comparator_with_helper_methods() {
    #[derive(Serialize)]
    struct Product {
        id: u64,
        price: f64,
        name: String,
    }

    let old = Product {
        id: 1,
        price: 99.99,
        name: "Widget".into(),
    };

    let new = Product {
        id: 1,
        price: 100.00,
        name: "Widget".into(),
    };

    // Use ValueExt helper methods
    let config = DiffConfig::new().comparator(
        "",
        Rc::new(|old, new| {
            // Compare by ID and name, ignore price
            let id_match = old.get_field("id") == new.get_field("id");
            let name_match = old.get_field("name") == new.get_field("name");
            id_match && name_match
        }),
    );

    let diff = diff_with(&old, &new, &config).unwrap();
    assert!(diff.is_empty(), "Should be equal when ignoring price");
}

#[test]
fn test_comparator_with_nested_paths() {
    #[derive(Serialize)]
    struct Outer {
        inner: Inner,
        other: String,
    }

    #[derive(Serialize)]
    struct Inner {
        value: i32,
    }

    let old = Outer {
        inner: Inner { value: 10 },
        other: "same".into(),
    };

    let new = Outer {
        inner: Inner { value: 20 },
        other: "same".into(),
    };

    // Comparator for nested path
    let config = DiffConfig::new().comparator(
        "inner",
        Rc::new(|_old, _new| {
            true // Always equal
        }),
    );

    let diff = diff_with(&old, &new, &config).unwrap();
    assert!(diff.is_empty(), "Should ignore inner changes");
}

#[test]
fn test_multiple_comparators() {
    #[derive(Serialize)]
    struct Data {
        field_a: String,
        field_b: String,
        field_c: String,
    }

    let old = Data {
        field_a: "old_a".into(),
        field_b: "old_b".into(),
        field_c: "same".into(),
    };

    let new = Data {
        field_a: "new_a".into(),
        field_b: "new_b".into(),
        field_c: "same".into(),
    };

    // Multiple comparators for different fields
    let config = DiffConfig::new()
        .comparator("field_a", Rc::new(|_old, _new| true)) // Ignore field_a
        .comparator("field_b", Rc::new(|_old, _new| true)); // Ignore field_b

    let diff = diff_with(&old, &new, &config).unwrap();
    assert!(diff.is_empty(), "Should ignore both field_a and field_b");
}

#[test]
fn test_comparator_with_none_values() {
    #[derive(Serialize)]
    struct Data {
        optional: Option<String>,
    }

    let old = Data {
        optional: Some("value".into()),
    };

    let new = Data { optional: None };

    // Custom comparator that treats Some and None as equal
    let config = DiffConfig::new().comparator(
        "optional",
        Rc::new(|_old, _new| {
            true // Always equal
        }),
    );

    let diff = diff_with(&old, &new, &config).unwrap();
    assert!(diff.is_empty(), "Should treat Some and None as equal");
}