use serde::{Deserialize, Serialize};
use crate::entity::EntityId;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum EntityDiff {
Added {
id: EntityId,
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
entity_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
content_after: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
ripple: Vec<IncomingRipple>,
},
Modified {
id: EntityId,
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
entity_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
content_before: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
content_after: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
ripple: Vec<IncomingRipple>,
},
Deleted {
id: EntityId,
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
entity_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
content_before: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
ripple: Vec<IncomingRipple>,
},
Renamed {
from_id: EntityId,
to_id: EntityId,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
rename_chain: Vec<EntityId>,
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
entity_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
content_before: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
content_after: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
ripple: Vec<IncomingRipple>,
},
InvalidEntity {
id: EntityId,
side: String,
error: String,
#[serde(skip_serializing_if = "Option::is_none")]
content_before: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
content_after: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct IncomingRipple {
pub from_id: EntityId,
pub side: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub section: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DiffConfig {
pub rename_similarity: f32,
pub include_content: bool,
pub include_ripple: bool,
}
impl Default for DiffConfig {
fn default() -> Self {
Self {
rename_similarity: crate::ops::RENAME_SIMILARITY_DEFAULT,
include_content: true,
include_ripple: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Diff {
pub ref_a: String,
pub ref_b: String,
pub resolved_a_sha: String,
pub resolved_b_sha: String,
pub config: DiffConfig,
pub entries: Vec<EntityDiff>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn diff_config_default_matches_rename_similarity_default() {
let cfg = DiffConfig::default();
assert_eq!(cfg.rename_similarity, crate::ops::RENAME_SIMILARITY_DEFAULT);
assert!(cfg.include_content);
assert!(cfg.include_ripple);
}
#[test]
fn entity_diff_added_serialises_with_status_tag() {
let entry = EntityDiff::Added {
id: EntityId::new("specs", "alpha"),
title: Some("Alpha".to_string()),
entity_type: Some("spec".to_string()),
content_after: Some("# Alpha\n".to_string()),
ripple: Vec::new(),
};
let json = serde_json::to_value(&entry).unwrap();
assert_eq!(json["status"], "added");
assert_eq!(json["id"], "specs--alpha");
assert_eq!(json["title"], "Alpha");
assert_eq!(json["content_after"], "# Alpha\n");
assert!(json.get("ripple").is_none());
}
#[test]
fn entity_diff_renamed_carries_optional_rename_chain() {
let entry = EntityDiff::Renamed {
from_id: EntityId::new("specs", "old"),
to_id: EntityId::new("specs", "new"),
rename_chain: vec![EntityId::new("specs", "interim")],
title: None,
entity_type: None,
content_before: None,
content_after: None,
ripple: Vec::new(),
};
let json = serde_json::to_value(&entry).unwrap();
assert_eq!(json["status"], "renamed");
assert_eq!(json["from_id"], "specs--old");
assert_eq!(json["to_id"], "specs--new");
assert_eq!(json["rename_chain"], serde_json::json!(["specs--interim"]));
}
#[test]
fn entity_diff_invalid_entity_carries_side_and_error() {
let entry = EntityDiff::InvalidEntity {
id: EntityId::new("specs", "broken"),
side: "ref_a".to_string(),
error: "missing frontmatter".to_string(),
content_before: Some("not yaml".to_string()),
content_after: None,
};
let json = serde_json::to_value(&entry).unwrap();
assert_eq!(json["status"], "invalid_entity");
assert_eq!(json["side"], "ref_a");
assert_eq!(json["error"], "missing frontmatter");
}
#[test]
fn diff_top_level_round_trips_through_serde() {
let diff = Diff {
ref_a: "main".to_string(),
ref_b: "feature".to_string(),
resolved_a_sha: "a".repeat(40),
resolved_b_sha: "b".repeat(40),
config: DiffConfig::default(),
entries: vec![EntityDiff::Added {
id: EntityId::new("v", "x"),
title: None,
entity_type: None,
content_after: None,
ripple: Vec::new(),
}],
};
let json = serde_json::to_string(&diff).unwrap();
let parsed: Diff = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, diff);
}
}