use crate::content::node::{NodeState, PropertyState, PropertyValues};
use crate::content::provider::SegmentProvider;
use crate::error::Result;
use crate::journal::parse_record_identifier_text;
use crate::progress::{DiscardedProgress, ProgressObserver, Step, WorkUnit};
use crate::segment::record::RecordIdentifier;
use crate::store::{ArchiveSet, open_all_archives_with_progress};
const MAXIMUM_DIFF_DEPTH: usize = 4000;
const MAXIMUM_DIFF_VISITS: u64 = 1_000_000_000;
const DIFF_VISIT_REPORT_STRIDE: u64 = 512;
#[derive(Debug, Clone, PartialEq)]
pub enum PropertyChange {
Added(PropertyState),
Removed(PropertyState),
Changed {
before: PropertyState,
after: PropertyState,
},
}
#[derive(Debug, Clone, PartialEq)]
pub enum NodeDifference {
NodeAdded {
path: String,
},
NodeRemoved {
path: String,
},
PropertyChanged {
path: String,
change: PropertyChange,
},
}
pub fn diff_revisions(
directory: &std::path::Path,
before_revision: &str,
after_revision: &str,
filter_path: &str,
) -> Result<Vec<NodeDifference>> {
diff_revisions_with_progress(
directory,
before_revision,
after_revision,
filter_path,
&mut DiscardedProgress,
)
}
pub fn diff_revisions_with_progress(
directory: &std::path::Path,
before_revision: &str,
after_revision: &str,
filter_path: &str,
observer: &mut dyn ProgressObserver,
) -> Result<Vec<NodeDifference>> {
let mut differences = Vec::new();
diff_revisions_visiting(
directory,
before_revision,
after_revision,
filter_path,
observer,
&mut |difference| differences.push(difference),
)?;
Ok(differences)
}
pub fn diff_revisions_visiting(
directory: &std::path::Path,
before_revision: &str,
after_revision: &str,
filter_path: &str,
observer: &mut dyn ProgressObserver,
emit: &mut dyn FnMut(NodeDifference),
) -> Result<()> {
let archives = open_all_archives_with_progress(directory, observer)?;
let provider = ArchiveSet::new(archives);
let before_head = resolve_revision(directory, before_revision, &provider)?;
let after_head = resolve_revision(directory, after_revision, &provider)?;
let before_node = content_node_at(&provider, before_head, filter_path)?;
let after_node = content_node_at(&provider, after_head, filter_path)?;
let base_path = normalized_filter_path(filter_path);
let mut visits = 0u64;
crate::progress::observe(
observer,
&Step::new("comparing revisions", WorkUnit::Nodes),
|observer| {
let compared = diff_nodes(
&provider,
before_node.as_ref(),
after_node.as_ref(),
&base_path,
0,
emit,
&mut visits,
observer,
);
observer.step_advanced(visits);
compared
},
)?;
Ok(())
}
fn resolve_revision(
directory: &std::path::Path,
revision: &str,
provider: &ArchiveSet,
) -> Result<RecordIdentifier> {
if revision.eq_ignore_ascii_case("head") {
let entries = crate::journal::read_journal(&directory.join("journal.log"))?;
return entries
.iter()
.filter_map(crate::journal::JournalEntry::record_identifier)
.find(|identifier| provider.segment(identifier.segment).is_ok())
.ok_or_else(|| crate::error::Error::InvalidFormat {
details: "the journal has no resolvable head".to_owned(),
});
}
parse_record_identifier_text(revision).ok_or_else(|| crate::error::Error::InvalidFormat {
details: format!("{revision:?} is not a valid record identifier"),
})
}
fn content_node_at<'provider>(
provider: &'provider ArchiveSet,
head: RecordIdentifier,
filter_path: &str,
) -> Result<Option<NodeState<'provider>>> {
let super_root = NodeState::new(provider, head);
let Some(mut current) = super_root.child_node("root")? else {
return Ok(None);
};
for name in filter_path.split('/').filter(|segment| !segment.is_empty()) {
match current.child_node(name)? {
Some(child) => current = child,
None => return Ok(None),
}
}
Ok(Some(current))
}
fn normalized_filter_path(path: &str) -> String {
let segments: Vec<&str> = path
.split('/')
.filter(|segment| !segment.is_empty())
.collect();
if segments.is_empty() {
String::new()
} else {
format!("/{}", segments.join("/"))
}
}
#[allow(
clippy::too_many_arguments,
reason = "the walk threads its path, depth, output, and work budget"
)]
fn diff_nodes(
provider: &dyn SegmentProvider,
before: Option<&NodeState<'_>>,
after: Option<&NodeState<'_>>,
path: &str,
depth: usize,
emit: &mut dyn FnMut(NodeDifference),
visits: &mut u64,
observer: &mut dyn ProgressObserver,
) -> Result<()> {
if depth > MAXIMUM_DIFF_DEPTH {
return Err(crate::error::Error::InvalidFormat {
details: format!(
"node tree exceeds depth {MAXIMUM_DIFF_DEPTH}; the records probably form a cycle"
),
});
}
if (*visits).is_multiple_of(DIFF_VISIT_REPORT_STRIDE) {
observer.step_advanced(*visits);
}
*visits += 1;
if *visits > MAXIMUM_DIFF_VISITS {
return Err(crate::error::Error::InvalidFormat {
details: format!(
"diff exceeds {MAXIMUM_DIFF_VISITS} visited nodes; \
the records probably form a pathological graph"
),
});
}
match (before, after) {
(None, None) => {}
(None, Some(_)) => emit(NodeDifference::NodeAdded {
path: display_path(path),
}),
(Some(_), None) => emit(NodeDifference::NodeRemoved {
path: display_path(path),
}),
(Some(before_node), Some(after_node)) => {
if before_node.record_identifier() == after_node.record_identifier() {
return Ok(());
}
diff_properties(provider, before_node, after_node, path, emit)?;
diff_children(
provider,
before_node,
after_node,
path,
depth,
emit,
visits,
observer,
)?;
}
}
Ok(())
}
fn diff_properties(
provider: &dyn SegmentProvider,
before: &NodeState<'_>,
after: &NodeState<'_>,
path: &str,
emit: &mut dyn FnMut(NodeDifference),
) -> Result<()> {
let before_properties = before.properties()?;
let after_properties = after.properties()?;
for after_property in &after_properties {
match before_properties
.iter()
.find(|property| property.name == after_property.name)
{
None => emit(NodeDifference::PropertyChanged {
path: display_path(path),
change: PropertyChange::Added(after_property.clone()),
}),
Some(before_property)
if before_property.property_type != after_property.property_type
|| !property_values_equal(
provider,
&before_property.values,
&after_property.values,
)? =>
{
emit(NodeDifference::PropertyChanged {
path: display_path(path),
change: PropertyChange::Changed {
before: before_property.clone(),
after: after_property.clone(),
},
});
}
Some(_) => {}
}
}
for before_property in &before_properties {
if !after_properties
.iter()
.any(|property| property.name == before_property.name)
{
emit(NodeDifference::PropertyChanged {
path: display_path(path),
change: PropertyChange::Removed(before_property.clone()),
});
}
}
Ok(())
}
#[allow(
clippy::too_many_arguments,
reason = "the walk threads its path, depth, output, and work budget"
)]
fn diff_children(
provider: &dyn SegmentProvider,
before: &NodeState<'_>,
after: &NodeState<'_>,
path: &str,
depth: usize,
emit: &mut dyn FnMut(NodeDifference),
visits: &mut u64,
observer: &mut dyn ProgressObserver,
) -> Result<()> {
let before_children = before.child_node_entries()?;
let after_children = after.child_node_entries()?;
for (name, after_child) in &after_children {
let before_child = before_children
.iter()
.find(|(before_name, _)| before_name == name)
.map(|(_, node)| node);
let child_path = format!("{path}/{name}");
diff_nodes(
provider,
before_child,
Some(after_child),
&child_path,
depth + 1,
emit,
visits,
observer,
)?;
}
for (name, before_child) in &before_children {
if !after_children
.iter()
.any(|(after_name, _)| after_name == name)
{
let child_path = format!("{path}/{name}");
diff_nodes(
provider,
Some(before_child),
None,
&child_path,
depth + 1,
emit,
visits,
observer,
)?;
}
}
Ok(())
}
fn property_values_equal(
provider: &dyn SegmentProvider,
before: &PropertyValues,
after: &PropertyValues,
) -> Result<bool> {
match (before, after) {
(PropertyValues::Single(before_value), PropertyValues::Single(after_value)) => {
property_value_equal(provider, before_value, after_value)
}
(PropertyValues::Multiple(before_values), PropertyValues::Multiple(after_values)) => {
if before_values.len() != after_values.len() {
return Ok(false);
}
for (before_value, after_value) in before_values.iter().zip(after_values) {
if !property_value_equal(provider, before_value, after_value)? {
return Ok(false);
}
}
Ok(true)
}
_ => Ok(false),
}
}
fn property_value_equal(
provider: &dyn SegmentProvider,
before: &crate::content::property::PropertyValue,
after: &crate::content::property::PropertyValue,
) -> Result<bool> {
use crate::content::property::PropertyValue;
use crate::content::value::BinaryValue;
if let (
PropertyValue::Binary(BinaryValue::Inline {
length: before_length,
record_identifier: before_record,
}),
PropertyValue::Binary(BinaryValue::Inline {
length: after_length,
record_identifier: after_record,
}),
) = (before, after)
{
if before_length != after_length {
return Ok(false);
}
return crate::content::value::inline_binary_contents_equal(
provider,
*before_record,
*after_record,
*before_length,
);
}
if let (PropertyValue::Double(before_value), PropertyValue::Double(after_value)) =
(before, after)
{
return Ok(
double_bits_for_equality(*before_value) == double_bits_for_equality(*after_value)
);
}
Ok(before == after)
}
fn double_bits_for_equality(value: f64) -> u64 {
if value.is_nan() {
0x7FF8_0000_0000_0000
} else {
value.to_bits()
}
}
fn display_path(path: &str) -> String {
if path.is_empty() {
"/".to_owned()
} else {
path.to_owned()
}
}
#[cfg(test)]
mod tests {
use super::{NodeDifference, PropertyChange, diff_revisions};
use crate::content::node::PropertyValues;
use crate::content::property::PropertyValue;
use crate::writer::record_writer::{ChildNodesToWrite, PropertyToWrite, PropertyValuesToWrite};
use crate::writer::store_writer::WritableRepository;
struct TestDirectory {
path: std::path::PathBuf,
}
impl TestDirectory {
fn new(name: &str) -> Self {
let path =
std::env::temp_dir().join(format!("froe-diff-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
Self { path }
}
}
impl Drop for TestDirectory {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
fn write_revision(directory: &std::path::Path, title: &str, children: &[&str]) -> String {
let store = WritableRepository::open(directory).expect("open");
let generation = store.writing_generation().expect("generation");
let mut writer = store.record_writer(generation);
let mut child_nodes = Vec::new();
for name in children {
let child = writer
.write_node(Some("nt:unstructured"), &[], &ChildNodesToWrite::Zero, &[])
.expect("child");
child_nodes.push(((*name).to_owned(), child));
}
let value = writer.write_string(title).expect("value");
let child_structure = if child_nodes.is_empty() {
ChildNodesToWrite::Zero
} else {
ChildNodesToWrite::Many(child_nodes)
};
let content = writer
.write_node(
Some("nt:unstructured"),
&[],
&child_structure,
&[PropertyToWrite {
name: "title".to_owned(),
property_type: crate::content::property::PropertyType::String,
values: PropertyValuesToWrite::Single(value),
}],
)
.expect("content");
let root = writer
.write_node(
None,
&[],
&ChildNodesToWrite::One {
name: "content".to_owned(),
node: content,
},
&[],
)
.expect("root");
let head = writer
.write_node(
None,
&[],
&ChildNodesToWrite::One {
name: "root".to_owned(),
node: root,
},
&[],
)
.expect("super root");
writer.finish().expect("finish");
let previous = store.head();
assert!(store.set_head(previous, head));
store.close().expect("close");
format!("{}:{}", head.segment, head.record_number as i32)
}
#[test]
fn detects_property_and_child_changes() {
let directory = TestDirectory::new("changes");
let before = write_revision(&directory.path, "original", &["alpha"]);
let after = write_revision(&directory.path, "updated", &["alpha", "beta"]);
let differences =
diff_revisions(&directory.path, &before, &after, "/content").expect("diff");
let title_changed = differences.iter().any(|difference| {
matches!(
difference,
NodeDifference::PropertyChanged {
change: PropertyChange::Changed { before, after },
..
} if before.name == "title"
&& before.values
== PropertyValues::Single(PropertyValue::String("original".to_owned()))
&& after.values
== PropertyValues::Single(PropertyValue::String("updated".to_owned()))
)
});
assert!(
title_changed,
"the title change is reported: {differences:?}"
);
let beta_added = differences.iter().any(|difference| {
matches!(difference, NodeDifference::NodeAdded { path } if path == "/content/beta")
});
assert!(beta_added, "the added child is reported: {differences:?}");
}
#[test]
fn identical_revisions_have_no_differences() {
let directory = TestDirectory::new("identical");
let revision = write_revision(&directory.path, "same", &["child"]);
let differences =
diff_revisions(&directory.path, &revision, &revision, "/content").expect("diff");
assert!(differences.is_empty(), "no differences: {differences:?}");
}
fn write_typed_revision(
directory: &std::path::Path,
single_type: crate::content::property::PropertyType,
multiple_type: crate::content::property::PropertyType,
) -> String {
let store = WritableRepository::open(directory).expect("open");
let generation = store.writing_generation().expect("generation");
let mut writer = store.record_writer(generation);
let single_value = writer.write_string("x").expect("value");
let content = writer
.write_node(
Some("nt:unstructured"),
&[],
&ChildNodesToWrite::Zero,
&[
PropertyToWrite {
name: "single_prop".to_owned(),
property_type: single_type,
values: PropertyValuesToWrite::Single(single_value),
},
PropertyToWrite {
name: "multi_prop".to_owned(),
property_type: multiple_type,
values: PropertyValuesToWrite::Multiple(Vec::new()),
},
],
)
.expect("content");
let root = writer
.write_node(
None,
&[],
&ChildNodesToWrite::One {
name: "content".to_owned(),
node: content,
},
&[],
)
.expect("root");
let head = writer
.write_node(
None,
&[],
&ChildNodesToWrite::One {
name: "root".to_owned(),
node: root,
},
&[],
)
.expect("super root");
writer.finish().expect("finish");
let previous = store.head();
assert!(store.set_head(previous, head));
store.close().expect("close");
format!("{}:{}", head.segment, head.record_number as i32)
}
#[test]
fn detects_type_only_property_changes() {
use crate::content::property::PropertyType;
let directory = TestDirectory::new("type-changes");
let before =
write_typed_revision(&directory.path, PropertyType::String, PropertyType::String);
let after = write_typed_revision(&directory.path, PropertyType::Name, PropertyType::Long);
let differences =
diff_revisions(&directory.path, &before, &after, "/content").expect("diff");
let type_of = |name: &str| {
differences.iter().find_map(|difference| match difference {
NodeDifference::PropertyChanged {
change: PropertyChange::Changed { before, after },
..
} if before.name == name => Some((before.property_type, after.property_type)),
_ => None,
})
};
assert_eq!(
type_of("single_prop"),
Some((PropertyType::String, PropertyType::Name)),
"a same-bytes String-to-Name retype is reported: {differences:?}"
);
assert_eq!(
type_of("multi_prop"),
Some((PropertyType::String, PropertyType::Long)),
"an empty String[]-to-Long[] retype is reported: {differences:?}"
);
}
#[test]
fn head_resolves_to_the_newest_revision() {
let directory = TestDirectory::new("head");
let before = write_revision(&directory.path, "first", &[]);
write_revision(&directory.path, "second", &[]);
let differences =
diff_revisions(&directory.path, &before, "head", "/content").expect("diff");
assert!(
!differences.is_empty(),
"the title differs between the revisions"
);
}
}