use std::collections::HashMap;
use panproto_schema::Schema;
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use crate::diff::SchemaDiff;
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct ScopeReport {
pub scopes: Vec<ScopeChange>,
pub named_elements: Vec<NamedElement>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ScopeChange {
pub scope_id: String,
pub scope_name: String,
pub kind: ScopeChangeKind,
pub summary: String,
pub anonymous_added: usize,
pub anonymous_removed: usize,
pub start_line: Option<usize>,
pub end_line: Option<usize>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ScopeChangeKind {
Added,
Removed,
SignatureChanged,
BodyModified,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct NamedElement {
pub id: String,
pub name: String,
pub kind: String,
pub status: ElementStatus,
pub start_line: Option<usize>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ElementStatus {
Unchanged,
BodyModified,
SignatureChanged,
Added,
Removed,
}
fn is_anonymous_segment(segment: &str) -> bool {
segment.starts_with('$')
}
fn nearest_named_scope(vertex_id: &str) -> &str {
let segments: Vec<&str> = vertex_id.split("::").collect();
for i in (0..segments.len()).rev() {
if !is_anonymous_segment(segments[i]) {
let char_len: usize = segments[..=i].iter().map(|s| s.len()).sum::<usize>() + i * 2;
return &vertex_id[..char_len];
}
}
vertex_id
}
fn scope_display_name(scope_id: &str) -> &str {
scope_id
.rsplit("::")
.find(|s| !is_anonymous_segment(s))
.unwrap_or(scope_id)
}
fn byte_offset_to_line(offset: usize, source: &[u8]) -> usize {
memchr::memchr_iter(b'\n', &source[..offset.min(source.len())]).count() + 1
}
fn start_byte_for_vertex(schema: &Schema, vertex_id: &str) -> Option<usize> {
let constraints = schema.constraints.get(vertex_id)?;
constraints
.iter()
.find(|c| c.sort.as_ref() == "start-byte")
.and_then(|c| c.value.parse().ok())
}
fn end_byte_for_vertex(schema: &Schema, vertex_id: &str) -> Option<usize> {
let constraints = schema.constraints.get(vertex_id)?;
constraints
.iter()
.find(|c| c.sort.as_ref() == "end-byte")
.and_then(|c| c.value.parse().ok())
}
fn count_named_edge_changes(edges: &[panproto_schema::Edge]) -> FxHashMap<&str, usize> {
let mut m: FxHashMap<&str, usize> = FxHashMap::default();
for e in edges {
if !is_anonymous_segment(e.src.rsplit("::").next().unwrap_or(&e.src)) {
*m.entry(nearest_named_scope(&e.src)).or_default() += 1;
}
}
m
}
fn count_anonymous(vertices: &[&str]) -> usize {
vertices
.iter()
.filter(|v| {
let last = v.rsplit("::").next().unwrap_or(v);
is_anonymous_segment(last)
})
.count()
}
#[must_use]
pub fn report_by_scope(
diff: &SchemaDiff,
old_schema: &Schema,
new_schema: &Schema,
old_bytes: Option<&[u8]>,
new_bytes: Option<&[u8]>,
) -> ScopeReport {
let added_set: rustc_hash::FxHashSet<&str> =
diff.added_vertices.iter().map(String::as_str).collect();
let removed_set: rustc_hash::FxHashSet<&str> =
diff.removed_vertices.iter().map(String::as_str).collect();
let mut scope_added: FxHashMap<&str, Vec<&str>> = FxHashMap::default();
let mut scope_removed: FxHashMap<&str, Vec<&str>> = FxHashMap::default();
for v in &diff.added_vertices {
let scope = nearest_named_scope(v);
scope_added.entry(scope).or_default().push(v);
}
for v in &diff.removed_vertices {
let scope = nearest_named_scope(v);
scope_removed.entry(scope).or_default().push(v);
}
let mut all_scopes: rustc_hash::FxHashSet<&str> = rustc_hash::FxHashSet::default();
all_scopes.extend(scope_added.keys());
all_scopes.extend(scope_removed.keys());
for edge in &diff.added_edges {
all_scopes.insert(nearest_named_scope(&edge.src));
all_scopes.insert(nearest_named_scope(&edge.tgt));
}
for edge in &diff.removed_edges {
all_scopes.insert(nearest_named_scope(&edge.src));
all_scopes.insert(nearest_named_scope(&edge.tgt));
}
let edges_added_for = count_named_edge_changes(&diff.added_edges);
let edges_removed_for = count_named_edge_changes(&diff.removed_edges);
let mut scopes = Vec::new();
let mut sorted_scopes: Vec<&str> = all_scopes.into_iter().collect();
sorted_scopes.sort_unstable();
for &scope_id in &sorted_scopes {
let added = scope_added.get(scope_id).map_or(&[][..], |v| v.as_slice());
let removed = scope_removed
.get(scope_id)
.map_or(&[][..], |v| v.as_slice());
let anon_added = count_anonymous(added);
let anon_removed = count_anonymous(removed);
let scope_itself_added = added_set.contains(scope_id);
let scope_itself_removed = removed_set.contains(scope_id);
let has_named_edge_changes =
edges_added_for.contains_key(scope_id) || edges_removed_for.contains_key(scope_id);
let kind = if scope_itself_added {
ScopeChangeKind::Added
} else if scope_itself_removed {
ScopeChangeKind::Removed
} else if has_named_edge_changes {
ScopeChangeKind::SignatureChanged
} else {
ScopeChangeKind::BodyModified
};
let summary = match &kind {
ScopeChangeKind::Added => format!("{} added", scope_display_name(scope_id)),
ScopeChangeKind::Removed => format!("{} removed", scope_display_name(scope_id)),
ScopeChangeKind::SignatureChanged => {
format!("{} signature changed", scope_display_name(scope_id))
}
ScopeChangeKind::BodyModified => {
let total = anon_added + anon_removed;
format!(
"{} body modified ({total} anonymous node{})",
scope_display_name(scope_id),
if total == 1 { "" } else { "s" },
)
}
};
let (start_line, end_line) = resolve_scope_lines(
scope_id, &kind, old_schema, new_schema, old_bytes, new_bytes,
);
scopes.push(ScopeChange {
scope_id: scope_id.to_string(),
scope_name: scope_display_name(scope_id).to_string(),
kind,
summary,
anonymous_added: anon_added,
anonymous_removed: anon_removed,
start_line,
end_line,
});
}
let named_elements = build_named_elements(
old_schema,
new_schema,
&added_set,
&removed_set,
&scope_added,
&scope_removed,
&edges_added_for,
&edges_removed_for,
old_bytes,
new_bytes,
);
ScopeReport {
scopes,
named_elements,
}
}
fn resolve_scope_lines(
scope_id: &str,
kind: &ScopeChangeKind,
old_schema: &Schema,
new_schema: &Schema,
old_bytes: Option<&[u8]>,
new_bytes: Option<&[u8]>,
) -> (Option<usize>, Option<usize>) {
let (schema, source) = if *kind == ScopeChangeKind::Removed {
(old_schema, old_bytes)
} else {
(new_schema, new_bytes)
};
let start = source.and_then(|bytes| {
start_byte_for_vertex(schema, scope_id).map(|off| byte_offset_to_line(off, bytes))
});
let end = source.and_then(|bytes| {
end_byte_for_vertex(schema, scope_id).map(|off| byte_offset_to_line(off, bytes))
});
(start, end)
}
#[allow(clippy::too_many_arguments)]
fn build_named_elements(
old_schema: &Schema,
new_schema: &Schema,
added_set: &rustc_hash::FxHashSet<&str>,
removed_set: &rustc_hash::FxHashSet<&str>,
scope_added: &FxHashMap<&str, Vec<&str>>,
scope_removed: &FxHashMap<&str, Vec<&str>>,
edges_added_for: &FxHashMap<&str, usize>,
edges_removed_for: &FxHashMap<&str, usize>,
old_bytes: Option<&[u8]>,
new_bytes: Option<&[u8]>,
) -> Vec<NamedElement> {
let mut elements: HashMap<String, NamedElement> = HashMap::new();
for (vid, vertex) in &new_schema.vertices {
let id_str = vid.to_string();
let name = id_str.rsplit("::").next().unwrap_or(&id_str).to_string();
if is_anonymous_segment(&name) {
continue;
}
let status = if added_set.contains(id_str.as_str()) {
ElementStatus::Added
} else if edges_added_for.contains_key(id_str.as_str())
|| edges_removed_for.contains_key(id_str.as_str())
{
ElementStatus::SignatureChanged
} else if scope_added.contains_key(id_str.as_str())
|| scope_removed.contains_key(id_str.as_str())
{
ElementStatus::BodyModified
} else {
ElementStatus::Unchanged
};
let start_line = new_bytes.and_then(|bytes| {
start_byte_for_vertex(new_schema, &id_str).map(|off| byte_offset_to_line(off, bytes))
});
elements.insert(
id_str.clone(),
NamedElement {
id: id_str,
name,
kind: vertex.kind.to_string(),
status,
start_line,
},
);
}
for (vid, vertex) in &old_schema.vertices {
let id_str = vid.to_string();
let name = id_str.rsplit("::").next().unwrap_or(&id_str).to_string();
if is_anonymous_segment(&name) {
continue;
}
if elements.contains_key(&id_str) {
continue;
}
if !removed_set.contains(id_str.as_str()) {
continue;
}
let start_line = old_bytes.and_then(|bytes| {
start_byte_for_vertex(old_schema, &id_str).map(|off| byte_offset_to_line(off, bytes))
});
elements.insert(
id_str.clone(),
NamedElement {
id: id_str,
name,
kind: vertex.kind.to_string(),
status: ElementStatus::Removed,
start_line,
},
);
}
let mut result: Vec<NamedElement> = elements.into_values().collect();
result.sort_by(|a, b| a.id.cmp(&b.id));
result
}
#[must_use]
pub fn report_scope_text(report: &ScopeReport) -> String {
use std::fmt::Write;
let mut out = String::new();
if report.scopes.is_empty() {
out.push_str("No scope-level changes detected.\n");
return out;
}
let _ = writeln!(out, "{} scope(s) changed:\n", report.scopes.len());
for scope in &report.scopes {
let line_info = match (scope.start_line, scope.end_line) {
(Some(s), Some(e)) => format!(" (lines {s}:{e})"),
(Some(s), None) => format!(" (line {s})"),
_ => String::new(),
};
let _ = writeln!(out, " {:?} {}{}", scope.kind, scope.scope_id, line_info);
let _ = writeln!(out, " {}", scope.summary);
}
out
}
#[must_use]
pub fn report_scope_json(report: &ScopeReport) -> serde_json::Value {
serde_json::to_value(report).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use panproto_gat::Name;
use panproto_schema::Vertex;
fn test_schema(verts: &[(&str, &str)]) -> Schema {
let mut vertices = HashMap::new();
for (id, kind) in verts {
vertices.insert(
Name::from(*id),
Vertex {
id: Name::from(*id),
kind: Name::from(*kind),
nsid: None,
},
);
}
Schema {
protocol: "test".into(),
vertices,
edges: HashMap::new(),
hyper_edges: HashMap::new(),
constraints: HashMap::new(),
required: HashMap::new(),
nsids: HashMap::new(),
variants: HashMap::new(),
orderings: HashMap::new(),
recursion_points: HashMap::new(),
spans: HashMap::new(),
usage_modes: HashMap::new(),
nominal: HashMap::new(),
coercions: HashMap::new(),
mergers: HashMap::new(),
defaults: HashMap::new(),
policies: HashMap::new(),
outgoing: HashMap::new(),
incoming: HashMap::new(),
between: HashMap::new(),
}
}
#[test]
fn nearest_scope_skips_anonymous() {
assert_eq!(
nearest_named_scope("file.rs::MyClass::method::$0::$1"),
"file.rs::MyClass::method"
);
assert_eq!(nearest_named_scope("file.rs::MyClass"), "file.rs::MyClass");
assert_eq!(nearest_named_scope("$0::$1"), "$0::$1");
}
#[test]
fn scope_name_extracts_last_named() {
assert_eq!(scope_display_name("file.rs::MyClass::method"), "method");
assert_eq!(scope_display_name("file.rs"), "file.rs");
}
#[test]
fn body_modified_groups_anonymous() {
let old = test_schema(&[
("file.rs::fn_a", "function"),
("file.rs::fn_a::$0", "expression_statement"),
("file.rs::fn_a::$1", "expression_statement"),
]);
let new = test_schema(&[
("file.rs::fn_a", "function"),
("file.rs::fn_a::$0", "expression_statement"),
("file.rs::fn_a::$1", "expression_statement"),
("file.rs::fn_a::$2", "expression_statement"),
]);
let diff = SchemaDiff {
added_vertices: vec!["file.rs::fn_a::$2".to_string()],
..SchemaDiff::default()
};
let report = report_by_scope(&diff, &old, &new, None, None);
assert_eq!(report.scopes.len(), 1);
assert_eq!(report.scopes[0].scope_id, "file.rs::fn_a");
assert_eq!(report.scopes[0].kind, ScopeChangeKind::BodyModified);
assert_eq!(report.scopes[0].anonymous_added, 1);
}
#[test]
fn added_scope_detected() {
let old = test_schema(&[("file.rs", "source_file")]);
let new = test_schema(&[("file.rs", "source_file"), ("file.rs::new_fn", "function")]);
let diff = SchemaDiff {
added_vertices: vec!["file.rs::new_fn".to_string()],
..SchemaDiff::default()
};
let report = report_by_scope(&diff, &old, &new, None, None);
assert_eq!(report.scopes.len(), 1);
assert_eq!(report.scopes[0].kind, ScopeChangeKind::Added);
}
#[test]
fn removed_scope_detected() {
let old = test_schema(&[("file.rs", "source_file"), ("file.rs::old_fn", "function")]);
let new = test_schema(&[("file.rs", "source_file")]);
let diff = SchemaDiff {
removed_vertices: vec!["file.rs::old_fn".to_string()],
..SchemaDiff::default()
};
let report = report_by_scope(&diff, &old, &new, None, None);
assert_eq!(report.scopes.len(), 1);
assert_eq!(report.scopes[0].kind, ScopeChangeKind::Removed);
}
#[test]
fn named_elements_lists_all() {
let old = test_schema(&[
("file.rs", "source_file"),
("file.rs::fn_a", "function"),
("file.rs::fn_a::$0", "statement"),
]);
let new = test_schema(&[
("file.rs", "source_file"),
("file.rs::fn_a", "function"),
("file.rs::fn_a::$0", "statement"),
("file.rs::fn_a::$1", "statement"),
]);
let diff = SchemaDiff {
added_vertices: vec!["file.rs::fn_a::$1".to_string()],
..SchemaDiff::default()
};
let report = report_by_scope(&diff, &old, &new, None, None);
let names: Vec<&str> = report
.named_elements
.iter()
.map(|e| e.name.as_str())
.collect();
assert!(names.contains(&"fn_a"));
assert!(!names.contains(&"$0"));
assert!(!names.contains(&"$1"));
}
#[test]
fn empty_diff_produces_empty_report() {
let schema = test_schema(&[("file.rs", "source_file")]);
let diff = SchemaDiff::default();
let report = report_by_scope(&diff, &schema, &schema, None, None);
assert!(report.scopes.is_empty());
}
#[test]
fn text_report_formats() {
let old = test_schema(&[
("file.rs::fn_a", "function"),
("file.rs::fn_a::$0", "statement"),
]);
let new = test_schema(&[
("file.rs::fn_a", "function"),
("file.rs::fn_a::$0", "statement"),
("file.rs::fn_a::$1", "statement"),
]);
let diff = SchemaDiff {
added_vertices: vec!["file.rs::fn_a::$1".to_string()],
..SchemaDiff::default()
};
let report = report_by_scope(&diff, &old, &new, None, None);
let text = report_scope_text(&report);
assert!(text.contains("fn_a"));
assert!(text.contains("BodyModified"));
}
}