Files
mtgc/src-tauri/src/sync.rs
Garrett Mills 04e9c3cbbb Rework library sync around a JSON document; add card filters
Sync

The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.

Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.

Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.

SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.

Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.

Filters

Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.

Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.

The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.

Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00

745 lines
26 KiB
Rust

//! Reading and writing the user's library document, wherever they keep it.
//!
//! The reconciliation itself lives in [`mtgc_core::sync`] as a pure function.
//! This module is only the plumbing: getting the document in and out of a
//! folder that differs by platform, and running the cycle in the right order.
//!
//! Desktop points at an ordinary file. Android can't: the working database must
//! stay in app-private storage (SQLite can't open a `content://` URI), and the
//! user's folder is reachable only through the Storage Access Framework. So on
//! Android every transfer is staged through an app-private file that Rust owns
//! and the Kotlin side copies to or from — deliberately avoiding
//! `MANAGE_EXTERNAL_STORAGE`, which would hand the app the entire shared volume
//! just to reach one folder. [`Folder`] hides that split.
use std::path::{Path, PathBuf};
use mtgc_core::sync::{self as core_sync, LibraryDoc, MergeReport};
use serde::Serialize;
#[cfg(target_os = "android")]
use serde::Deserialize;
use tauri::{AppHandle, Manager};
use crate::state::AppState;
type Db<'a> = tauri::State<'a, std::sync::Mutex<AppState>>;
/// Document name used on Android, where the user grants a folder rather than
/// choosing a file. Desktop uses whatever they named their file.
#[cfg(target_os = "android")]
const ANDROID_DOC_NAME: &str = "library.json";
/// Where transfers are staged across the SAF boundary. Inside the app data dir,
/// so it inherits the same private-storage guarantees as the working copy.
/// Android-only: desktop reads and writes the document directly.
#[cfg(target_os = "android")]
fn staging_path(data_dir: &Path, name: &str) -> PathBuf {
data_dir.join("sync-staging").join(name)
}
fn base_path(data_dir: &Path) -> PathBuf {
data_dir.join("sync-base.json")
}
/// State of the library document, as shown in the UI.
#[derive(Serialize, Default)]
pub struct SyncStatus {
/// Whether a document has been chosen at all.
pub chosen: bool,
/// Whether it's currently reachable. Distinct from `chosen`: an Android SAF
/// grant can be revoked and a desktop folder can be deleted or unmounted,
/// both of which need re-picking rather than first-time setup.
pub granted: bool,
pub label: Option<String>,
/// True where the folder is chosen through the system picker (Android)
/// rather than by path — the one thing the UI branches on.
pub picker_based: bool,
/// A pre-document `library.db` waiting to be converted, if any.
pub legacy_library: Option<String>,
}
#[cfg(target_os = "android")]
mod bridge {
use super::*;
/// Handle on the Kotlin-side `SyncPlugin`, put into Tauri's state store
/// during plugin setup (see `lib.rs`).
pub struct SyncBridge(pub tauri::plugin::PluginHandle<tauri::Wry>);
#[derive(Serialize)]
struct FileArgs<'a> {
name: &'a str,
path: &'a str,
}
#[derive(Serialize)]
struct NameArgs<'a> {
name: &'a str,
}
#[derive(Deserialize)]
struct ReadResult {
found: bool,
}
#[derive(Deserialize)]
struct ListResult {
#[serde(default)]
names: Vec<String>,
}
#[derive(Deserialize, Default)]
pub struct FolderStatus {
#[serde(default)]
pub chosen: bool,
#[serde(default)]
pub granted: bool,
#[serde(default)]
pub label: Option<String>,
}
fn call<T: serde::de::DeserializeOwned>(
app: &AppHandle,
command: &str,
payload: impl Serialize,
) -> Result<T, String> {
let bridge = app
.try_state::<SyncBridge>()
.ok_or_else(|| "Sync plugin failed to load — see the logs.".to_string())?;
bridge
.0
.run_mobile_plugin::<T>(command, payload)
.map_err(|e| e.to_string())
}
pub fn status(app: &AppHandle) -> Result<FolderStatus, String> {
call(app, "status", ())
}
pub fn pick_folder(app: &AppHandle) -> Result<FolderStatus, String> {
call(app, "pickFolder", ())
}
pub fn forget_folder(app: &AppHandle) -> Result<FolderStatus, String> {
call(app, "forgetFolder", ())
}
pub fn list(app: &AppHandle) -> Result<Vec<String>, String> {
Ok(call::<ListResult>(app, "list", ())?.names)
}
pub fn read_into(app: &AppHandle, name: &str, path: &Path) -> Result<bool, String> {
let res: ReadResult = call(
app,
"readInto",
FileArgs { name, path: &path.display().to_string() },
)?;
Ok(res.found)
}
pub fn write_from(app: &AppHandle, name: &str, path: &Path) -> Result<(), String> {
let _: serde_json::Value = call(
app,
"writeFrom",
FileArgs { name, path: &path.display().to_string() },
)?;
Ok(())
}
pub fn delete(app: &AppHandle, name: &str) -> Result<(), String> {
let _: serde_json::Value = call(app, "deleteFile", NameArgs { name })?;
Ok(())
}
}
#[cfg(target_os = "android")]
pub use bridge::SyncBridge;
/// The folder as an ordinary directory — desktop's whole implementation. Free
/// functions so they stay callable, and testable, without a running app.
mod plain {
use std::path::Path;
pub fn list(dir: &Path) -> Result<Vec<String>, String> {
let entries =
std::fs::read_dir(dir).map_err(|e| format!("Can't read {}: {e}", dir.display()))?;
let mut names = Vec::new();
for entry in entries.flatten() {
if entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
names.push(entry.file_name().to_string_lossy().into_owned());
}
}
Ok(names)
}
pub fn read(dir: &Path, name: &str) -> Result<Option<String>, String> {
let path = dir.join(name);
if !path.is_file() {
return Ok(None);
}
std::fs::read_to_string(&path)
.map(Some)
.map_err(|e| format!("Can't read {}: {e}", path.display()))
}
pub fn write(dir: &Path, name: &str, text: &str) -> Result<(), String> {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
// Write beside the target and rename, so whatever is watching the
// folder never observes a half-written document.
let tmp = dir.join(format!("{name}.tmp"));
std::fs::write(&tmp, text)
.map_err(|e| format!("Can't write in {}: {e}", dir.display()))?;
std::fs::rename(&tmp, dir.join(name)).map_err(|e| {
let _ = std::fs::remove_file(&tmp);
format!("Can't save {name} in {}: {e}", dir.display())
})
}
pub fn delete(dir: &Path, name: &str) -> Result<(), String> {
std::fs::remove_file(dir.join(name))
.map_err(|e| format!("Can't remove {name}: {e}"))
}
}
/// Where the library document lives, however this platform reaches it.
struct Location {
folder: Folder,
doc_name: String,
}
enum Folder {
#[cfg(target_os = "android")]
Picker,
Path(PathBuf),
}
impl Location {
fn list(&self, #[allow(unused_variables)] app: &AppHandle) -> Result<Vec<String>, String> {
match &self.folder {
#[cfg(target_os = "android")]
Folder::Picker => bridge::list(app),
Folder::Path(dir) => plain::list(dir),
}
}
/// Read a document by name. `Ok(None)` means it isn't there — a normal
/// state (nothing published yet), not a failure.
fn read(
&self,
#[allow(unused_variables)] app: &AppHandle,
#[allow(unused_variables)] data_dir: &Path,
name: &str,
) -> Result<Option<String>, String> {
match &self.folder {
#[cfg(target_os = "android")]
Folder::Picker => {
let staged = staging_path(data_dir, "incoming.json");
if !bridge::read_into(app, name, &staged)? {
return Ok(None);
}
let text = std::fs::read_to_string(&staged).map_err(|e| e.to_string())?;
let _ = std::fs::remove_file(&staged);
Ok(Some(text))
}
Folder::Path(dir) => plain::read(dir, name),
}
}
fn write(
&self,
#[allow(unused_variables)] app: &AppHandle,
#[allow(unused_variables)] data_dir: &Path,
text: &str,
) -> Result<(), String> {
match &self.folder {
#[cfg(target_os = "android")]
Folder::Picker => {
let staged = staging_path(data_dir, "outgoing.json");
if let Some(parent) = staged.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
std::fs::write(&staged, text).map_err(|e| e.to_string())?;
let result = bridge::write_from(app, &self.doc_name, &staged);
let _ = std::fs::remove_file(&staged);
result
}
Folder::Path(dir) => plain::write(dir, &self.doc_name, text),
}
}
fn delete(&self, #[allow(unused_variables)] app: &AppHandle, name: &str) -> Result<(), String> {
match &self.folder {
#[cfg(target_os = "android")]
Folder::Picker => bridge::delete(app, name),
Folder::Path(dir) => plain::delete(dir, name),
}
}
/// Names of files that look like conflict copies of our document — what
/// sync tools leave behind when two devices publish while offline.
///
/// Matched by shape (same stem, `.json`) rather than by any tool's naming
/// convention, so it works for Syncthing, Dropbox, Nextcloud and the rest
/// without knowing about any of them.
fn conflict_copies(&self, app: &AppHandle) -> Vec<String> {
conflict_copies_of(&self.doc_name, self.list(app).unwrap_or_default())
}
}
/// Pick the conflict copies of `doc_name` out of a directory listing.
///
/// Split out from [`Location::conflict_copies`] so the matching rule — the part
/// with all the edge cases — is testable without a running app.
fn conflict_copies_of(doc_name: &str, names: Vec<String>) -> Vec<String> {
let stem = doc_name.strip_suffix(".json").unwrap_or(doc_name);
let mut found: Vec<String> = names
.into_iter()
.filter(|n| {
n != doc_name
&& n.starts_with(stem)
&& n.ends_with(".json")
// Our own atomic-write temporary, if a previous run died
// between writing and renaming.
&& !n.ends_with(".json.tmp")
})
.collect();
found.sort();
found
}
/// Resolve where the document lives.
///
/// Never fails for want of a choice: with nothing picked, the document sits in
/// app-private storage, so the library is always saved somewhere. Choosing a
/// synced location is a preference on top of that.
fn locate(#[allow(unused_variables)] app: &AppHandle, db: &Db) -> Result<Location, String> {
#[cfg(target_os = "android")]
{
let status = bridge::status(app)?;
if status.chosen && !status.granted {
// Silently falling back to private storage here would look like the
// library had been emptied, so this is worth surfacing.
return Err("Access to your library folder was lost — choose it again.".into());
}
if status.granted {
return Ok(Location {
folder: Folder::Picker,
doc_name: ANDROID_DOC_NAME.into(),
});
}
}
let path = db.lock().unwrap().effective_document_path();
let dir = path
.parent()
.ok_or_else(|| format!("{} has no folder.", path.display()))?
.to_path_buf();
let doc_name = path
.file_name()
.ok_or_else(|| format!("{} isn't a file name.", path.display()))?
.to_string_lossy()
.into_owned();
Ok(Location { folder: Folder::Path(dir), doc_name })
}
#[tauri::command]
pub fn sync_status(
#[allow(unused_variables)] app: AppHandle,
db: Db,
) -> Result<SyncStatus, String> {
let legacy_library = db
.lock()
.unwrap()
.legacy_library_path()
.map(|p| p.display().to_string());
#[cfg(target_os = "android")]
{
let s = bridge::status(&app)?;
if s.chosen {
return Ok(SyncStatus {
chosen: true,
granted: s.granted,
label: s.label,
picker_based: true,
legacy_library,
});
}
// No folder picked: the library is in private storage and works, it
// just isn't synced anywhere yet.
return Ok(SyncStatus {
chosen: false,
granted: true,
label: Some(
db.lock()
.unwrap()
.effective_document_path()
.display()
.to_string(),
),
picker_based: true,
legacy_library,
});
}
#[cfg(not(target_os = "android"))]
{
let s = db.lock().unwrap();
let path = s.effective_document_path();
Ok(SyncStatus {
chosen: s.document_path().is_some(),
// The folder can be gone — deleted, or on a drive that isn't
// mounted. The document itself needn't exist: sync creates it.
granted: path.parent().map(Path::is_dir).unwrap_or(false),
label: Some(path.display().to_string()),
picker_based: false,
legacy_library,
})
}
}
/// Android only: grant access to a folder through the system picker.
#[tauri::command]
pub fn sync_pick_folder(
#[allow(unused_variables)] app: AppHandle,
db: Db,
) -> Result<SyncStatus, String> {
#[cfg(target_os = "android")]
{
crate::logged("sync_pick_folder", bridge::pick_folder(&app))?;
sync_status(app, db)
}
#[cfg(not(target_os = "android"))]
{
let _ = (app, db);
Err("This platform chooses the library file by path.".into())
}
}
/// Desktop only: adopt a library document the frontend picked.
#[tauri::command]
pub fn sync_set_document(
app: AppHandle,
db: Db,
#[allow(unused_variables)] path: String,
) -> Result<SyncStatus, String> {
#[cfg(target_os = "android")]
{
let _ = (&app, &db, path);
Err("This platform chooses the library folder through the system picker.".into())
}
#[cfg(not(target_os = "android"))]
{
let mut path = PathBuf::from(path);
if path.extension().is_none() {
path.set_extension("json");
}
match path.parent() {
Some(dir) if dir.is_dir() => {}
_ => return Err(format!("{} isn't in a folder that exists.", path.display())),
}
// Two very different intents share this one command, told apart by
// whether the target already holds a library:
//
// * an existing document — "open that library", so the working copy
// is dropped and rebuilt from it;
// * a new file — "keep my library here from now on", so the working
// copy is *kept* and published into it.
//
// Getting this wrong destroys data: always resetting would discard
// everything the user had whenever they pointed at a fresh file.
let adopting = path.is_file();
db.lock().unwrap().set_document_path(Some(path));
if adopting {
reset_working_copy(&app, &db)?;
} else {
// Clear only the merge base, so the new file is seen as empty and
// the whole current library gets published into it.
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
let _ = std::fs::remove_file(base_path(&data_dir));
}
// Either way, get the document and the working copy agreeing now rather
// than at some later trigger.
if let Err(e) = sync_now(app.clone(), db.clone()) {
crate::mlog!("sync_set_document: initial sync failed: {e}");
}
sync_status(app, db)
}
}
#[tauri::command]
pub fn sync_forget_document(app: AppHandle, db: Db) -> Result<SyncStatus, String> {
#[cfg(target_os = "android")]
{
crate::logged("sync_forget_document", bridge::forget_folder(&app))?;
}
#[cfg(not(target_os = "android"))]
{
db.lock().unwrap().set_document_path(None);
}
sync_status(app, db)
}
/// Empty the working copy and forget the merge base, so the next sync adopts
/// whatever the document says from scratch.
fn reset_working_copy(app: &AppHandle, db: &Db) -> Result<(), String> {
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
let _ = std::fs::remove_file(base_path(&data_dir));
let mut s = db.lock().unwrap();
let working = s.working_db_path.clone();
// Close first: the file can't be replaced while the catalog holds it open.
s.close();
let _ = std::fs::remove_file(&working);
if let Err(e) = s.reopen() {
crate::mlog!("reset_working_copy: reopen failed: {e}");
}
Ok(())
}
/// Outcome of one sync.
#[derive(Serialize, Default)]
pub struct SyncReport {
pub changed: bool,
pub published: bool,
/// Conflict copies absorbed and cleaned up, if any.
pub absorbed: usize,
pub report: MergeReport,
}
/// Reconcile the working copy with the library document.
///
/// `Ok(None)` when a sync was already in flight and this call stood down. Sync
/// runs automatically on launch and on every foreground/background transition,
/// so overlap is routine rather than exceptional.
#[tauri::command]
pub fn sync_now(app: AppHandle, db: Db) -> Result<Option<SyncReport>, String> {
{
let mut state = db.lock().unwrap();
if state.sync_running {
return Ok(None);
}
state.sync_running = true;
}
let result = sync_inner(&app, &db);
db.lock().unwrap().sync_running = false;
result.map(Some)
}
fn sync_inner(app: &AppHandle, db: &Db) -> Result<SyncReport, String> {
let location = locate(app, db)?;
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
let working = db.lock().unwrap().working_db_path.clone();
// 1. Read the document. Absent simply means nothing has been written yet —
// the file the user named gets created by the first sync.
let existing = location.read(app, &data_dir, &location.doc_name)?;
let doc_exists = existing.is_some();
let mut theirs = match existing {
Some(text) => LibraryDoc::from_json(&text).map_err(|e| e.to_string())?,
None => LibraryDoc::default(),
};
// 2. Absorb any conflict copies, so nothing is stranded in one.
let base = read_base(&data_dir);
let copies = location.conflict_copies(app);
let mut absorbed = Vec::new();
for name in &copies {
let Ok(Some(text)) = location.read(app, &data_dir, name) else {
continue;
};
let Ok(copy) = LibraryDoc::from_json(&text) else {
continue; // Not one of ours, or truncated mid-sync; leave it alone.
};
let (merged, _) = core_sync::merge3(&base, &theirs, &copy);
theirs = merged;
absorbed.push(name.clone());
}
// 3-4. Merge our edits with theirs and make the working copy match.
let conn = mtgc_core::db::open_library(&working).map_err(|e| e.to_string())?;
let ours = core_sync::read_doc(&conn).map_err(|e| e.to_string())?;
let (merged, report) = core_sync::merge3(&base, &ours, &theirs);
if merged != ours {
core_sync::apply_doc(&conn, &merged).map_err(|e| e.to_string())?;
}
// 5. Publish only when we have something to contribute, or the file the
// user named doesn't exist yet — otherwise every sync would look like a
// modification to whatever is watching the folder.
let published = merged != theirs || !doc_exists;
if published {
let text = merged.to_json().map_err(|e| e.to_string())?;
location.write(app, &data_dir, &text)?;
}
// Only now that their content is safely in the published document is it
// safe to remove the copies it came from.
if published {
for name in &absorbed {
if let Err(e) = location.delete(app, name) {
crate::mlog!("sync: couldn't remove absorbed copy {name}: {e}");
}
}
}
// 6. Remember the document *as read*, never the merged result. If the write
// above was lost, the remote still matches this base, so the next sync sees
// our edits as ours and republishes them instead of reading their absence
// from the document as a deletion.
write_base(&data_dir, &theirs);
crate::mlog!(
"sync_now: changed={} published={} absorbed={} conflicts={}",
!report.is_empty(),
published,
absorbed.len(),
report.conflicts.len()
);
Ok(SyncReport {
changed: !report.is_empty(),
published,
absorbed: absorbed.len(),
report,
})
}
/// The document as last read. Missing or unreadable yields an empty document,
/// which is the safe default: with no ancestor, a merge never deletes anything.
fn read_base(data_dir: &Path) -> LibraryDoc {
std::fs::read_to_string(base_path(data_dir))
.ok()
.and_then(|t| LibraryDoc::from_json(&t).ok())
.unwrap_or_default()
}
fn write_base(data_dir: &Path, doc: &LibraryDoc) {
if let Ok(text) = doc.to_json() {
let _ = std::fs::write(base_path(data_dir), text);
}
}
/// Convert a pre-document `library.db` into the current format, merging it into
/// the working copy so the next sync publishes it.
#[tauri::command]
pub fn convert_legacy_library(app: AppHandle, db: Db, path: String) -> Result<SyncReport, String> {
let legacy = PathBuf::from(&path);
if !legacy.is_file() {
return Err(format!("{} isn't there.", legacy.display()));
}
let doc = crate::logged(
"convert_legacy_library",
mtgc_core::sync::convert_legacy_library(&legacy).map_err(|e| e.to_string()),
)?;
let working = db.lock().unwrap().working_db_path.clone();
let conn = mtgc_core::db::open_library(&working).map_err(|e| e.to_string())?;
let ours = core_sync::read_doc(&conn).map_err(|e| e.to_string())?;
// Merged rather than applied outright, so converting doesn't discard
// anything already in the working copy. With an empty ancestor nothing is
// treated as a deletion.
let (merged, report) = core_sync::merge3(&LibraryDoc::default(), &ours, &doc);
core_sync::apply_doc(&conn, &merged).map_err(|e| e.to_string())?;
db.lock().unwrap().clear_legacy_library();
crate::mlog!(
"convert_legacy_library: {} cards, {} decks from {}",
doc.cards.len(),
doc.decks.len(),
legacy.display()
);
// Publish immediately when a document is already configured, so the
// converted library reaches the other devices without waiting.
if locate(&app, &db).is_ok() {
if let Some(r) = sync_now(app, db)? {
return Ok(r);
}
}
Ok(SyncReport {
changed: !report.is_empty(),
published: false,
absorbed: 0,
report,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("mtgc-doc-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn writing_replaces_the_document_and_leaves_no_temporary() {
// A stray "<name>.tmp" would be picked up as a conflict copy and, worse,
// synced as a partial document.
let dir = scratch("write");
plain::write(&dir, "library.json", "{\"version\":1}").unwrap();
plain::write(&dir, "library.json", "{}").unwrap();
assert_eq!(plain::read(&dir, "library.json").unwrap().unwrap(), "{}");
assert_eq!(plain::list(&dir).unwrap(), vec!["library.json"]);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn reading_a_document_that_isnt_there_is_not_an_error() {
// Normal before anything has been published.
let dir = scratch("missing");
assert_eq!(plain::read(&dir, "library.json").unwrap(), None);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn conflict_copies_are_matched_by_shape_not_by_tool() {
let dir = scratch("conflicts");
for name in [
"library.json",
"library.sync-conflict-20260803-120000-ABCDEFG.json", // Syncthing
"library (SFConflict garrettmills 2026-08-03).json", // Seafile
"library (conflicted copy 2026-08-03).json", // Dropbox
"library.json.tmp", // our own, mid-write
"notes.json", // unrelated
"reference.db", // unrelated
] {
std::fs::write(dir.join(name), "{}").unwrap();
}
let found = conflict_copies_of("library.json", plain::list(&dir).unwrap());
assert_eq!(
found,
vec![
"library (SFConflict garrettmills 2026-08-03).json",
"library (conflicted copy 2026-08-03).json",
"library.sync-conflict-20260803-120000-ABCDEFG.json",
],
"should catch every tool's naming, and nothing else"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_differently_named_document_only_claims_its_own_copies() {
// The user names their own file on desktop, so the rule has to key off
// that name rather than assuming "library".
let found = conflict_copies_of(
"Decks.json",
vec![
"Decks.json".into(),
"Decks (conflicted copy).json".into(),
"library.sync-conflict-1.json".into(),
"Decks-backup.txt".into(),
],
);
assert_eq!(found, vec!["Decks (conflicted copy).json"]);
}
}