mod common;
use std::collections::BTreeMap;
use std::time::Duration;
use musefs_core::{Mode, MountConfig, Musefs, scan_directory};
use musefs_db::{Db, Tag};
use common::corpus::{CorpusParams, Format, Target, prepare};
use proptest::prelude::*;
fn small_corpus(n: usize) -> Target {
prepare(&CorpusParams::single(Format::Flac, 1, n))
}
fn config() -> MountConfig {
MountConfig {
template: "$artist/$album/$title".into(),
fallbacks: BTreeMap::new(),
default_fallback: "Unknown".into(),
mode: Mode::Synthesis,
poll_interval: Duration::ZERO,
case_insensitive: false,
read_ahead_budget: 64 * 1024 * 1024,
read_ahead_prefetch: false,
skip_on_missing: false,
}
}
fn config_ci() -> MountConfig {
MountConfig {
case_insensitive: true,
read_ahead_budget: 64 * 1024 * 1024,
read_ahead_prefetch: false,
skip_on_missing: false,
..config()
}
}
fn config_skip() -> MountConfig {
MountConfig {
skip_on_missing: true,
..config()
}
}
fn tree_fingerprint(fs: &Musefs) -> BTreeMap<String, u64> {
let mut out = BTreeMap::new();
let mut stack = vec![(1u64, String::new())];
while let Some((ino, prefix)) = stack.pop() {
for (name, child, is_dir) in fs.readdir(ino).unwrap() {
let path = if prefix.is_empty() {
name.clone()
} else {
format!("{prefix}/{name}")
};
if is_dir {
stack.push((child, path));
} else {
out.insert(path, child);
}
}
}
out
}
#[test]
fn incremental_refresh_matches_full_rebuild_over_edits() {
let target = small_corpus(8);
let db_path = target.db_path.clone();
let corpus = target.corpus_dir.clone();
let db = Db::open(&db_path).unwrap();
scan_directory(&db, &corpus).unwrap();
let fs = Musefs::open(Db::open(&db_path).unwrap(), config()).unwrap();
let writer = Db::open(&db_path).unwrap();
let ids: Vec<i64> = writer.list_tracks().unwrap().iter().map(|t| t.id).collect();
writer
.replace_tags(
ids[0],
&[Tag::new("ARTIST", "Zed", 0), Tag::new("TITLE", "moved", 0)],
)
.unwrap();
fs.poll_refresh().unwrap();
writer
.replace_tags(ids[1], &[Tag::new("ALBUM", "NewAlbum", 0)])
.unwrap();
fs.poll_refresh().unwrap();
writer.delete_track(ids[2]).unwrap();
fs.poll_refresh().unwrap();
let reference = Musefs::open(Db::open(&db_path).unwrap(), config()).unwrap();
assert_eq!(
tree_fingerprint(&fs).keys().collect::<Vec<_>>(),
tree_fingerprint(&reference).keys().collect::<Vec<_>>(),
"incremental and full-rebuild paths must match"
);
}
#[test]
fn incremental_skip_on_missing_matches_full_rebuild_over_key_loss_and_gain() {
let target = small_corpus(4);
let db_path = target.db_path.clone();
let corpus = target.corpus_dir.clone();
let db = Db::open(&db_path).unwrap();
scan_directory(&db, &corpus).unwrap();
let fs = Musefs::open(Db::open(&db_path).unwrap(), config_skip()).unwrap();
let writer = Db::open(&db_path).unwrap();
let ids: Vec<i64> = writer.list_tracks().unwrap().iter().map(|t| t.id).collect();
let full = |label: &str, fs: &Musefs| {
let reference = Musefs::open(Db::open(&db_path).unwrap(), config_skip()).unwrap();
let inc = tree_fingerprint(fs);
assert_eq!(
inc.keys().collect::<Vec<_>>(),
tree_fingerprint(&reference).keys().collect::<Vec<_>>(),
"{label}: incremental must match full rebuild"
);
inc.len()
};
assert_eq!(full("baseline", &fs), 4);
writer
.replace_tags(
ids[0],
&[Tag::new("ARTIST", "Zed", 0), Tag::new("ALBUM", "Al", 0)],
)
.unwrap();
fs.poll_refresh().unwrap();
assert_eq!(full("key loss", &fs), 3);
writer
.replace_tags(
ids[0],
&[
Tag::new("ARTIST", "Zed", 0),
Tag::new("ALBUM", "Al", 0),
Tag::new("TITLE", "Back", 0),
],
)
.unwrap();
fs.poll_refresh().unwrap();
assert_eq!(full("key gain", &fs), 4);
}
#[test]
fn case_insensitive_refresh_merges_and_matches_full_rebuild() {
let target = small_corpus(2);
let db_path = target.db_path.clone();
let corpus = target.corpus_dir.clone();
let db = Db::open(&db_path).unwrap();
scan_directory(&db, &corpus).unwrap();
let writer = Db::open(&db_path).unwrap();
let ids: Vec<i64> = writer.list_tracks().unwrap().iter().map(|t| t.id).collect();
writer
.replace_tags(
ids[0],
&[
Tag::new("ARTIST", "Foo", 0),
Tag::new("ALBUM", "Al", 0),
Tag::new("TITLE", "One", 0),
],
)
.unwrap();
writer
.replace_tags(
ids[1],
&[
Tag::new("ARTIST", "foo", 0),
Tag::new("ALBUM", "Al", 0),
Tag::new("TITLE", "Two", 0),
],
)
.unwrap();
let fs = Musefs::open(Db::open(&db_path).unwrap(), config_ci()).unwrap();
let fp = tree_fingerprint(&fs);
let top_dirs: std::collections::BTreeSet<String> = fp
.keys()
.map(|p| p.split('/').next().unwrap().to_string())
.collect();
assert_eq!(
top_dirs.len(),
1,
"case-variant artists must merge into one dir"
);
writer
.replace_tags(
ids[1],
&[
Tag::new("ARTIST", "foo", 0),
Tag::new("ALBUM", "Al", 0),
Tag::new("TITLE", "Renamed", 0),
],
)
.unwrap();
fs.poll_refresh().unwrap();
let reference = Musefs::open(Db::open(&db_path).unwrap(), config_ci()).unwrap();
assert_eq!(
tree_fingerprint(&fs).keys().collect::<Vec<_>>(),
tree_fingerprint(&reference).keys().collect::<Vec<_>>(),
"case-insensitive refresh (full rebuild) must match a fresh folded build"
);
}
#[test]
fn non_render_column_edit_is_noop_refresh() {
let target = small_corpus(4);
let db_path = target.db_path.clone();
let corpus = target.corpus_dir.clone();
let db = Db::open(&db_path).unwrap();
scan_directory(&db, &corpus).unwrap();
let fs = Musefs::open(Db::open(&db_path).unwrap(), config()).unwrap();
let db2 = Db::open(&db_path).unwrap();
scan_directory(&db2, &corpus).unwrap();
let before = tree_fingerprint(&fs);
let rebuilt = fs.poll_refresh().unwrap();
let after = tree_fingerprint(&fs);
assert_eq!(before, after, "non-render edit must not change the tree");
let _ = rebuilt; }
#[test]
fn format_only_change_notifies_old_inode() {
let target = small_corpus(2);
let db_path = target.db_path.clone();
let corpus = target.corpus_dir.clone();
let db = Db::open(&db_path).unwrap();
scan_directory(&db, &corpus).unwrap();
let fs = Musefs::open(Db::open(&db_path).unwrap(), config()).unwrap();
let writer = Db::open(&db_path).unwrap();
let id = writer.list_tracks().unwrap()[0].id;
let old_ino = fs.lookup_track_inode_for_test(id).unwrap();
writer
.set_format_for_test(id, musefs_db::Format::Mp3)
.unwrap();
let mut notified = Vec::new();
fs.poll_refresh_notify(|ino| notified.push(ino)).unwrap();
assert!(
notified.contains(&old_ino),
"format-only move must invalidate the old inode (extension changed)"
);
}
#[derive(Clone, Debug)]
enum Op {
Retag(usize, String, String), Delete(usize), Add(String, String), }
#[test]
fn apply_failure_falls_back_to_full_rebuild() {
let target = small_corpus(4);
let db_path = target.db_path.clone();
let corpus = target.corpus_dir.clone();
let db = Db::open(&db_path).unwrap();
scan_directory(&db, &corpus).unwrap();
let fs = Musefs::open(Db::open(&db_path).unwrap(), config()).unwrap();
let writer = Db::open(&db_path).unwrap();
let id = writer.list_tracks().unwrap()[0].id;
writer
.replace_tags(id, &[Tag::new("TITLE", "moved", 0)])
.unwrap();
fs.force_apply_failure_for_test(true);
fs.poll_refresh().unwrap();
let reference = Musefs::open(Db::open(&db_path).unwrap(), config()).unwrap();
let fs_keys: Vec<String> = tree_fingerprint(&fs).into_keys().collect();
let ref_keys: Vec<String> = tree_fingerprint(&reference).into_keys().collect();
assert_eq!(
fs_keys, ref_keys,
"fallback full rebuild must produce a tree identical to a fresh open"
);
}
#[test]
fn changelog_gap_falls_back_to_full_rebuild() {
let target = small_corpus(4);
let db_path = target.db_path.clone();
let corpus = target.corpus_dir.clone();
let db = Db::open(&db_path).unwrap();
scan_directory(&db, &corpus).unwrap();
let fs = Musefs::open(Db::open(&db_path).unwrap(), config()).unwrap();
let writer = Db::open(&db_path).unwrap();
let ids: Vec<i64> = writer.list_tracks().unwrap().iter().map(|t| t.id).collect();
writer
.replace_tags(ids[0], &[Tag::new("TITLE", "moved-by-gap", 0)])
.unwrap();
let max_seq = writer.changelog_since(0).unwrap().max_seq;
writer.delete_changelog_through_for_test(max_seq).unwrap();
assert!(fs.poll_refresh().unwrap());
assert_eq!(
fs.gap_fallbacks_for_test(),
1,
"a fully truncated ring must be detected as a gap"
);
let reference = Musefs::open(Db::open(&db_path).unwrap(), config()).unwrap();
assert_eq!(
tree_fingerprint(&fs).into_keys().collect::<Vec<_>>(),
tree_fingerprint(&reference).into_keys().collect::<Vec<_>>(),
"gap fallback must produce a tree identical to a fresh open"
);
}
#[test]
fn removed_track_is_pruned_and_refresh_recovers_after_gap() {
let target = small_corpus(4);
let db_path = target.db_path.clone();
let corpus = target.corpus_dir.clone();
let db = Db::open(&db_path).unwrap();
scan_directory(&db, &corpus).unwrap();
let fs = Musefs::open(Db::open(&db_path).unwrap(), config()).unwrap();
let writer = Db::open(&db_path).unwrap();
let ids: Vec<i64> = writer.list_tracks().unwrap().iter().map(|t| t.id).collect();
let max_seq = writer.changelog_since(0).unwrap().max_seq;
writer.delete_changelog_through_for_test(max_seq).unwrap();
writer.delete_track(ids[0]).unwrap();
assert!(fs.poll_refresh().unwrap());
assert_eq!(
fs.gap_fallbacks_for_test(),
0,
"an adjacent (min_seq == last_seq + 1) ring read is not a gap"
);
writer.delete_track(ids[1]).unwrap();
assert!(fs.poll_refresh().unwrap());
assert_eq!(fs.gap_fallbacks_for_test(), 0);
let reference = Musefs::open(Db::open(&db_path).unwrap(), config()).unwrap();
assert_eq!(
tree_fingerprint(&fs).into_keys().collect::<Vec<_>>(),
tree_fingerprint(&reference).into_keys().collect::<Vec<_>>()
);
}
#[test]
fn pruned_ring_prefix_is_a_gap_and_full_rebuild_recovers_lost_change() {
let target = small_corpus(4);
let db_path = target.db_path.clone();
let corpus = target.corpus_dir.clone();
let db = Db::open(&db_path).unwrap();
scan_directory(&db, &corpus).unwrap();
let fs = Musefs::open(Db::open(&db_path).unwrap(), config()).unwrap();
let writer = Db::open(&db_path).unwrap();
let ids: Vec<i64> = writer.list_tracks().unwrap().iter().map(|t| t.id).collect();
let last_seq = writer.changelog_since(0).unwrap().max_seq; writer
.replace_tags(ids[0], &[Tag::new("TITLE", "lost-in-pruned-prefix", 0)])
.unwrap();
let x_rows = writer.changelog_since(last_seq).unwrap().max_seq;
writer
.replace_tags(ids[1], &[Tag::new("TITLE", "still-in-ring", 0)])
.unwrap();
writer.delete_changelog_through_for_test(x_rows).unwrap();
assert!(fs.poll_refresh().unwrap());
assert_eq!(
fs.gap_fallbacks_for_test(),
1,
"a pruned-prefix ring (min_seq > last_seq + 1) must be detected as a gap"
);
let reference = Musefs::open(Db::open(&db_path).unwrap(), config()).unwrap();
assert_eq!(
tree_fingerprint(&fs).into_keys().collect::<Vec<_>>(),
tree_fingerprint(&reference).into_keys().collect::<Vec<_>>(),
"the gap rebuild must recover the change lost from the pruned prefix"
);
}
#[test]
fn empty_ring_with_zero_watermark_polls_incremental() {
let target = small_corpus(2);
let db_path = target.db_path.clone();
let corpus = target.corpus_dir.clone();
let db = Db::open(&db_path).unwrap();
scan_directory(&db, &corpus).unwrap();
let pre = Db::open(&db_path).unwrap();
let max_seq = pre.changelog_since(0).unwrap().max_seq;
pre.delete_changelog_through_for_test(max_seq).unwrap();
let fs = Musefs::open(Db::open(&db_path).unwrap(), config()).unwrap();
let writer = Db::open(&db_path).unwrap();
writer
.upsert_art(&musefs_db::NewArt {
mime: "image/png".into(),
width: None,
height: None,
data: vec![0u8; 8],
})
.unwrap();
assert!(fs.poll_refresh().unwrap());
assert_eq!(
fs.gap_fallbacks_for_test(),
0,
"empty ring + zero watermark must stay on the incremental path"
);
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(64))]
#[test]
fn incremental_equivalent_to_full_under_random_edits(
ops in proptest::collection::vec(
prop_oneof![
(0usize..8, "[A-B]", "[x-y]").prop_map(|(i, a, t)| Op::Retag(i, a, t)),
(0usize..8).prop_map(Op::Delete),
("[A-B]", "[x-y]").prop_map(|(a, t)| Op::Add(a, t)),
], 0..24)
) {
let target = small_corpus(6);
let db_path = target.db_path.clone();
let corpus = target.corpus_dir.clone();
let db = Db::open(&db_path).unwrap();
scan_directory(&db, &corpus).unwrap();
let fs = Musefs::open(Db::open(&db_path).unwrap(), config()).unwrap();
let writer = Db::open(&db_path).unwrap();
let mut add_seq = 0u32;
for op in ops {
let live: Vec<i64> = writer.list_tracks().unwrap().iter().map(|t| t.id).collect();
match op {
Op::Retag(i, album, title) if !live.is_empty() => {
writer.replace_tags(live[i % live.len()], &[
Tag::new("ARTIST", "X", 0),
Tag::new("ALBUM", &album, 0),
Tag::new("TITLE", &title, 0),
]).unwrap();
}
Op::Delete(i) if !live.is_empty() => {
writer.delete_track(live[i % live.len()]).unwrap();
}
Op::Add(album, title) => {
add_seq += 1;
let new = musefs_db::NewTrack {
backing_path: format!("/virt/added-{add_seq}.flac"),
format: musefs_db::Format::Flac,
audio_offset: 0, audio_length: 1, backing_size: 1, backing_mtime_ns: 0, backing_ctime_ns: 0,
};
let id = writer.upsert_track(&new).unwrap();
writer
.replace_tags(
id,
&[
Tag::new("ARTIST", "X", 0),
Tag::new("ALBUM", &album, 0),
Tag::new("TITLE", &title, 0),
],
)
.unwrap();
}
_ => {}
}
fs.poll_refresh().unwrap();
let reference = Musefs::open(Db::open(&db_path).unwrap(), config()).unwrap();
let incr_keys: Vec<String> = tree_fingerprint(&fs).into_keys().collect();
let full_keys: Vec<String> = tree_fingerprint(&reference).into_keys().collect();
prop_assert_eq!(incr_keys, full_keys);
}
}
}
#[test]
fn revalidate_reprobes_on_ctime_only_change() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("a.flac");
common::write_flac(&src, &["TITLE=Old"], &[0xAB; 4096]);
let db_path = dir.path().join("m.db");
{
let db = Db::open(&db_path).unwrap();
scan_directory(&db, dir.path()).unwrap();
}
let original_modified = std::fs::metadata(&src).unwrap().modified().unwrap();
common::write_flac(&src, &["TITLE=New"], &[0xCD; 4096]);
let f = std::fs::OpenOptions::new().write(true).open(&src).unwrap();
f.set_times(std::fs::FileTimes::new().set_modified(original_modified))
.unwrap();
drop(f);
let db = Db::open(&db_path).unwrap();
let stats = musefs_core::revalidate(&db, dir.path()).unwrap();
assert_eq!(stats.updated, 1, "ctime-only change must be re-probed");
}
#[test]
fn revalidate_changed_file_refreshes_layer_a_preserves_layer_b() {
let dir = tempfile::tempdir().unwrap();
let db = Db::open_in_memory().unwrap();
let path = dir.path().join("a.flac");
common::write_flac(&path, &["TITLE=A"], &[0xAA; 30]);
scan_directory(&db, dir.path()).unwrap();
let id = db.list_tracks().unwrap()[0].id;
db.replace_tags(id, &[Tag::new("title", "Curated", 0)])
.unwrap();
common::write_flac(&path, &["TITLE=B-on-disk"], &[0xBB; 40]);
let stats = musefs_core::revalidate(&db, dir.path()).unwrap();
assert_eq!(stats.updated, 1);
assert_eq!(stats.pruned, 0);
let tags = db.get_tags(id).unwrap();
assert_eq!(tags[0].value, "Curated");
}
#[test]
fn revalidate_ignores_new_files() {
let dir = tempfile::tempdir().unwrap();
let db = Db::open_in_memory().unwrap();
common::write_flac(&dir.path().join("a.flac"), &["TITLE=A"], &[0xAA; 30]);
scan_directory(&db, dir.path()).unwrap();
common::write_flac(&dir.path().join("b.flac"), &["TITLE=B"], &[0xBB; 40]);
let stats = musefs_core::revalidate(&db, dir.path()).unwrap();
assert_eq!(stats.updated, 0);
assert_eq!(db.list_tracks().unwrap().len(), 1);
}
#[test]
fn revalidate_prunes_only_with_flag() {
let dir = tempfile::tempdir().unwrap();
let db = Db::open_in_memory().unwrap();
let path = dir.path().join("a.flac");
common::write_flac(&path, &["TITLE=A"], &[0xAA; 30]);
scan_directory(&db, dir.path()).unwrap();
std::fs::remove_file(&path).unwrap();
let stats = musefs_core::revalidate(&db, dir.path()).unwrap();
assert_eq!(stats.pruned, 0);
assert_eq!(db.list_tracks().unwrap().len(), 1);
let opts = musefs_core::ScanOptions {
prune: true,
..Default::default()
};
let stats = musefs_core::revalidate_with(&db, dir.path(), &opts).unwrap();
assert_eq!(stats.pruned, 1);
assert_eq!(db.list_tracks().unwrap().len(), 0);
}
#[test]
fn revalidate_backfill_does_not_clobber_tags() {
let dir = tempfile::tempdir().unwrap();
let db = Db::open_in_memory().unwrap();
let path = dir.path().join("a.flac");
common::write_flac(&path, &["TITLE=OnDisk"], &[0xAA; 30]);
scan_directory(&db, dir.path()).unwrap();
let id = db.list_tracks().unwrap()[0].id;
db.replace_tags(id, &[Tag::new("title", "Curated", 0)])
.unwrap();
db.set_structural_blocks(id, &[]).unwrap();
let stats = musefs_core::revalidate(&db, dir.path()).unwrap();
assert_eq!(stats.updated, 1);
let tags = db.get_tags(id).unwrap();
assert_eq!(tags[0].value, "Curated");
assert!(!db.get_structural_blocks(id).unwrap().is_empty());
}