- 1
//! L4: a semantic diff between two read projections (docs/design/72, P3). - 2
//! - 3
//! The diff compares what a reader sees, anchor by anchor, so it describes - 4
//! changes the way a person thinks about them ("Budget: B4 100 → 150", - 5
//! "Slide 2 added", "§ Outlook: paragraph rewritten") and works the same - 6
//! for an Agent's edit and for a file changed outside Vakyartha. It is computed - 7
//! from two re-reads, never from anyone's description of the change. - 8
- 9
use std::collections::{BTreeMap, HashMap}; - 10
- 11
use serde::Serialize; - 12
- 13
use crate::package::Vocabulary; - 14
use crate::read::{Document, Unit, UnitKind}; - 15
- 16
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] - 17
#[serde(rename_all = "snake_case")] - 18
pub enum ChangeKind { - 19
Added, - 20
Removed, - 21
Changed, - 22
Moved, - 23
} - 24
- 25
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] - 26
pub struct Change { - 27
/// Where a person would look: a heading, a sheet, a slide. - 28
pub section: String, - 29
pub anchor: String, - 30
pub kind: ChangeKind, - 31
pub before: Option<String>, - 32
pub after: Option<String>, - 33
} - 34
- 35
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] - 36
pub struct Diff { - 37
pub changes: Vec<Change>, - 38
/// One line per section, in document order. - 39
pub summary: Vec<String>, - 40
} - 41
- 42
impl Diff { - 43
pub fn is_empty(&self) -> bool { - 44
self.changes.is_empty() - 45
} - 46
} - 47
- 48
/// Compares `before` (absent for a new file) with `after`. - 49
pub fn diff(before: Option<&Document>, after: &Document) -> Diff { - 50
let mut changes = Vec::new(); - 51
let before_title = before.and_then(|document| document.title.clone()); - 52
if before.is_some() && before_title != after.title { - 53
changes.push(Change { - 54
section: "Properties".into(), - 55
anchor: "title".into(), - 56
kind: ChangeKind::Changed, - 57
before: before_title, - 58
after: after.title.clone(), - 59
}); - 60
} - 61
let Some(before) = before else { - 62
let section = match after.inspection.format.vocabulary { - 63
Vocabulary::Excel => "Workbook", - 64
Vocabulary::PowerPoint => "Deck", - 65
_ => "Document", - 66
}; - 67
changes.push(Change { - 68
section: section.into(), - 69
anchor: String::new(), - 70
kind: ChangeKind::Added, - 71
before: None, - 72
after: Some(format!( - 73
"new file: {}", - 74
after - 75
.stats - 76
.iter() - 77
.filter(|(_, count)| *count > 0) - 78
.map(|(name, count)| counted(*count, name)) - 79
.collect::<Vec<_>>() - 80
.join(", ") - 81
)), - 82
}); - 83
return finish(changes); - 84
}; - 85
match after.inspection.format.vocabulary { - 86
Vocabulary::Excel => cells(before, after, &mut changes), - 87
Vocabulary::PowerPoint => { - 88
slide_order(before, after, &mut changes); - 89
units(before, after, &mut changes, |unit| { - 90
unit.kind != UnitKind::Slide - 91
}); - 92
slide_titles(before, after, &mut changes); - 93
} - 94
_ => units(before, after, &mut changes, |_| true), - 95
} - 96
finish(changes) - 97
} - 98
- 99
/// `1 heading`, `2 headings`: a reader's stat with its count. Stat names - 100
/// are plural; one of a thing drops the plural from its noun. - 101
fn counted(count: usize, name: &str) -> String { - 102
if count != 1 { - 103
return format!("{count} {name}"); - 104
} - 105
let singular = match name.split_once(" with ") { - 106
Some((noun, rest)) => format!("{} with {rest}", noun.trim_end_matches('s')), - 107
None => name.strip_suffix('s').unwrap_or(name).to_string(), - 108
}; - 109
format!("1 {singular}") - 110
} - 111
- 112
fn finish(changes: Vec<Change>) -> Diff { - 113
let mut order: Vec<String> = Vec::new(); - 114
let mut counts: HashMap<String, BTreeMap<&'static str, usize>> = HashMap::new(); - 115
for change in &changes { - 116
if !order.contains(&change.section) { - 117
order.push(change.section.clone()); - 118
} - 119
let word = match change.kind { - 120
ChangeKind::Added => "added", - 121
ChangeKind::Removed => "removed", - 122
ChangeKind::Changed => "changed", - 123
ChangeKind::Moved => "moved", - 124
}; - 125
*counts - 126
.entry(change.section.clone()) - 127
.or_default() - 128
.entry(word) - 129
.or_default() += 1; - 130
} - 131
let summary = order - 132
.iter() - 133
.map(|section| { - 134
let parts: Vec<String> = counts[section] - 135
.iter() - 136
.map(|(word, count)| format!("{count} {word}")) - 137
.collect(); - 138
format!("{section}: {}", parts.join(", ")) - 139
}) - 140
.collect(); - 141
Diff { changes, summary } - 142
} - 143
- 144
/// The section title each unit belongs to. - 145
fn section_of(document: &Document) -> Vec<String> { - 146
let fallback = match document.inspection.format.vocabulary { - 147
Vocabulary::Word => "Before the first heading", - 148
_ => "Document", - 149
}; - 150
let mut titles = vec![fallback.to_string(); document.units.len()]; - 151
for section in &document.sections { - 152
for index in section.units.clone() { - 153
if let Some(slot) = titles.get_mut(index) { - 154
*slot = section.title.clone(); - 155
} - 156
} - 157
} - 158
titles - 159
} - 160
- 161
/// Anchor-keyed comparison of units that `keep` selects. - 162
fn units( - 163
before: &Document, - 164
after: &Document, - 165
changes: &mut Vec<Change>, - 166
keep: impl Fn(&Unit) -> bool, - 167
) { - 168
let before_sections = section_of(before); - 169
let after_sections = section_of(after); - 170
let old: HashMap<&str, (usize, &Unit)> = before - 171
.units - 172
.iter() - 173
.enumerate() - 174
.filter(|(_, unit)| keep(unit)) - 175
.map(|(index, unit)| (unit.anchor.as_str(), (index, unit))) - 176
.collect(); - 177
let mut seen = std::collections::HashSet::new(); - 178
for (index, unit) in after - 179
.units - 180
.iter() - 181
.enumerate() - 182
.filter(|(_, unit)| keep(unit)) - 183
{ - 184
seen.insert(unit.anchor.as_str()); - 185
match old.get(unit.anchor.as_str()) { - 186
Some((_, previous)) if previous.text == unit.text && previous.labels == unit.labels => { - 187
} - 188
Some((_, previous)) => changes.push(Change { - 189
section: after_sections[index].clone(), - 190
anchor: unit.anchor.clone(), - 191
kind: ChangeKind::Changed, - 192
before: Some(previous.text.clone()), - 193
after: Some(unit.text.clone()), - 194
}), - 195
None => changes.push(Change { - 196
section: after_sections[index].clone(), - 197
anchor: unit.anchor.clone(), - 198
kind: ChangeKind::Added, - 199
before: None, - 200
after: Some(unit.text.clone()), - 201
}), - 202
} - 203
} - 204
let mut removed: Vec<&(usize, &Unit)> = old - 205
.iter() - 206
.filter(|(anchor, _)| !seen.contains(*anchor)) - 207
.map(|(_, entry)| entry) - 208
.collect(); - 209
removed.sort_by_key(|(index, _)| *index); - 210
for (index, unit) in removed { - 211
changes.push(Change { - 212
section: before_sections[*index].clone(), - 213
anchor: unit.anchor.clone(), - 214
kind: ChangeKind::Removed, - 215
before: Some(unit.text.clone()), - 216
after: None, - 217
}); - 218
} - 219
} - 220
- 221
/// Cell-by-cell comparison per sheet, plus sheets added or removed. - 222
fn cells(before: &Document, after: &Document, changes: &mut Vec<Change>) { - 223
fn grid(document: &Document) -> BTreeMap<String, Vec<(String, String)>> { - 224
let sections = section_of(document); - 225
let mut sheets: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new(); - 226
for section in &document.sections { - 227
sheets.entry(section.title.clone()).or_default(); - 228
} - 229
for (index, unit) in document.units.iter().enumerate() { - 230
if unit.kind == UnitKind::SheetRow { - 231
sheets - 232
.entry(sections[index].clone()) - 233
.or_default() - 234
.extend(unit.cells.iter().cloned()); - 235
} - 236
} - 237
sheets - 238
} - 239
// Cell anchors use the sheet's anchor as the reader writes it - 240
// (`'Q4 plan'!B4`), so a change can be cited and commented on exactly - 241
// like a cell `doc_read` returned. - 242
let prefixes: HashMap<&str, &str> = before - 243
.sections - 244
.iter() - 245
.chain(&after.sections) - 246
.map(|section| (section.title.as_str(), section.anchor.as_str())) - 247
.collect(); - 248
let prefix = |sheet: &str| -> String { - 249
prefixes - 250
.get(sheet) - 251
.map(|anchor| anchor.to_string()) - 252
.unwrap_or_else(|| format!("{sheet}!")) - 253
}; - 254
let old = grid(before); - 255
let new = grid(after); - 256
for (sheet, cells) in &new { - 257
let Some(previous) = old.get(sheet) else { - 258
changes.push(Change { - 259
section: sheet.clone(), - 260
anchor: prefix(sheet), - 261
kind: ChangeKind::Added, - 262
before: None, - 263
after: Some(format!("sheet added with {} cell(s)", cells.len())), - 264
}); - 265
continue; - 266
}; - 267
let previous: HashMap<&str, &str> = previous - 268
.iter() - 269
.map(|(address, value)| (address.as_str(), value.as_str())) - 270
.collect(); - 271
let current: HashMap<&str, &str> = cells - 272
.iter() - 273
.map(|(address, value)| (address.as_str(), value.as_str())) - 274
.collect(); - 275
for (address, value) in cells { - 276
match previous.get(address.as_str()) { - 277
Some(old) if strip_stale(old) == strip_stale(value) => {} - 278
Some(old) => changes.push(Change { - 279
section: sheet.clone(), - 280
anchor: format!("{}{address}", prefix(sheet)), - 281
kind: ChangeKind::Changed, - 282
before: Some((*old).to_string()), - 283
after: Some(value.clone()), - 284
}), - 285
None => changes.push(Change { - 286
section: sheet.clone(), - 287
anchor: format!("{}{address}", prefix(sheet)), - 288
kind: ChangeKind::Added, - 289
before: None, - 290
after: Some(value.clone()), - 291
}), - 292
} - 293
} - 294
for (address, value) in old.get(sheet).into_iter().flatten() { - 295
if !current.contains_key(address.as_str()) { - 296
changes.push(Change { - 297
section: sheet.clone(), - 298
anchor: format!("{}{address}", prefix(sheet)), - 299
kind: ChangeKind::Removed, - 300
before: Some(value.clone()), - 301
after: None, - 302
}); - 303
} - 304
} - 305
} - 306
for sheet in old.keys() { - 307
if !new.contains_key(sheet) { - 308
changes.push(Change { - 309
section: sheet.clone(), - 310
anchor: prefix(sheet), - 311
kind: ChangeKind::Removed, - 312
before: Some("sheet".into()), - 313
after: None, - 314
}); - 315
} - 316
} - 317
} - 318
- 319
/// A cached value turning stale is a consequence of another change, not a - 320
/// change a person made, so it does not count on its own. - 321
fn strip_stale(value: &str) -> &str { - 322
value - 323
.split_once(" [cached") - 324
.map(|(formula, _)| formula) - 325
.unwrap_or(value) - 326
} - 327
- 328
fn slide_order(before: &Document, after: &Document, changes: &mut Vec<Change>) { - 329
let old: Vec<&str> = before.sections.iter().map(|s| s.anchor.as_str()).collect(); - 330
let new: Vec<&str> = after.sections.iter().map(|s| s.anchor.as_str()).collect(); - 331
for section in &after.sections { - 332
if !old.contains(§ion.anchor.as_str()) { - 333
changes.push(Change { - 334
section: section.title.clone(), - 335
anchor: section.anchor.clone(), - 336
kind: ChangeKind::Added, - 337
before: None, - 338
after: Some(section.title.clone()), - 339
}); - 340
} - 341
} - 342
for section in &before.sections { - 343
if !new.contains(§ion.anchor.as_str()) { - 344
changes.push(Change { - 345
section: section.title.clone(), - 346
anchor: section.anchor.clone(), - 347
kind: ChangeKind::Removed, - 348
before: Some(section.title.clone()), - 349
after: None, - 350
}); - 351
} - 352
} - 353
let kept_old: Vec<&str> = old - 354
.iter() - 355
.copied() - 356
.filter(|anchor| new.contains(anchor)) - 357
.collect(); - 358
let kept_new: Vec<&str> = new - 359
.iter() - 360
.copied() - 361
.filter(|anchor| old.contains(anchor)) - 362
.collect(); - 363
if kept_old != kept_new { - 364
for (position, anchor) in kept_new.iter().enumerate() { - 365
if kept_old.get(position) != Some(anchor) - 366
&& let Some(section) = after.sections.iter().find(|s| s.anchor == *anchor) - 367
{ - 368
changes.push(Change { - 369
section: section.title.clone(), - 370
anchor: section.anchor.clone(), - 371
kind: ChangeKind::Moved, - 372
before: old - 373
.iter() - 374
.position(|a| a == anchor) - 375
.map(|i| format!("position {}", i + 1)), - 376
after: new - 377
.iter() - 378
.position(|a| a == anchor) - 379
.map(|i| format!("position {}", i + 1)), - 380
}); - 381
} - 382
} - 383
} - 384
} - 385
- 386
/// A slide's title changing is reported once, against the slide itself. - 387
fn slide_titles(before: &Document, after: &Document, changes: &mut Vec<Change>) { - 388
let title = |text: &str| { - 389
text.split_once(": ") - 390
.map(|(_, title)| title.to_string()) - 391
.unwrap_or_default() - 392
}; - 393
for unit in after - 394
.units - 395
.iter() - 396
.filter(|unit| unit.kind == UnitKind::Slide) - 397
{ - 398
if let Some(previous) = before - 399
.units - 400
.iter() - 401
.find(|old| old.kind == UnitKind::Slide && old.anchor == unit.anchor) - 402
&& title(&previous.text) != title(&unit.text) - 403
{ - 404
changes.push(Change { - 405
section: unit.text.clone(), - 406
anchor: unit.anchor.clone(), - 407
kind: ChangeKind::Changed, - 408
before: Some(title(&previous.text)), - 409
after: Some(title(&unit.text)), - 410
}); - 411
} - 412
} - 413
} - 414
- 415
/// Formula cells whose cached value the draft marks stale and `before` did - 416
/// not, as `Sheet!A1` anchors in document order. - 417
fn newly_stale_formulas(before: Option<&Document>, after: &Document) -> Vec<String> { - 418
const STALE: &str = ", stale until recalculated]"; - 419
let stale_in = |document: &Document| -> std::collections::HashSet<String> { - 420
document - 421
.units - 422
.iter() - 423
.filter(|unit| unit.kind == UnitKind::SheetRow) - 424
.flat_map(|unit| { - 425
let sheet = unit.anchor.rsplit_once('!').map_or("", |(sheet, _)| sheet); - 426
unit.cells - 427
.iter() - 428
.filter(|(_, value)| value.ends_with(STALE)) - 429
.map(move |(address, _)| format!("{sheet}!{address}")) - 430
}) - 431
.collect() - 432
}; - 433
let already = before.map(stale_in).unwrap_or_default(); - 434
let mut stale: Vec<String> = Vec::new(); - 435
for unit in after - 436
.units - 437
.iter() - 438
.filter(|unit| unit.kind == UnitKind::SheetRow) - 439
{ - 440
let sheet = unit.anchor.rsplit_once('!').map_or("", |(sheet, _)| sheet); - 441
for (address, value) in &unit.cells { - 442
let anchor = format!("{sheet}!{address}"); - 443
if value.ends_with(STALE) && !already.contains(&anchor) { - 444
stale.push(anchor); - 445
} - 446
} - 447
} - 448
stale - 449
} - 450
- 451
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] - 452
#[serde(rename_all = "snake_case")] - 453
pub enum ImpactKind { - 454
Signature, - 455
Label, - 456
/// Formulas whose values in the file predate the change. - 457
Recalculation, - 458
} - 459
- 460
/// Something accepting the draft does beyond its visible changes, stated - 461
/// before acceptance (D4, O9). - 462
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] - 463
pub struct Impact { - 464
pub kind: ImpactKind, - 465
pub message: String, - 466
/// It removes or weakens something: a signature, or a label's - 467
/// protection. - 468
pub warning: bool, - 469
} - 470
- 471
/// What accepting `after` in place of `before` (absent for a new file) does - 472
/// to digital signatures, sensitivity labels and calculated values. - 473
pub fn impact(before: Option<&Document>, after: &Document) -> Vec<Impact> { - 474
let mut impacts = Vec::new(); - 475
let stale = newly_stale_formulas(before, after); - 476
if !stale.is_empty() { - 477
let examples: Vec<&str> = stale.iter().take(3).map(String::as_str).collect(); - 478
impacts.push(Impact { - 479
kind: ImpactKind::Recalculation, - 480
message: format!( - 481
"{} formula{} ({}{}) still show{} values from before these changes. Excel recalculates when it opens the file; until then, anything that shows the saved values, such as a preview, shows old numbers.", - 482
stale.len(), - 483
if stale.len() == 1 { "" } else { "s" }, - 484
examples.join(", "), - 485
if stale.len() > examples.len() { ", …" } else { "" }, - 486
if stale.len() == 1 { "s" } else { "" }, - 487
), - 488
warning: false, - 489
}); - 490
} - 491
let was_signed = before.is_some_and(|before| before.inspection.signed); - 492
let signed = after.inspection.signed; - 493
if was_signed && !signed { - 494
impacts.push(Impact { - 495
kind: ImpactKind::Signature, - 496
message: "The file is digitally signed. Accepting removes its signatures, because any edit invalidates them; sign it again in Office if it must stay signed.".into(), - 497
warning: true, - 498
}); - 499
} else if signed && before.is_some_and(|before| !diff(Some(before), after).is_empty()) { - 500
impacts.push(Impact { - 501
kind: ImpactKind::Signature, - 502
message: "The draft still carries a signature, but its content differs from the file that was signed, so the signature no longer holds. Vakyartha does not verify signatures.".into(), - 503
warning: true, - 504
}); - 505
} else if signed && before.is_none() { - 506
impacts.push(Impact { - 507
kind: ImpactKind::Signature, - 508
message: "The new file carries a digital signature Vakyartha has not verified.".into(), - 509
warning: false, - 510
}); - 511
} - 512
let old: &[String] = before.map_or(&[], |before| &before.inspection.sensitivity_labels); - 513
let new = &after.inspection.sensitivity_labels; - 514
let join = |labels: &[String]| labels.join(", "); - 515
let label = |message: String, warning: bool| Impact { - 516
kind: ImpactKind::Label, - 517
message, - 518
warning, - 519
}; - 520
match (old.is_empty(), new.is_empty()) { - 521
(true, true) => {} - 522
(false, true) => impacts.push(label( - 523
format!( - 524
"Accepting removes the sensitivity label {}, and with it the limits it puts on where the file may go.", - 525
join(old) - 526
), - 527
true, - 528
)), - 529
(true, false) => impacts.push(label( - 530
format!("Accepting adds the sensitivity label {}.", join(new)), - 531
false, - 532
)), - 533
(false, false) if old == new.as_slice() => impacts.push(label( - 534
format!("Labelled {}; the label is kept.", join(new)), - 535
false, - 536
)), - 537
(false, false) => impacts.push(label( - 538
format!( - 539
"The sensitivity label changes from {} to {}.", - 540
join(old), - 541
join(new) - 542
), - 543
true, - 544
)), - 545
} - 546
impacts - 547
} - 548
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.