From a90cf83ff518c785b263ef2f772a6ca8352f5733 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sat, 18 Jul 2026 19:24:11 +0200 Subject: [PATCH] fixed some bugs --- crates/pdfs-cli/src/main.rs | 8 +- crates/pdfs-core/src/control.rs | 4 + crates/pdfs-core/src/db/activity.rs | 1 - crates/pdfs-core/src/db/cache.rs | 1 - crates/pdfs-core/src/db/devices.rs | 1 - crates/pdfs-core/src/db/local.rs | 1 - crates/pdfs-core/src/db/migrations.rs | 3 - crates/pdfs-core/src/db/mod.rs | 5 +- crates/pdfs-core/src/db/nodes.rs | 2 - crates/pdfs-core/src/db/ops.rs | 7 +- crates/pdfs-core/src/db/photos.rs | 29 +- crates/pdfs-core/src/db/pins.rs | 3 - crates/pdfs-core/src/db/state.rs | 1 - crates/pdfs-core/src/db/sync.rs | 1 - crates/pdfs-core/src/db/tests.rs | 1994 +++++++++-------- crates/pdfs-core/src/db/trash.rs | 1 - crates/pdfs-core/src/error.rs | 147 +- crates/pdfs-fuse/src/control.rs | 49 +- crates/pdfs-fuse/src/devices.rs | 92 +- crates/pdfs-fuse/src/drain.rs | 13 +- crates/pdfs-fuse/src/lib.rs | 158 +- crates/pdfs-fuse/src/photos.rs | 13 +- crates/pdfs-fuse/src/sharing.rs | 83 +- crates/pdfs-fuse/src/state.rs | 12 +- crates/pdfs-fuse/src/sync.rs | 2 +- crates/pdfs-fuse/src/transfers.rs | 4 +- crates/pdfs-gui/src/app/main.rs | 13 +- crates/pdfs-gui/src/app/pages/activity.rs | 8 +- crates/pdfs-gui/src/app/pages/browser.rs | 25 +- crates/pdfs-gui/src/app/pages/devices.rs | 7 +- crates/pdfs-gui/src/app/pages/login.rs | 15 +- crates/pdfs-gui/src/app/pages/photo_viewer.rs | 8 +- crates/pdfs-gui/src/app/pages/photos.rs | 55 +- crates/pdfs-gui/src/app/pages/shared.rs | 7 +- crates/pdfs-gui/src/app/pages/shared_by_me.rs | 8 +- crates/pdfs-gui/src/app/pages/status.rs | 27 +- crates/pdfs-gui/src/app/widgets/details.rs | 40 +- .../pdfs-gui/src/app/widgets/share_dialog.rs | 6 +- crates/pdfs-gui/src/prompt.rs | 4 +- 39 files changed, 1573 insertions(+), 1285 deletions(-) diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 06b81e3..25ab446 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -11,9 +11,8 @@ use pdfs_core::auth; use pdfs_core::cache::ContentCache; use pdfs_core::config::AppDirs; use pdfs_core::control::{ - ErrorKind, - RefreshScope, Request as CtlRequest, Response as CtlResponse, ShareEntryKind, SyncPhase, - pending_summary, + ErrorKind, RefreshScope, Request as CtlRequest, Response as CtlResponse, ShareEntryKind, + SyncPhase, pending_summary, }; use pdfs_core::db::Db; @@ -361,6 +360,9 @@ enum BookmarkCmd { fn cli_error(kind: ErrorKind, message: &str) -> String { match kind { ErrorKind::Offline => "offline: the Proton Drive API is unreachable".to_string(), + // The daemon's wording names the failed call ("upload x: ..."), which + // does not say the thing the user has to act on. + ErrorKind::Quota => format!("out of storage: {message}"), _ => message.to_string(), } } diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index 8237806..5f08d32 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -927,6 +927,10 @@ pub enum ErrorKind { /// The API was reached and refused, or the transfer broke. The one class /// where an unchanged retry can legitimately succeed. Remote, + /// The account is out of storage. Distinct from [`Denied`](Self::Denied) + /// because the user *can* fix it, and distinct from [`Remote`](Self::Remote) + /// because retrying an upload that did not fit will not make it fit. + Quota, /// Something on this machine failed: the database, the content cache, the /// filesystem. Not the user's doing and not theirs to fix. #[default] diff --git a/crates/pdfs-core/src/db/activity.rs b/crates/pdfs-core/src/db/activity.rs index cbab0d5..4a77ee1 100644 --- a/crates/pdfs-core/src/db/activity.rs +++ b/crates/pdfs-core/src/db/activity.rs @@ -63,5 +63,4 @@ impl Db { } Ok(out) } - } diff --git a/crates/pdfs-core/src/db/cache.rs b/crates/pdfs-core/src/db/cache.rs index 18f8d01..2db9b56 100644 --- a/crates/pdfs-core/src/db/cache.rs +++ b/crates/pdfs-core/src/db/cache.rs @@ -122,5 +122,4 @@ impl Db { // keyed by uid display string. `recursive` marks a folder pin: its whole // subtree counts as pinned, resolved against `nodes` via a CTE so a new // descendant is covered the moment it lands in the node cache. - } diff --git a/crates/pdfs-core/src/db/devices.rs b/crates/pdfs-core/src/db/devices.rs index 1dae831..e17a52e 100644 --- a/crates/pdfs-core/src/db/devices.rs +++ b/crates/pdfs-core/src/db/devices.rs @@ -44,5 +44,4 @@ impl Db { )?; Ok(()) } - } diff --git a/crates/pdfs-core/src/db/local.rs b/crates/pdfs-core/src/db/local.rs index c37cc29..5382f79 100644 --- a/crates/pdfs-core/src/db/local.rs +++ b/crates/pdfs-core/src/db/local.rs @@ -160,4 +160,3 @@ pub(super) fn local_hit(row: &rusqlite::Row) -> rusqlite::Result { mtime: row.get(4)?, }) } - diff --git a/crates/pdfs-core/src/db/migrations.rs b/crates/pdfs-core/src/db/migrations.rs index 7b66243..290118f 100644 --- a/crates/pdfs-core/src/db/migrations.rs +++ b/crates/pdfs-core/src/db/migrations.rs @@ -5,7 +5,6 @@ //! is already on disk somewhere, so a change here would silently diverge from //! what an existing install actually has. Add a new one instead. - use super::Db; use crate::Result; @@ -84,7 +83,6 @@ impl Db { tx.commit()?; Ok(()) } - } /// Schema v1: nodes + FTS5 trigram index + cache LRU. `sync_state` is created @@ -329,4 +327,3 @@ const MIGRATION_V13: &str = " ALTER TABLE photos ADD COLUMN media_type TEXT; ALTER TABLE photos ADD COLUMN kind INTEGER NOT NULL DEFAULT 0; "; - diff --git a/crates/pdfs-core/src/db/mod.rs b/crates/pdfs-core/src/db/mod.rs index b4b51ee..02cc456 100644 --- a/crates/pdfs-core/src/db/mod.rs +++ b/crates/pdfs-core/src/db/mod.rs @@ -40,9 +40,8 @@ pub use devices::StoredDevice; pub use local::LocalFileHit; pub use nodes::{SearchHit, StoredNode}; pub use ops::{ - LOCAL_VOLUME, - AttachedBlob, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingCounts, - PendingOp, op_supersedes, + AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, + PendingCounts, PendingOp, op_supersedes, }; pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN}; pub use sync::{StoredSyncEntry, StoredSyncFolder}; diff --git a/crates/pdfs-core/src/db/nodes.rs b/crates/pdfs-core/src/db/nodes.rs index 23a3307..119a3bd 100644 --- a/crates/pdfs-core/src/db/nodes.rs +++ b/crates/pdfs-core/src/db/nodes.rs @@ -223,10 +223,8 @@ impl Db { // separate. `last_accessed` (unix seconds) is the LRU key. The daemon owns // the on-disk cache and rebuilds this index from disk on open, then keeps it // in sync on every store/read/evict, so it is authoritative for eviction. - } - /// Effective plaintext size of a node for the indexed `size` column: the /// claimed size when known, else the on-storage size; folders are 0. pub(super) fn node_size(node: &Node) -> i64 { diff --git a/crates/pdfs-core/src/db/ops.rs b/crates/pdfs-core/src/db/ops.rs index 8e438f8..990ceaa 100644 --- a/crates/pdfs-core/src/db/ops.rs +++ b/crates/pdfs-core/src/db/ops.rs @@ -317,9 +317,9 @@ impl Db { Ok(PendingCounts { uploads, changes }) } -/// Nothing bearing this volume may be handed to the API — it would 404. The -/// drain replaces it with the uid the server assigns. -pub const LOCAL_VOLUME: &str = "local"; + /// Nothing bearing this volume may be handed to the API — it would 404. The + /// drain replaces it with the uid the server assigns. + pub const LOCAL_VOLUME: &str = "local"; /// Drop a queued op, once its upload has actually landed. pub fn delete_op(&self, id: i64) -> Result<()> { @@ -340,5 +340,4 @@ pub const LOCAL_VOLUME: &str = "local"; )?; Ok(()) } - } diff --git a/crates/pdfs-core/src/db/photos.rs b/crates/pdfs-core/src/db/photos.rs index 79a3fdd..39da889 100644 --- a/crates/pdfs-core/src/db/photos.rs +++ b/crates/pdfs-core/src/db/photos.rs @@ -47,8 +47,7 @@ impl Db { // any previously learned value so the Photos/Videos/Raw split survives a // refresh instead of collapsing back to name-extension guesses. let learned: HashMap, i64, Option)> = { - let mut stmt = - tx.prepare("SELECT uid, ratio, thumb_state, media_type FROM photos")?; + let mut stmt = tx.prepare("SELECT uid, ratio, thumb_state, media_type FROM photos")?; let rows = stmt.query_map([], |r| { Ok((r.get::<_, String>(0)?, (r.get(1)?, r.get(2)?, r.get(3)?))) })?; @@ -63,18 +62,17 @@ impl Db { VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", )?; for (seq, (uid, capture_time, name, media_type)) in items.iter().enumerate() { - let (ratio, thumb_state, learned_media) = learned - .get(uid) - .cloned() - .unwrap_or((None, THUMB_UNKNOWN, None)); + let (ratio, thumb_state, learned_media) = + learned + .get(uid) + .cloned() + .unwrap_or((None, THUMB_UNKNOWN, None)); let media_type = media_type.clone().or(learned_media); // The tab this photo lands in is derived here, once, so a page or // count query is a plain indexed `WHERE kind = ?` rather than a // reclassification of every row. - let kind = crate::control::PhotoKind::classify( - name.as_deref(), - media_type.as_deref(), - ); + let kind = + crate::control::PhotoKind::classify(name.as_deref(), media_type.as_deref()); stmt.execute(params![ uid, capture_time, @@ -127,7 +125,9 @@ impl Db { let limit_pos = binds.len(); binds.push(offset as i64); let offset_pos = binds.len(); - sql.push_str(&format!(" ORDER BY seq LIMIT ?{limit_pos} OFFSET ?{offset_pos}")); + sql.push_str(&format!( + " ORDER BY seq LIMIT ?{limit_pos} OFFSET ?{offset_pos}" + )); let mut stmt = conn.prepare(&sql)?; let rows = stmt @@ -168,7 +168,11 @@ impl Db { let mut stmt = conn.prepare(&sql)?; let rows = stmt .query_map(rusqlite::params_from_iter(binds), |r| { - Ok((r.get::<_, i32>(0)?, r.get::<_, i32>(1)?, r.get::<_, i64>(2)? as usize)) + Ok(( + r.get::<_, i32>(0)?, + r.get::<_, i32>(1)?, + r.get::<_, i64>(2)? as usize, + )) })? .collect::>()?; Ok(rows) @@ -240,5 +244,4 @@ impl Db { let n: i64 = conn.query_row("SELECT COUNT(*) FROM photos", [], |r| r.get(0))?; Ok(n.max(0) as usize) } - } diff --git a/crates/pdfs-core/src/db/pins.rs b/crates/pdfs-core/src/db/pins.rs index f4d37bc..d00f94b 100644 --- a/crates/pdfs-core/src/db/pins.rs +++ b/crates/pdfs-core/src/db/pins.rs @@ -6,8 +6,6 @@ use rusqlite::{OptionalExtension, params}; use super::Db; use crate::Result; - - impl Db { pub fn pin_add(&self, uid: &str, path: &str, recursive: bool) -> Result<()> { let conn = self.conn.lock(); @@ -120,5 +118,4 @@ impl Db { } // ---- local (non-Drive) file index (schema v6) ---- - } diff --git a/crates/pdfs-core/src/db/state.rs b/crates/pdfs-core/src/db/state.rs index 02a22d9..d0583ce 100644 --- a/crates/pdfs-core/src/db/state.rs +++ b/crates/pdfs-core/src/db/state.rs @@ -94,5 +94,4 @@ impl Db { conn.execute("DELETE FROM sync_state WHERE key = ?1", params![key])?; Ok(()) } - } diff --git a/crates/pdfs-core/src/db/sync.rs b/crates/pdfs-core/src/db/sync.rs index ea9844b..d562765 100644 --- a/crates/pdfs-core/src/db/sync.rs +++ b/crates/pdfs-core/src/db/sync.rs @@ -222,5 +222,4 @@ impl Db { )?; Ok(()) } - } diff --git a/crates/pdfs-core/src/db/tests.rs b/crates/pdfs-core/src/db/tests.rs index 91a5881..7447a58 100644 --- a/crates/pdfs-core/src/db/tests.rs +++ b/crates/pdfs-core/src/db/tests.rs @@ -1,1061 +1,1063 @@ -use super::*; use super::activity::ACTIVITY_KEEP; use super::migrations::SCHEMA_VERSION; +use super::*; use crate::control::{ActivityEntry, ActivityKind}; use crate::localindex::LocalEntry; use proton_drive_rs::proton_sdk::ids::NodeUid; use proton_drive_rs::{Node, NodeKind}; +/// `Db::open` applies the pragmas that `open_in_memory` skips, so the WAL +/// settings can only be checked against a real file. +#[test] +fn open_bounds_the_wal_size() { + let path = std::env::temp_dir().join(format!("pdfs-db-wal-{}.db", std::process::id())); + let _ = std::fs::remove_file(&path); + let db = Db::open(&path).unwrap(); + let conn = db.conn.lock(); - /// `Db::open` applies the pragmas that `open_in_memory` skips, so the WAL - /// settings can only be checked against a real file. - #[test] - fn open_bounds_the_wal_size() { - let path = std::env::temp_dir().join(format!("pdfs-db-wal-{}.db", std::process::id())); - let _ = std::fs::remove_file(&path); - let db = Db::open(&path).unwrap(); - let conn = db.conn.lock(); - - let mode: String = conn - .query_row("PRAGMA journal_mode", [], |r| r.get(0)) - .unwrap(); - assert_eq!(mode, "wal"); - // Without this the WAL is reused in place but never shrinks, so one - // oversized transaction strands its high-water mark on disk forever. - let limit: i64 = conn - .query_row("PRAGMA journal_size_limit", [], |r| r.get(0)) - .unwrap(); - assert_eq!(limit, WAL_SIZE_LIMIT); - - drop(conn); - drop(db); - let _ = std::fs::remove_file(&path); - } - use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId}; - - fn uid(link: &str) -> NodeUid { - NodeUid::new(VolumeId::from("vol"), LinkId::from(link)) - } - - // `NodeVerification` is not re-exported, so build test nodes by - // deserializing JSON (the field has a serde default and can be omitted). - fn node_from( - parent: serde_json::Value, - link: &str, - name: &str, - kind: serde_json::Value, - ) -> Node { - let v = serde_json::json!({ - "uid": {"volume_id": "vol", "link_id": link}, - "parent_uid": parent, - "kind": kind, - "name": name, - "creation_time": 100, - "modification_time": 200, - "trashed": false, - "signature_email": null, - }); - serde_json::from_value(v).unwrap() - } - - #[test] - fn photos_replace_keeps_what_was_learned_and_drops_what_left() { - let db = Db::open_in_memory().unwrap(); - db.photos_replace(&[ - ("p1".into(), 300, None, Some("video/mp4".into())), - ("p2".into(), 200, None, None), - ("p3".into(), 100, None, None), - ]) + let mode: String = conn + .query_row("PRAGMA journal_mode", [], |r| r.get(0)) .unwrap(); - - // A thumbnail attempt teaches us p1's ratio and that p2 can never have one. - db.photo_set_thumb("p1", THUMB_HAVE, Some(1.5)).unwrap(); - db.photo_set_thumb("p2", THUMB_NONE, None).unwrap(); - - // The next refresh brings a new photo, keeps p1 and p2, and loses p3. - // p1's media type arrives as `None` this time and must not be forgotten. - db.photos_replace(&[ - ("p0".into(), 400, Some("new.jpg".into()), None), - ("p1".into(), 300, None, None), - ("p2".into(), 200, None, None), - ]) + assert_eq!(mode, "wal"); + // Without this the WAL is reused in place but never shrinks, so one + // oversized transaction strands its high-water mark on disk forever. + let limit: i64 = conn + .query_row("PRAGMA journal_size_limit", [], |r| r.get(0)) .unwrap(); + assert_eq!(limit, WAL_SIZE_LIMIT); - let page = db.photos_page(0, 10, None, None).unwrap(); - assert_eq!( - page.iter().map(|p| p.uid.as_str()).collect::>(), - ["p0", "p1", "p2"], - "server order is preserved, and the dropped photo is gone" - ); - assert_eq!(page[0].name.as_deref(), Some("new.jpg")); - // Ratios and verdicts cost a download to rediscover: they survive a refresh. - assert_eq!(page[1].ratio, Some(1.5)); - assert_eq!(page[1].thumb_state, THUMB_HAVE); - assert_eq!(page[2].thumb_state, THUMB_NONE); - // A photo we know nothing about yet starts blank. - assert_eq!(page[0].ratio, None); - assert_eq!(page[0].thumb_state, THUMB_UNKNOWN); - // Media type is learned-and-kept like the ratio: p1 keeps the video type - // it was first seen with even though the later refresh carried `None`, so - // it stays classified as a video. - assert_eq!(page[1].kind, crate::control::PhotoKind::Video); + drop(conn); + drop(db); + let _ = std::fs::remove_file(&path); +} +use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId}; - assert_eq!(db.photos_count().unwrap(), 3); - // The counts break down by tab, and a filtered page returns only its tab. - assert_eq!(db.photos_counts().unwrap(), (2, 1, 0)); - let videos = db - .photos_page(0, 10, Some(crate::control::PhotoKind::Video), None) - .unwrap(); - assert_eq!( - videos.iter().map(|p| p.uid.as_str()).collect::>(), - ["p1"] - ); - let by_uid = db.photos_by_uid(&["p2".into()]).unwrap(); - assert_eq!(by_uid.len(), 1); - assert_eq!(by_uid[0].capture_time, 200); +fn uid(link: &str) -> NodeUid { + NodeUid::new(VolumeId::from("vol"), LinkId::from(link)) +} - // A date-range page keeps only the window's photos: [150, 350) is p1+p2, - // not p0 at 400. Combined with a kind filter both conditions apply. - let ranged = db.photos_page(0, 10, None, Some((150, 350))).unwrap(); - assert_eq!( - ranged.iter().map(|p| p.uid.as_str()).collect::>(), - ["p1", "p2"] - ); - let ranged_video = db - .photos_page(0, 10, Some(crate::control::PhotoKind::Video), Some((150, 350))) - .unwrap(); - assert_eq!( - ranged_video.iter().map(|p| p.uid.as_str()).collect::>(), - ["p1"] - ); - // All three surviving photos sit in the same (1970-01) local month. - let months = db.photos_months(None).unwrap(); - assert_eq!(months.len(), 1); - assert_eq!(months[0].2, 3); - } +// `NodeVerification` is not re-exported, so build test nodes by +// deserializing JSON (the field has a serde default and can be omitted). +fn node_from(parent: serde_json::Value, link: &str, name: &str, kind: serde_json::Value) -> Node { + let v = serde_json::json!({ + "uid": {"volume_id": "vol", "link_id": link}, + "parent_uid": parent, + "kind": kind, + "name": name, + "creation_time": 100, + "modification_time": 200, + "trashed": false, + "signature_email": null, + }); + serde_json::from_value(v).unwrap() +} - #[test] - fn photos_page_slices_the_timeline_in_order() { - let db = Db::open_in_memory().unwrap(); - let items: Vec<_> = (0..5) - .map(|i| (format!("p{i}"), 500 - i as i64, None, None::)) - .collect(); - db.photos_replace(&items).unwrap(); +#[test] +fn photos_replace_keeps_what_was_learned_and_drops_what_left() { + let db = Db::open_in_memory().unwrap(); + db.photos_replace(&[ + ("p1".into(), 300, None, Some("video/mp4".into())), + ("p2".into(), 200, None, None), + ("p3".into(), 100, None, None), + ]) + .unwrap(); - let page = db.photos_page(2, 2, None, None).unwrap(); - assert_eq!( - page.iter().map(|p| p.uid.as_str()).collect::>(), - ["p2", "p3"] - ); - assert!(db.photos_page(9, 2, None, None).unwrap().is_empty()); - } + // A thumbnail attempt teaches us p1's ratio and that p2 can never have one. + db.photo_set_thumb("p1", THUMB_HAVE, Some(1.5)).unwrap(); + db.photo_set_thumb("p2", THUMB_NONE, None).unwrap(); - #[test] - fn trash_replace_lists_folders_first() { - let db = Db::open_in_memory().unwrap(); - db.trash_replace(&[ - StoredTrash { - uid: "t1".into(), - name: "zeta.txt".into(), - is_dir: false, - size: 10, - mtime: 1, - }, - StoredTrash { - uid: "t2".into(), - name: "Alpha".into(), - is_dir: true, - size: 0, - mtime: 2, - }, - ]) + // The next refresh brings a new photo, keeps p1 and p2, and loses p3. + // p1's media type arrives as `None` this time and must not be forgotten. + db.photos_replace(&[ + ("p0".into(), 400, Some("new.jpg".into()), None), + ("p1".into(), 300, None, None), + ("p2".into(), 200, None, None), + ]) + .unwrap(); + + let page = db.photos_page(0, 10, None, None).unwrap(); + assert_eq!( + page.iter().map(|p| p.uid.as_str()).collect::>(), + ["p0", "p1", "p2"], + "server order is preserved, and the dropped photo is gone" + ); + assert_eq!(page[0].name.as_deref(), Some("new.jpg")); + // Ratios and verdicts cost a download to rediscover: they survive a refresh. + assert_eq!(page[1].ratio, Some(1.5)); + assert_eq!(page[1].thumb_state, THUMB_HAVE); + assert_eq!(page[2].thumb_state, THUMB_NONE); + // A photo we know nothing about yet starts blank. + assert_eq!(page[0].ratio, None); + assert_eq!(page[0].thumb_state, THUMB_UNKNOWN); + // Media type is learned-and-kept like the ratio: p1 keeps the video type + // it was first seen with even though the later refresh carried `None`, so + // it stays classified as a video. + assert_eq!(page[1].kind, crate::control::PhotoKind::Video); + + assert_eq!(db.photos_count().unwrap(), 3); + // The counts break down by tab, and a filtered page returns only its tab. + assert_eq!(db.photos_counts().unwrap(), (2, 1, 0)); + let videos = db + .photos_page(0, 10, Some(crate::control::PhotoKind::Video), None) .unwrap(); + assert_eq!( + videos.iter().map(|p| p.uid.as_str()).collect::>(), + ["p1"] + ); + let by_uid = db.photos_by_uid(&["p2".into()]).unwrap(); + assert_eq!(by_uid.len(), 1); + assert_eq!(by_uid[0].capture_time, 200); - let items = db.trash_list().unwrap(); - assert_eq!( - items.iter().map(|i| i.name.as_str()).collect::>(), - ["Alpha", "zeta.txt"] - ); - - // A replace is a replace: emptying the trash on the server empties it here. - db.trash_replace(&[]).unwrap(); - assert!(db.trash_list().unwrap().is_empty()); - } - - #[test] - fn state_stamps_round_trip_and_clear() { - let db = Db::open_in_memory().unwrap(); - assert_eq!(db.state_i64("photos_synced_ms").unwrap(), None); - db.set_state_i64("photos_synced_ms", 1234).unwrap(); - assert_eq!(db.state_i64("photos_synced_ms").unwrap(), Some(1234)); - db.clear_state("photos_synced_ms").unwrap(); - assert_eq!( - db.state_i64("photos_synced_ms").unwrap(), - None, - "a cleared stamp reads as never fetched, so the next request blocks on a refresh" - ); - } - - fn folder(link: &str, parent: Option<&str>, name: &str) -> Node { - let parent = match parent { - Some(p) => serde_json::json!({"volume_id": "vol", "link_id": p}), - None => serde_json::Value::Null, - }; - node_from(parent, link, name, serde_json::json!("Folder")) - } - - fn file(link: &str, parent: &str, name: &str, size: i64) -> Node { - let kind = serde_json::json!({ - "File": { - "media_type": "text/plain", - "total_size_on_storage": size + 10, - "claimed_size": size, - "claimed_modification_time": null, - } - }); - node_from( - serde_json::json!({"volume_id": "vol", "link_id": parent}), - link, - name, - kind, + // A date-range page keeps only the window's photos: [150, 350) is p1+p2, + // not p0 at 400. Combined with a kind filter both conditions apply. + let ranged = db.photos_page(0, 10, None, Some((150, 350))).unwrap(); + assert_eq!( + ranged.iter().map(|p| p.uid.as_str()).collect::>(), + ["p1", "p2"] + ); + let ranged_video = db + .photos_page( + 0, + 10, + Some(crate::control::PhotoKind::Video), + Some((150, 350)), ) - } + .unwrap(); + assert_eq!( + ranged_video + .iter() + .map(|p| p.uid.as_str()) + .collect::>(), + ["p1"] + ); + // All three surviving photos sit in the same (1970-01) local month. + let months = db.photos_months(None).unwrap(); + assert_eq!(months.len(), 1); + assert_eq!(months[0].2, 3); +} - /// Recovering the root by uid is what lets the daemon mount offline - /// (offline.md Phase 1): the uid is remembered in `sync_state`, the node - /// itself comes back out of `nodes`. - #[test] - fn node_by_uid_recovers_a_stored_node() { - let db = Db::open_in_memory().unwrap(); - let root = folder("root", None, "My Files"); - db.upsert_node(&root).unwrap(); - db.set_state_str("root_uid", &root.uid.to_string()).unwrap(); +#[test] +fn photos_page_slices_the_timeline_in_order() { + let db = Db::open_in_memory().unwrap(); + let items: Vec<_> = (0..5) + .map(|i| (format!("p{i}"), 500 - i as i64, None, None::)) + .collect(); + db.photos_replace(&items).unwrap(); - let key = db.state_str("root_uid").unwrap().unwrap(); - let got = db.node_by_uid(&key).unwrap().expect("root recovered"); - assert_eq!(got.uid, root.uid); - assert_eq!(got.name, "My Files"); - assert!(got.is_folder()); + let page = db.photos_page(2, 2, None, None).unwrap(); + assert_eq!( + page.iter().map(|p| p.uid.as_str()).collect::>(), + ["p2", "p3"] + ); + assert!(db.photos_page(9, 2, None, None).unwrap().is_empty()); +} - assert!(db.node_by_uid("vol~nope").unwrap().is_none()); - assert!(db.state_str("never_written").unwrap().is_none()); - } - - #[test] - fn upsert_and_load_all_roundtrip() { - let db = Db::open_in_memory().unwrap(); - let root = folder("root", None, "My Files"); - let child = file("f1", "root", "hello.txt", 42); - db.upsert_node(&root).unwrap(); - db.upsert_node(&child).unwrap(); - - let loaded = db.load_all().unwrap(); - assert_eq!(loaded.len(), 2); - let f = loaded.iter().find(|s| s.node.uid == uid("f1")).unwrap(); - assert_eq!(f.node.name, "hello.txt"); - assert!(!f.listed); - match &f.node.kind { - NodeKind::File { claimed_size, .. } => assert_eq!(*claimed_size, Some(42)), - _ => panic!("expected file"), - } - } - - #[test] - fn upsert_nodes_and_load_all_roundtrip() { - let db = Db::open_in_memory().unwrap(); - let root = folder("root", None, "My Files"); - let child1 = file("f1", "root", "hello.txt", 42); - let child2 = file("f2", "root", "world.txt", 100); - db.upsert_nodes(&[root, child1, child2]).unwrap(); - - let loaded = db.load_all().unwrap(); - assert_eq!(loaded.len(), 3); - let f1 = loaded.iter().find(|s| s.node.uid == uid("f1")).unwrap(); - assert_eq!(f1.node.name, "hello.txt"); - let f2 = loaded.iter().find(|s| s.node.uid == uid("f2")).unwrap(); - assert_eq!(f2.node.name, "world.txt"); - } - - #[test] - fn upsert_is_idempotent_update() { - let db = Db::open_in_memory().unwrap(); - let mut n = folder("root", None, "My Files"); - db.upsert_node(&n).unwrap(); - n.name = "Renamed".into(); - db.upsert_node(&n).unwrap(); - let loaded = db.load_all().unwrap(); - assert_eq!(loaded.len(), 1); - assert_eq!(loaded[0].node.name, "Renamed"); - } - - #[test] - fn delete_node_removes_row() { - let db = Db::open_in_memory().unwrap(); - let n = folder("root", None, "My Files"); - db.upsert_node(&n).unwrap(); - db.delete_node(&uid("root")).unwrap(); - assert!(db.load_all().unwrap().is_empty()); - } - - #[test] - fn children_if_listed_gated_on_flag() { - let db = Db::open_in_memory().unwrap(); - db.upsert_node(&folder("root", None, "My Files")).unwrap(); - db.upsert_node(&file("f1", "root", "a.txt", 1)).unwrap(); - db.upsert_node(&file("f2", "root", "b.txt", 2)).unwrap(); - - // Not listed yet → unknown, force a re-fetch. - assert!(db.children_if_listed(&uid("root")).unwrap().is_none()); - - db.set_listed(&uid("root"), true).unwrap(); - let kids = db.children_if_listed(&uid("root")).unwrap().unwrap(); - assert_eq!(kids.len(), 2); - } - - #[test] - fn children_if_listed_excludes_trashed() { - let db = Db::open_in_memory().unwrap(); - db.upsert_node(&folder("root", None, "My Files")).unwrap(); - let mut trashed = file("f1", "root", "a.txt", 1); - trashed.trashed = true; - db.upsert_node(&trashed).unwrap(); - db.set_listed(&uid("root"), true).unwrap(); - assert_eq!( - db.children_if_listed(&uid("root")).unwrap().unwrap().len(), - 0 - ); - } - - fn local(path: &str, name: &str, is_dir: bool) -> LocalEntry { - LocalEntry { - path: path.into(), - name: name.into(), - is_dir, +#[test] +fn trash_replace_lists_folders_first() { + let db = Db::open_in_memory().unwrap(); + db.trash_replace(&[ + StoredTrash { + uid: "t1".into(), + name: "zeta.txt".into(), + is_dir: false, size: 10, - mtime: 5, + mtime: 1, + }, + StoredTrash { + uid: "t2".into(), + name: "Alpha".into(), + is_dir: true, + size: 0, + mtime: 2, + }, + ]) + .unwrap(); + + let items = db.trash_list().unwrap(); + assert_eq!( + items.iter().map(|i| i.name.as_str()).collect::>(), + ["Alpha", "zeta.txt"] + ); + + // A replace is a replace: emptying the trash on the server empties it here. + db.trash_replace(&[]).unwrap(); + assert!(db.trash_list().unwrap().is_empty()); +} + +#[test] +fn state_stamps_round_trip_and_clear() { + let db = Db::open_in_memory().unwrap(); + assert_eq!(db.state_i64("photos_synced_ms").unwrap(), None); + db.set_state_i64("photos_synced_ms", 1234).unwrap(); + assert_eq!(db.state_i64("photos_synced_ms").unwrap(), Some(1234)); + db.clear_state("photos_synced_ms").unwrap(); + assert_eq!( + db.state_i64("photos_synced_ms").unwrap(), + None, + "a cleared stamp reads as never fetched, so the next request blocks on a refresh" + ); +} + +fn folder(link: &str, parent: Option<&str>, name: &str) -> Node { + let parent = match parent { + Some(p) => serde_json::json!({"volume_id": "vol", "link_id": p}), + None => serde_json::Value::Null, + }; + node_from(parent, link, name, serde_json::json!("Folder")) +} + +fn file(link: &str, parent: &str, name: &str, size: i64) -> Node { + let kind = serde_json::json!({ + "File": { + "media_type": "text/plain", + "total_size_on_storage": size + 10, + "claimed_size": size, + "claimed_modification_time": null, } + }); + node_from( + serde_json::json!({"volume_id": "vol", "link_id": parent}), + link, + name, + kind, + ) +} + +/// Recovering the root by uid is what lets the daemon mount offline +/// (offline.md Phase 1): the uid is remembered in `sync_state`, the node +/// itself comes back out of `nodes`. +#[test] +fn node_by_uid_recovers_a_stored_node() { + let db = Db::open_in_memory().unwrap(); + let root = folder("root", None, "My Files"); + db.upsert_node(&root).unwrap(); + db.set_state_str("root_uid", &root.uid.to_string()).unwrap(); + + let key = db.state_str("root_uid").unwrap().unwrap(); + let got = db.node_by_uid(&key).unwrap().expect("root recovered"); + assert_eq!(got.uid, root.uid); + assert_eq!(got.name, "My Files"); + assert!(got.is_folder()); + + assert!(db.node_by_uid("vol~nope").unwrap().is_none()); + assert!(db.state_str("never_written").unwrap().is_none()); +} + +#[test] +fn upsert_and_load_all_roundtrip() { + let db = Db::open_in_memory().unwrap(); + let root = folder("root", None, "My Files"); + let child = file("f1", "root", "hello.txt", 42); + db.upsert_node(&root).unwrap(); + db.upsert_node(&child).unwrap(); + + let loaded = db.load_all().unwrap(); + assert_eq!(loaded.len(), 2); + let f = loaded.iter().find(|s| s.node.uid == uid("f1")).unwrap(); + assert_eq!(f.node.name, "hello.txt"); + assert!(!f.listed); + match &f.node.kind { + NodeKind::File { claimed_size, .. } => assert_eq!(*claimed_size, Some(42)), + _ => panic!("expected file"), } +} - /// A local scan is searchable by substring, and a *later* scan prunes the - /// paths it no longer sees — including out of the FTS index, so a deleted - /// file cannot keep surfacing in the prompt. - #[test] - fn local_scan_indexes_then_prunes_stale_paths() { - let db = Db::open_in_memory().unwrap(); +#[test] +fn upsert_nodes_and_load_all_roundtrip() { + let db = Db::open_in_memory().unwrap(); + let root = folder("root", None, "My Files"); + let child1 = file("f1", "root", "hello.txt", 42); + let child2 = file("f2", "root", "world.txt", 100); + db.upsert_nodes(&[root, child1, child2]).unwrap(); - let gen1 = db.local_begin_scan().unwrap(); - db.local_upsert_batch( - gen1, - &[ - local("/home/u/docs/report.pdf", "report.pdf", false), - local("/home/u/docs/notes.md", "notes.md", false), - ], - ) + let loaded = db.load_all().unwrap(); + assert_eq!(loaded.len(), 3); + let f1 = loaded.iter().find(|s| s.node.uid == uid("f1")).unwrap(); + assert_eq!(f1.node.name, "hello.txt"); + let f2 = loaded.iter().find(|s| s.node.uid == uid("f2")).unwrap(); + assert_eq!(f2.node.name, "world.txt"); +} + +#[test] +fn upsert_is_idempotent_update() { + let db = Db::open_in_memory().unwrap(); + let mut n = folder("root", None, "My Files"); + db.upsert_node(&n).unwrap(); + n.name = "Renamed".into(); + db.upsert_node(&n).unwrap(); + let loaded = db.load_all().unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].node.name, "Renamed"); +} + +#[test] +fn delete_node_removes_row() { + let db = Db::open_in_memory().unwrap(); + let n = folder("root", None, "My Files"); + db.upsert_node(&n).unwrap(); + db.delete_node(&uid("root")).unwrap(); + assert!(db.load_all().unwrap().is_empty()); +} + +#[test] +fn children_if_listed_gated_on_flag() { + let db = Db::open_in_memory().unwrap(); + db.upsert_node(&folder("root", None, "My Files")).unwrap(); + db.upsert_node(&file("f1", "root", "a.txt", 1)).unwrap(); + db.upsert_node(&file("f2", "root", "b.txt", 2)).unwrap(); + + // Not listed yet → unknown, force a re-fetch. + assert!(db.children_if_listed(&uid("root")).unwrap().is_none()); + + db.set_listed(&uid("root"), true).unwrap(); + let kids = db.children_if_listed(&uid("root")).unwrap().unwrap(); + assert_eq!(kids.len(), 2); +} + +#[test] +fn children_if_listed_excludes_trashed() { + let db = Db::open_in_memory().unwrap(); + db.upsert_node(&folder("root", None, "My Files")).unwrap(); + let mut trashed = file("f1", "root", "a.txt", 1); + trashed.trashed = true; + db.upsert_node(&trashed).unwrap(); + db.set_listed(&uid("root"), true).unwrap(); + assert_eq!( + db.children_if_listed(&uid("root")).unwrap().unwrap().len(), + 0 + ); +} + +fn local(path: &str, name: &str, is_dir: bool) -> LocalEntry { + LocalEntry { + path: path.into(), + name: name.into(), + is_dir, + size: 10, + mtime: 5, + } +} + +/// A local scan is searchable by substring, and a *later* scan prunes the +/// paths it no longer sees — including out of the FTS index, so a deleted +/// file cannot keep surfacing in the prompt. +#[test] +fn local_scan_indexes_then_prunes_stale_paths() { + let db = Db::open_in_memory().unwrap(); + + let gen1 = db.local_begin_scan().unwrap(); + db.local_upsert_batch( + gen1, + &[ + local("/home/u/docs/report.pdf", "report.pdf", false), + local("/home/u/docs/notes.md", "notes.md", false), + ], + ) + .unwrap(); + assert_eq!(db.local_finish_scan(gen1, 1_000).unwrap(), 2); + + // Trigram index gives substring (not just prefix) matches. + let hits = db.search_local("port", 10).unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].path, "/home/u/docs/report.pdf"); + assert!(!hits[0].is_dir); + assert_eq!(db.local_indexed_at().unwrap(), Some(1_000)); + + // Second scan sees only notes.md → report.pdf is gone from disk. + let gen2 = db.local_begin_scan().unwrap(); + db.local_upsert_batch(gen2, &[local("/home/u/docs/notes.md", "notes.md", false)]) .unwrap(); - assert_eq!(db.local_finish_scan(gen1, 1_000).unwrap(), 2); + assert_eq!(db.local_finish_scan(gen2, 2_000).unwrap(), 1); + assert!(db.search_local("report", 10).unwrap().is_empty()); + assert_eq!(db.search_local("notes", 10).unwrap().len(), 1); +} - // Trigram index gives substring (not just prefix) matches. - let hits = db.search_local("port", 10).unwrap(); - assert_eq!(hits.len(), 1); - assert_eq!(hits[0].path, "/home/u/docs/report.pdf"); - assert!(!hits[0].is_dir); - assert_eq!(db.local_indexed_at().unwrap(), Some(1_000)); +/// Queries below the trigram minimum still match, via the `LIKE` fallback. +#[test] +fn local_search_short_query_like_fallback() { + let db = Db::open_in_memory().unwrap(); + let g = db.local_begin_scan().unwrap(); + db.local_upsert_batch(g, &[local("/home/u/a.txt", "a.txt", false)]) + .unwrap(); + db.local_finish_scan(g, 1).unwrap(); + assert_eq!(db.search_local("a", 10).unwrap().len(), 1); + assert!(db.search_local("", 10).unwrap().is_empty()); +} - // Second scan sees only notes.md → report.pdf is gone from disk. - let gen2 = db.local_begin_scan().unwrap(); - db.local_upsert_batch(gen2, &[local("/home/u/docs/notes.md", "notes.md", false)]) - .unwrap(); - assert_eq!(db.local_finish_scan(gen2, 2_000).unwrap(), 1); - assert!(db.search_local("report", 10).unwrap().is_empty()); - assert_eq!(db.search_local("notes", 10).unwrap().len(), 1); +#[test] +fn event_cursor_roundtrip() { + let db = Db::open_in_memory().unwrap(); + // Absent before first write → seed from server head on first mount. + assert!(db.get_event_cursor().unwrap().is_none()); + db.set_event_cursor("evt-1").unwrap(); + assert_eq!(db.get_event_cursor().unwrap().as_deref(), Some("evt-1")); + // Overwrites, not appends. + db.set_event_cursor("evt-2").unwrap(); + assert_eq!(db.get_event_cursor().unwrap().as_deref(), Some("evt-2")); +} + +#[test] +fn search_trigram_substring_and_path() { + let db = Db::open_in_memory().unwrap(); + db.upsert_node(&folder("root", None, "My Files")).unwrap(); + db.upsert_node(&folder("docs", Some("root"), "Documents")) + .unwrap(); + db.upsert_node(&file("f1", "docs", "report.pdf", 1)) + .unwrap(); + db.upsert_node(&file("f2", "root", "notes.txt", 2)).unwrap(); + db.upsert_node(&file( + "f3", + "root", + "Rampage Open Air 2026 - order 166765244.pdf", + 3, + )) + .unwrap(); + + // Substring match (trigram), not just prefix. + let hits = db.search("port", 10).unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].node.name, "report.pdf"); + // Path is mountpoint-relative, root excluded. + assert_eq!(hits[0].path, "Documents/report.pdf"); + + // Top-level file → bare name. + let hits = db.search("notes", 10).unwrap(); + assert_eq!(hits[0].path, "notes.txt"); + + // Multi-term FTS5 query matching (out of order, separated terms) + let hits = db.search("rampage 2026", 10).unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!( + hits[0].node.name, + "Rampage Open Air 2026 - order 166765244.pdf" + ); +} + +#[test] +fn search_excludes_trashed_and_respects_limit() { + let db = Db::open_in_memory().unwrap(); + db.upsert_node(&folder("root", None, "My Files")).unwrap(); + db.upsert_node(&file("f1", "root", "alpha.txt", 1)).unwrap(); + let mut gone = file("f2", "root", "alphb.txt", 1); + gone.trashed = true; + db.upsert_node(&gone).unwrap(); + + let hits = db.search("alph", 10).unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].node.name, "alpha.txt"); + + db.upsert_node(&file("f3", "root", "alphc.txt", 1)).unwrap(); + assert_eq!(db.search("alph", 1).unwrap().len(), 1); +} + +#[test] +fn search_short_query_like_fallback() { + let db = Db::open_in_memory().unwrap(); + db.upsert_node(&folder("root", None, "My Files")).unwrap(); + db.upsert_node(&file("f1", "root", "ab.txt", 1)).unwrap(); + // Under trigram min length → LIKE path still finds it. + let hits = db.search("ab", 10).unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].node.name, "ab.txt"); +} + +#[test] +fn search_drops_fts_row_on_delete_and_trash() { + let db = Db::open_in_memory().unwrap(); + db.upsert_node(&folder("root", None, "My Files")).unwrap(); + db.upsert_node(&file("f1", "root", "unique.txt", 1)) + .unwrap(); + assert_eq!(db.search("unique", 10).unwrap().len(), 1); + + // Re-upsert as trashed → leaves the index. + let mut t = file("f1", "root", "unique.txt", 1); + t.trashed = true; + db.upsert_node(&t).unwrap(); + assert_eq!(db.search("unique", 10).unwrap().len(), 0); + + // Resurrect, then hard-delete. + db.upsert_node(&file("f1", "root", "unique.txt", 1)) + .unwrap(); + assert_eq!(db.search("unique", 10).unwrap().len(), 1); + db.delete_node(&uid("f1")).unwrap(); + assert_eq!(db.search("unique", 10).unwrap().len(), 0); +} + +fn activity(target: &str, kind: ActivityKind, ok: bool) -> ActivityEntry { + ActivityEntry { + time: 1700, + kind, + target: target.into(), + detail: "detail".into(), + ok, } +} - /// Queries below the trigram minimum still match, via the `LIKE` fallback. - #[test] - fn local_search_short_query_like_fallback() { - let db = Db::open_in_memory().unwrap(); - let g = db.local_begin_scan().unwrap(); - db.local_upsert_batch(g, &[local("/home/u/a.txt", "a.txt", false)]) - .unwrap(); - db.local_finish_scan(g, 1).unwrap(); - assert_eq!(db.search_local("a", 10).unwrap().len(), 1); - assert!(db.search_local("", 10).unwrap().is_empty()); - } - - #[test] - fn event_cursor_roundtrip() { - let db = Db::open_in_memory().unwrap(); - // Absent before first write → seed from server head on first mount. - assert!(db.get_event_cursor().unwrap().is_none()); - db.set_event_cursor("evt-1").unwrap(); - assert_eq!(db.get_event_cursor().unwrap().as_deref(), Some("evt-1")); - // Overwrites, not appends. - db.set_event_cursor("evt-2").unwrap(); - assert_eq!(db.get_event_cursor().unwrap().as_deref(), Some("evt-2")); - } - - #[test] - fn search_trigram_substring_and_path() { - let db = Db::open_in_memory().unwrap(); - db.upsert_node(&folder("root", None, "My Files")).unwrap(); - db.upsert_node(&folder("docs", Some("root"), "Documents")) - .unwrap(); - db.upsert_node(&file("f1", "docs", "report.pdf", 1)) - .unwrap(); - db.upsert_node(&file("f2", "root", "notes.txt", 2)).unwrap(); - db.upsert_node(&file( - "f3", - "root", - "Rampage Open Air 2026 - order 166765244.pdf", - 3, - )) +#[test] +fn activity_reads_back_newest_first() { + let db = Db::open_in_memory().unwrap(); + db.activity_add(&activity("a.txt", ActivityKind::Upload, true)) + .unwrap(); + db.activity_add(&activity("b.txt", ActivityKind::Download, false)) .unwrap(); - // Substring match (trigram), not just prefix. - let hits = db.search("port", 10).unwrap(); - assert_eq!(hits.len(), 1); - assert_eq!(hits[0].node.name, "report.pdf"); - // Path is mountpoint-relative, root excluded. - assert_eq!(hits[0].path, "Documents/report.pdf"); + let items = db.activity_list(10).unwrap(); + assert_eq!(items.len(), 2); + assert_eq!(items[0].target, "b.txt"); + assert_eq!(items[0].kind, ActivityKind::Download); + assert!(!items[0].ok); + assert_eq!(items[0].detail, "detail"); + assert_eq!(items[0].time, 1700); + assert_eq!(items[1].target, "a.txt"); - // Top-level file → bare name. - let hits = db.search("notes", 10).unwrap(); - assert_eq!(hits[0].path, "notes.txt"); + assert_eq!(db.activity_list(1).unwrap().len(), 1); +} - // Multi-term FTS5 query matching (out of order, separated terms) - let hits = db.search("rampage 2026", 10).unwrap(); - assert_eq!(hits.len(), 1); - assert_eq!( - hits[0].node.name, - "Rampage Open Air 2026 - order 166765244.pdf" - ); - } - - #[test] - fn search_excludes_trashed_and_respects_limit() { - let db = Db::open_in_memory().unwrap(); - db.upsert_node(&folder("root", None, "My Files")).unwrap(); - db.upsert_node(&file("f1", "root", "alpha.txt", 1)).unwrap(); - let mut gone = file("f2", "root", "alphb.txt", 1); - gone.trashed = true; - db.upsert_node(&gone).unwrap(); - - let hits = db.search("alph", 10).unwrap(); - assert_eq!(hits.len(), 1); - assert_eq!(hits[0].node.name, "alpha.txt"); - - db.upsert_node(&file("f3", "root", "alphc.txt", 1)).unwrap(); - assert_eq!(db.search("alph", 1).unwrap().len(), 1); - } - - #[test] - fn search_short_query_like_fallback() { - let db = Db::open_in_memory().unwrap(); - db.upsert_node(&folder("root", None, "My Files")).unwrap(); - db.upsert_node(&file("f1", "root", "ab.txt", 1)).unwrap(); - // Under trigram min length → LIKE path still finds it. - let hits = db.search("ab", 10).unwrap(); - assert_eq!(hits.len(), 1); - assert_eq!(hits[0].node.name, "ab.txt"); - } - - #[test] - fn search_drops_fts_row_on_delete_and_trash() { - let db = Db::open_in_memory().unwrap(); - db.upsert_node(&folder("root", None, "My Files")).unwrap(); - db.upsert_node(&file("f1", "root", "unique.txt", 1)) +#[test] +fn activity_prunes_to_the_keep_limit() { + let db = Db::open_in_memory().unwrap(); + for i in 0..(ACTIVITY_KEEP + 10) { + db.activity_add(&activity(&format!("f{i}"), ActivityKind::Upload, true)) .unwrap(); - assert_eq!(db.search("unique", 10).unwrap().len(), 1); - - // Re-upsert as trashed → leaves the index. - let mut t = file("f1", "root", "unique.txt", 1); - t.trashed = true; - db.upsert_node(&t).unwrap(); - assert_eq!(db.search("unique", 10).unwrap().len(), 0); - - // Resurrect, then hard-delete. - db.upsert_node(&file("f1", "root", "unique.txt", 1)) - .unwrap(); - assert_eq!(db.search("unique", 10).unwrap().len(), 1); - db.delete_node(&uid("f1")).unwrap(); - assert_eq!(db.search("unique", 10).unwrap().len(), 0); } + let items = db.activity_list(ACTIVITY_KEEP as usize * 2).unwrap(); + assert_eq!(items.len(), ACTIVITY_KEEP as usize); + // The newest survive; the oldest are the ones dropped. + assert_eq!(items[0].target, format!("f{}", ACTIVITY_KEEP + 9)); +} - fn activity(target: &str, kind: ActivityKind, ok: bool) -> ActivityEntry { - ActivityEntry { - time: 1700, - kind, - target: target.into(), - detail: "detail".into(), - ok, - } - } - - #[test] - fn activity_reads_back_newest_first() { - let db = Db::open_in_memory().unwrap(); - db.activity_add(&activity("a.txt", ActivityKind::Upload, true)) - .unwrap(); - db.activity_add(&activity("b.txt", ActivityKind::Download, false)) - .unwrap(); - - let items = db.activity_list(10).unwrap(); - assert_eq!(items.len(), 2); - assert_eq!(items[0].target, "b.txt"); - assert_eq!(items[0].kind, ActivityKind::Download); - assert!(!items[0].ok); - assert_eq!(items[0].detail, "detail"); - assert_eq!(items[0].time, 1700); - assert_eq!(items[1].target, "a.txt"); - - assert_eq!(db.activity_list(1).unwrap().len(), 1); - } - - #[test] - fn activity_prunes_to_the_keep_limit() { - let db = Db::open_in_memory().unwrap(); - for i in 0..(ACTIVITY_KEEP + 10) { - db.activity_add(&activity(&format!("f{i}"), ActivityKind::Upload, true)) - .unwrap(); - } - let items = db.activity_list(ACTIVITY_KEEP as usize * 2).unwrap(); - assert_eq!(items.len(), ACTIVITY_KEEP as usize); - // The newest survive; the oldest are the ones dropped. - assert_eq!(items[0].target, format!("f{}", ACTIVITY_KEEP + 9)); - } - - #[test] - fn opens_and_migrates() { - let db = Db::open_in_memory().unwrap(); - let version: String = db - .with_conn(|c| { - Ok(c.query_row( - "SELECT value FROM sync_state WHERE key = 'schema_version'", - [], - |r| r.get(0), - )?) - }) - .unwrap(); - assert_eq!(version, SCHEMA_VERSION.to_string()); - } - - #[test] - fn a_second_write_supersedes_the_first_pending_op() { - let db = Db::open_in_memory().unwrap(); - let op = |blob: &str| PendingOp { - id: 0, - kind: OP_REVISION.to_string(), - uid: uid("a").to_string(), - parent_uid: None, - name: None, - blob_path: Some(blob.to_string()), - meta_json: Some("{}".to_string()), - created_at: 1, - attempts: 0, - last_error: None, - next_attempt_at: 0, - }; - - let (_, superseded) = db.enqueue_op(&op("/staging/first")).unwrap(); - assert_eq!(superseded, None, "nothing to supersede on the first write"); - - // The newer blob already contains everything the older one did, so the - // older op must go — and its blob must be reported so it can be deleted - // rather than leaked. - let (id2, superseded) = db.enqueue_op(&op("/staging/second")).unwrap(); - assert_eq!(superseded.as_deref(), Some("/staging/first")); - - let ops = db.pending_ops().unwrap(); - assert_eq!(ops.len(), 1, "one queued upload per node"); - assert_eq!(ops[0].id, id2); - assert_eq!(ops[0].blob_path.as_deref(), Some("/staging/second")); - assert_eq!(db.pending_op_counts().unwrap().uploads, 1); - } - - /// Deleting a folder that was created offline must take the ops queued - /// underneath it with it: they name a placeholder parent that will now never - /// become real, so nothing could ever drain them and nothing is left to - /// rewrite them. - #[test] - fn deleting_a_queued_folder_takes_its_queued_children_with_it() { - let db = Db::open_in_memory().unwrap(); - let op = |kind: &str, uid: &str, parent: &str, blob: Option<&str>| PendingOp { - id: 0, - kind: kind.to_string(), - uid: uid.to_string(), - parent_uid: Some(parent.to_string()), - name: Some("n".to_string()), - blob_path: blob.map(str::to_string), - meta_json: None, - created_at: 1, - attempts: 0, - last_error: None, - next_attempt_at: 0, - }; - let root = uid("root").to_string(); - db.enqueue_op(&op(OP_MKDIR, "local~dir", &root, None)) - .unwrap(); - db.enqueue_op(&op(OP_MKDIR, "local~sub", "local~dir", None)) - .unwrap(); - db.enqueue_op(&op( - OP_CREATE, - "local~deep", - "local~sub", - Some("/staging/deep"), - )) +#[test] +fn opens_and_migrates() { + let db = Db::open_in_memory().unwrap(); + let version: String = db + .with_conn(|c| { + Ok(c.query_row( + "SELECT value FROM sync_state WHERE key = 'schema_version'", + [], + |r| r.get(0), + )?) + }) .unwrap(); - // A sibling outside the doomed subtree must survive. - db.enqueue_op(&op(OP_CREATE, "local~other", &root, Some("/staging/other"))) - .unwrap(); + assert_eq!(version, SCHEMA_VERSION.to_string()); +} - let blobs = db.delete_ops_for_uid("local~dir").unwrap(); - assert_eq!( - blobs, - vec!["/staging/deep"], - "the subtree's bytes come back" - ); +#[test] +fn a_second_write_supersedes_the_first_pending_op() { + let db = Db::open_in_memory().unwrap(); + let op = |blob: &str| PendingOp { + id: 0, + kind: OP_REVISION.to_string(), + uid: uid("a").to_string(), + parent_uid: None, + name: None, + blob_path: Some(blob.to_string()), + meta_json: Some("{}".to_string()), + created_at: 1, + attempts: 0, + last_error: None, + next_attempt_at: 0, + }; - let left: Vec = db - .pending_ops() + let (_, superseded) = db.enqueue_op(&op("/staging/first")).unwrap(); + assert_eq!(superseded, None, "nothing to supersede on the first write"); + + // The newer blob already contains everything the older one did, so the + // older op must go — and its blob must be reported so it can be deleted + // rather than leaked. + let (id2, superseded) = db.enqueue_op(&op("/staging/second")).unwrap(); + assert_eq!(superseded.as_deref(), Some("/staging/first")); + + let ops = db.pending_ops().unwrap(); + assert_eq!(ops.len(), 1, "one queued upload per node"); + assert_eq!(ops[0].id, id2); + assert_eq!(ops[0].blob_path.as_deref(), Some("/staging/second")); + assert_eq!(db.pending_op_counts().unwrap().uploads, 1); +} + +/// Deleting a folder that was created offline must take the ops queued +/// underneath it with it: they name a placeholder parent that will now never +/// become real, so nothing could ever drain them and nothing is left to +/// rewrite them. +#[test] +fn deleting_a_queued_folder_takes_its_queued_children_with_it() { + let db = Db::open_in_memory().unwrap(); + let op = |kind: &str, uid: &str, parent: &str, blob: Option<&str>| PendingOp { + id: 0, + kind: kind.to_string(), + uid: uid.to_string(), + parent_uid: Some(parent.to_string()), + name: Some("n".to_string()), + blob_path: blob.map(str::to_string), + meta_json: None, + created_at: 1, + attempts: 0, + last_error: None, + next_attempt_at: 0, + }; + let root = uid("root").to_string(); + db.enqueue_op(&op(OP_MKDIR, "local~dir", &root, None)) + .unwrap(); + db.enqueue_op(&op(OP_MKDIR, "local~sub", "local~dir", None)) + .unwrap(); + db.enqueue_op(&op( + OP_CREATE, + "local~deep", + "local~sub", + Some("/staging/deep"), + )) + .unwrap(); + // A sibling outside the doomed subtree must survive. + db.enqueue_op(&op(OP_CREATE, "local~other", &root, Some("/staging/other"))) + .unwrap(); + + let blobs = db.delete_ops_for_uid("local~dir").unwrap(); + assert_eq!( + blobs, + vec!["/staging/deep"], + "the subtree's bytes come back" + ); + + let left: Vec = db + .pending_ops() + .unwrap() + .into_iter() + .map(|o| o.uid) + .collect(); + assert_eq!(left, vec!["local~other"]); +} + +/// A rename is the node's desired end state, so the newest one is the only +/// one worth performing — but it must not disturb the queued *upload* of the +/// same node, which is unrelated work. +#[test] +fn a_second_rename_supersedes_the_first_but_leaves_the_upload_alone() { + let db = Db::open_in_memory().unwrap(); + let rename = |name: &str| PendingOp { + id: 0, + kind: OP_RENAME.to_string(), + uid: uid("a").to_string(), + parent_uid: Some(uid("parent").to_string()), + name: Some(name.to_string()), + blob_path: None, + meta_json: None, + created_at: 1, + attempts: 0, + last_error: None, + next_attempt_at: 0, + }; + db.enqueue_op(&PendingOp { + id: 0, + kind: OP_REVISION.to_string(), + uid: uid("a").to_string(), + parent_uid: None, + name: None, + blob_path: Some("/staging/blob".to_string()), + meta_json: Some("{}".to_string()), + created_at: 1, + attempts: 0, + last_error: None, + next_attempt_at: 0, + }) + .unwrap(); + + db.enqueue_op(&rename("first")).unwrap(); + let (_, superseded) = db.enqueue_op(&rename("second")).unwrap(); + assert_eq!(superseded, None, "a rename owns no blob to clean up"); + + let ops = db.pending_ops().unwrap(); + assert_eq!(ops.len(), 2, "the queued upload survives the rename"); + let renames: Vec<_> = ops.iter().filter(|o| o.kind == OP_RENAME).collect(); + assert_eq!(renames.len(), 1, "one rename per node"); + assert_eq!(renames[0].name.as_deref(), Some("second")); + + let counts = db.pending_op_counts().unwrap(); + assert_eq!(counts.uploads, 1, "the revision is the only upload"); + assert_eq!(counts.changes, 1, "the rename carries no bytes"); +} + +/// Renaming a node whose create has not drained rewrites the intent rather +/// than queueing a rename against a uid the server has never issued. +#[test] +fn renaming_a_queued_create_rewrites_its_target() { + let db = Db::open_in_memory().unwrap(); + let local = "local~abc"; + db.enqueue_op(&PendingOp { + id: 0, + kind: OP_CREATE.to_string(), + uid: local.to_string(), + parent_uid: Some(uid("old").to_string()), + name: Some("draft.txt".to_string()), + blob_path: Some("/staging/blob".to_string()), + meta_json: Some("{}".to_string()), + created_at: 1, + attempts: 0, + last_error: None, + next_attempt_at: 0, + }) + .unwrap(); + + let rewritten = db + .rewrite_op_target(local, &uid("new").to_string(), "final.txt") + .unwrap(); + assert!(rewritten); + + let ops = db.pending_ops().unwrap(); + assert_eq!(ops.len(), 1, "a rewrite is not a second op"); + assert_eq!(ops[0].name.as_deref(), Some("final.txt")); + assert_eq!( + ops[0].parent_uid.as_deref(), + Some(uid("new").to_string()).as_deref() + ); + assert_eq!( + ops[0].blob_path.as_deref(), + Some("/staging/blob"), + "the bytes riding on the create are untouched" + ); + + // Once the create has drained there is no intent left to rewrite, and the + // caller has to rename the real node instead. + db.delete_op(ops[0].id).unwrap(); + assert!( + !db.rewrite_op_target(local, &uid("new").to_string(), "final.txt") .unwrap() - .into_iter() - .map(|o| o.uid) - .collect(); - assert_eq!(left, vec!["local~other"]); - } + ); +} - /// A rename is the node's desired end state, so the newest one is the only - /// one worth performing — but it must not disturb the queued *upload* of the - /// same node, which is unrelated work. - #[test] - fn a_second_rename_supersedes_the_first_but_leaves_the_upload_alone() { - let db = Db::open_in_memory().unwrap(); - let rename = |name: &str| PendingOp { - id: 0, - kind: OP_RENAME.to_string(), - uid: uid("a").to_string(), - parent_uid: Some(uid("parent").to_string()), - name: Some(name.to_string()), - blob_path: None, - meta_json: None, - created_at: 1, - attempts: 0, - last_error: None, - next_attempt_at: 0, - }; - db.enqueue_op(&PendingOp { - id: 0, - kind: OP_REVISION.to_string(), - uid: uid("a").to_string(), - parent_uid: None, - name: None, - blob_path: Some("/staging/blob".to_string()), - meta_json: Some("{}".to_string()), - created_at: 1, - attempts: 0, - last_error: None, - next_attempt_at: 0, - }) +#[test] +fn a_write_folds_into_a_queued_create_instead_of_superseding_it() { + let db = Db::open_in_memory().unwrap(); + let local = "local~abc"; + db.enqueue_op(&PendingOp { + id: 0, + kind: OP_CREATE.to_string(), + uid: local.to_string(), + parent_uid: Some(uid("parent").to_string()), + name: Some("new.txt".to_string()), + blob_path: None, + meta_json: None, + created_at: 1, + attempts: 0, + last_error: None, + next_attempt_at: 0, + }) + .unwrap(); + + let first = db + .attach_blob_to_create(local, "/staging/first", "{}") + .unwrap() + .expect("create is still queued"); + assert_eq!(first.superseded, None); + + // Rewriting the file before it drains replaces the bytes but must leave + // the create itself alone: it is the only thing that will ever bring this + // file into existence remotely. + let second = db + .attach_blob_to_create(local, "/staging/second", "{}") + .unwrap() + .expect("create is still queued"); + assert_eq!(second.superseded.as_deref(), Some("/staging/first")); + + let ops = db.pending_ops().unwrap(); + assert_eq!(ops.len(), 1, "still exactly one create"); + assert_eq!(ops[0].kind, OP_CREATE); + assert_eq!(ops[0].blob_path.as_deref(), Some("/staging/second")); + assert_eq!(ops[0].name.as_deref(), Some("new.txt")); +} + +#[test] +fn attaching_to_an_already_drained_create_reports_it_is_gone() { + let db = Db::open_in_memory().unwrap(); + // No create queued: the caller must fall back to a revision op rather + // than silently dropping the bytes. + let out = db + .attach_blob_to_create("local~gone", "/staging/x", "{}") + .unwrap(); + assert!(out.is_none()); +} + +#[test] +fn draining_a_folder_repoints_its_queued_children() { + let db = Db::open_in_memory().unwrap(); + let local_dir = "local~dir"; + let real_dir = uid("realdir").to_string(); + db.enqueue_op(&PendingOp { + id: 0, + kind: OP_CREATE.to_string(), + uid: "local~child".to_string(), + parent_uid: Some(local_dir.to_string()), + name: Some("inside.txt".to_string()), + blob_path: Some("/staging/child".to_string()), + meta_json: Some("{}".to_string()), + created_at: 2, + attempts: 0, + last_error: None, + next_attempt_at: 0, + }) + .unwrap(); + + db.remap_local_uid(local_dir, &real_dir).unwrap(); + + // The child was queued against a folder that did not exist yet. Once the + // folder is real, the child must target the server's uid — otherwise the + // upload would address `local~dir` and 404. + let ops = db.pending_ops().unwrap(); + assert_eq!(ops[0].parent_uid.as_deref(), Some(real_dir.as_str())); +} + +#[test] +fn a_failed_op_stays_queued_with_backoff() { + let db = Db::open_in_memory().unwrap(); + db.enqueue_op(&PendingOp { + id: 0, + kind: OP_REVISION.to_string(), + uid: uid("a").to_string(), + parent_uid: None, + name: None, + blob_path: Some("/staging/blob".to_string()), + meta_json: Some("{}".to_string()), + created_at: 1, + attempts: 0, + last_error: None, + next_attempt_at: 0, + }) + .unwrap(); + let id = db.pending_ops().unwrap()[0].id; + + db.record_op_failure(id, "network unreachable", 5_000) .unwrap(); - db.enqueue_op(&rename("first")).unwrap(); - let (_, superseded) = db.enqueue_op(&rename("second")).unwrap(); - assert_eq!(superseded, None, "a rename owns no blob to clean up"); + // The staged blob is the only copy of the user's bytes: a failure must + // never drop the row, only defer it. + let ops = db.pending_ops().unwrap(); + assert_eq!(ops.len(), 1); + assert_eq!(ops[0].attempts, 1); + assert_eq!(ops[0].last_error.as_deref(), Some("network unreachable")); + assert_eq!(ops[0].next_attempt_at, 5_000); - let ops = db.pending_ops().unwrap(); - assert_eq!(ops.len(), 2, "the queued upload survives the rename"); - let renames: Vec<_> = ops.iter().filter(|o| o.kind == OP_RENAME).collect(); - assert_eq!(renames.len(), 1, "one rename per node"); - assert_eq!(renames[0].name.as_deref(), Some("second")); + db.record_op_failure(id, "still down", 9_000).unwrap(); + assert_eq!(db.pending_ops().unwrap()[0].attempts, 2); - let counts = db.pending_op_counts().unwrap(); - assert_eq!(counts.uploads, 1, "the revision is the only upload"); - assert_eq!(counts.changes, 1, "the rename carries no bytes"); - } + db.delete_op(id).unwrap(); + assert_eq!(db.pending_op_counts().unwrap().uploads, 0); +} - /// Renaming a node whose create has not drained rewrites the intent rather - /// than queueing a rename against a uid the server has never issued. - #[test] - fn renaming_a_queued_create_rewrites_its_target() { - let db = Db::open_in_memory().unwrap(); - let local = "local~abc"; - db.enqueue_op(&PendingOp { - id: 0, - kind: OP_CREATE.to_string(), - uid: local.to_string(), - parent_uid: Some(uid("old").to_string()), - name: Some("draft.txt".to_string()), - blob_path: Some("/staging/blob".to_string()), - meta_json: Some("{}".to_string()), - created_at: 1, - attempts: 0, - last_error: None, - next_attempt_at: 0, - }) +#[test] +fn migrate_is_idempotent() { + let db = Db::open_in_memory().unwrap(); + // Second migrate is a no-op (already at head) and must not error. + db.migrate().unwrap(); +} + +/// A queued mode switch is a promise the daemon has to keep across a restart, +/// so it lives in the row, and reaching the mode is what retires it. +#[test] +fn pending_mode_is_queued_until_the_mode_is_reached() { + let db = Db::open_in_memory().unwrap(); + let id = db + .sync_folder_add("/home/me/Downloads", "v~l", "s") .unwrap(); + assert_eq!(db.sync_folder_get(id).unwrap().unwrap().pending_mode, None); - let rewritten = db - .rewrite_op_target(local, &uid("new").to_string(), "final.txt") - .unwrap(); - assert!(rewritten); - - let ops = db.pending_ops().unwrap(); - assert_eq!(ops.len(), 1, "a rewrite is not a second op"); - assert_eq!(ops[0].name.as_deref(), Some("final.txt")); - assert_eq!( - ops[0].parent_uid.as_deref(), - Some(uid("new").to_string()).as_deref() - ); - assert_eq!( - ops[0].blob_path.as_deref(), - Some("/staging/blob"), - "the bytes riding on the create are untouched" - ); - - // Once the create has drained there is no intent left to rewrite, and the - // caller has to rename the real node instead. - db.delete_op(ops[0].id).unwrap(); - assert!( - !db.rewrite_op_target(local, &uid("new").to_string(), "final.txt") - .unwrap() - ); - } - - #[test] - fn a_write_folds_into_a_queued_create_instead_of_superseding_it() { - let db = Db::open_in_memory().unwrap(); - let local = "local~abc"; - db.enqueue_op(&PendingOp { - id: 0, - kind: OP_CREATE.to_string(), - uid: local.to_string(), - parent_uid: Some(uid("parent").to_string()), - name: Some("new.txt".to_string()), - blob_path: None, - meta_json: None, - created_at: 1, - attempts: 0, - last_error: None, - next_attempt_at: 0, - }) + db.sync_folder_set_pending_mode(id, Some("ondemand")) .unwrap(); - - let first = db - .attach_blob_to_create(local, "/staging/first", "{}") + assert_eq!( + db.sync_folder_get(id) .unwrap() - .expect("create is still queued"); - assert_eq!(first.superseded, None); - - // Rewriting the file before it drains replaces the bytes but must leave - // the create itself alone: it is the only thing that will ever bring this - // file into existence remotely. - let second = db - .attach_blob_to_create(local, "/staging/second", "{}") .unwrap() - .expect("create is still queued"); - assert_eq!(second.superseded.as_deref(), Some("/staging/first")); + .pending_mode + .as_deref(), + Some("ondemand") + ); + // The listing carries it too — it is what the front-ends paint from. + assert_eq!( + db.sync_folder_list().unwrap()[0].pending_mode.as_deref(), + Some("ondemand") + ); - let ops = db.pending_ops().unwrap(); - assert_eq!(ops.len(), 1, "still exactly one create"); - assert_eq!(ops[0].kind, OP_CREATE); - assert_eq!(ops[0].blob_path.as_deref(), Some("/staging/second")); - assert_eq!(ops[0].name.as_deref(), Some("new.txt")); - } + // Landing the switch satisfies the request: a `pending_mode` outliving it + // would have the engine try to apply the same switch on every later pass. + db.sync_folder_set_mode(id, "ondemand").unwrap(); + let folder = db.sync_folder_get(id).unwrap().unwrap(); + assert_eq!(folder.mode, "ondemand"); + assert_eq!(folder.pending_mode, None); - #[test] - fn attaching_to_an_already_drained_create_reports_it_is_gone() { - let db = Db::open_in_memory().unwrap(); - // No create queued: the caller must fall back to a revision op rather - // than silently dropping the bytes. - let out = db - .attach_blob_to_create("local~gone", "/staging/x", "{}") - .unwrap(); - assert!(out.is_none()); - } + // And the user can withdraw a request that hasn't landed yet. + db.sync_folder_set_pending_mode(id, Some("mirror")).unwrap(); + db.sync_folder_set_pending_mode(id, None).unwrap(); + let folder = db.sync_folder_get(id).unwrap().unwrap(); + assert_eq!(folder.mode, "ondemand"); + assert_eq!(folder.pending_mode, None); +} - #[test] - fn draining_a_folder_repoints_its_queued_children() { - let db = Db::open_in_memory().unwrap(); - let local_dir = "local~dir"; - let real_dir = uid("realdir").to_string(); - db.enqueue_op(&PendingOp { - id: 0, - kind: OP_CREATE.to_string(), - uid: "local~child".to_string(), - parent_uid: Some(local_dir.to_string()), - name: Some("inside.txt".to_string()), - blob_path: Some("/staging/child".to_string()), - meta_json: Some("{}".to_string()), - created_at: 2, - attempts: 0, - last_error: None, - next_attempt_at: 0, - }) +#[test] +fn cache_index_touch_access_and_lru_order() { + let db = Db::open_in_memory().unwrap(); + db.cache_touch("k1", "blob", 100, 10).unwrap(); + db.cache_touch("k2", "blob", 200, 20).unwrap(); + // LRU-first: k1 (older access) before k2. + let rows = db.cache_entries_by_kind("blob").unwrap(); + assert_eq!(rows, vec![("k1".into(), 100), ("k2".into(), 200)]); + + // Accessing k1 moves it to the back (most recent). + db.cache_accessed("k1", 30).unwrap(); + let rows = db.cache_entries_by_kind("blob").unwrap(); + assert_eq!(rows[0].0, "k2"); + assert_eq!(rows[1].0, "k1"); + + // Re-touch updates size, not just time. + db.cache_touch("k1", "blob", 150, 40).unwrap(); + let rows = db.cache_entries_by_kind("blob").unwrap(); + assert_eq!(rows.iter().find(|(k, _)| k == "k1").unwrap().1, 150); +} + +#[test] +fn cache_index_kinds_are_separate() { + let db = Db::open_in_memory().unwrap(); + db.cache_touch("blob1", "blob", 100, 1).unwrap(); + db.cache_touch("blk1.b0", "block", 50, 1).unwrap(); + assert_eq!(db.cache_entries_by_kind("blob").unwrap().len(), 1); + assert_eq!(db.cache_entries_by_kind("block").unwrap().len(), 1); +} + +#[test] +fn cache_index_remove_and_remove_all() { + let db = Db::open_in_memory().unwrap(); + // A blob plus two of its blocks (key prefix shared). + db.cache_touch("abc", "blob", 1, 1).unwrap(); + db.cache_touch("abc.b0", "block", 1, 1).unwrap(); + db.cache_touch("abc.b1", "block", 1, 1).unwrap(); + // An unrelated entry that must survive. + db.cache_touch("xyz", "blob", 1, 1).unwrap(); + + db.cache_remove("abc.b0").unwrap(); + assert_eq!(db.cache_entries_by_kind("block").unwrap().len(), 1); + + // remove_all drops the blob row and every remaining block of that uid. + db.cache_remove_all("abc").unwrap(); + assert!(db.cache_entries_by_kind("block").unwrap().is_empty()); + let blobs = db.cache_entries_by_kind("blob").unwrap(); + assert_eq!(blobs, vec![("xyz".into(), 1)]); +} + +/// A rebuild is a replacement, not a merge: whatever the index said before +/// is what a stale or externally-deleted cache file would leave behind. +#[test] +fn cache_index_rebuild_replaces_every_row() { + let db = Db::open_in_memory().unwrap(); + db.cache_touch("gone", "blob", 1, 1).unwrap(); + db.cache_touch("also-gone", "block", 1, 1).unwrap(); + + db.cache_rebuild(&[CacheEntryInput { + key: "kept", + kind: "blob", + size: 7, + last_accessed: 42, + }]) + .unwrap(); + + assert_eq!( + db.cache_entries_by_kind("blob").unwrap(), + vec![("kept".to_string(), 7)] + ); + assert!(db.cache_entries_by_kind("block").unwrap().is_empty()); + + // An empty rebuild is how a cache directory that vanished reports itself. + db.cache_rebuild(&[]).unwrap(); + assert!(db.cache_entries_by_kind("blob").unwrap().is_empty()); +} + +#[test] +fn pin_add_list_remove_roundtrip() { + let db = Db::open_in_memory().unwrap(); + db.pin_add("vol~a", "docs/a.txt", false).unwrap(); + db.pin_add("vol~d", "docs", true).unwrap(); + let list = db.pin_list().unwrap(); + assert_eq!(list.len(), 2); + assert_eq!(list[0], ("vol~a".into(), "docs/a.txt".into(), false)); + assert_eq!(list[1], ("vol~d".into(), "docs".into(), true)); + + // Re-pin refreshes path/flag, not a duplicate row. + db.pin_add("vol~a", "moved/a.txt", false).unwrap(); + let list = db.pin_list().unwrap(); + assert_eq!(list.len(), 2); + assert_eq!(list[0].1, "moved/a.txt"); + + assert!(db.pin_remove("vol~a").unwrap()); + assert!(!db.pin_remove("vol~a").unwrap()); + assert_eq!(db.pin_list().unwrap().len(), 1); +} + +#[test] +fn is_pinned_direct_without_node_row() { + // A direct pin counts even when the node was never hydrated into `nodes`. + let db = Db::open_in_memory().unwrap(); + db.pin_add("vol~a", "a.txt", false).unwrap(); + assert!(db.is_pinned("vol~a").unwrap()); + assert!(!db.is_pinned("vol~b").unwrap()); +} + +#[test] +fn recursive_folder_pin_covers_subtree() { + let db = Db::open_in_memory().unwrap(); + // root/docs/{report.pdf, sub/deep.txt}, root/loose.txt + db.upsert_node(&folder("root", None, "My Files")).unwrap(); + db.upsert_node(&folder("docs", Some("root"), "Documents")) + .unwrap(); + db.upsert_node(&file("rep", "docs", "report.pdf", 1)) + .unwrap(); + db.upsert_node(&folder("sub", Some("docs"), "Sub")).unwrap(); + db.upsert_node(&file("deep", "sub", "deep.txt", 1)).unwrap(); + db.upsert_node(&file("loose", "root", "loose.txt", 1)) .unwrap(); - db.remap_local_uid(local_dir, &real_dir).unwrap(); + // Pin the Documents folder recursively (uids are `vol~link` display form). + let du = |l: &str| uid(l).to_string(); + db.pin_add(&du("docs"), "Documents", true).unwrap(); - // The child was queued against a folder that did not exist yet. Once the - // folder is real, the child must target the server's uid — otherwise the - // upload would address `local~dir` and 404. - let ops = db.pending_ops().unwrap(); - assert_eq!(ops[0].parent_uid.as_deref(), Some(real_dir.as_str())); - } + // Everything under docs (any depth) is pinned; loose.txt is not. + assert!(db.is_pinned(&du("docs")).unwrap()); + assert!(db.is_pinned(&du("rep")).unwrap()); + assert!(db.is_pinned(&du("deep")).unwrap()); + assert!(!db.is_pinned(&du("loose")).unwrap()); - #[test] - fn a_failed_op_stays_queued_with_backoff() { - let db = Db::open_in_memory().unwrap(); - db.enqueue_op(&PendingOp { - id: 0, - kind: OP_REVISION.to_string(), - uid: uid("a").to_string(), - parent_uid: None, - name: None, - blob_path: Some("/staging/blob".to_string()), - meta_json: Some("{}".to_string()), - created_at: 1, - attempts: 0, - last_error: None, - next_attempt_at: 0, - }) + // pinned_uids expands the subtree (folder + descendants), no loose.txt. + let mut got = db.pinned_uids().unwrap(); + got.sort(); + assert_eq!(got, vec![du("deep"), du("docs"), du("rep"), du("sub")]); + + // descendants() lists the subtree of a folder (excludes the folder). + let mut desc = db.descendants(&du("docs")).unwrap(); + desc.sort(); + assert_eq!(desc, vec![du("deep"), du("rep"), du("sub")]); +} + +#[test] +fn non_recursive_folder_pin_does_not_cover_children() { + let db = Db::open_in_memory().unwrap(); + db.upsert_node(&folder("root", None, "My Files")).unwrap(); + db.upsert_node(&folder("docs", Some("root"), "Documents")) .unwrap(); - let id = db.pending_ops().unwrap()[0].id; - - db.record_op_failure(id, "network unreachable", 5_000) - .unwrap(); - - // The staged blob is the only copy of the user's bytes: a failure must - // never drop the row, only defer it. - let ops = db.pending_ops().unwrap(); - assert_eq!(ops.len(), 1); - assert_eq!(ops[0].attempts, 1); - assert_eq!(ops[0].last_error.as_deref(), Some("network unreachable")); - assert_eq!(ops[0].next_attempt_at, 5_000); - - db.record_op_failure(id, "still down", 9_000).unwrap(); - assert_eq!(db.pending_ops().unwrap()[0].attempts, 2); - - db.delete_op(id).unwrap(); - assert_eq!(db.pending_op_counts().unwrap().uploads, 0); - } - - #[test] - fn migrate_is_idempotent() { - let db = Db::open_in_memory().unwrap(); - // Second migrate is a no-op (already at head) and must not error. - db.migrate().unwrap(); - } - - /// A queued mode switch is a promise the daemon has to keep across a restart, - /// so it lives in the row, and reaching the mode is what retires it. - #[test] - fn pending_mode_is_queued_until_the_mode_is_reached() { - let db = Db::open_in_memory().unwrap(); - let id = db - .sync_folder_add("/home/me/Downloads", "v~l", "s") - .unwrap(); - assert_eq!(db.sync_folder_get(id).unwrap().unwrap().pending_mode, None); - - db.sync_folder_set_pending_mode(id, Some("ondemand")) - .unwrap(); - assert_eq!( - db.sync_folder_get(id) - .unwrap() - .unwrap() - .pending_mode - .as_deref(), - Some("ondemand") - ); - // The listing carries it too — it is what the front-ends paint from. - assert_eq!( - db.sync_folder_list().unwrap()[0].pending_mode.as_deref(), - Some("ondemand") - ); - - // Landing the switch satisfies the request: a `pending_mode` outliving it - // would have the engine try to apply the same switch on every later pass. - db.sync_folder_set_mode(id, "ondemand").unwrap(); - let folder = db.sync_folder_get(id).unwrap().unwrap(); - assert_eq!(folder.mode, "ondemand"); - assert_eq!(folder.pending_mode, None); - - // And the user can withdraw a request that hasn't landed yet. - db.sync_folder_set_pending_mode(id, Some("mirror")).unwrap(); - db.sync_folder_set_pending_mode(id, None).unwrap(); - let folder = db.sync_folder_get(id).unwrap().unwrap(); - assert_eq!(folder.mode, "ondemand"); - assert_eq!(folder.pending_mode, None); - } - - #[test] - fn cache_index_touch_access_and_lru_order() { - let db = Db::open_in_memory().unwrap(); - db.cache_touch("k1", "blob", 100, 10).unwrap(); - db.cache_touch("k2", "blob", 200, 20).unwrap(); - // LRU-first: k1 (older access) before k2. - let rows = db.cache_entries_by_kind("blob").unwrap(); - assert_eq!(rows, vec![("k1".into(), 100), ("k2".into(), 200)]); - - // Accessing k1 moves it to the back (most recent). - db.cache_accessed("k1", 30).unwrap(); - let rows = db.cache_entries_by_kind("blob").unwrap(); - assert_eq!(rows[0].0, "k2"); - assert_eq!(rows[1].0, "k1"); - - // Re-touch updates size, not just time. - db.cache_touch("k1", "blob", 150, 40).unwrap(); - let rows = db.cache_entries_by_kind("blob").unwrap(); - assert_eq!(rows.iter().find(|(k, _)| k == "k1").unwrap().1, 150); - } - - #[test] - fn cache_index_kinds_are_separate() { - let db = Db::open_in_memory().unwrap(); - db.cache_touch("blob1", "blob", 100, 1).unwrap(); - db.cache_touch("blk1.b0", "block", 50, 1).unwrap(); - assert_eq!(db.cache_entries_by_kind("blob").unwrap().len(), 1); - assert_eq!(db.cache_entries_by_kind("block").unwrap().len(), 1); - } - - #[test] - fn cache_index_remove_and_remove_all() { - let db = Db::open_in_memory().unwrap(); - // A blob plus two of its blocks (key prefix shared). - db.cache_touch("abc", "blob", 1, 1).unwrap(); - db.cache_touch("abc.b0", "block", 1, 1).unwrap(); - db.cache_touch("abc.b1", "block", 1, 1).unwrap(); - // An unrelated entry that must survive. - db.cache_touch("xyz", "blob", 1, 1).unwrap(); - - db.cache_remove("abc.b0").unwrap(); - assert_eq!(db.cache_entries_by_kind("block").unwrap().len(), 1); - - // remove_all drops the blob row and every remaining block of that uid. - db.cache_remove_all("abc").unwrap(); - assert!(db.cache_entries_by_kind("block").unwrap().is_empty()); - let blobs = db.cache_entries_by_kind("blob").unwrap(); - assert_eq!(blobs, vec![("xyz".into(), 1)]); - } - - /// A rebuild is a replacement, not a merge: whatever the index said before - /// is what a stale or externally-deleted cache file would leave behind. - #[test] - fn cache_index_rebuild_replaces_every_row() { - let db = Db::open_in_memory().unwrap(); - db.cache_touch("gone", "blob", 1, 1).unwrap(); - db.cache_touch("also-gone", "block", 1, 1).unwrap(); - - db.cache_rebuild(&[CacheEntryInput { - key: "kept", - kind: "blob", - size: 7, - last_accessed: 42, - }]) + db.upsert_node(&file("rep", "docs", "report.pdf", 1)) .unwrap(); + // A non-recursive pin on the folder covers only the folder itself. + let du = |l: &str| uid(l).to_string(); + db.pin_add(&du("docs"), "Documents", false).unwrap(); + assert!(db.is_pinned(&du("docs")).unwrap()); + assert!(!db.is_pinned(&du("rep")).unwrap()); +} - assert_eq!( - db.cache_entries_by_kind("blob").unwrap(), - vec![("kept".to_string(), 7)] - ); - assert!(db.cache_entries_by_kind("block").unwrap().is_empty()); - - // An empty rebuild is how a cache directory that vanished reports itself. - db.cache_rebuild(&[]).unwrap(); - assert!(db.cache_entries_by_kind("blob").unwrap().is_empty()); - } - - #[test] - fn pin_add_list_remove_roundtrip() { - let db = Db::open_in_memory().unwrap(); - db.pin_add("vol~a", "docs/a.txt", false).unwrap(); - db.pin_add("vol~d", "docs", true).unwrap(); - let list = db.pin_list().unwrap(); - assert_eq!(list.len(), 2); - assert_eq!(list[0], ("vol~a".into(), "docs/a.txt".into(), false)); - assert_eq!(list[1], ("vol~d".into(), "docs".into(), true)); - - // Re-pin refreshes path/flag, not a duplicate row. - db.pin_add("vol~a", "moved/a.txt", false).unwrap(); - let list = db.pin_list().unwrap(); - assert_eq!(list.len(), 2); - assert_eq!(list[0].1, "moved/a.txt"); - - assert!(db.pin_remove("vol~a").unwrap()); - assert!(!db.pin_remove("vol~a").unwrap()); - assert_eq!(db.pin_list().unwrap().len(), 1); - } - - #[test] - fn is_pinned_direct_without_node_row() { - // A direct pin counts even when the node was never hydrated into `nodes`. - let db = Db::open_in_memory().unwrap(); - db.pin_add("vol~a", "a.txt", false).unwrap(); - assert!(db.is_pinned("vol~a").unwrap()); - assert!(!db.is_pinned("vol~b").unwrap()); - } - - #[test] - fn recursive_folder_pin_covers_subtree() { - let db = Db::open_in_memory().unwrap(); - // root/docs/{report.pdf, sub/deep.txt}, root/loose.txt - db.upsert_node(&folder("root", None, "My Files")).unwrap(); - db.upsert_node(&folder("docs", Some("root"), "Documents")) - .unwrap(); - db.upsert_node(&file("rep", "docs", "report.pdf", 1)) - .unwrap(); - db.upsert_node(&folder("sub", Some("docs"), "Sub")).unwrap(); - db.upsert_node(&file("deep", "sub", "deep.txt", 1)).unwrap(); - db.upsert_node(&file("loose", "root", "loose.txt", 1)) - .unwrap(); - - // Pin the Documents folder recursively (uids are `vol~link` display form). - let du = |l: &str| uid(l).to_string(); - db.pin_add(&du("docs"), "Documents", true).unwrap(); - - // Everything under docs (any depth) is pinned; loose.txt is not. - assert!(db.is_pinned(&du("docs")).unwrap()); - assert!(db.is_pinned(&du("rep")).unwrap()); - assert!(db.is_pinned(&du("deep")).unwrap()); - assert!(!db.is_pinned(&du("loose")).unwrap()); - - // pinned_uids expands the subtree (folder + descendants), no loose.txt. - let mut got = db.pinned_uids().unwrap(); - got.sort(); - assert_eq!(got, vec![du("deep"), du("docs"), du("rep"), du("sub")]); - - // descendants() lists the subtree of a folder (excludes the folder). - let mut desc = db.descendants(&du("docs")).unwrap(); - desc.sort(); - assert_eq!(desc, vec![du("deep"), du("rep"), du("sub")]); - } - - #[test] - fn non_recursive_folder_pin_does_not_cover_children() { - let db = Db::open_in_memory().unwrap(); - db.upsert_node(&folder("root", None, "My Files")).unwrap(); - db.upsert_node(&folder("docs", Some("root"), "Documents")) - .unwrap(); - db.upsert_node(&file("rep", "docs", "report.pdf", 1)) - .unwrap(); - // A non-recursive pin on the folder covers only the folder itself. - let du = |l: &str| uid(l).to_string(); - db.pin_add(&du("docs"), "Documents", false).unwrap(); - assert!(db.is_pinned(&du("docs")).unwrap()); - assert!(!db.is_pinned(&du("rep")).unwrap()); - } - - #[test] - fn schema_objects_exist() { - let db = Db::open_in_memory().unwrap(); - let count: i64 = db - .with_conn(|c| { - Ok(c.query_row( - "SELECT count(*) FROM sqlite_master +#[test] +fn schema_objects_exist() { + let db = Db::open_in_memory().unwrap(); + let count: i64 = db + .with_conn(|c| { + Ok(c.query_row( + "SELECT count(*) FROM sqlite_master WHERE name IN ('nodes', 'nodes_fts', 'cache_entries')", - [], - |r| r.get(0), - )?) - }) - .unwrap(); - assert_eq!(count, 3); - } + [], + |r| r.get(0), + )?) + }) + .unwrap(); + assert_eq!(count, 3); +} diff --git a/crates/pdfs-core/src/db/trash.rs b/crates/pdfs-core/src/db/trash.rs index e2f8ad7..1ff305f 100644 --- a/crates/pdfs-core/src/db/trash.rs +++ b/crates/pdfs-core/src/db/trash.rs @@ -61,5 +61,4 @@ impl Db { } // ---- device sync (devices.md) ----------------------------------------- - } diff --git a/crates/pdfs-core/src/error.rs b/crates/pdfs-core/src/error.rs index e998dae..8d1bfa3 100644 --- a/crates/pdfs-core/src/error.rs +++ b/crates/pdfs-core/src/error.rs @@ -93,6 +93,27 @@ impl CoreError { Self::new(ErrorKind::Internal, what) } + /// Classify a failure from an SDK call, prefixing `what` for the log. + /// + /// Takes `&dyn Error` rather than `&ProtonError` so it reads the same + /// through the boxes the drain deals in, where the concrete type survives + /// but the static one does not. + /// + /// A failure that is not an API response at all — a transport error, a + /// timeout, a TLS failure — is what being offline looks like from here, so + /// it classifies as [`ErrorKind::Offline`] rather than + /// [`ErrorKind::Remote`]: the request never reached Proton, and telling the + /// user their connection is down beats telling them the server refused. + pub fn from_api(e: &(dyn std::error::Error + 'static), what: &str) -> Self { + let kind = match e.downcast_ref::() { + Some(proton_sdk::ProtonError::Api(api)) => api_kind(api.code), + // A `ProtonError` that is not an `Api` variant never reached the + // API; so does anything that is not a `ProtonError` at all. + _ => ErrorKind::Offline, + }; + Self::new(kind, format!("{what}: {e}")) + } + /// Prefix the message while keeping the classification. /// /// For adding the caller's context to an already-classified failure — the @@ -134,12 +155,136 @@ impl From for CoreError { fn from(e: Error) -> Self { let kind = match &e { Error::NotLoggedIn | Error::ReloginRequired => ErrorKind::Denied, - Error::Proton(_) => ErrorKind::Remote, + Error::Proton(proton_sdk::ProtonError::Api(api)) => api_kind(api.code), + // A `ProtonError` that never reached the API is a transport failure. + Error::Proton(_) => ErrorKind::Offline, _ => ErrorKind::Internal, }; Self::new(kind, e.to_string()) } } +/// What an API response code means to a front-end. +/// +/// The grouping is by *what the user can do about it*, which is what +/// [`ErrorKind`] exists to express — not by HTTP-ish similarity. So a 403 and a +/// `NotEnoughPermissions` land together despite being different numbers, while +/// `AlreadyExists` and `DoesNotExist` stay apart despite both being 25xx. +fn api_kind(code: proton_sdk::api::ResponseCode) -> ErrorKind { + use proton_sdk::api::ResponseCode as C; + match code { + // The account may not do this, and repeating it will not help. + C::Unauthorized + | C::Forbidden + | C::NotEnoughPermissions + | C::NotEnoughPermissionsToGrantPermissions + | C::InsufficientScope + | C::IncorrectLoginCredentials + | C::InvalidRefreshToken + | C::AccountDeleted + | C::AccountDisabled + | C::NoActiveSubscription => ErrorKind::Denied, + + C::DoesNotExist => ErrorKind::NotFound, + + // The remote moved underneath the request; the caller has to decide. + C::AlreadyExists | C::IncompatibleState => ErrorKind::Conflict, + + // The request was malformed or asks for something structurally + // impossible. A bug in the caller, not a condition to retry. + C::InvalidRequirements + | C::InvalidValue + | C::InvalidEncryptedIdFormat + | C::InvalidApp + | C::TooManyChildren + | C::NestingTooDeep => ErrorKind::Invalid, + + C::InsufficientQuota | C::InsufficientSpace | C::InsufficientVolumeQuota => { + ErrorKind::Quota + } + + // Reached Proton, but it could not answer *now*. Retrying is exactly + // the right response, so these are Offline rather than Remote — the + // front-end already words that as "not right now". + C::RequestTimeout + | C::Timeout + | C::TooManyRequests + | C::ServiceUnavailable + | C::Offline => ErrorKind::Offline, + + // Reached Proton and it refused for a reason we have no specific + // handling for. Retryable, because an unchanged retry can legitimately + // succeed against a transient server-side condition. + _ => ErrorKind::Remote, + } +} + /// The result every request-serving method in the daemon returns. pub type CoreResult = std::result::Result; + +#[cfg(test)] +mod tests { + use super::*; + use proton_sdk::api::ResponseCode as C; + use proton_sdk::{ProtonApiError, ProtonError}; + + fn api(code: C) -> ProtonError { + ProtonError::Api(ProtonApiError { + code, + http_status: 422, + message: "nope".into(), + details: None, + }) + } + + #[test] + fn api_codes_classify_by_what_the_user_can_do() { + assert_eq!(api_kind(C::Forbidden), ErrorKind::Denied); + assert_eq!(api_kind(C::NotEnoughPermissions), ErrorKind::Denied); + assert_eq!(api_kind(C::DoesNotExist), ErrorKind::NotFound); + assert_eq!(api_kind(C::AlreadyExists), ErrorKind::Conflict); + assert_eq!(api_kind(C::InvalidValue), ErrorKind::Invalid); + assert_eq!(api_kind(C::InsufficientSpace), ErrorKind::Quota); + assert_eq!(api_kind(C::TooManyRequests), ErrorKind::Offline); + // Nothing specific known about it, but Proton did answer. + assert_eq!(api_kind(C::ProtonDriveUnknown), ErrorKind::Remote); + } + + /// The regression this whole pass exists to prevent: before it, every SDK + /// failure was hardcoded to `Remote`, so a 403 asked the user to retry a + /// request that could never succeed. + #[test] + fn a_forbidden_response_is_not_offered_as_retryable() { + let e = CoreError::from_api(&api(C::Forbidden), "rename"); + assert_eq!(e.kind, ErrorKind::Denied); + assert!(!e.kind.retryable()); + assert!(e.message.starts_with("rename: ")); + } + + /// Being full is not a transient condition, so retrying an upload that did + /// not fit must not be offered as a fix. + #[test] + fn running_out_of_space_is_not_retryable() { + assert!( + !CoreError::from_api(&api(C::InsufficientQuota), "upload") + .kind + .retryable() + ); + } + + /// A failure that never reached the API is the user's connection, not + /// Proton refusing — the front-end words those differently. + #[test] + fn a_transport_failure_reads_as_offline() { + let io = std::io::Error::other("connection reset"); + assert_eq!(CoreError::from_api(&io, "list").kind, ErrorKind::Offline); + } + + /// `Error` is the plumbing type; converting it must classify as sharply as + /// the direct path, or the two disagree about the same failure. + #[test] + fn converting_from_the_plumbing_error_classifies_the_api_code() { + let e: CoreError = Error::Proton(api(C::DoesNotExist)).into(); + assert_eq!(e.kind, ErrorKind::NotFound); + } +} diff --git a/crates/pdfs-fuse/src/control.rs b/crates/pdfs-fuse/src/control.rs index 97cd835..a1906e3 100644 --- a/crates/pdfs-fuse/src/control.rs +++ b/crates/pdfs-fuse/src/control.rs @@ -10,17 +10,17 @@ //! would stall the GUI`s 2s status poll behind it. use std::io::{BufRead, BufReader, Write as _}; -use std::sync::atomic::Ordering; -use std::time::Instant; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::{Path, PathBuf}; +use std::sync::atomic::Ordering; +use std::time::Instant; -use pdfs_core::{CoreError, CoreResult}; use pdfs_core::config::AppDirs; use pdfs_core::control::{ ActivityKind, PhotoMonth, RefreshScope, Request as CtlRequest, Response as CtlResponse, TransferDirection, }; +use pdfs_core::{CoreError, CoreResult}; use proton_drive_rs::proton_sdk::ids::NodeUid; use tracing::{info, warn}; @@ -125,30 +125,24 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U limit, kind, range, - }) => { - match core.photos_timeline(offset, limit, kind, range) { - Ok(Some(items)) => CtlResponse::Photos { - available: true, - items, - counts: core.db.photos_counts().ok(), - }, - Ok(None) => CtlResponse::Photos { - available: false, - items: Vec::new(), - counts: None, - }, - Err(e) => CtlResponse::error(e), - } - } + }) => match core.photos_timeline(offset, limit, kind, range) { + Ok(Some(items)) => CtlResponse::Photos { + available: true, + items, + counts: core.db.photos_counts().ok(), + }, + Ok(None) => CtlResponse::Photos { + available: false, + items: Vec::new(), + counts: None, + }, + Err(e) => CtlResponse::error(e), + }, Ok(CtlRequest::PhotoMonths { kind }) => match core.db.photos_months(kind) { Ok(months) => CtlResponse::PhotoMonths { months: months .into_iter() - .map(|(year, month, count)| PhotoMonth { - year, - month, - count, - }) + .map(|(year, month, count)| PhotoMonth { year, month, count }) .collect(), }, Err(e) => CtlResponse::error(e.into()), @@ -207,7 +201,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U message: format!("uploaded photo with uid {uid}"), } } - Err(e) => CtlResponse::error(CoreError::remote(format!("upload photo failed: {e}"))), + Err(e) => CtlResponse::error(CoreError::from_api(&e, "upload photo failed")), } } Ok(CtlRequest::OpenFile { path }) => match rel_to_mount(mountpoint, &path) { @@ -688,7 +682,12 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U /// stall every other caller behind it — the GUI's 2s status poll, and the /// thumbnail batches the gallery needs to paint. [`Core`] is a bundle of handles /// (`Arc`/`Clone`), so each connection gets its own copy of it. -pub(crate) fn run_control_socket(core: Core, username: String, mountpoint: PathBuf, listener: UnixListener) { +pub(crate) fn run_control_socket( + core: Core, + username: String, + mountpoint: PathBuf, + listener: UnixListener, +) { info!("control socket listening"); for conn in listener.incoming() { match conn { diff --git a/crates/pdfs-fuse/src/devices.rs b/crates/pdfs-fuse/src/devices.rs index 994501f..fc40995 100644 --- a/crates/pdfs-fuse/src/devices.rs +++ b/crates/pdfs-fuse/src/devices.rs @@ -11,20 +11,22 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; -use parking_lot::Mutex; -use pdfs_core::{CoreError, CoreResult}; -use pdfs_core::control::{ActivityKind, DeviceInfo, JobItem, SyncFolderInfo, SyncPhase, SyncProgress}; -use pdfs_core::db::{StoredDevice, StoredSyncFolder}; use fuser::{Config, MountOption, Session}; -use proton_drive_rs::{DeviceType, Node}; +use parking_lot::Mutex; +use pdfs_core::control::{ + ActivityKind, DeviceInfo, JobItem, SyncFolderInfo, SyncPhase, SyncProgress, +}; +use pdfs_core::db::{StoredDevice, StoredSyncFolder}; +use pdfs_core::{CoreError, CoreResult}; use proton_drive_rs::proton_sdk::ids::{DeviceUid, NodeUid}; +use proton_drive_rs::{DeviceType, Node}; use tracing::{info, warn}; -use super::{ - BackgroundSession, Core, ProtonFs, State, SwitchBlocked, clear_stale_mount, device_type_str, dir_is_empty, - evict_dir_contents, now_secs, parse_uid, sync_folder_info, this_hostname, -}; use super::sync::{self, base_name}; +use super::{ + BackgroundSession, Core, ProtonFs, State, SwitchBlocked, clear_stale_mount, device_type_str, + dir_is_empty, evict_dir_contents, now_secs, parse_uid, sync_folder_info, this_hostname, +}; impl Core { // ---- devices ---------------------------------------------------------- @@ -36,7 +38,7 @@ impl Core { let devices = self .rt .block_on(self.client.enumerate_devices()) - .map_err(|e| CoreError::remote(format!("list devices: {e}")))?; + .map_err(|e| CoreError::from_api(&e, "list devices"))?; // No cached device row yet means this machine syncs nothing, so none of // the listed devices is ours. let this_uid = self.db.device_get().ok().flatten().map(|d| d.uid); @@ -63,7 +65,7 @@ impl Core { let device_uid = DeviceUid::from(uid); self.rt .block_on(self.client.rename_device(&device_uid, name)) - .map_err(|e| CoreError::remote(format!("rename device: {e}")))?; + .map_err(|e| CoreError::from_api(&e, "rename device"))?; Ok(()) } @@ -72,7 +74,7 @@ impl Core { let device_uid = DeviceUid::from(uid); self.rt .block_on(self.client.delete_device(&device_uid)) - .map_err(|e| CoreError::remote(format!("delete device: {e}")))?; + .map_err(|e| CoreError::from_api(&e, "delete device"))?; Ok(()) } @@ -89,13 +91,17 @@ impl Core { let remote = self .rt .block_on(self.client.enumerate_devices()) - .map_err(|e| CoreError::remote(format!("enumerate devices: {e}")))?; + .map_err(|e| CoreError::from_api(&e, "enumerate devices"))?; // A cached device is only trustworthy if it still exists remotely. A // device deleted from another client (or the web UI) leaves a stale row // whose root folder is gone, so creating folders under it fails with // "parent node is not a folder". Re-register in that case. - if let Some(dev) = self.db.device_get().map_err(|e| CoreError::internal(format!("db: {e:?}")))? { + if let Some(dev) = self + .db + .device_get() + .map_err(|e| CoreError::internal(format!("db: {e:?}")))? + { if remote.iter().any(|d| d.uid.to_string() == dev.uid) { return Ok(dev); } @@ -118,7 +124,7 @@ impl Core { let d = self .rt .block_on(self.client.create_device(&name, DeviceType::Linux)) - .map_err(|e| CoreError::remote(format!("create device: {e}")))?; + .map_err(|e| CoreError::from_api(&e, "create device"))?; StoredDevice { uid: d.uid.to_string(), share_id: d.share_id.to_string(), @@ -128,7 +134,9 @@ impl Core { } } }; - self.db.device_set(&dev).map_err(|e| CoreError::internal(format!("db: {e:?}")))?; + self.db + .device_set(&dev) + .map_err(|e| CoreError::internal(format!("db: {e:?}")))?; Ok(dev) } @@ -142,14 +150,14 @@ impl Core { let uids = self .rt .block_on(self.client.enumerate_folder_children_node_uids(root_uid)) - .map_err(|e| CoreError::remote(format!("list device root: {e}")))?; + .map_err(|e| CoreError::from_api(&e, "list device root"))?; if uids.is_empty() { return Ok(None); } let nodes = self .rt .block_on(self.client.enumerate_nodes(&uids)) - .map_err(|e| CoreError::remote(format!("resolve device root children: {e}")))?; + .map_err(|e| CoreError::from_api(&e, "resolve device root children"))?; Ok(nodes .into_iter() .find(|n| n.is_folder() && !n.trashed && n.name == name) @@ -161,10 +169,13 @@ impl Core { /// tree into it once, and record the mapping. Phase 1 is a one-shot upload — /// the two-way engine (Phase 2) reconciles later changes. pub(crate) fn add_sync_folder(&self, local: &Path) -> CoreResult { - let meta = - std::fs::metadata(local).map_err(|e| CoreError::internal(format!("stat {}: {e}", local.display())))?; + let meta = std::fs::metadata(local) + .map_err(|e| CoreError::internal(format!("stat {}: {e}", local.display())))?; if !meta.is_dir() { - return Err(CoreError::invalid(format!("{} is not a directory", local.display()))); + return Err(CoreError::invalid(format!( + "{} is not a directory", + local.display() + ))); } let local = local .canonicalize() @@ -179,18 +190,24 @@ impl Core { .iter() .any(|f| f.local_path == local_str) { - return Err(CoreError::invalid(format!("{} is already synced", local.display()))); + return Err(CoreError::invalid(format!( + "{} is already synced", + local.display() + ))); } let name = local .file_name() .and_then(|n| n.to_str()) - .ok_or_else(|| CoreError::invalid(format!("unusable folder name: {}", local.display())))? + .ok_or_else(|| { + CoreError::invalid(format!("unusable folder name: {}", local.display())) + })? .to_string(); let device = self.ensure_device()?; - let root_uid = parse_uid(&device.root_uid) - .ok_or_else(|| CoreError::internal(format!("bad device root uid: {}", device.root_uid)))?; + let root_uid = parse_uid(&device.root_uid).ok_or_else(|| { + CoreError::internal(format!("bad device root uid: {}", device.root_uid)) + })?; // The synced folder's remote root: the folder under the device root named // after the local basename. Reuse an existing one rather than creating a @@ -210,7 +227,7 @@ impl Core { self.client .create_folder(&root_uid, &name, Some(now_secs())), ) - .map_err(|e| CoreError::remote(format!("create device folder {name}: {e}")))?, + .map_err(|e| CoreError::from_api(&e, &format!("create device folder {name}")))?, }; let id = self @@ -312,11 +329,7 @@ impl Core { /// The lock guarding sync-folder `id` against concurrent reconcile/mode-switch. pub(crate) fn sync_lock(&self, id: i64) -> Arc> { - self.sync_locks - .lock() - .entry(id) - .or_default() - .clone() + self.sync_locks.lock().entry(id).or_default().clone() } /// Remove a synced folder from the sync set. `delete_remote` also deletes its @@ -397,7 +410,9 @@ impl Core { /// it is about to evict ([`Core::push_pass`]). pub(crate) fn request_sync_folder_mode(&self, id: i64, mode: &str) -> CoreResult { if mode != "mirror" && mode != "ondemand" { - return Err(CoreError::invalid(format!("unknown mode {mode:?} (want mirror|ondemand)"))); + return Err(CoreError::invalid(format!( + "unknown mode {mode:?} (want mirror|ondemand)" + ))); } let folder = self .db @@ -493,7 +508,11 @@ impl Core { /// /// [`SwitchBlocked::NotNow`] means "not yet, try after a pass" and is never an /// error the user needs to see — callers queue on it. - pub(crate) fn apply_sync_folder_mode(&self, id: i64, mode: &str) -> Result { + pub(crate) fn apply_sync_folder_mode( + &self, + id: i64, + mode: &str, + ) -> Result { // Hold the folder's lock for the whole switch so no reconcile pass can be // running over the tree we are about to evict (or start while we mount over // it). A pass in flight holds the lock for its full duration, so `try_lock` @@ -588,7 +607,11 @@ impl Core { /// Spawn a secondary FUSE session rooted at `root` over `local` on a forked /// inode space. Clears any stale kernel mount first (a crashed run can leave /// one, which would fail the fresh mount with EBUSY). - pub(crate) fn spawn_ondemand_mount(&self, local: &Path, root: Node) -> CoreResult { + pub(crate) fn spawn_ondemand_mount( + &self, + local: &Path, + root: Node, + ) -> CoreResult { clear_stale_mount(local); let mut config = Config::default(); config.mount_options = vec![ @@ -695,5 +718,4 @@ impl Core { } } } - } diff --git a/crates/pdfs-fuse/src/drain.rs b/crates/pdfs-fuse/src/drain.rs index 1d07015..6768657 100644 --- a/crates/pdfs-fuse/src/drain.rs +++ b/crates/pdfs-fuse/src/drain.rs @@ -24,7 +24,6 @@ use std::fs::File; use std::path::{Path, PathBuf}; use std::sync::atomic::Ordering; -use pdfs_core::CoreError; use pdfs_core::cache::StagedWrite; use pdfs_core::control::{ActivityKind, TransferDirection}; use pdfs_core::db::{OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp}; @@ -253,7 +252,10 @@ impl Core { /// Make a node that so far exists only on this machine real, and adopt the /// uid the server gives it (offline.md Phase 3b). - pub(crate) fn drain_local_node(&self, op: &PendingOp) -> Result<(), Box> { + pub(crate) fn drain_local_node( + &self, + op: &PendingOp, + ) -> Result<(), Box> { let local = parse_node_uid(&op.uid).ok_or("pending op has an unparseable uid")?; let parent_str = op.parent_uid.as_deref().ok_or("create op has no parent")?; // `run_pending_drain` will not offer an op whose parent is still a @@ -395,7 +397,9 @@ impl Core { local: &NodeUid, real: &NodeUid, ) -> Result<(), Box> { - let node = self.fetch_node(real).map_err(|e| CoreError::remote(format!("{e:?}")))?; + let node = self + .fetch_node(real) + .map_err(|e| self.errno_error(e, "fetch node"))?; // Repoints queued children and node rows, and drops the placeholder row. self.db .remap_local_uid(&local.to_string(), &real.to_string())?; @@ -676,7 +680,7 @@ impl Core { meta.base_size, &written, ) - .map_err(|e| CoreError::remote(format!("gap-fill from base failed: {e:?}")))?; + .map_err(|e| self.errno_error(e, "gap-fill from base failed"))?; } let name = { @@ -766,5 +770,4 @@ impl Core { }; st.intern(parent, node); } - } diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index fc9f1a9..0ed7224 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -31,15 +31,15 @@ //! set, so a multi-GiB write never buffers in RAM and only the untouched //! remainder of the file is pulled from the remote — lazily, at commit. +use parking_lot::{Condvar, Mutex}; use std::collections::{HashMap, HashSet, VecDeque}; use std::ffi::OsStr; use std::fs::File; use std::os::unix::fs::FileExt; use std::os::unix::net::UnixListener; use std::path::{Component, Path, PathBuf}; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use parking_lot::{Condvar, Mutex}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use fuser::ReplyXattr; @@ -49,18 +49,18 @@ use fuser::{ RenameFlags, ReplyAttr, ReplyCreate, ReplyData, ReplyDirectory, ReplyEmpty, ReplyEntry, ReplyOpen, ReplyWrite, Request, Session, TimeOrNow, WriteFlags, }; -use pdfs_core::{CoreError, CoreResult}; use pdfs_core::cache::{BLOCK_SIZE, Baseline, ContentCache, StagedWrite}; use pdfs_core::config::AppDirs; use pdfs_core::control::{ - ActivityEntry, ActivityKind, DirEntry, - LocalHit, PhotoKind, PublicLinkInfo, SearchHit, SyncFolderInfo, - SyncPhase, SyncProgress, TransferDirection, + ActivityEntry, ActivityKind, DirEntry, ErrorKind, LocalHit, PhotoKind, PublicLinkInfo, + SearchHit, SyncFolderInfo, SyncPhase, SyncProgress, TransferDirection, }; use pdfs_core::db::{ - Db, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp, StoredNode, StoredSyncFolder, StoredTrash, + Db, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp, StoredNode, + StoredSyncFolder, StoredTrash, }; use pdfs_core::localindex; +use pdfs_core::{CoreError, CoreResult}; use proton_drive_rs::proton_sdk::api::ResponseCode; use proton_drive_rs::proton_sdk::error::ProtonError; use proton_drive_rs::proton_sdk::ids::{DriveEventId, LinkId, NodeUid, VolumeId}; @@ -81,8 +81,8 @@ mod transfers; mod workers; use state::{Entry, Intervals, PendingRevision, State, WriteHandle}; use tracing::{debug, error, info, warn}; -use workers::{FUSE_WORKERS, Workers}; use transfers::{CountingReader, CountingWriter, JobGuard, OwnedCountingReader, TransferRegistry}; +use workers::{FUSE_WORKERS, Workers}; /// Attribute/entry cache lifetime handed back to the kernel. Long because the /// Phase 2 event poller actively invalidates changed inodes; without a remote @@ -284,7 +284,6 @@ async fn run_uploads( /// (two API calls and a node-key unlock) the next time that file is read. const MAX_OPEN_READERS: usize = 64; - /// An open reader plus the node metadata it was opened against. /// /// The SDK pins a reader to the revision that was active at `open_revision`, so @@ -828,23 +827,34 @@ impl Core { /// API is down" want different copy and different buttons. This is the one /// place that knows enough to tell them apart, so it is the place that does. fn resolve(&self, rel: &Path) -> CoreResult<(u64, NodeUid)> { - self.resolve_path(rel).map_err(|e| { - let what = rel.display(); - // `Errno` is neither `PartialEq` nor structural-match, so this - // compares the raw codes rather than the constants. - match e.code() { - libc::ENOENT => CoreError::not_found(format!("no such file or folder: {what}")), - libc::EACCES | libc::EPERM => { - CoreError::denied(format!("not allowed to read {what}")) - } - // The resolver walks the tree lazily, so a cold path needs the - // API. Offline that surfaces as EIO, which on its own would read - // to the user as a broken file rather than a missing network. - libc::EIO if !self.online.load(Ordering::Relaxed) => CoreError::offline(), - libc::EINVAL => CoreError::invalid(format!("not a usable path: {what}")), - _ => CoreError::remote(format!("could not resolve {what}: {e:?}")), - } - }) + self.resolve_path(rel) + .map_err(|e| self.errno_error(e, &format!("could not resolve {}", rel.display()))) + } + + /// Classify a failure that arrived as an `Errno`. + /// + /// The internal paths speak `Errno` because they also serve FUSE, where a + /// number is the whole vocabulary. Everything crossing the control socket + /// has to be turned back into something a person can read, and this is the + /// one place that knows how — a call site holding only an `Errno` has + /// already lost the context needed to say what went wrong. + /// + /// `Errno` is neither `PartialEq` nor structural-match, so this compares + /// raw codes rather than the `libc` constants. + fn errno_error(&self, e: Errno, what: &str) -> CoreError { + match e.code() { + libc::ENOENT => CoreError::not_found(format!("{what}: no such file or folder")), + libc::EACCES | libc::EPERM => CoreError::denied(format!("{what}: not allowed")), + // These paths walk the tree lazily, so a cold node needs the API. + // Offline that surfaces as EIO, which on its own would read to the + // user as a broken file rather than a missing network. + libc::EIO if !self.online.load(Ordering::Relaxed) => CoreError::offline(), + libc::EINVAL => CoreError::invalid(format!("{what}: not a usable path")), + libc::ENOSPC => CoreError::new(ErrorKind::Quota, format!("{what}: out of space")), + libc::EEXIST => CoreError::conflict(format!("{what}: already exists")), + libc::ENOTEMPTY => CoreError::conflict(format!("{what}: folder is not empty")), + _ => CoreError::internal(format!("{what}: {e:?}")), + } } /// Fetch a single node's current metadata from the remote. @@ -1155,13 +1165,11 @@ impl Core { while let Some(joined) = set.join_next().await { let Ok((bidx, read)) = joined else { continue }; match read { - Ok(bytes) => core.stream_ring.lock().insert( - &uid, - mtime, - fsize, - bidx, - Arc::new(bytes), - ), + Ok(bytes) => { + core.stream_ring + .lock() + .insert(&uid, mtime, fsize, bidx, Arc::new(bytes)) + } Err(e) => debug!(%uid, bidx, error = %e, "stream readahead failed"), } } @@ -1207,11 +1215,11 @@ impl Core { // hold them) and before the network (which would refetch the same // 4 MiB block for every 128 KiB the player consumes). let hit = if cache_blocks { - self.cache.cached_block(uid, mtime, fsize, bidx).map(Arc::new) + self.cache + .cached_block(uid, mtime, fsize, bidx) + .map(Arc::new) } else { - self.stream_ring - .lock() - .get(uid, mtime, fsize, bidx) + self.stream_ring.lock().get(uid, mtime, fsize, bidx) }; match hit { Some(b) => blocks.push(Some(b)), @@ -1843,7 +1851,10 @@ impl Core { let (ino, uid) = self.resolve(rel)?; let (name, is_folder, mtime, size) = { let st = self.state.lock(); - let e = st.entries.get(&ino).ok_or_else(|| CoreError::not_found("node vanished"))?; + let e = st + .entries + .get(&ino) + .ok_or_else(|| CoreError::not_found("node vanished"))?; ( e.node.name.clone(), e.node.is_folder(), @@ -1856,19 +1867,19 @@ impl Core { // exempt before we start filling the cache with the subtree. self.cache .add_pin(&uid, rel, true) - .map_err(|e| CoreError::remote(format!("pin: {e}")))?; + .map_err(|e| CoreError::from_api(&e, "pin"))?; let n = self.pin_subtree(ino)?; return Ok(format!("{name} ({n} files)")); } let bytes = self .download_file_tracked(&uid, &name, size) - .map_err(|e| CoreError::remote(format!("download: {e}")))?; + .map_err(|e| CoreError::from_api(&e, "download"))?; self.cache .store(&uid, mtime, size, &bytes) .map_err(|e| CoreError::internal(format!("cache store: {e}")))?; self.cache .add_pin(&uid, rel, false) - .map_err(|e| CoreError::remote(format!("pin: {e}")))?; + .map_err(|e| CoreError::from_api(&e, "pin"))?; Ok(name) } @@ -1881,7 +1892,7 @@ impl Core { let mut stack = vec![ino]; while let Some(dir) = stack.pop() { self.ensure_children(dir) - .map_err(|e| CoreError::remote(format!("enumerate: {e:?}")))?; + .map_err(|e| self.errno_error(e, "enumerate"))?; let st = self.state.lock(); if let Some(kids) = st.children.get(&dir) { for &k in kids { @@ -1961,7 +1972,7 @@ impl Core { }; self.cache .remove_pin(&uid) - .map_err(|e| CoreError::remote(format!("unpin: {e}")))?; + .map_err(|e| CoreError::from_api(&e, "unpin"))?; // A recursively-pinned folder's descendants were eviction-exempt; now // that the pin is gone, reclaim their blobs eagerly instead of waiting // for budget pressure. Descendants come from the DB node tree. @@ -1990,7 +2001,7 @@ impl Core { fn list_dir(&self, rel: &Path) -> CoreResult> { let (ino, _uid) = self.resolve(rel)?; self.ensure_children(ino) - .map_err(|e| CoreError::remote(format!("enumerate: {e:?}")))?; + .map_err(|e| self.errno_error(e, "enumerate"))?; // Snapshot the listing, then drop the lock before touching the on-disk // pin registry so a slow disk read doesn't block FUSE metadata ops. let rows: Vec<(String, bool, u64, i64, NodeUid)> = { @@ -2037,7 +2048,7 @@ impl Core { let hits = self .db .search(query, limit) - .map_err(|e| CoreError::remote(format!("search: {e}")))?; + .map_err(|e| CoreError::from_api(&e, "search"))?; Ok(hits .into_iter() .map(|h| SearchHit { @@ -2059,7 +2070,7 @@ impl Core { let hits = self .db .search_local(query, limit) - .map_err(|e| CoreError::remote(format!("local search: {e}")))?; + .map_err(|e| CoreError::from_api(&e, "local search"))?; Ok(hits .into_iter() .map(|h| LocalHit { @@ -2101,7 +2112,10 @@ impl Core { let (ino, uid) = self.resolve(rel)?; let (name, mtime, size) = { let st = self.state.lock(); - let e = st.entries.get(&ino).ok_or_else(|| CoreError::not_found("node vanished"))?; + let e = st + .entries + .get(&ino) + .ok_or_else(|| CoreError::not_found("node vanished"))?; if !e.node.is_file() { return Err(CoreError::invalid("not a regular file")); } @@ -2116,7 +2130,7 @@ impl Core { } let bytes = self .download_file_tracked(&uid, &name, size) - .map_err(|e| CoreError::remote(format!("download: {e}")))?; + .map_err(|e| CoreError::from_api(&e, "download"))?; self.cache .store(&uid, mtime, size, &bytes) .map_err(|e| CoreError::internal(format!("cache store: {e}")))?; @@ -2145,7 +2159,7 @@ impl Core { let (_ino, uid) = self.resolve(rel)?; self.rt .block_on(self.client.rename_node(&uid, new_name, None)) - .map_err(|e| CoreError::remote(format!("rename: {e}")))?; + .map_err(|e| CoreError::from_api(&e, "rename"))?; self.state.lock().forget(&uid); self.invalidate_parent_listing(rel); Ok(new_name.to_string()) @@ -2158,10 +2172,10 @@ impl Core { let (_ino, uid) = self.resolve(rel)?; let (pino, new_parent_uid) = self .resolve_path(new_parent_rel) - .map_err(|e| CoreError::remote(format!("resolve new parent: {e:?}")))?; + .map_err(|e| self.errno_error(e, "resolve new parent"))?; self.rt .block_on(self.client.move_node(&uid, &new_parent_uid)) - .map_err(|e| CoreError::remote(format!("move: {e}")))?; + .map_err(|e| CoreError::from_api(&e, "move"))?; let name = self .state .lock() @@ -2179,7 +2193,7 @@ impl Core { let (_ino, uid) = self.resolve(rel)?; self.rt .block_on(self.client.trash_nodes(std::slice::from_ref(&uid))) - .map_err(|e| CoreError::remote(format!("trash: {e}")))?; + .map_err(|e| CoreError::from_api(&e, "trash"))?; let name = self .state .lock() @@ -2238,14 +2252,14 @@ impl Core { .client .enumerate_trash_node_uids() .await - .map_err(|e| CoreError::remote(format!("enumerate trash: {e}")))?; + .map_err(|e| CoreError::from_api(&e, "enumerate trash"))?; let nodes = if uids.is_empty() { Vec::new() } else { self.client .enumerate_nodes(&uids) .await - .map_err(|e| CoreError::remote(format!("enumerate nodes: {e}")))? + .map_err(|e| CoreError::from_api(&e, "enumerate nodes"))? }; let items: Vec = nodes .into_iter() @@ -2327,13 +2341,13 @@ impl Core { let parents: Vec = self .rt .block_on(self.client.enumerate_nodes(&parsed)) - .map_err(|e| CoreError::remote(format!("enumerate nodes: {e}")))? + .map_err(|e| CoreError::from_api(&e, "enumerate nodes"))? .into_iter() .filter_map(|n| n.parent_uid) .collect(); self.rt .block_on(self.client.restore_nodes(&parsed)) - .map_err(|e| CoreError::remote(format!("restore: {e}")))?; + .map_err(|e| CoreError::from_api(&e, "restore"))?; { let mut st = self.state.lock(); for parent in parents { @@ -2352,7 +2366,7 @@ impl Core { let parsed = Self::parse_uids(uids)?; self.rt .block_on(self.client.delete_nodes(&parsed)) - .map_err(|e| CoreError::remote(format!("delete: {e}")))?; + .map_err(|e| CoreError::from_api(&e, "delete"))?; self.drop_local(&parsed); self.invalidate_trash(); Ok(parsed.len()) @@ -2365,10 +2379,10 @@ impl Core { let uids = self .rt .block_on(self.client.enumerate_trash_node_uids()) - .map_err(|e| CoreError::remote(format!("enumerate trash: {e}")))?; + .map_err(|e| CoreError::from_api(&e, "enumerate trash"))?; self.rt .block_on(self.client.empty_trash()) - .map_err(|e| CoreError::remote(format!("empty trash: {e}")))?; + .map_err(|e| CoreError::from_api(&e, "empty trash"))?; self.drop_local(&uids); self.invalidate_trash(); Ok(uids.len()) @@ -2396,17 +2410,17 @@ impl Core { } let (pino, parent_uid) = self.resolve(parent_rel)?; self.ensure_children(pino) - .map_err(|e| CoreError::remote(format!("enumerate: {e:?}")))?; + .map_err(|e| self.errno_error(e, "enumerate"))?; let new_uid = self .rt .block_on( self.client .create_folder(&parent_uid, name, Some(now_secs())), ) - .map_err(|e| CoreError::remote(format!("create folder: {e}")))?; + .map_err(|e| CoreError::from_api(&e, "create folder"))?; let node = self .fetch_node(&new_uid) - .map_err(|e| CoreError::remote(format!("fetch node: {e:?}")))?; + .map_err(|e| self.errno_error(e, "fetch node"))?; let mut st = self.state.lock(); let ino = st.intern(pino, node); if let Some(kids) = st.children.get_mut(&pino) @@ -2425,7 +2439,7 @@ impl Core { } let (pino, parent_uid) = self.resolve(parent_rel)?; self.ensure_children(pino) - .map_err(|e| CoreError::remote(format!("enumerate: {e:?}")))?; + .map_err(|e| self.errno_error(e, "enumerate"))?; let guard = self .transfers .begin(name, "", TransferDirection::Upload, bytes.len() as u64); @@ -2442,11 +2456,11 @@ impl Core { None, false, )) - .map_err(|e| CoreError::remote(format!("upload: {e}")))?; + .map_err(|e| CoreError::from_api(&e, "upload"))?; drop(guard); let node = self .fetch_node(&new_uid) - .map_err(|e| CoreError::remote(format!("fetch node: {e:?}")))?; + .map_err(|e| self.errno_error(e, "fetch node"))?; let mut st = self.state.lock(); let ino = st.intern(pino, node); if let Some(kids) = st.children.get_mut(&pino) @@ -2468,7 +2482,7 @@ impl Core { fn upload_paths(&self, parent_rel: &Path, sources: &[PathBuf]) -> CoreResult { let (pino, parent_uid) = self.resolve(parent_rel)?; self.ensure_children(pino) - .map_err(|e| CoreError::remote(format!("enumerate: {e:?}")))?; + .map_err(|e| self.errno_error(e, "enumerate"))?; // Phase 1 (sequential): build the remote folder skeleton and collect the // flat list of files to upload. Folders must exist before their children, @@ -2563,7 +2577,7 @@ impl Core { return Err(CoreError::invalid(format!("invalid folder name: {name:?}"))); } self.ensure_children(pino) - .map_err(|e| CoreError::remote(format!("enumerate: {e:?}")))?; + .map_err(|e| self.errno_error(e, "enumerate"))?; { let st = self.state.lock(); if let Some(kids) = st.children.get(&pino) { @@ -2583,10 +2597,10 @@ impl Core { self.client .create_folder(parent_uid, name, Some(now_secs())), ) - .map_err(|e| CoreError::remote(format!("create folder {name}: {e}")))?; + .map_err(|e| CoreError::from_api(&e, &format!("create folder {name}")))?; let node = self .fetch_node(&new_uid) - .map_err(|e| CoreError::remote(format!("fetch node: {e:?}")))?; + .map_err(|e| self.errno_error(e, "fetch node"))?; let mut st = self.state.lock(); let ino = st.intern(pino, node); if let Some(kids) = st.children.get_mut(&pino) @@ -2612,8 +2626,8 @@ impl Core { folders: &mut usize, job: &JobGuard, ) -> CoreResult<()> { - let meta = - std::fs::symlink_metadata(src).map_err(|e| CoreError::internal(format!("stat {}: {e}", src.display())))?; + let meta = std::fs::symlink_metadata(src) + .map_err(|e| CoreError::internal(format!("stat {}: {e}", src.display())))?; let name = src .file_name() .and_then(|n| n.to_str()) @@ -2655,7 +2669,6 @@ impl Core { Ok(()) } - // ---- activity log ----------------------------------------------------- /// Append one entry to the activity log. Callable from any thread (the sync @@ -4378,7 +4391,6 @@ fn scan_local_once( indexing.store(false, Ordering::Relaxed); } - /// Why a [`mount`] call returned. Lets the daemon decide whether to exit (clean /// shutdown) or remount (the mount went away under it). #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/pdfs-fuse/src/photos.rs b/crates/pdfs-fuse/src/photos.rs index bb9792c..f345e2a 100644 --- a/crates/pdfs-fuse/src/photos.rs +++ b/crates/pdfs-fuse/src/photos.rs @@ -8,13 +8,13 @@ use std::collections::HashMap; use std::path::PathBuf; -use pdfs_core::{CoreError, CoreResult}; use pdfs_core::control::{PhotoItem, PhotoKind, PhotoThumb}; use pdfs_core::db::{self, StoredPhoto}; +use pdfs_core::{CoreError, CoreResult}; use std::sync::atomic::Ordering; -use proton_drive_rs::{NodeKind, ThumbnailType}; use proton_drive_rs::proton_sdk::ids::NodeUid; +use proton_drive_rs::{NodeKind, ThumbnailType}; use tracing::{info, warn}; use super::{ @@ -275,7 +275,7 @@ impl Core { if photos .get_photos_root() .await - .map_err(|e| CoreError::remote(format!("photos root: {e}")))? + .map_err(|e| CoreError::from_api(&e, "photos root"))? .is_none() { let _ = self.db.set_state_i64(PHOTOS_AVAILABLE, 0); @@ -285,7 +285,7 @@ impl Core { let items = photos .enumerate_timeline() .await - .map_err(|e| CoreError::remote(format!("timeline: {e}")))?; + .map_err(|e| CoreError::from_api(&e, "timeline"))?; // The timeline DTO carries only a uid and capture time, but the Photos // page has to split into Photos / Videos / Raw — which needs each photo's @@ -346,7 +346,7 @@ impl Core { let node = self .rt .block_on(photos.get_node(uid)) - .map_err(|e| CoreError::remote(format!("photo node: {e}")))? + .map_err(|e| CoreError::from_api(&e, "photo node"))? .ok_or_else(|| CoreError::not_found("photo not found"))?; let (mtime, size) = (node.modification_time, node_size(&node)); if let Some(p) = self.cache.cached_content_path(uid, mtime, size) { @@ -354,11 +354,10 @@ impl Core { } let bytes = self .download_photo_tracked(&photos, uid, &node.name, size) - .map_err(|e| CoreError::remote(format!("download photo: {e}")))?; + .map_err(|e| CoreError::from_api(&e, "download photo"))?; self.cache .store(uid, mtime, size, &bytes) .map_err(|e| CoreError::internal(format!("cache store: {e}")))?; Ok(self.cache.content_path(uid)) } - } diff --git a/crates/pdfs-fuse/src/sharing.rs b/crates/pdfs-fuse/src/sharing.rs index 187f2f3..9c2deb5 100644 --- a/crates/pdfs-fuse/src/sharing.rs +++ b/crates/pdfs-fuse/src/sharing.rs @@ -8,12 +8,12 @@ use std::path::Path; -use pdfs_core::{CoreError, CoreResult}; use pdfs_core::control::{ BookmarkInfo, DirEntry, InvitationInfo, PublicLinkInfo, ShareEntry, ShareEntryKind, SharedItem, }; -use proton_drive_rs::{MemberRole, NodeKind}; +use pdfs_core::{CoreError, CoreResult}; use proton_drive_rs::proton_sdk::ids::NodeUid; +use proton_drive_rs::{MemberRole, NodeKind}; use super::{Core, ROOT_INO, parse_uid, public_link_info, role_from_str, role_to_str}; @@ -35,18 +35,21 @@ impl Core { emails.iter().map(|e| (e.clone(), role)).collect(); self.rt .block_on(self.client.invite_users(&uid, &invitees, message)) - .map_err(|e| CoreError::remote(format!("share: {e}"))) + .map_err(|e| CoreError::from_api(&e, "share")) } /// List the members, pending invitations and public link of the node at `rel`. - pub(crate) fn list_share(&self, rel: &Path) -> CoreResult<(Vec, Option)> { + pub(crate) fn list_share( + &self, + rel: &Path, + ) -> CoreResult<(Vec, Option)> { let (_ino, uid) = self.resolve(rel)?; let mut entries = Vec::new(); for m in self .rt .block_on(self.client.list_share_members(&uid)) - .map_err(|e| CoreError::remote(format!("list members: {e}")))? + .map_err(|e| CoreError::from_api(&e, "list members"))? { entries.push(ShareEntry { id: m.membership_id.to_string(), @@ -58,7 +61,7 @@ impl Core { for inv in self .rt .block_on(self.client.list_share_invitations(&uid)) - .map_err(|e| CoreError::remote(format!("list invitations: {e}")))? + .map_err(|e| CoreError::from_api(&e, "list invitations"))? { entries.push(ShareEntry { id: inv.invitation_id, @@ -70,7 +73,7 @@ impl Core { for ext in self .rt .block_on(self.client.list_external_invitations(&uid)) - .map_err(|e| CoreError::remote(format!("list external invitations: {e}")))? + .map_err(|e| CoreError::from_api(&e, "list external invitations"))? { entries.push(ShareEntry { id: ext.invitation_id, @@ -83,7 +86,7 @@ impl Core { let link = self .rt .block_on(self.client.get_public_link(&uid)) - .map_err(|e| CoreError::remote(format!("get public link: {e}")))? + .map_err(|e| CoreError::from_api(&e, "get public link"))? .map(public_link_info); Ok((entries, link)) @@ -105,72 +108,77 @@ impl Core { let member = self .rt .block_on(self.client.list_share_members(&uid)) - .map_err(|e| CoreError::remote(format!("list members: {e}")))? + .map_err(|e| CoreError::from_api(&e, "list members"))? .into_iter() .find(|m| m.membership_id.to_string() == id) .ok_or_else(|| CoreError::not_found("member not found"))?; self.rt .block_on(self.client.update_member_role(&member, role)) - .map_err(|e| CoreError::remote(format!("update role: {e}"))) + .map_err(|e| CoreError::from_api(&e, "update role")) } ShareEntryKind::ProtonInvite => { let inv = self .rt .block_on(self.client.list_share_invitations(&uid)) - .map_err(|e| CoreError::remote(format!("list invitations: {e}")))? + .map_err(|e| CoreError::from_api(&e, "list invitations"))? .into_iter() .find(|i| i.invitation_id == id) .ok_or_else(|| CoreError::not_found("invitation not found"))?; self.rt .block_on(self.client.update_invitation_role(&inv, role)) - .map_err(|e| CoreError::remote(format!("update role: {e}"))) - } - ShareEntryKind::ExternalInvite => { - Err(CoreError::invalid("an external invitation's role cannot be changed")) + .map_err(|e| CoreError::from_api(&e, "update role")) } + ShareEntryKind::ExternalInvite => Err(CoreError::invalid( + "an external invitation's role cannot be changed", + )), } } /// Remove a member, pending Proton invite, or external invite from the node /// at `rel`. - pub(crate) fn remove_share_entry(&self, rel: &Path, id: &str, kind: ShareEntryKind) -> CoreResult<()> { + pub(crate) fn remove_share_entry( + &self, + rel: &Path, + id: &str, + kind: ShareEntryKind, + ) -> CoreResult<()> { let (_ino, uid) = self.resolve(rel)?; match kind { ShareEntryKind::Member => { let member = self .rt .block_on(self.client.list_share_members(&uid)) - .map_err(|e| CoreError::remote(format!("list members: {e}")))? + .map_err(|e| CoreError::from_api(&e, "list members"))? .into_iter() .find(|m| m.membership_id.to_string() == id) .ok_or_else(|| CoreError::not_found("member not found"))?; self.rt .block_on(self.client.remove_member(&member)) - .map_err(|e| CoreError::remote(format!("remove member: {e}"))) + .map_err(|e| CoreError::from_api(&e, "remove member")) } ShareEntryKind::ProtonInvite => { let inv = self .rt .block_on(self.client.list_share_invitations(&uid)) - .map_err(|e| CoreError::remote(format!("list invitations: {e}")))? + .map_err(|e| CoreError::from_api(&e, "list invitations"))? .into_iter() .find(|i| i.invitation_id == id) .ok_or_else(|| CoreError::not_found("invitation not found"))?; self.rt .block_on(self.client.delete_invitation(&inv)) - .map_err(|e| CoreError::remote(format!("revoke invitation: {e}"))) + .map_err(|e| CoreError::from_api(&e, "revoke invitation")) } ShareEntryKind::ExternalInvite => { let ext = self .rt .block_on(self.client.list_external_invitations(&uid)) - .map_err(|e| CoreError::remote(format!("list external invitations: {e}")))? + .map_err(|e| CoreError::from_api(&e, "list external invitations"))? .into_iter() .find(|i| i.invitation_id == id) .ok_or_else(|| CoreError::not_found("external invitation not found"))?; self.rt .block_on(self.client.delete_external_invitation(&ext)) - .map_err(|e| CoreError::remote(format!("revoke external invitation: {e}"))) + .map_err(|e| CoreError::from_api(&e, "revoke external invitation")) } } } @@ -191,7 +199,7 @@ impl Core { self.client .create_public_link(&uid, role, password, expires), ) - .map_err(|e| CoreError::remote(format!("create public link: {e}")))?; + .map_err(|e| CoreError::from_api(&e, "create public link"))?; Ok(public_link_info(link)) } @@ -201,12 +209,12 @@ impl Core { let link = self .rt .block_on(self.client.get_public_link(&uid)) - .map_err(|e| CoreError::remote(format!("get public link: {e}")))? + .map_err(|e| CoreError::from_api(&e, "get public link"))? .filter(|l| l.public_link_id == id) .ok_or_else(|| CoreError::not_found("public link not found"))?; self.rt .block_on(self.client.remove_public_link(&link)) - .map_err(|e| CoreError::remote(format!("remove public link: {e}"))) + .map_err(|e| CoreError::from_api(&e, "remove public link")) } // ---- shared with me --------------------------------------------------- @@ -216,14 +224,14 @@ impl Core { let uids = self .rt .block_on(self.client.enumerate_shared_with_me_node_uids()) - .map_err(|e| CoreError::remote(format!("enumerate shared: {e}")))?; + .map_err(|e| CoreError::from_api(&e, "enumerate shared"))?; if uids.is_empty() { return Ok(Vec::new()); } let nodes = self .rt .block_on(self.client.enumerate_nodes(&uids)) - .map_err(|e| CoreError::remote(format!("enumerate nodes: {e}")))?; + .map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?; Ok(nodes .into_iter() .map(|n| { @@ -252,10 +260,11 @@ impl Core { /// Leave a shared node by its uid. pub(crate) fn leave_shared(&self, uid: &str) -> CoreResult<()> { - let uid = parse_uid(uid).ok_or_else(|| CoreError::invalid(format!("invalid uid: {uid}")))?; + let uid = + parse_uid(uid).ok_or_else(|| CoreError::invalid(format!("invalid uid: {uid}")))?; self.rt .block_on(self.client.leave_shared_node(&uid)) - .map_err(|e| CoreError::remote(format!("leave shared: {e}"))) + .map_err(|e| CoreError::from_api(&e, "leave shared")) } // ---- incoming invitations --------------------------------------------- @@ -265,7 +274,7 @@ impl Core { let invitations = self .rt .block_on(self.client.list_incoming_invitations()) - .map_err(|e| CoreError::remote(format!("list invitations: {e}")))?; + .map_err(|e| CoreError::from_api(&e, "list invitations"))?; Ok(invitations .into_iter() .map(|i| InvitationInfo { @@ -281,14 +290,14 @@ impl Core { pub(crate) fn accept_invitation(&self, id: &str) -> CoreResult<()> { self.rt .block_on(self.client.accept_invitation(id)) - .map_err(|e| CoreError::remote(format!("accept invitation: {e}"))) + .map_err(|e| CoreError::from_api(&e, "accept invitation")) } /// Reject an invitation addressed to me by its id. pub(crate) fn reject_invitation(&self, id: &str) -> CoreResult<()> { self.rt .block_on(self.client.reject_invitation(id)) - .map_err(|e| CoreError::remote(format!("reject invitation: {e}"))) + .map_err(|e| CoreError::from_api(&e, "reject invitation")) } // ---- bookmarks -------------------------------------------------------- @@ -298,7 +307,7 @@ impl Core { let bookmarks = self .rt .block_on(self.client.list_bookmarks()) - .map_err(|e| CoreError::remote(format!("list bookmarks: {e}")))?; + .map_err(|e| CoreError::from_api(&e, "list bookmarks"))?; Ok(bookmarks .into_iter() .map(|b| BookmarkInfo { @@ -314,14 +323,14 @@ impl Core { pub(crate) fn create_bookmark(&self, url: &str, password: Option<&str>) -> CoreResult<()> { self.rt .block_on(self.client.create_bookmark(url, password)) - .map_err(|e| CoreError::remote(format!("create bookmark: {e}"))) + .map_err(|e| CoreError::from_api(&e, "create bookmark")) } /// Remove a saved bookmark by its token. pub(crate) fn delete_bookmark(&self, token: &str) -> CoreResult<()> { self.rt .block_on(self.client.delete_bookmark(token)) - .map_err(|e| CoreError::remote(format!("delete bookmark: {e}"))) + .map_err(|e| CoreError::from_api(&e, "delete bookmark")) } // ---- shared by me ----------------------------------------------------- @@ -335,14 +344,14 @@ impl Core { let uids = self .rt .block_on(self.client.enumerate_shared_by_me_node_uids()) - .map_err(|e| CoreError::remote(format!("enumerate shared-by-me: {e}")))?; + .map_err(|e| CoreError::from_api(&e, "enumerate shared-by-me"))?; if uids.is_empty() { return Ok(Vec::new()); } let nodes = self .rt .block_on(self.client.enumerate_nodes(&uids)) - .map_err(|e| CoreError::remote(format!("enumerate nodes: {e}")))?; + .map_err(|e| CoreError::from_api(&e, "enumerate nodes"))?; let mut items = Vec::with_capacity(nodes.len()); for n in nodes { let uid = n.uid.clone(); diff --git a/crates/pdfs-fuse/src/state.rs b/crates/pdfs-fuse/src/state.rs index b074a6e..36d0c2c 100644 --- a/crates/pdfs-fuse/src/state.rs +++ b/crates/pdfs-fuse/src/state.rs @@ -9,12 +9,10 @@ use std::sync::Arc; use pdfs_core::cache::StagedWrite; use pdfs_core::db::Db; -use proton_drive_rs::{Node, NodeKind}; use proton_drive_rs::proton_sdk::ids::NodeUid; +use proton_drive_rs::{Node, NodeKind}; use tracing::warn; - - /// A node known to the filesystem, addressed by its kernel inode. pub(crate) struct Entry { pub(crate) uid: NodeUid, @@ -222,7 +220,13 @@ impl State { /// server did. A queued rename cannot: re-enumerating needs the network, and /// the server has not been told yet in any case — so this *is* the tree's /// new truth until the op drains (offline.md Phase 3b). - pub(crate) fn rename_in_place(&mut self, ino: u64, new_parent: u64, new_parent_uid: &NodeUid, name: &str) { + pub(crate) fn rename_in_place( + &mut self, + ino: u64, + new_parent: u64, + new_parent_uid: &NodeUid, + name: &str, + ) { let Some(entry) = self.entries.get_mut(&ino) else { return; }; diff --git a/crates/pdfs-fuse/src/sync.rs b/crates/pdfs-fuse/src/sync.rs index 6a1124e..24e709b 100644 --- a/crates/pdfs-fuse/src/sync.rs +++ b/crates/pdfs-fuse/src/sync.rs @@ -12,11 +12,11 @@ //! action, so a file just uploaded or downloaded reads as "unchanged" on the //! next pass and never ping-pongs. +use parking_lot::Mutex; use std::collections::{HashMap, HashSet}; use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::Arc; -use parking_lot::Mutex; use std::sync::mpsc::Receiver; use std::time::{Duration, SystemTime}; diff --git a/crates/pdfs-fuse/src/transfers.rs b/crates/pdfs-fuse/src/transfers.rs index 2c516e4..60264e8 100644 --- a/crates/pdfs-fuse/src/transfers.rs +++ b/crates/pdfs-fuse/src/transfers.rs @@ -15,11 +15,11 @@ //! //! [`Request::GetQueueStatus`]: pdfs_core::control::Request::GetQueueStatus +use parking_lot::Mutex; use std::collections::HashMap; use std::io::{Read, Write}; -use std::sync::atomic::{AtomicU64, Ordering}; -use parking_lot::Mutex; use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Instant; use pdfs_core::control::{JobItem, TransferDirection, TransferItem}; diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 311e309..4a6ae42 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -39,11 +39,10 @@ use pdfs_core::auth; use pdfs_core::config::AppDirs; use pdfs_core::control::{ - ErrorKind, - ActivityEntry, ActivityKind, BookmarkInfo, DeviceInfo, DirEntry, InvitationInfo, JobItem, - PhotoItem, PhotoKind, PublicLinkInfo, RefreshScope, Request, Response, SearchHit, ShareEntry, - ShareEntryKind, SharedItem, SyncFolderInfo, SyncPhase, SyncProgress, TransferDirection, - TransferItem, pending_summary, send, + ActivityEntry, ActivityKind, BookmarkInfo, DeviceInfo, DirEntry, ErrorKind, InvitationInfo, + JobItem, PhotoItem, PhotoKind, PublicLinkInfo, RefreshScope, Request, Response, SearchHit, + ShareEntry, ShareEntryKind, SharedItem, SyncFolderInfo, SyncPhase, SyncProgress, + TransferDirection, TransferItem, pending_summary, send, }; use pdfs_core::service; @@ -134,7 +133,8 @@ impl Ui { /// The aspect ratio (w/h) to lay `uid`'s tile out at: the real one once its /// thumbnail has been seen, otherwise [`RATIO_UNKNOWN`]. fn ratio(&self, uid: &str) -> f64 { - self.gallery.photo_ratio + self.gallery + .photo_ratio .borrow() .get(uid) .copied() @@ -809,6 +809,7 @@ fn error_headline(kind: ErrorKind, fallback: &str) -> &str { ErrorKind::NotFound => "That's not there any more", ErrorKind::Denied => "You don't have access to that", ErrorKind::Conflict => "Something changed this first", + ErrorKind::Quota => "Your Proton Drive is full", ErrorKind::Invalid | ErrorKind::Remote | ErrorKind::Internal => fallback, } } diff --git a/crates/pdfs-gui/src/app/pages/activity.rs b/crates/pdfs-gui/src/app/pages/activity.rs index 3bc9d60..4f9f52d 100644 --- a/crates/pdfs-gui/src/app/pages/activity.rs +++ b/crates/pdfs-gui/src/app/pages/activity.rs @@ -96,7 +96,13 @@ pub(crate) fn wire_activity(ui: &Rc, retry: >k4::Button) { } /// Show a status page in place of the Activity list. -pub(crate) fn activity_status(ui: &Rc, icon: &str, title: &str, description: &str, retry: bool) { +pub(crate) fn activity_status( + ui: &Rc, + icon: &str, + title: &str, + description: &str, + retry: bool, +) { ui.activity.status.set_icon_name(Some(icon)); ui.activity.status.set_title(title); ui.activity.status.set_description(Some(description)); diff --git a/crates/pdfs-gui/src/app/pages/browser.rs b/crates/pdfs-gui/src/app/pages/browser.rs index 85f215b..8d5baa1 100644 --- a/crates/pdfs-gui/src/app/pages/browser.rs +++ b/crates/pdfs-gui/src/app/pages/browser.rs @@ -609,7 +609,11 @@ pub(crate) fn stream_entry(ui: &Rc, entry: &DirEntry) { let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config()); let abs = mountpoint.join(&rel); let Some(path) = abs.to_str() else { - toast_error(ui, "Couldn't play video", "The file path isn't valid UTF-8."); + toast_error( + ui, + "Couldn't play video", + "The file path isn't valid UTF-8.", + ); return; }; toast(ui, &format!("Streaming “{}”…", entry.name)); @@ -744,7 +748,8 @@ pub(crate) fn repaint_crumb(ui: &Rc, path: &str) { } else { path.split('/').collect() }; - ui.browser.crumb + ui.browser + .crumb .append(&crumb_node(ui, "Proton Drive", "", segments.is_empty())); let mut acc = String::new(); for (i, seg) in segments.iter().enumerate() { @@ -1071,7 +1076,9 @@ pub(crate) fn start_upload(ui: &Rc, sources: Vec) { }; toast(&ui, &what); } - Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, "Couldn't upload", &message, kind), + Ok(Ok(Response::Error { message, kind })) => { + toast_failure(&ui, "Couldn't upload", &message, kind) + } _ => toast_error(&ui, "Couldn't upload", "The mount service didn't respond."), } }); @@ -1237,7 +1244,11 @@ pub(crate) fn load_browser(ui: &Rc) { match result { Ok(Ok(Response::Entries { entries })) => repaint_browser(&ui, &entries), Ok(Ok(Response::Error { message, kind })) => browser_failed(&ui, &message, kind), - Ok(Ok(_)) => browser_failed(&ui, "Unexpected reply from the mount service.", ErrorKind::Internal), + Ok(Ok(_)) => browser_failed( + &ui, + "Unexpected reply from the mount service.", + ErrorKind::Internal, + ), Ok(Err(_)) | Err(_) => browser_unreachable(&ui), } }); @@ -1374,7 +1385,11 @@ pub(crate) fn run_search(ui: &Rc, query: &str) { match result { Ok(Ok(Response::SearchResults { hits })) => repaint_search(&ui, &hits), Ok(Ok(Response::Error { message, kind })) => browser_failed(&ui, &message, kind), - Ok(Ok(_)) => browser_failed(&ui, "Unexpected reply from the mount service.", ErrorKind::Internal), + Ok(Ok(_)) => browser_failed( + &ui, + "Unexpected reply from the mount service.", + ErrorKind::Internal, + ), Ok(Err(_)) | Err(_) => browser_unreachable(&ui), } }); diff --git a/crates/pdfs-gui/src/app/pages/devices.rs b/crates/pdfs-gui/src/app/pages/devices.rs index 3022f3c..905af9c 100644 --- a/crates/pdfs-gui/src/app/pages/devices.rs +++ b/crates/pdfs-gui/src/app/pages/devices.rs @@ -699,7 +699,12 @@ pub(crate) fn prompt_remove_device(ui: &Rc, uid: &str, name: &str) { } /// Run a mutation raised from the Devices page and reload the page on success. -pub(crate) fn run_devices_mutation(ui: &Rc, req: Request, done: &'static str, failed: &'static str) { +pub(crate) fn run_devices_mutation( + ui: &Rc, + req: Request, + done: &'static str, + failed: &'static str, +) { ui.busy_begin(); let rx = spawn_request(ui.dirs.control_socket(), req); let ui = ui.clone(); diff --git a/crates/pdfs-gui/src/app/pages/login.rs b/crates/pdfs-gui/src/app/pages/login.rs index 0452a6f..1857273 100644 --- a/crates/pdfs-gui/src/app/pages/login.rs +++ b/crates/pdfs-gui/src/app/pages/login.rs @@ -82,10 +82,12 @@ pub(crate) fn wire_login(ui: &Rc) { // Pressing Enter in either field submits the form, so signing in never needs // a reach for the mouse. Both fields route to the same button. let btn = ui.login.login_button.clone(); - ui.login.email + ui.login + .email .connect_entry_activated(move |_| btn.emit_clicked()); let btn = ui.login.login_button.clone(); - ui.login.password + ui.login + .password .connect_entry_activated(move |_| btn.emit_clicked()); let ui = ui.clone(); @@ -94,7 +96,9 @@ pub(crate) fn wire_login(ui: &Rc) { let username = ui.login.email.text().to_string(); let password = ui.login.password.text().to_string(); if username.is_empty() || password.is_empty() { - ui.login.login_status.set_text("Enter your email and password."); + ui.login + .login_status + .set_text("Enter your email and password."); return; } @@ -129,7 +133,10 @@ pub(crate) fn wire_login(ui: &Rc) { service::enable_start(); refresh(&ui); } - Err(e) => ui.login.login_status.set_text(&format!("Sign-in failed: {e}")), + Err(e) => ui + .login + .login_status + .set_text(&format!("Sign-in failed: {e}")), } }); }); diff --git a/crates/pdfs-gui/src/app/pages/photo_viewer.rs b/crates/pdfs-gui/src/app/pages/photo_viewer.rs index a84062f..f163935 100644 --- a/crates/pdfs-gui/src/app/pages/photo_viewer.rs +++ b/crates/pdfs-gui/src/app/pages/photo_viewer.rs @@ -322,7 +322,10 @@ pub(crate) fn navigate_photo(ui: &Rc, viewer: &Rc, delta: i32) { let renderable = |idx: u32| -> Option { model .item(idx) - .and_then(|obj| obj.downcast_ref::().map(|b| b.borrow::().clone())) + .and_then(|obj| { + obj.downcast_ref::() + .map(|b| b.borrow::().clone()) + }) .filter(|photo| photo.kind != PhotoKind::Video) }; let scan = |from: i32, step: i32| -> Option<(u32, PhotoItem)> { @@ -564,7 +567,8 @@ pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { let n = ui.gallery.model.n_items(); let initial_idx = find_photo_index(&ui.gallery.model, &initial_uid).unwrap_or(0); let capture_time = ui - .gallery.model + .gallery + .model .item(initial_idx) .and_downcast::() .map_or(0, |boxed| boxed.borrow::().capture_time); diff --git a/crates/pdfs-gui/src/app/pages/photos.rs b/crates/pdfs-gui/src/app/pages/photos.rs index c2de44d..b347969 100644 --- a/crates/pdfs-gui/src/app/pages/photos.rs +++ b/crates/pdfs-gui/src/app/pages/photos.rs @@ -356,8 +356,18 @@ pub(crate) fn month_range(year: i32, month: i32) -> Option<(i64, i64)> { /// English month names, indexed 1..=12. pub(crate) const MONTH_NAMES: [&str; 12] = [ - "January", "February", "March", "April", "May", "June", "July", "August", "September", - "October", "November", "December", + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", ]; /// Rebuild the date-jump dropdown for the active kind: ask the daemon which @@ -380,7 +390,10 @@ pub(crate) fn refresh_photo_months(ui: &Rc) { let mut labels = vec!["All dates".to_string()]; let mut ranges: Vec> = vec![None]; for m in months { - let name = MONTH_NAMES.get((m.month - 1) as usize).copied().unwrap_or("?"); + let name = MONTH_NAMES + .get((m.month - 1) as usize) + .copied() + .unwrap_or("?"); labels.push(format!("{name} {} ({})", m.year, m.count)); ranges.push(month_range(m.year, m.month)); } @@ -389,7 +402,8 @@ pub(crate) fn refresh_photo_months(ui: &Rc) { // Repopulating resets the selection to 0, which would otherwise fire the // handler and reload; suppress that — the caller is already reloading. ui.gallery.date_suppress.set(true); - ui.gallery.dates + ui.gallery + .dates .set_model(Some(>k4::StringList::new(&label_refs))); ui.gallery.dates.set_selected(0); *ui.gallery.date_ranges.borrow_mut() = ranges; @@ -444,7 +458,8 @@ pub(crate) fn wire_gallery(ui: &Rc, list: >k4::ListView, scroll: >k4::Sc // re-justify it in place, without rebuilding the ListStore (which would // yank the scroll position back to the top). ui_bind - .gallery.bound + .gallery + .bound .borrow_mut() .insert(item.position(), section); }); @@ -456,7 +471,8 @@ pub(crate) fn wire_gallery(ui: &Rc, list: >k4::ListView, scroll: >k4::Sc factory.connect_unbind(move |_, item| { let item = item.downcast_ref::().unwrap(); ui_unbind - .gallery.bound + .gallery + .bound .borrow_mut() .remove(&item.position()); if let Some(obj) = item.item().and_downcast::() { @@ -562,7 +578,8 @@ pub(crate) fn wire_gallery(ui: &Rc, list: >k4::ListView, scroll: >k4::Sc return; } let range = ui_dates - .gallery.date_ranges + .gallery + .date_ranges .borrow() .get(dd.selected() as usize) .copied() @@ -742,7 +759,12 @@ pub(crate) fn plan_rows(ratios: &[f64], target: f64, width: f64) -> Vec Vec<(i32, i32)> { +pub(crate) fn size_row( + ratios: &[f64], + height: f64, + avail: f64, + justified: bool, +) -> Vec<(i32, i32)> { let height = height.round().max(1.0); let mut sizes: Vec<(i32, i32)> = ratios .iter() @@ -896,13 +918,15 @@ pub(crate) fn want_thumb(ui: &Rc, photo: &PhotoItem, picture: >k4::Picture return; } - ui.gallery.thumb_wanted + ui.gallery + .thumb_wanted .borrow_mut() .insert(photo.uid.clone(), picture.clone()); match photo.thumb_path.as_deref() { Some(path) => { - ui.gallery.decode_queue + ui.gallery + .decode_queue .borrow_mut() .push_back((photo.uid.clone(), path.to_string())); schedule_decode(ui); @@ -1082,7 +1106,8 @@ pub(crate) fn schedule_relayout(ui: &Rc) { /// ListView binds them. pub(crate) fn relayout_gallery(ui: &Rc) { let bound: Vec<(u32, gtk4::Box)> = ui - .gallery.bound + .gallery + .bound .borrow() .iter() .map(|(pos, section)| (*pos, section.clone())) @@ -1444,9 +1469,11 @@ pub(crate) fn play_video(ui: &Rc, uid: String) { "Couldn't open this video", "Unexpected reply from the mount service.", ), - Ok(Err(_)) | Err(_) => { - toast_error(&ui, "Couldn't open this video", "Couldn't reach Proton Drive.") - } + Ok(Err(_)) | Err(_) => toast_error( + &ui, + "Couldn't open this video", + "Couldn't reach Proton Drive.", + ), } }); } diff --git a/crates/pdfs-gui/src/app/pages/shared.rs b/crates/pdfs-gui/src/app/pages/shared.rs index 984297f..51f5149 100644 --- a/crates/pdfs-gui/src/app/pages/shared.rs +++ b/crates/pdfs-gui/src/app/pages/shared.rs @@ -472,7 +472,12 @@ pub(crate) fn prompt_add_bookmark(ui: &Rc) { } /// Run a mutation raised from the Shared page and reload the page on success. -pub(crate) fn run_shared_mutation(ui: &Rc, req: Request, done: &'static str, failed: &'static str) { +pub(crate) fn run_shared_mutation( + ui: &Rc, + req: Request, + done: &'static str, + failed: &'static str, +) { ui.busy_begin(); let rx = spawn_request(ui.dirs.control_socket(), req); let ui = ui.clone(); diff --git a/crates/pdfs-gui/src/app/pages/shared_by_me.rs b/crates/pdfs-gui/src/app/pages/shared_by_me.rs index 48d0a50..a5b2b97 100644 --- a/crates/pdfs-gui/src/app/pages/shared_by_me.rs +++ b/crates/pdfs-gui/src/app/pages/shared_by_me.rs @@ -94,7 +94,13 @@ pub(crate) fn wire_shared_by_me(ui: &Rc, retry: >k4::Button) { } /// Show a status page in place of the Shared (by-me) list. -pub(crate) fn shared_by_me_status(ui: &Rc, icon: &str, title: &str, description: &str, retry: bool) { +pub(crate) fn shared_by_me_status( + ui: &Rc, + icon: &str, + title: &str, + description: &str, + retry: bool, +) { ui.shared_by_me.status.set_icon_name(Some(icon)); ui.shared_by_me.status.set_title(title); ui.shared_by_me.status.set_description(Some(description)); diff --git a/crates/pdfs-gui/src/app/pages/status.rs b/crates/pdfs-gui/src/app/pages/status.rs index a821a18..029f758 100644 --- a/crates/pdfs-gui/src/app/pages/status.rs +++ b/crates/pdfs-gui/src/app/pages/status.rs @@ -259,14 +259,20 @@ pub(crate) const GIB: f64 = 1024.0 * 1024.0 * 1024.0; /// read once from config / systemd here (the refresh loop owns only the live /// mount + cache-usage read-out), with [`Ui::settings_suppress`] set around the /// programmatic populate so the change handlers don't fire on it. -pub(crate) fn wire_settings(ui: &Rc, purge_button: >k4::Button, mountpoint_button: >k4::Button) { +pub(crate) fn wire_settings( + ui: &Rc, + purge_button: >k4::Button, + mountpoint_button: >k4::Button, +) { let config = ui.dirs.load_config(); // Populate from persisted config + the systemd unit state, suppressed. ui.status.settings_suppress.set(true); - ui.status.budget_row + ui.status + .budget_row .set_value(config.resolved_cache_budget() as f64 / GIB); - ui.status.mountpoint_row + ui.status + .mountpoint_row .set_subtitle(&ui.dirs.resolved_mountpoint(&config).display().to_string()); ui.status.autostart_row.set_active(service::is_enabled()); ui.status.settings_suppress.set(false); @@ -337,7 +343,12 @@ pub(crate) fn wire_settings(ui: &Rc, purge_button: >k4::Button, mountpoint /// confirming with `done` or reporting the daemon's error under `failed`. Unlike /// [`run_mutation`] there's no browser reload; the next refresh tick repaints the /// cache read-out. -pub(crate) fn settings_request(ui: &Rc, req: Request, done: &'static str, failed: &'static str) { +pub(crate) fn settings_request( + ui: &Rc, + req: Request, + done: &'static str, + failed: &'static str, +) { ui.busy_begin(); let rx = spawn_request(ui.dirs.control_socket(), req); let ui = ui.clone(); @@ -586,7 +597,8 @@ pub(crate) fn repaint_transfers(ui: &Rc, items: &[TransferItem], jobs: &[Job .child(&row_box) .build(); ui.status.transfers_group.add(&row); - ui.status.transfer_rows + ui.status + .transfer_rows .borrow_mut() .push(TransferRow { row, label, bar }); } @@ -742,7 +754,10 @@ pub(crate) fn repaint_pins(ui: &Rc, pins: &[pdfs_core::cache::Pin], mounted: .subtitle("Right-click a file in the mount to keep it offline.") .build(); ui.status.pins_group.add(&row); - ui.status.pin_rows.borrow_mut().push(PinRow { row, unpin: None }); + ui.status + .pin_rows + .borrow_mut() + .push(PinRow { row, unpin: None }); return; } diff --git a/crates/pdfs-gui/src/app/widgets/details.rs b/crates/pdfs-gui/src/app/widgets/details.rs index 972ad68..58a8a0e 100644 --- a/crates/pdfs-gui/src/app/widgets/details.rs +++ b/crates/pdfs-gui/src/app/widgets/details.rs @@ -147,7 +147,10 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) { /// wire its buttons back onto the entry it's showing. pub(crate) fn wire_details(ui: &Rc) { // Both views share one model but have their own selection, so watch both. - for selection in [ui.details.grid_selection.clone(), ui.details.list_selection.clone()] { + for selection in [ + ui.details.grid_selection.clone(), + ui.details.list_selection.clone(), + ] { let ui_sel = ui.clone(); selection.connect_selection_changed(move |sel, _, _| { match entry_at(sel.model().as_ref(), sel.selected()) { @@ -160,10 +163,12 @@ pub(crate) fn wire_details(ui: &Rc) { let ui_close = ui.clone(); ui.details.details.close_button.connect_clicked(move |_| { ui_close - .details.grid_selection + .details + .grid_selection .set_selected(gtk4::INVALID_LIST_POSITION); ui_close - .details.list_selection + .details + .list_selection .set_selected(gtk4::INVALID_LIST_POSITION); hide_details(&ui_close); }); @@ -193,19 +198,22 @@ pub(crate) fn wire_details(ui: &Rc) { } }); let ui_pin = ui.clone(); - ui.details.details.pin_row.connect_active_notify(move |row| { - if ui_pin.details.details_suppress.get() { - return; - } - let Some(entry) = ui_pin.details.details_entry.borrow().clone() else { - return; - }; - // The switch reads the *desired* state; `toggle_pin` derives the request - // from the entry's current one, so only act when they actually differ. - if row.is_active() != entry.pinned { - toggle_pin(&ui_pin, &entry); - } - }); + ui.details + .details + .pin_row + .connect_active_notify(move |row| { + if ui_pin.details.details_suppress.get() { + return; + } + let Some(entry) = ui_pin.details.details_entry.borrow().clone() else { + return; + }; + // The switch reads the *desired* state; `toggle_pin` derives the request + // from the entry's current one, so only act when they actually differ. + if row.is_active() != entry.pinned { + toggle_pin(&ui_pin, &entry); + } + }); } /// Reveal the details pane and paint it from `entry`. diff --git a/crates/pdfs-gui/src/app/widgets/share_dialog.rs b/crates/pdfs-gui/src/app/widgets/share_dialog.rs index c36510e..ef90981 100644 --- a/crates/pdfs-gui/src/app/widgets/share_dialog.rs +++ b/crates/pdfs-gui/src/app/widgets/share_dialog.rs @@ -400,7 +400,11 @@ pub(crate) fn repaint_share_link(state: &Rc, link: Option<&PublicLi /// Create a public link, then reload the dialog so the copy/remove controls /// replace the create form (and the freshly minted URL is shown). -pub(crate) fn share_dialog_create_link(state: &Rc, role: String, password: Option) { +pub(crate) fn share_dialog_create_link( + state: &Rc, + role: String, + password: Option, +) { let rx = spawn_request( state.ui.dirs.control_socket(), Request::CreatePublicLink { diff --git a/crates/pdfs-gui/src/prompt.rs b/crates/pdfs-gui/src/prompt.rs index d86ee1f..39bb8b5 100644 --- a/crates/pdfs-gui/src/prompt.rs +++ b/crates/pdfs-gui/src/prompt.rs @@ -840,9 +840,7 @@ impl Ui { // so it lands on the final list, not a half-populated one. if self.open_pending.get() && self.pending.get() == 0 { self.open_pending.set(false); - if let (Some(index), Some(window)) = - (self.cursor.get(), self.window.borrow().clone()) - { + if let (Some(index), Some(window)) = (self.cursor.get(), self.window.borrow().clone()) { self.open(index, &window); } }