Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

74 changes: 72 additions & 2 deletions crates/wit-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
//! (census + extracted names; see `wit-logic`'s module docs for why byte
//! comparison is a diagnostic, never the verdict). M3 adds `scan` and
//! `dupes` (`wit-index`) — the first commands that persist anything, via
//! the one crate in the workspace allowed to write. `wit log`/`diff`/
//! `report` land later; see `docs/ROADMAP.md`.
//! the one crate in the workspace allowed to write. M2.5 adds
//! `logic-report` — the issue #15 reality-gate tool, running `logic-probe`'s
//! comparison across an entire library instead of one pair. `wit log`/
//! `diff`/`report` land later; see `docs/ROADMAP.md`.

use clap::{Parser, Subcommand};
use std::collections::BTreeSet;
Expand Down Expand Up @@ -51,6 +53,11 @@ enum Command {
/// Report byte-for-byte duplicate audio files under `path`. Read-only
/// — Wit never deletes anything; this is just a map.
Dupes { path: PathBuf },
/// M2.5 (issue #15) reality-gate report: walk every Logic/GarageBand
/// alternative's backup chain under `path`, run `logic-probe`'s
/// comparison on every consecutive pair, and print the empty-verdict
/// rate across the whole library. Read-only.
LogicReport { path: PathBuf },
}

fn main() -> ExitCode {
Expand All @@ -60,6 +67,7 @@ fn main() -> ExitCode {
Command::LogicProbe { old, new } => logic_probe(&old, &new),
Command::Scan { path, data_dir } => scan(&path, data_dir),
Command::Dupes { path } => dupes(&path),
Command::LogicReport { path } => logic_report(&path),
}
}

Expand Down Expand Up @@ -345,3 +353,65 @@ fn human_bytes(bytes: u64) -> String {
format!("{size:.1} {}", UNITS[unit])
}
}

// --------------------------------------------------------------------- //
// M2.5: logic-report (issue #15 reality gate)
// --------------------------------------------------------------------- //

fn logic_report(path: &std::path::Path) -> ExitCode {
let report = wit_index::logic_report(path);

if report.projects_scanned == 0 {
println!(" no Logic/GarageBand project found under this path — nothing to report");
return ExitCode::SUCCESS;
}

// Project/alternative *names* only, never a full path — same privacy
// discipline `wit scan`/`wit dupes` already follow (no home-directory
// path leaves this machine's report output).
let mut out = String::new();
out.push_str(&format!(
" scanned {} project(s), {} alternative(s), {} consecutive save pair(s)\n",
report.projects_scanned,
report.alternatives_scanned,
report.total_pairs()
));

if report.total_pairs() == 0 {
out.push_str(" no consecutive save pairs found (every alternative has 0 or 1 version) — nothing to compare\n");
} else {
out.push_str(&format!(
" {:.1}% of save pairs show a structural change Wit can see ({} of {})\n",
report.structural_change_percent(),
report.pairs_with_structural_change(),
report.total_pairs()
));
out.push_str(
" distribution of change counts per save pair (0 = no visible structural change):\n",
);
for (count, n) in report.change_count_distribution() {
out.push_str(&format!(" {count} change(s): {n} pair(s)\n"));
}
let byte_different_but_same = report.byte_different_structurally_identical();
out.push_str(&format!(
" {byte_different_but_same} pair(s) ({:.1}%) are byte-different but structurally identical\n",
byte_different_but_same as f64 / report.total_pairs() as f64 * 100.0
));
}
if !report.read_errors.is_empty() {
out.push_str(&format!(
" ({} ProjectData file(s) could not be read or walked and were skipped)\n",
report.read_errors.len()
));
}

if let Err(msg) = wit_index::assert_no_home_paths(&out) {
// Must never happen — a bug in this function, not a recoverable
// runtime condition, so fail loudly rather than print a path that
// was supposed to be impossible to print (mirrors `dupes` above).
eprintln!("wit: internal error — {msg}");
return ExitCode::FAILURE;
}
print!("{out}");
ExitCode::SUCCESS
}
1 change: 1 addition & 0 deletions crates/wit-index/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ workspace = true
blake3 = "1.5"
zstd = "0.13"
rusqlite = { version = "0.32", features = ["bundled"] }
wit-logic = { path = "../wit-logic" }

[dev-dependencies]
tempfile = "3"
2 changes: 2 additions & 0 deletions crates/wit-index/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
pub mod discover;
pub mod dupes;
pub mod registry;
pub mod report;
pub mod scan;
pub mod store;

