use serde::{Deserialize, Serialize};
use crate::ops::ChangeEnvelope;
use super::change_detection::{
Digest, StatDiff, StatMap, diff_stat_maps, digest_stat_map, digests_equal, parse_digest_token,
serialize_digest_token,
};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Slice {
pub added: Vec<String>,
pub modified: Vec<String>,
pub deleted: Vec<String>,
}
impl Slice {
fn sort(&mut self) {
self.added.sort();
self.modified.sort();
self.deleted.sort();
}
}
impl From<StatDiff> for Slice {
fn from(d: StatDiff) -> Self {
Slice {
added: d.added,
modified: d.modified,
deleted: d.deleted,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NoSignalReason {
Unscoped,
DetectionNone,
GitUnavailable,
GraphSnapshotMissing,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SliceOutcome {
NoSignal {
reason: NoSignalReason,
},
Reseed {
token: String,
},
Unchanged {
token: String,
},
Changed {
token: String,
slice: Slice,
degraded: bool,
},
}
pub fn is_git_token(s: &str) -> bool {
(7..=64).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_hexdigit())
}
pub fn graph_changes_to_slice(changes: &[ChangeEnvelope]) -> Slice {
let mut slice = Slice::default();
for change in changes {
match change {
ChangeEnvelope::Added { id, .. } => slice.added.push(id.as_ref().to_string()),
ChangeEnvelope::Removed { id, .. } => slice.deleted.push(id.as_ref().to_string()),
ChangeEnvelope::Renamed { from_id, to_id, .. } => {
slice.added.push(to_id.as_ref().to_string());
slice.deleted.push(from_id.as_ref().to_string());
}
ChangeEnvelope::Updated { id, .. } => slice.modified.push(id.as_ref().to_string()),
}
}
slice.sort();
slice
}
pub fn graph_slice_outcome(
baseline: Option<&str>,
current: &str,
changes: &[ChangeEnvelope],
) -> SliceOutcome {
if !is_git_token(current) {
return SliceOutcome::NoSignal {
reason: NoSignalReason::GraphSnapshotMissing,
};
}
match baseline {
Some(b) if is_git_token(b) => {
if b == current {
SliceOutcome::Unchanged {
token: current.to_string(),
}
} else {
SliceOutcome::Changed {
token: current.to_string(),
slice: graph_changes_to_slice(changes),
degraded: false,
}
}
}
_ => SliceOutcome::Reseed {
token: current.to_string(),
},
}
}
pub fn mtime_slice_outcome(
baseline: Option<&str>,
prev_map: Option<&StatMap>,
current_map: &StatMap,
) -> SliceOutcome {
let current_digest: Digest = digest_stat_map(current_map);
let token = serialize_digest_token(¤t_digest);
let baseline_digest = baseline.and_then(parse_digest_token);
let Some(baseline_digest) = baseline_digest else {
return SliceOutcome::Reseed { token };
};
if digests_equal(Some(&baseline_digest), Some(¤t_digest)) {
return SliceOutcome::Unchanged { token };
}
match prev_map {
Some(prev) => SliceOutcome::Changed {
token,
slice: diff_stat_maps(prev, current_map).into(),
degraded: false,
},
None => {
let mut added: Vec<String> = current_map.keys().cloned().collect();
added.sort();
SliceOutcome::Changed {
token,
slice: Slice {
added,
modified: Vec::new(),
deleted: Vec::new(),
},
degraded: true,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::entity::EntityId;
fn added(mem: &str, slug: &str) -> ChangeEnvelope {
ChangeEnvelope::Added {
id: EntityId::new(mem, slug),
title: None,
entity_type: None,
}
}
fn updated(mem: &str, slug: &str) -> ChangeEnvelope {
ChangeEnvelope::Updated {
id: EntityId::new(mem, slug),
title: None,
entity_type: None,
}
}
fn removed(mem: &str, slug: &str) -> ChangeEnvelope {
ChangeEnvelope::Removed {
id: EntityId::new(mem, slug),
title: None,
entity_type: None,
}
}
fn renamed(mem: &str, from: &str, to: &str) -> ChangeEnvelope {
ChangeEnvelope::Renamed {
from_id: EntityId::new(mem, from),
to_id: EntityId::new(mem, to),
title: None,
entity_type: None,
}
}
fn entry(mtime: i64, size: u64) -> super::super::change_detection::StatEntry {
super::super::change_detection::StatEntry { mtime, size }
}
fn stat_map(pairs: &[(&str, i64, u64)]) -> StatMap {
pairs
.iter()
.map(|(k, m, s)| ((*k).to_string(), entry(*m, *s)))
.collect()
}
#[test]
fn is_git_token_recognizes_hex_shas() {
assert!(is_git_token("a1b2c3d")); assert!(is_git_token(&"a".repeat(40))); assert!(is_git_token("ABCDEF0")); assert!(!is_git_token("a1b2c3")); assert!(!is_git_token(&"a".repeat(65))); assert!(!is_git_token("not-hex")); assert!(!is_git_token(
r#"{"v":1,"count":2,"watermark":9,"aggregate":"x"}"#
));
assert!(!is_git_token(""));
}
#[test]
fn graph_mapping_routes_each_action() {
let changes = vec![
added("m", "new-a"),
updated("m", "changed-b"),
removed("m", "gone-c"),
renamed("m", "old-d", "new-d"),
];
let slice = graph_changes_to_slice(&changes);
assert_eq!(
slice.added,
vec![
EntityId::new("m", "new-a").as_ref().to_string(),
EntityId::new("m", "new-d").as_ref().to_string(),
]
);
assert_eq!(
slice.modified,
vec![EntityId::new("m", "changed-b").as_ref().to_string()]
);
assert_eq!(
slice.deleted,
vec![
EntityId::new("m", "gone-c").as_ref().to_string(),
EntityId::new("m", "old-d").as_ref().to_string(),
]
);
}
#[test]
fn graph_outcome_classifies_against_baseline() {
let cur = "a".repeat(40);
assert_eq!(
graph_slice_outcome(Some(&cur), "not-a-sha", &[]),
SliceOutcome::NoSignal {
reason: NoSignalReason::GraphSnapshotMissing
}
);
assert_eq!(
graph_slice_outcome(None, &cur, &[]),
SliceOutcome::Reseed { token: cur.clone() }
);
assert_eq!(
graph_slice_outcome(Some("{\"v\":1}"), &cur, &[]),
SliceOutcome::Reseed { token: cur.clone() }
);
assert_eq!(
graph_slice_outcome(Some(&cur), &cur, &[]),
SliceOutcome::Unchanged { token: cur.clone() }
);
let base = "b".repeat(40);
match graph_slice_outcome(Some(&base), &cur, &[added("m", "x")]) {
SliceOutcome::Changed {
token,
slice,
degraded,
} => {
assert_eq!(token, cur);
assert!(!degraded);
assert_eq!(
slice.added,
vec![EntityId::new("m", "x").as_ref().to_string()]
);
}
other => panic!("expected Changed, got {other:?}"),
}
}
#[test]
fn mtime_outcome_classifies_and_degrades() {
let now = stat_map(&[("a.rs", 100, 10), ("b.rs", 200, 20)]);
let token_now = serialize_digest_token(&digest_stat_map(&now));
assert_eq!(
mtime_slice_outcome(None, None, &now),
SliceOutcome::Reseed {
token: token_now.clone()
}
);
assert_eq!(
mtime_slice_outcome(Some(&"a".repeat(40)), None, &now),
SliceOutcome::Reseed {
token: token_now.clone()
}
);
assert_eq!(
mtime_slice_outcome(Some(&token_now), None, &now),
SliceOutcome::Unchanged {
token: token_now.clone()
}
);
let prev = stat_map(&[("a.rs", 100, 10), ("gone.rs", 5, 5)]);
let prev_token = serialize_digest_token(&digest_stat_map(&prev));
match mtime_slice_outcome(Some(&prev_token), Some(&prev), &now) {
SliceOutcome::Changed {
token,
slice,
degraded,
} => {
assert_eq!(token, token_now);
assert!(!degraded);
assert_eq!(slice.added, vec!["b.rs"]);
assert_eq!(slice.deleted, vec!["gone.rs"]);
assert!(slice.modified.is_empty());
}
other => panic!("expected precise Changed, got {other:?}"),
}
match mtime_slice_outcome(Some(&prev_token), None, &now) {
SliceOutcome::Changed {
token,
slice,
degraded,
} => {
assert_eq!(token, token_now);
assert!(degraded, "memo miss is a degraded full scan");
assert_eq!(slice.added, vec!["a.rs", "b.rs"]);
assert!(slice.modified.is_empty() && slice.deleted.is_empty());
}
other => panic!("expected degraded Changed, got {other:?}"),
}
}
}