Expand All @@ -19,5 +20,6 @@ pub use discover::{
};
pub use dupes::{assert_no_home_paths, duplicate_report, DuplicateGroup, DuplicateReport};
pub use registry::{ProjectRow, Registry, RegistryError};
pub use report::{logic_report, LogicLibraryReport, SavePairResult};
pub use scan::{scan, ScanResult};
pub use store::{Hash, Store, StoreError};
244 changes: 244 additions & 0 deletions crates/wit-index/src/report.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
//! `wit logic-report` (M2.5, [issue #15](https://github.com/sep-lab/Wit/issues/15)):
//! the library-wide "reality gate". Walks every discovered Logic/GarageBand
//! alternative's chain — `Project File Backups/00`–`09` (oldest first) then
//! the current `ProjectData`, matching [`LogicAlternative`]'s own field
//! order — compares every consecutive pair at `wit-logic`'s Structure
//! honesty tier, and reports the three statistics the issue asks for:
//!
//! - % of saves with **any** structural change Wit can see
//! ([`LogicLibraryReport::structural_change_percent`])
//! - distribution of change counts per save
//! ([`LogicLibraryReport::change_count_distribution`], via
//! [`wit_logic::change_count`])
//! - how often two adjacent saves are byte-different but structurally
//! identical ([`LogicLibraryReport::byte_different_structurally_identical`])
//!
//! Read-only — this only reads `ProjectData` bytes (via `wit_logic::walk_file`
//! and a plain `std::fs::read` for the byte-identity check) and never writes
//! or modifies anything under the library root, the same discipline
//! `discover.rs` and `dupes.rs` already follow.

use crate::discover::discover_logic_projects;
use std::path::{Path, PathBuf};

/// One consecutive-pair comparison within a single alternative's chain.
#[derive(Debug, Clone, PartialEq)]
pub struct SavePairResult {
pub project_name: String,
pub alternative_name: String,
pub older: PathBuf,
pub newer: PathBuf,
pub structural_change: bool,
/// `wit_logic::change_count` — always `0` when `structural_change` is
/// `false` (see that function's doc comment for why).
pub change_count: usize,
pub bytes_identical: bool,
}

#[derive(Debug, Clone, PartialEq, Default)]
pub struct LogicLibraryReport {
pub projects_scanned: usize,
pub alternatives_scanned: usize,
pub pairs: Vec<SavePairResult>,
/// `ProjectData` paths that failed to read or failed to walk. Excluded
/// from every pair touching them rather than silently dropped from
/// the count — one bad file must not blank the whole report (mirrors
/// `duplicate_report`'s and `wit-index::scan`'s read-error handling).
pub read_errors: Vec<PathBuf>,
}

impl LogicLibraryReport {
pub fn total_pairs(&self) -> usize {
self.pairs.len()
}

pub fn pairs_with_structural_change(&self) -> usize {
self.pairs.iter().filter(|p| p.structural_change).count()
}

/// Issue #15's first published statistic: % of saves with *any*
/// structural change Wit can see. `0.0` on an empty report rather than
/// `NaN` — mirrors `DuplicateReport::duplicate_percent`.
pub fn structural_change_percent(&self) -> f64 {
if self.pairs.is_empty() {
0.0
} else {
self.pairs_with_structural_change() as f64 / self.pairs.len() as f64 * 100.0
}
}

/// Issue #15's third statistic: adjacent saves that differ byte-for-byte
/// but walk to the same Structure-tier verdict — the case that makes
/// raw byte comparison useless as a change signal on this format (see
/// `wit-logic`'s module docs).
pub fn byte_different_structurally_identical(&self) -> usize {
self.pairs
.iter()
.filter(|p| !p.bytes_identical && !p.structural_change)
.count()
}

/// Issue #15's second statistic: how many pairs land at each
/// `change_count`, ascending by count. Bucket `0` holds every pair
/// `semantic_equal` called `NoStructuralChange`.
pub fn change_count_distribution(&self) -> Vec<(usize, usize)> {
let mut buckets: std::collections::BTreeMap<usize, usize> =
std::collections::BTreeMap::new();
for pair in &self.pairs {
*buckets.entry(pair.change_count).or_insert(0) += 1;
}
buckets.into_iter().collect()
}
}

/// Scan `root` for Logic/GarageBand bundles and report M2.5's three
/// statistics across every alternative's chain.
pub fn logic_report(root: &Path) -> LogicLibraryReport {
let mut report = LogicLibraryReport::default();
let projects = discover_logic_projects(root);
report.projects_scanned = projects.len();

for project in &projects {
for alt in &project.alternatives {
report.alternatives_scanned += 1;

let mut chain: Vec<&PathBuf> = alt.backups.iter().collect();
chain.push(&alt.current);

let mut walks: Vec<(&PathBuf, wit_logic::Walked)> = Vec::new();
for path in chain {
match wit_logic::walk_file(path) {
Ok(w) => walks.push((path, w)),
Err(_) => report.read_errors.push(path.clone()),
}
}

for window in walks.windows(2) {
let (path_a, a) = &window[0];
let (path_b, b) = &window[1];
let structural_change =
wit_logic::semantic_equal(a, b) == wit_logic::Verdict::StructuralChange;
let change_count = wit_logic::change_count(a, b);
let bytes_identical = wit_logic::bytes_equal(
&std::fs::read(path_a).unwrap_or_default(),
&std::fs::read(path_b).unwrap_or_default(),
);
report.pairs.push(SavePairResult {
project_name: project.name.clone(),
alternative_name: alt.name.clone(),
older: (*path_a).clone(),
newer: (*path_b).clone(),
structural_change,
change_count,
bytes_identical,
});
}
}
}

report
}

#[cfg(test)]
mod tests {
use super::*;

// Minimal ProjectData container builder — same layout as
// `wit-logic/src/frame.rs` documents (magic, version word, root
// LENGTH, then a flat record sequence), reimplemented here because
// `wit-logic`'s own builder is private to its crate's test module.
const MAGIC: [u8; 4] = [0x23, 0x47, 0xC0, 0xAB];
const RECORD_HEADER_LEN: usize = 0x24;

fn build_container(records: &[(&[u8; 4], Vec<u8>)]) -> Vec<u8> {
let mut payload = Vec::new();
for (tag, rec_payload) in records {
payload.extend_from_slice(*tag);
payload.extend_from_slice(&[0u8; 0x1c - 4]);
payload.extend_from_slice(&(rec_payload.len() as u32).to_le_bytes());
payload.extend_from_slice(&[0u8; RECORD_HEADER_LEN - 0x20]);
payload.extend_from_slice(rec_payload);
}
let mut out = Vec::new();
out.extend_from_slice(&MAGIC);
out.extend_from_slice(&[0xd0, 0x09]);
out.extend_from_slice(&[0u8; 10]);
out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
out.extend_from_slice(&[0u8; 4]);
out.extend_from_slice(&payload);
out
}

fn write(path: &Path, bytes: &[u8]) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, bytes).unwrap();
}

#[test]
fn computes_the_three_statistics_on_a_crafted_two_pair_chain() {
let dir = tempfile::tempdir().unwrap();
let bundle = dir.path().join("Song.logicx");

// v1 and v2: same tag census (byte-different filler, same length)
// -> NoStructuralChange, change_count 0, bytes differ.
let v1 = build_container(&[(b"karT", vec![0xAA; 8])]);
let v2 = build_container(&[(b"karT", vec![0xBB; 8])]);
// v3: adds a new record tag -> StructuralChange, change_count >= 1.
let v3 = build_container(&[(b"karT", vec![0xBB; 8]), (b"gRuA", vec![0u8; 8])]);
assert_ne!(v1, v2);

write(
&bundle.join("Alternatives/000/Project File Backups/00/ProjectData"),
&v1,
);
write(
&bundle.join("Alternatives/000/Project File Backups/01/ProjectData"),
&v2,
);
write(&bundle.join("Alternatives/000/ProjectData"), &v3);

let report = logic_report(dir.path());

assert_eq!(report.projects_scanned, 1);
assert_eq!(report.alternatives_scanned, 1);
assert_eq!(report.total_pairs(), 2);
assert!(report.read_errors.is_empty());

// Pair 1 (00 -> 01): byte-different, structurally identical.
// Pair 2 (01 -> current): structural change.
assert_eq!(report.pairs_with_structural_change(), 1);
assert_eq!(report.structural_change_percent(), 50.0);
assert_eq!(report.byte_different_structurally_identical(), 1);

let distribution = report.change_count_distribution();
assert_eq!(distribution[0], (0, 1)); // pair 1: change_count 0
assert!(distribution.iter().any(|&(count, n)| count >= 1 && n == 1)); // pair 2
}

#[test]
fn an_unreadable_projectdata_is_recorded_as_a_read_error_not_a_panic() {
let dir = tempfile::tempdir().unwrap();
let bundle = dir.path().join("Song.logicx");
// Not a valid ProjectData container -- walk_file must fail cleanly.
write(
&bundle.join("Alternatives/000/ProjectData"),
b"not a real ProjectData file",
);

let report = logic_report(dir.path());
assert_eq!(report.projects_scanned, 1);
assert_eq!(report.total_pairs(), 0);
assert_eq!(report.read_errors.len(), 1);
}

#[test]
fn an_empty_library_reports_zero_everything_not_nan() {
let dir = tempfile::tempdir().unwrap();
let report = logic_report(dir.path());
assert_eq!(report.projects_scanned, 0);
assert_eq!(report.total_pairs(), 0);
assert_eq!(report.structural_change_percent(), 0.0);
assert_eq!(report.byte_different_structurally_identical(), 0);
assert!(report.change_count_distribution().is_empty());
}
}
Loading
Loading