diff --git a/.gitea/workflows/build-and-test.yml b/.gitea/workflows/build-and-test.yml index b42f12e..4eaa4b1 100644 --- a/.gitea/workflows/build-and-test.yml +++ b/.gitea/workflows/build-and-test.yml @@ -93,16 +93,38 @@ jobs: # The linker targets MIN_API, not the compile SDK. cargo-ndk otherwise # defaults to API 21, far below the Vulkan floor this app needs — and the # mismatch is invisible until a device refuses to install. + # + # Look under the target triple, and fail on a mismatch. Searching the + # whole target dir for the first `*.so` found the host proc-macro + # libraries in target-android/debug/deps instead — x86-64 objects built + # by the runner's gcc, whose .comment section says nothing about Android + # and can never contradict the expected API. The step passed regardless + # of what the linker actually did, which is the one thing it exists to + # rule out. - name: Verify minimum API level env: CARGO_TARGET_DIR: target-android run: | set -e cargo ndk -t arm64-v8a build -p dr-gpu --release - SO=$(find target-android -name 'libdr_gpu*' -o -name '*.so' | head -1) - if [ -n "$SO" ]; then - echo "checking $SO" - readelf -p .comment "$SO" 2>/dev/null | head -5 || true + MIN_API=$(sed -n 's/^ARG MIN_API=\([0-9]*\).*/\1/p' docker/android/Dockerfile) + # Empty on both sides would compare equal and pass, so neither side + # is allowed to be the result of a failed parse. + if [ -z "$MIN_API" ]; then + echo "no ARG MIN_API= in docker/android/Dockerfile" + exit 1 + fi + SO=$(find target-android/aarch64-linux-android/release -maxdepth 1 -name '*.so' | head -1) + if [ -z "$SO" ]; then + echo "no aarch64 .so was produced" + exit 1 + fi + echo "checking $SO" + file "$SO" + API=$(file "$SO" | sed -n 's/.*for Android \([0-9]*\).*/\1/p') + if [ -z "$API" ] || [ "$API" != "$MIN_API" ]; then + echo "FAIL: linked for Android '${API:-unknown}', expected $MIN_API" + exit 1 fi layering: diff --git a/core/dr-catalog/src/cache.rs b/core/dr-catalog/src/cache.rs index da02c10..2e7c1ab 100644 --- a/core/dr-catalog/src/cache.rs +++ b/core/dr-catalog/src/cache.rs @@ -156,7 +156,9 @@ impl Cache { let ext = source_ref .rsplit_once('.') .map(|(_, e)| e.to_ascii_lowercase()) - .filter(|e| !e.is_empty() && e.len() <= 8 && e.chars().all(|c| c.is_ascii_alphanumeric())) + .filter(|e| { + !e.is_empty() && e.len() <= 8 && e.chars().all(|c| c.is_ascii_alphanumeric()) + }) .unwrap_or_else(|| "bin".to_string()); format!("{}.{ext}", image.0) } @@ -257,7 +259,10 @@ impl Cache { // The file is gone but the row says it is here. Believing the // row would report the image as locally available for ever // while every open failed. - log::debug!("cached original {} missing, forgetting it: {e}", abs.display()); + log::debug!( + "cached original {} missing, forgetting it: {e}", + abs.display() + ); self.forget(conn, &[image])?; Ok(None) } @@ -357,7 +362,11 @@ impl Cache { )?; let mut usage = Usage::default(); let rows = stmt.query_map(rusqlite::params![Tier::Original.stored()], |r| { - Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?)) + Ok(( + r.get::<_, i64>(0)?, + r.get::<_, i64>(1)?, + r.get::<_, i64>(2)?, + )) })?; for row in rows { let (pinned, count, bytes) = row?; @@ -522,9 +531,7 @@ mod tests { rusqlite::params![format!("Photos/img{i:03}.CR2")], ) .unwrap(); - ids.push(ImageId( - catalog.connection().last_insert_rowid() as u64 - )); + ids.push(ImageId(catalog.connection().last_insert_rowid() as u64)); } let dir = tempdir(); let cache = Cache::open(&dir, budget).unwrap(); @@ -557,9 +564,15 @@ mod tests { let (cat, cache, _dir, ids) = fixture(3); // 400 each against a 1000 budget: storing the third puts it 200 over. let bytes = vec![0u8; 400]; - cache.store(cat.connection(), ids[0], "a.CR2", &bytes, false, 10).unwrap(); - cache.store(cat.connection(), ids[1], "b.CR2", &bytes, false, 20).unwrap(); - cache.store(cat.connection(), ids[2], "c.CR2", &bytes, false, 30).unwrap(); + cache + .store(cat.connection(), ids[0], "a.CR2", &bytes, false, 10) + .unwrap(); + cache + .store(cat.connection(), ids[1], "b.CR2", &bytes, false, 20) + .unwrap(); + cache + .store(cat.connection(), ids[2], "c.CR2", &bytes, false, 30) + .unwrap(); // Touch the oldest so it is no longer the least recently used. cache.load(cat.connection(), ids[0], 40).unwrap(); @@ -578,9 +591,15 @@ mod tests { let (cat, cache, _dir, ids) = fixture(3); let bytes = vec![0u8; 800]; - cache.store(cat.connection(), ids[0], "a.CR2", &bytes, true, 10).unwrap(); - cache.store(cat.connection(), ids[1], "b.CR2", &bytes, false, 20).unwrap(); - cache.store(cat.connection(), ids[2], "c.CR2", &bytes, false, 30).unwrap(); + cache + .store(cat.connection(), ids[0], "a.CR2", &bytes, true, 10) + .unwrap(); + cache + .store(cat.connection(), ids[1], "b.CR2", &bytes, false, 20) + .unwrap(); + cache + .store(cat.connection(), ids[2], "c.CR2", &bytes, false, 30) + .unwrap(); cache.enforce(cat.connection()).unwrap(); @@ -597,10 +616,24 @@ mod tests { // pinned. let (cat, cache, _dir, ids) = fixture(2); cache - .store(cat.connection(), ids[0], "a.CR2", &vec![0u8; 5000], true, 10) + .store( + cat.connection(), + ids[0], + "a.CR2", + &vec![0u8; 5000], + true, + 10, + ) .unwrap(); cache - .store(cat.connection(), ids[1], "b.CR2", &vec![0u8; 500], false, 20) + .store( + cat.connection(), + ids[1], + "b.CR2", + &vec![0u8; 500], + false, + 20, + ) .unwrap(); // Pinned use is far past the 1000 budget, but the passive 500 fits. @@ -617,7 +650,14 @@ mod tests { let (cat, cache, _dir, ids) = fixture_with(2, Budget::unlimited()); for (i, id) in ids.iter().enumerate() { cache - .store(cat.connection(), *id, "a.CR2", &vec![0u8; 100_000], false, i as i64) + .store( + cat.connection(), + *id, + "a.CR2", + &vec![0u8; 100_000], + false, + i as i64, + ) .unwrap(); } assert_eq!(cache.enforce(cat.connection()).unwrap(), 0); @@ -692,7 +732,14 @@ mod tests { // But now it is a candidate. cache - .store(cat.connection(), ids[1], "b.CR2", &vec![0u8; 800], false, 20) + .store( + cat.connection(), + ids[1], + "b.CR2", + &vec![0u8; 800], + false, + 20, + ) .unwrap(); assert_eq!(cache.enforce(cat.connection()).unwrap(), 1); assert!(!cache.holds_original(cat.connection(), ids[0])); @@ -718,8 +765,12 @@ mod tests { #[test] fn storing_the_same_image_twice_is_one_entry() { let (cat, cache, _dir, ids) = fixture(1); - cache.store(cat.connection(), ids[0], "a.CR2", b"first", false, 10).unwrap(); - cache.store(cat.connection(), ids[0], "a.CR2", b"second try", false, 20).unwrap(); + cache + .store(cat.connection(), ids[0], "a.CR2", b"first", false, 10) + .unwrap(); + cache + .store(cat.connection(), ids[0], "a.CR2", b"second try", false, 20) + .unwrap(); let usage = cache.usage(cat.connection()).unwrap(); assert_eq!(usage.passive_count, 1); @@ -737,10 +788,24 @@ mod tests { // other, which is the worst failure this cache could have. let (cat, cache, _dir, ids) = fixture(2); cache - .store(cat.connection(), ids[0], "Photos/IMG_0001.CR2", b"first", false, 10) + .store( + cat.connection(), + ids[0], + "Photos/IMG_0001.CR2", + b"first", + false, + 10, + ) .unwrap(); cache - .store(cat.connection(), ids[1], "Trips/IMG_0001.CR2", b"second", false, 20) + .store( + cat.connection(), + ids[1], + "Trips/IMG_0001.CR2", + b"second", + false, + 20, + ) .unwrap(); assert_eq!( @@ -760,7 +825,14 @@ mod tests { let (cat, cache, _dir, ids) = fixture(3); for (i, id) in ids.iter().enumerate() { cache - .store(cat.connection(), *id, "a.CR2", &vec![0u8; 400], false, i as i64) + .store( + cat.connection(), + *id, + "a.CR2", + &vec![0u8; 400], + false, + i as i64, + ) .unwrap(); } // 1200 held against 1000: dropping one 400-byte entry suffices. @@ -772,7 +844,14 @@ mod tests { fn an_extensionless_source_still_gets_a_path() { let (cat, cache, _dir, ids) = fixture(1); cache - .store(cat.connection(), ids[0], "Photos/no-extension", b"bytes", false, 10) + .store( + cat.connection(), + ids[0], + "Photos/no-extension", + b"bytes", + false, + 10, + ) .unwrap(); assert_eq!( cache.load(cat.connection(), ids[0], 20).unwrap().as_deref(), diff --git a/core/dr-catalog/src/collections.rs b/core/dr-catalog/src/collections.rs index 058486e..30d642a 100644 --- a/core/dr-catalog/src/collections.rs +++ b/core/dr-catalog/src/collections.rs @@ -316,9 +316,8 @@ fn remove_within( } let mut removed = 0; { - let mut stmt = conn.prepare( - "DELETE FROM collection_members WHERE collection_id = ?1 AND image_id = ?2", - )?; + let mut stmt = conn + .prepare("DELETE FROM collection_members WHERE collection_id = ?1 AND image_id = ?2")?; for img in images { removed += stmt.execute(rusqlite::params![id.0 as i64, img.0 as i64])?; } @@ -397,7 +396,8 @@ pub fn save_smart( } } - let json = serde_json::to_string(selector).map_err(|e| CatalogError::BadSelector(e.to_string()))?; + let json = + serde_json::to_string(selector).map_err(|e| CatalogError::BadSelector(e.to_string()))?; let n = conn.execute( "UPDATE collections SET selector_json = ?2, kind = 1 WHERE id = ?1 AND deleted = 0", rusqlite::params![id.0 as i64, json], @@ -470,7 +470,9 @@ pub fn collections_for_image( ORDER BY c.name", )?; let rows = stmt - .query_map([image.0 as i64], |r| Ok(CollectionId(r.get::<_, i64>(0)? as u64)))? + .query_map([image.0 as i64], |r| { + Ok(CollectionId(r.get::<_, i64>(0)? as u64)) + })? .collect::, _>>()?; Ok(rows) } @@ -492,9 +494,8 @@ pub fn descendants( let mut frontier = vec![root]; let mut guard = 0usize; - let mut stmt = conn.prepare( - "SELECT id FROM collections WHERE parent_id = ?1 AND deleted = 0", - )?; + let mut stmt = + conn.prepare("SELECT id FROM collections WHERE parent_id = ?1 AND deleted = 0")?; while let Some(next) = frontier.pop() { guard += 1; @@ -507,7 +508,9 @@ pub fn descendants( } let children = stmt - .query_map([next.0 as i64], |r| Ok(CollectionId(r.get::<_, i64>(0)? as u64)))? + .query_map([next.0 as i64], |r| { + Ok(CollectionId(r.get::<_, i64>(0)? as u64)) + })? .collect::, _>>()?; for child in children { @@ -668,7 +671,9 @@ fn require_exists(conn: &Connection, id: CollectionId) -> Result<(), CatalogErro |r| r.get(0), ) .optional()?; - found.map(|_| ()).ok_or(CatalogError::NoSuchCollection(id.0)) + found + .map(|_| ()) + .ok_or(CatalogError::NoSuchCollection(id.0)) } /// A random UUID, formatted as the canonical 8-4-4-4-12. @@ -685,8 +690,22 @@ fn new_uuid() -> String { format!( "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-\ {:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", - b[0], b[1], b[2], b[3], b[4], b[5], v6, b[7], v8, b[9], b[10], b[11], b[12], b[13], - b[14], b[15] + b[0], + b[1], + b[2], + b[3], + b[4], + b[5], + v6, + b[7], + v8, + b[9], + b[10], + b[11], + b[12], + b[13], + b[14], + b[15] ) } @@ -797,7 +816,10 @@ mod tests { let rows = tree(c).unwrap(); assert_eq!(rows.len(), 2); assert_eq!(rows[0].collection.name, "Trips"); - assert!(rows[0].has_children, "a parent must draw a disclosure arrow"); + assert!( + rows[0].has_children, + "a parent must draw a disclosure arrow" + ); assert_eq!(rows[1].depth, 1, "the child is indented one level"); } diff --git a/core/dr-catalog/src/rating.rs b/core/dr-catalog/src/rating.rs index 3e12b92..1c90595 100644 --- a/core/dr-catalog/src/rating.rs +++ b/core/dr-catalog/src/rating.rs @@ -472,7 +472,9 @@ mod tests { let distinct: i64 = cat .connection() - .query_row("SELECT count(DISTINCT uuid) FROM versions", [], |r| r.get(0)) + .query_row("SELECT count(DISTINCT uuid) FROM versions", [], |r| { + r.get(0) + }) .unwrap(); assert_eq!(distinct, 200); } @@ -566,7 +568,10 @@ mod tests { fn a_bulk_write_over_an_empty_selection_is_a_no_op() { let cat = with_images(3); assert_eq!(set_rating_many(cat.connection(), &[], 5).unwrap(), 0); - assert_eq!(set_flag_many(cat.connection(), &[], FlagState::Pick).unwrap(), 0); + assert_eq!( + set_flag_many(cat.connection(), &[], FlagState::Pick).unwrap(), + 0 + ); } #[test] diff --git a/core/dr-catalog/src/schema.rs b/core/dr-catalog/src/schema.rs index bf66cf6..da13480 100644 --- a/core/dr-catalog/src/schema.rs +++ b/core/dr-catalog/src/schema.rs @@ -491,8 +491,6 @@ mod tests { c } - - /// How many rows a named backfill touched, ignoring the others. /// /// Asserting on the whole vector would couple every test to which other @@ -542,9 +540,11 @@ mod tests { assert_eq!(backfilled(&c, "shadowed_by"), 1); let got: Option = c - .query_row("SELECT shadowed_by FROM images WHERE id = ?1", [jpeg], |r| { - r.get(0) - }) + .query_row( + "SELECT shadowed_by FROM images WHERE id = ?1", + [jpeg], + |r| r.get(0), + ) .unwrap(); assert_eq!(got, Some(raw)); } @@ -600,11 +600,7 @@ mod tests { image(&c, Some(1), "a/IMG_1.JPG", "jpg"); assert_eq!(backfilled(&c, "shadowed_by"), 1); - assert_eq!( - backfilled(&c, "shadowed_by"), - 0, - "second pass is a no-op" - ); + assert_eq!(backfilled(&c, "shadowed_by"), 0, "second pass is a no-op"); } #[test] diff --git a/core/dr-catalog/src/trash.rs b/core/dr-catalog/src/trash.rs index b330cfd..63c5733 100644 --- a/core/dr-catalog/src/trash.rs +++ b/core/dr-catalog/src/trash.rs @@ -260,7 +260,9 @@ pub fn list(conn: &Connection, limit: usize) -> Result, Catalo // the current path keeps it listed and deletable rather than // invisible; a restore to the trash folder is a no-op the user // can see, where a hidden row is not. - trashed_from: r.get::<_, Option>(2)?.unwrap_or_else(|| source_ref.clone()), + trashed_from: r + .get::<_, Option>(2)? + .unwrap_or_else(|| source_ref.clone()), source_ref, trashed_at: r.get(3)?, file_id: r.get::<_, Option>(4)?.map(|v| v as u64), @@ -310,9 +312,7 @@ pub fn file_ids_for(conn: &Connection, images: &[ImageId]) -> Result, C let placeholders = std::iter::repeat_n("?", images.len()) .collect::>() .join(","); - let sql = format!( - "SELECT file_id FROM remote WHERE image_id IN ({placeholders})" - ); + let sql = format!("SELECT file_id FROM remote WHERE image_id IN ({placeholders})"); let params: Vec = images .iter() .map(|i| rusqlite::types::Value::Integer(i.0 as i64)) @@ -364,9 +364,11 @@ mod tests { fn do_trash(cat: &Catalog, i: u64, now: i64) -> String { let c = cat.connection(); let original: String = c - .query_row("SELECT source_ref FROM images WHERE id = ?1", [i as i64], |r| { - r.get(0) - }) + .query_row( + "SELECT source_ref FROM images WHERE id = ?1", + [i as i64], + |r| r.get(0), + ) .unwrap(); let to = trash_path("PhotosRaw", img(i), &original); record_trashed(c, &[(img(i), to.clone())], now).unwrap(); @@ -440,7 +442,9 @@ mod tests { let c = cat.connection(); do_trash(&cat, 1, 5_000); - let back = restore_path(c, img(1)).unwrap().expect("knows where it came from"); + let back = restore_path(c, img(1)) + .unwrap() + .expect("knows where it came from"); assert_eq!(back, "PhotosRaw/2019/IMG_0001.CR2"); record_restored(c, &[(img(1), back.clone())]).unwrap(); @@ -471,7 +475,10 @@ mod tests { do_trash(&cat, 1, 2_000); let second = restore_path(c, img(1)).unwrap().unwrap(); - assert_eq!(first, second, "the origin is the library path, not the trash"); + assert_eq!( + first, second, + "the origin is the library path, not the trash" + ); } #[test] @@ -506,7 +513,9 @@ mod tests { ); // And its path is untouched. let source: String = c - .query_row("SELECT source_ref FROM images WHERE id = 2", [], |r| r.get(0)) + .query_row("SELECT source_ref FROM images WHERE id = 2", [], |r| { + r.get(0) + }) .unwrap(); assert_eq!(source, "PhotosRaw/2019/IMG_0002.CR2"); } diff --git a/core/dr-decode/examples/hdr.rs b/core/dr-decode/examples/hdr.rs index 20994b5..0d51750 100644 --- a/core/dr-decode/examples/hdr.rs +++ b/core/dr-decode/examples/hdr.rs @@ -5,7 +5,10 @@ fn main() { // Exactly what the sweep sees: the first HEADER_BYTES only. let head = &d[..d.len().min(dr_decode::HEADER_BYTES as usize)]; match dr_decode::metadata(head) { - Ok(m) => println!("{n}: header-only at={:?} model={:?}", m.captured_at, m.model), + Ok(m) => println!( + "{n}: header-only at={:?} model={:?}", + m.captured_at, m.model + ), Err(e) => println!("{n}: header-only ERROR {e}"), } match dr_decode::metadata(&d) { diff --git a/core/dr-decode/src/locate.rs b/core/dr-decode/src/locate.rs index 537ea17..ef2ca8e 100644 --- a/core/dr-decode/src/locate.rs +++ b/core/dr-decode/src/locate.rs @@ -318,7 +318,9 @@ impl<'a> TiffReader<'a> { }; raw[..len.min(4)].to_vec() } else { - self.data.get(e.value as usize..e.value as usize + len)?.to_vec() + self.data + .get(e.value as usize..e.value as usize + len)? + .to_vec() }; let s = String::from_utf8_lossy(&bytes); @@ -501,7 +503,6 @@ fn read_exif_entries( md: &mut crate::Metadata, fb: &mut FallbackDates, ) { - for e in entries { match e.tag { exif_tag::MAKE => md.make = r.ascii(e), @@ -782,7 +783,6 @@ mod tests { assert!(locate_preview(b"\0\0\0\x18ftypcrx ", 1000).is_none()); } - /// Build a JPEG carrying an APP1 EXIF block with the given IFD entries. fn jpeg_with_exif(entries: &[(u16, u16, u32, u32)], extra: &[u8]) -> Vec { let mut tiff = Vec::new(); diff --git a/core/dr-pipeline/src/framing.rs b/core/dr-pipeline/src/framing.rs index 67c1036..52e0308 100644 --- a/core/dr-pipeline/src/framing.rs +++ b/core/dr-pipeline/src/framing.rs @@ -670,7 +670,11 @@ mod tests { height: 0.5, }); - assert_eq!(f.output_size(6000, 4000), before_size, "zoom resized output"); + assert_eq!( + f.output_size(6000, 4000), + before_size, + "zoom resized output" + ); assert!(f.crop().is_full(), "zoom altered the crop"); } diff --git a/core/dr-pipeline/src/sidecar.rs b/core/dr-pipeline/src/sidecar.rs index 539e0af..55eca61 100644 --- a/core/dr-pipeline/src/sidecar.rs +++ b/core/dr-pipeline/src/sidecar.rs @@ -456,7 +456,10 @@ impl Sidecar { continue; } - if let Some(uuid) = line.strip_prefix("[version ").and_then(|s| s.strip_suffix(']')) { + if let Some(uuid) = line + .strip_prefix("[version ") + .and_then(|s| s.strip_suffix(']')) + { if let Some(v) = current.take() { sidecar.put(v); } @@ -495,9 +498,7 @@ impl Sidecar { // by a build with a wider scale, or hand-edited. An // out-of-range rating would sort above five stars forever and // no filter would reach it. - "rating" => { - version.rating = value.parse::().unwrap_or(0).min(MAX_RATING) - } + "rating" => version.rating = value.parse::().unwrap_or(0).min(MAX_RATING), "flag" => version.flag = value.parse::().unwrap_or(0).min(MAX_FLAG), _ => match key.split_once('.') { // An `op.param` line whose value does not parse is a @@ -582,11 +583,10 @@ mod tests { // as neutral rather than as missing. let v = version_of(&edited()); assert_eq!(v.params.len(), 2); - assert!(v.params.contains_key(&("exposure".into(), "exposure".into()))); - assert!(!v + assert!(v .params - .keys() - .any(|(op, _)| op == colour::SATURATION_ID.0)); + .contains_key(&("exposure".into(), "exposure".into()))); + assert!(!v.params.keys().any(|(op, _)| op == colour::SATURATION_ID.0)); } #[test] @@ -602,12 +602,12 @@ mod tests { let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid"); let mut restored = EditGraph::default_chain(); - parsed.default_version().expect("a version").apply(&mut restored); + parsed + .default_version() + .expect("a version") + .apply(&mut restored); - assert_eq!( - restored.param(exposure::ID, exposure::EXPOSURE), - Some(0.75) - ); + assert_eq!(restored.param(exposure::ID, exposure::EXPOSURE), Some(0.75)); assert_eq!( restored.param(white_balance::ID, white_balance::TEMPERATURE), Some(30.0) @@ -636,7 +636,10 @@ mod tests { let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid"); let mut restored = EditGraph::default_chain(); - parsed.default_version().expect("a version").apply(&mut restored); + parsed + .default_version() + .expect("a version") + .apply(&mut restored); for cap in g.capabilities() { for p in &cap.params { @@ -670,7 +673,10 @@ mod tests { let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid"); let mut restored = EditGraph::default_chain(); - parsed.default_version().expect("a version").apply(&mut restored); + parsed + .default_version() + .expect("a version") + .apply(&mut restored); assert_eq!(restored.crop(), g.crop()); assert_eq!(restored.param(framing::ID, framing::ANGLE), Some(-1.5)); @@ -1090,4 +1096,3 @@ mod tests { assert_eq!(format_value(0.75), "0.75"); } } - diff --git a/core/dr-sync-nextcloud/examples/writetest.rs b/core/dr-sync-nextcloud/examples/writetest.rs index d45471b..75f2e0a 100644 --- a/core/dr-sync-nextcloud/examples/writetest.rs +++ b/core/dr-sync-nextcloud/examples/writetest.rs @@ -14,7 +14,10 @@ async fn main() { }; let creds = match store.credentials(&session) { Ok(c) => c, - Err(e) => { println!("credentials: {e}"); return; } + Err(e) => { + println!("credentials: {e}"); + return; + } }; println!("account: {} root={}", session.describe(), session.root); @@ -45,20 +48,38 @@ async fn main() { // range request, or the folder? { let c = dr_sync_nextcloud::http_client("DarkRoom").unwrap(); - let base = format!("{}/remote.php/dav/files/{}", - creds.server.trim_end_matches('/'), session.user_id); + let base = format!( + "{}/remote.php/dav/files/{}", + creds.server.trim_end_matches('/'), + session.user_id + ); let f = "PhotosRaw/Darktable/20230629_no_name/20230629_0030.jpeg"; - let enc: String = f.split('/').map(|seg| { - seg.bytes().map(|b| match b { - b'A'..=b'Z'|b'a'..=b'z'|b'0'..=b'9'|b'-'|b'_'|b'.'|b'~' => (b as char).to_string(), - _ => format!("%{b:02X}"), - }).collect::() - }).collect::>().join("/"); + let enc: String = f + .split('/') + .map(|seg| { + seg.bytes() + .map(|b| match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + (b as char).to_string() + } + _ => format!("%{b:02X}"), + }) + .collect::() + }) + .collect::>() + .join("/"); let url = format!("{base}/{enc}"); - for (what, range) in [("ranged 0-256k", Some("bytes=0-262143")), ("whole file", None)] { - let mut rq = c.get(&url).basic_auth(&creds.login_name, Some(&creds.app_password)); - if let Some(r) = range { rq = rq.header("Range", r); } + for (what, range) in [ + ("ranged 0-256k", Some("bytes=0-262143")), + ("whole file", None), + ] { + let mut rq = c + .get(&url) + .basic_auth(&creds.login_name, Some(&creds.app_password)); + if let Some(r) = range { + rq = rq.header("Range", r); + } match rq.send().await { Ok(r) => println!("GET {what}: {}", r.status()), Err(e) => println!("GET {what}: transport {e}"), @@ -68,11 +89,20 @@ async fn main() { // Raw HTTP, to see the status the connector maps away. { - let url = format!("{}/remote.php/dav/files/{}/{}/.darkroom-write-test", - creds.server.trim_end_matches('/'), session.user_id, session.root); + let url = format!( + "{}/remote.php/dav/files/{}/{}/.darkroom-write-test", + creds.server.trim_end_matches('/'), + session.user_id, + session.root + ); let c = dr_sync_nextcloud::http_client("DarkRoom").unwrap(); - match c.put(&url).basic_auth(&creds.login_name, Some(&creds.app_password)) - .body("probe").send().await { + match c + .put(&url) + .basic_auth(&creds.login_name, Some(&creds.app_password)) + .body("probe") + .send() + .await + { Ok(r) => { println!("RAW PUT status: {}", r.status()); let body = r.text().await.unwrap_or_default(); @@ -90,20 +120,33 @@ async fn main() { // freely elsewhere, which is a different fix from a credential problem. { let c = dr_sync_nextcloud::http_client("DarkRoom").unwrap(); - let base = format!("{}/remote.php/dav/files/{}", - creds.server.trim_end_matches('/'), session.user_id); + let base = format!( + "{}/remote.php/dav/files/{}", + creds.server.trim_end_matches('/'), + session.user_id + ); for (what, url) in [ ("account root", format!("{base}/.darkroom-write-test")), - ("library root", format!("{base}/{}/.darkroom-write-test", session.root)), + ( + "library root", + format!("{base}/{}/.darkroom-write-test", session.root), + ), ] { - match c.put(&url).basic_auth(&creds.login_name, Some(&creds.app_password)) - .body("probe").send().await { + match c + .put(&url) + .basic_auth(&creds.login_name, Some(&creds.app_password)) + .body("probe") + .send() + .await + { Ok(r) => { println!("PUT {what}: {}", r.status()); if r.status().is_success() { - let _ = c.delete(&url) + let _ = c + .delete(&url) .basic_auth(&creds.login_name, Some(&creds.app_password)) - .send().await; + .send() + .await; } } Err(e) => println!("PUT {what}: transport error {e}"), @@ -113,7 +156,10 @@ async fn main() { // Write into the library root. let p = RemotePath::new(format!("{}/.darkroom-write-test", session.root)); - match backend.put(&p, b"darkroom write probe\n".to_vec(), None).await { + match backend + .put(&p, b"darkroom write probe\n".to_vec(), None) + .await + { Ok(v) => { println!("WRITE ok: etag={}", v.as_str()); match backend.delete(&RemoteId::Path(p.clone()), None).await { diff --git a/core/dr-sync-nextcloud/src/lib.rs b/core/dr-sync-nextcloud/src/lib.rs index 6eb71e7..e345c45 100644 --- a/core/dr-sync-nextcloud/src/lib.rs +++ b/core/dr-sync-nextcloud/src/lib.rs @@ -134,7 +134,8 @@ impl NextcloudBackend { for (i, chunk) in body.chunks(chunk_size).enumerate() { if i + 1 > CHUNKS.max_chunks as usize { return Err(RemoteError::Protocol(format!( - "{total} bytes exceeds {} chunks", CHUNKS.max_chunks + "{total} bytes exceeds {} chunks", + CHUNKS.max_chunks ))); } let resp = self @@ -221,9 +222,7 @@ impl NextcloudBackend { .map_err(map_send_error)?; map_status(resp.status(), path.as_str())?; - resp.text() - .await - .map_err(map_send_error) + resp.text().await.map_err(map_send_error) } } @@ -281,18 +280,11 @@ impl RemoteBackend for NextcloudBackend { ); } - let resp = req - .send() - .await - .map_err(map_send_error)?; + let resp = req.send().await.map_err(map_send_error)?; let status = resp.status(); map_status(status, &url)?; - let body = resp - .bytes() - .await - .map_err(map_send_error)? - .to_vec(); + let body = resp.bytes().await.map_err(map_send_error)?.to_vec(); // Nextcloud does not advertise Accept-Ranges, so support is detected // by the response code rather than by probing with HEAD (ARCH §6.7). @@ -342,11 +334,7 @@ impl RemoteBackend for NextcloudBackend { None => {} } - let resp = req - .body(body) - .send() - .await - .map_err(map_send_error)?; + let resp = req.body(body).send().await.map_err(map_send_error)?; map_status(resp.status(), path.as_str())?; resp.headers() @@ -371,10 +359,7 @@ impl RemoteBackend for NextcloudBackend { req = req.header(reqwest::header::IF_MATCH, format!("\"{}\"", v.as_str())); } - let resp = req - .send() - .await - .map_err(map_send_error)?; + let resp = req.send().await.map_err(map_send_error)?; map_status(resp.status(), &url) } @@ -491,12 +476,7 @@ impl RemoteBackend for NextcloudBackend { } map_status(resp.status(), &url)?; - Ok(Some( - resp.bytes() - .await - .map_err(map_send_error)? - .to_vec(), - )) + Ok(Some(resp.bytes().await.map_err(map_send_error)?.to_vec())) } } @@ -533,15 +513,15 @@ const EXTRA_ROOTS: &[(&str, &[u8])] = &[( /// failing here would take down every connection including the ones that never /// needed the extra anchor. fn extra_roots() -> impl Iterator { - EXTRA_ROOTS.iter().filter_map(|(name, pem)| { - match reqwest::Certificate::from_pem(pem) { + EXTRA_ROOTS + .iter() + .filter_map(|(name, pem)| match reqwest::Certificate::from_pem(pem) { Ok(c) => Some(c), Err(e) => { log::warn!("bundled root {name} unusable: {e}"); None } - } - }) + }) } /// Build an HTTP client with the crypto provider already installed. @@ -852,5 +832,3 @@ mod live { } } } - - diff --git a/core/dr-sync-nextcloud/src/session.rs b/core/dr-sync-nextcloud/src/session.rs index 5f2b101..66105d6 100644 --- a/core/dr-sync-nextcloud/src/session.rs +++ b/core/dr-sync-nextcloud/src/session.rs @@ -49,9 +49,7 @@ fn config_dir() -> PathBuf { } std::env::var_os("XDG_CONFIG_HOME") .map(PathBuf::from) - .unwrap_or_else(|| { - PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".config") - }) + .unwrap_or_else(|| PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".config")) .join("darkroom") } diff --git a/core/dr-sync/src/reachability.rs b/core/dr-sync/src/reachability.rs index 28405c5..b8dfe28 100644 --- a/core/dr-sync/src/reachability.rs +++ b/core/dr-sync/src/reachability.rs @@ -326,7 +326,9 @@ mod tests { let later = start + Duration::from_secs(600); r.observe::<()>(&Err(network()), later); - let elapsed = r.offline_for(later).expect("offline since the first failure"); + let elapsed = r + .offline_for(later) + .expect("offline since the first failure"); assert!( elapsed >= Duration::from_secs(600), "measured from the first failure, got {elapsed:?}" diff --git a/core/dr-sync/src/scan.rs b/core/dr-sync/src/scan.rs index 8ba07cd..d54bb96 100644 --- a/core/dr-sync/src/scan.rs +++ b/core/dr-sync/src/scan.rs @@ -446,7 +446,10 @@ mod tests { "a trashed image must not come back as an ordinary one" ); // The real photographs are still found. - assert!(result.images.iter().any(|i| i.path.as_str().ends_with("a.CR2"))); + assert!(result + .images + .iter() + .any(|i| i.path.as_str().ends_with("a.CR2"))); } #[tokio::test] @@ -500,9 +503,7 @@ mod tests { // And nothing else is swept up by it. assert!(!is_excluded(&RemotePath::new("Photos/2025"))); assert!(!is_excluded(&RemotePath::new("Photos/trash"))); - assert!(!is_excluded(&RemotePath::new( - "Photos/.darkroom-trash-old" - ))); + assert!(!is_excluded(&RemotePath::new("Photos/.darkroom-trash-old"))); } #[tokio::test] diff --git a/core/dr-thumbs/src/lib.rs b/core/dr-thumbs/src/lib.rs index 3986af8..9f099e9 100644 --- a/core/dr-thumbs/src/lib.rs +++ b/core/dr-thumbs/src/lib.rs @@ -429,10 +429,9 @@ impl ThumbStore { for (file_id, shard) in in_unsealed { match self.open_shard(shard, false) { Ok(conn) => { - if let Err(e) = conn.execute( - "DELETE FROM thumbs WHERE file_id = ?1", - [file_id as i64], - ) { + if let Err(e) = + conn.execute("DELETE FROM thumbs WHERE file_id = ?1", [file_id as i64]) + { log::debug!("forgetting thumbnail {file_id} in shard {shard}: {e}"); } } @@ -502,7 +501,7 @@ impl ThumbStore { // fallback below assumes. let has_size = src .prepare("SELECT * FROM thumbs LIMIT 0") - .map(|stmt| stmt.column_names().iter().any(|c| *c == "size")) + .map(|stmt| stmt.column_names().contains(&"size")) .unwrap_or(false); let sql = if has_size { @@ -594,7 +593,7 @@ CREATE TABLE IF NOT EXISTS thumbs ( fn migrate_size_column(conn: &Connection, table: &str) -> Result<(), ThumbError> { let has_size: bool = conn .prepare(&format!("SELECT * FROM {table} LIMIT 0")) - .map(|stmt| stmt.column_names().iter().any(|c| *c == "size")) + .map(|stmt| stmt.column_names().contains(&"size")) .unwrap_or(true); if has_size { return Ok(()); @@ -748,7 +747,6 @@ mod tests { assert!(s.get(1, ThumbSize::Grid).unwrap().is_some()); } - #[test] fn the_two_size_classes_coexist() { // Adding the large class must not evict or shadow the grid one: the @@ -800,7 +798,6 @@ mod tests { assert_eq!(ThumbSize::for_cell(400), ThumbSize::Large); } - #[test] fn a_shard_written_before_the_size_class_accepts_new_thumbnails() { // The index is not the only table with a `size` column: every shard @@ -969,8 +966,16 @@ mod tests { let original = s.shard_of(0, ThumbSize::Grid).unwrap(); s.put(0, ThumbSize::Grid, &thumb(32)).unwrap(); - assert_eq!(s.shard_of(0, ThumbSize::Grid), Some(original), "must stay put"); - assert_eq!(s.get(0, ThumbSize::Grid).unwrap().unwrap().bytes.len(), 32, "but update"); + assert_eq!( + s.shard_of(0, ThumbSize::Grid), + Some(original), + "must stay put" + ); + assert_eq!( + s.get(0, ThumbSize::Grid).unwrap().unwrap().bytes.len(), + 32, + "but update" + ); } #[test] @@ -997,7 +1002,8 @@ mod tests { mine.put(1, ThumbSize::Grid, &thumb(64)).unwrap(); // A second store standing in for another device's downloaded shard. - let other_dir = std::env::temp_dir().join(format!("dr-thumbs-other-{}", std::process::id())); + let other_dir = + std::env::temp_dir().join(format!("dr-thumbs-other-{}", std::process::id())); let _ = std::fs::remove_dir_all(&other_dir); let mut theirs = ThumbStore::open(&other_dir).unwrap(); theirs.put(1, ThumbSize::Grid, &thumb(999)).unwrap(); // we already have this one @@ -1017,8 +1023,7 @@ mod tests { #[test] fn merging_is_idempotent() { let (mut mine, _d1) = store(); - let other_dir = - std::env::temp_dir().join(format!("dr-thumbs-idem-{}", std::process::id())); + let other_dir = std::env::temp_dir().join(format!("dr-thumbs-idem-{}", std::process::id())); let _ = std::fs::remove_dir_all(&other_dir); let mut theirs = ThumbStore::open(&other_dir).unwrap(); theirs.put(10, ThumbSize::Grid, &thumb(64)).unwrap(); diff --git a/core/dr-types/src/lib.rs b/core/dr-types/src/lib.rs index 108f054..9d9db7b 100644 --- a/core/dr-types/src/lib.rs +++ b/core/dr-types/src/lib.rs @@ -19,7 +19,9 @@ pub use settings::{ /// Identifies a granted library location — a directory on Linux, a persisted /// document tree on Android. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize, +)] #[serde(transparent)] pub struct RootId(pub u64); @@ -32,7 +34,9 @@ pub struct ImageId(pub u64); pub struct VersionId(pub u64); /// Identifies a user-defined collection. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize, +)] #[serde(transparent)] pub struct CollectionId(pub u64); @@ -251,7 +255,9 @@ impl FormatFilter { /// /// Surfaced in the UI so a user always knows what they have — the failure /// Lightroom makes by showing an original's filename beside a 2560px proxy. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize, +)] pub enum Availability { /// Catalogued, nothing cached. MetadataOnly, diff --git a/core/dr-types/src/selector.rs b/core/dr-types/src/selector.rs index 2d72c8b..c815d45 100644 --- a/core/dr-types/src/selector.rs +++ b/core/dr-types/src/selector.rs @@ -115,7 +115,9 @@ pub enum DateSelector { } /// Colour labels, matching the conventional set. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize, +)] pub enum ColourLabel { Red, Yellow, @@ -130,8 +132,17 @@ pub enum ColourLabel { /// anyone has looked at it — an unjudged frame, not a rejected one. Any other /// default would silently assert a judgement the photographer never made. #[derive( - Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, - serde::Serialize, serde::Deserialize, + Debug, + Clone, + Copy, + Default, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + serde::Serialize, + serde::Deserialize, )] pub enum FlagState { /// Not yet judged. What "filter to unjudged" selects (FR-CULL-4). @@ -144,7 +155,9 @@ pub enum FlagState { /// How much of an image a cache rule asks to keep locally. /// /// Ordered so the most generous matching rule wins (ARCH §9.3). -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize, +)] pub enum Tier { /// Catalog rows and sidecars only. Never evicted — authoritative and tiny. Metadata, diff --git a/core/dr-types/src/settings.rs b/core/dr-types/src/settings.rs index f709f42..4970c1a 100644 --- a/core/dr-types/src/settings.rs +++ b/core/dr-types/src/settings.rs @@ -363,7 +363,12 @@ pub enum OutputSharpening { } impl OutputSharpening { - pub const ALL: [Self; 4] = [Self::None, Self::Screen, Self::MattePaper, Self::GlossyPaper]; + pub const ALL: [Self; 4] = [ + Self::None, + Self::Screen, + Self::MattePaper, + Self::GlossyPaper, + ]; pub fn label(self) -> &'static str { match self { @@ -454,9 +459,11 @@ pub mod budget { /// stray keystroke should leave the previous budget in place, where /// clamping to a minimum would silently shrink a cache to nothing. pub fn from_gb(text: &str) -> Option { - let gb: f64 = text.trim().trim_end_matches(|c: char| { - c.is_ascii_alphabetic() || c.is_whitespace() - }).parse().ok()?; + let gb: f64 = text + .trim() + .trim_end_matches(|c: char| c.is_ascii_alphabetic() || c.is_whitespace()) + .parse() + .ok()?; if !gb.is_finite() || gb <= 0.0 { return None; } @@ -480,10 +487,7 @@ mod tests { fn defaults_round_trip_through_json() { let settings = Settings::default(); let json = serde_json::to_string(&settings).unwrap(); - assert_eq!( - serde_json::from_str::(&json).unwrap(), - settings - ); + assert_eq!(serde_json::from_str::(&json).unwrap(), settings); } #[test] diff --git a/docker/android/Dockerfile b/docker/android/Dockerfile index 6a97e23..8920b11 100644 --- a/docker/android/Dockerfile +++ b/docker/android/Dockerfile @@ -77,13 +77,27 @@ RUN mkdir -p ${ANDROID_HOME}/cmdline-tools \ && mv ${ANDROID_HOME}/cmdline-tools/cmdline-tools ${ANDROID_HOME}/cmdline-tools/latest \ && rm /tmp/tools.zip -RUN yes | sdkmanager --licenses > /dev/null 2>&1 || true \ - && sdkmanager --install \ - "platform-tools" \ - "platforms;android-${ANDROID_API}" \ - "build-tools;${BUILD_TOOLS}" \ - "ndk;${NDK_VERSION}" \ - > /dev/null +# One package per layer, and the output kept. +# +# Both halves are scar tissue from the same build. sdkmanager is a JVM program +# that aborts (SIGABRT, exit 134) when it cannot get memory — which it will on a +# loaded machine, since the NDK alone unpacks some 4.5 GB. With the whole +# install in one `> /dev/null` step, that surfaced as "exit code 134" and +# nothing else, and a retry re-downloaded the three packages that had already +# succeeded before reaching the one that had not. +# +# pipefail matters here: without it the `tr | tail` pipeline would report the +# exit status of `tail`, which is exactly the masking this step is undoing. +# Progress bars are carriage returns, hence the tr — the tail keeps the summary +# without the several thousand redraws. +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +RUN yes | sdkmanager --licenses > /dev/null 2>&1 || true + +RUN sdkmanager --install "platform-tools" 2>&1 | tr '\r' '\n' | tail -3 +RUN sdkmanager --install "platforms;android-${ANDROID_API}" 2>&1 | tr '\r' '\n' | tail -3 +RUN sdkmanager --install "build-tools;${BUILD_TOOLS}" 2>&1 | tr '\r' '\n' | tail -3 +RUN sdkmanager --install "ndk;${NDK_VERSION}" 2>&1 | tr '\r' '\n' | tail -3 ENV ANDROID_NDK_HOME=${ANDROID_HOME}/ndk/${NDK_VERSION} \ ANDROID_NDK_ROOT=${ANDROID_HOME}/ndk/${NDK_VERSION} diff --git a/platform/dr-plat/src/secrets.rs b/platform/dr-plat/src/secrets.rs index d2d4188..ef2bb0b 100644 --- a/platform/dr-plat/src/secrets.rs +++ b/platform/dr-plat/src/secrets.rs @@ -195,9 +195,7 @@ impl PlatformSecretStore { fn store(&self) -> Result<&std::sync::Arc, SecretError> { self.store - .get_or_init(|| { - android_native_keyring_store::Store::new().map_err(|e| e.to_string()) - }) + .get_or_init(|| android_native_keyring_store::Store::new().map_err(|e| e.to_string())) .as_ref() .map_err(|e| SecretError::Unavailable(e.clone())) } @@ -205,10 +203,7 @@ impl PlatformSecretStore { /// A credential specifier for one secret. Filed under the same /// service/key pair as the Linux path, so the two platforms agree on /// naming even though the backing stores differ. - fn entry( - &self, - r: &SecretRef, - ) -> Result { + fn entry(&self, r: &SecretRef) -> Result { use keyring_core::api::CredentialStoreApi; self.store()? @@ -229,7 +224,9 @@ impl SecretStore for PlatformSecretStore { fn store(&self, secret_ref: &SecretRef, secret: &str) -> Result<(), SecretError> { use keyring_core::api::CredentialApi; - self.entry(secret_ref)?.set_password(secret).map_err(map_err) + self.entry(secret_ref)? + .set_password(secret) + .map_err(map_err) } fn retrieve(&self, secret_ref: &SecretRef) -> Result { diff --git a/tools/ci-local.sh b/tools/ci-local.sh new file mode 100755 index 0000000..ec9c99c --- /dev/null +++ b/tools/ci-local.sh @@ -0,0 +1,214 @@ +#!/usr/bin/env bash +# Run what .gitea/workflows/ runs, on this machine. +# +# ./tools/ci-local.sh # every job +# ./tools/ci-local.sh desktop # one job +# ./tools/ci-local.sh desktop layering # several +# ./tools/ci-local.sh --list +# +# Jobs: desktop, android, layering, traceability. +# +# Fidelity and its limits. The desktop, layering and traceability jobs run +# natively rather than inside catthehacker/ubuntu:act-latest, which is honest +# only because the host toolchain is pinned to the same 1.92.0 CI installs — +# the check below refuses to run otherwise, since a clippy lint set that drifts +# between here and CI makes a green local run worthless. What this cannot catch +# is a missing *system* library: CI apt-installs libfontconfig and libxkbcommon +# into a minimal image, so a build that only succeeds here because the +# development box has some other -dev package will still break CI. The android +# job does run in its container, because nothing about a cross-compile +# reproduces natively. +# +# Also unmodelled: actions/checkout (this runs against the working tree, dirty +# or not — that is the point) and actions/cache (the host cargo cache stands in, +# which makes repeat runs far faster than CI's). +set -uo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${REPO}" + +RUST_PIN="1.92.0" +ALL_JOBS=(desktop android layering traceability) + +if [[ "${1:-}" == "--list" ]]; then + printf '%s\n' "${ALL_JOBS[@]}" + exit 0 +fi + +if [[ $# -gt 0 ]]; then + JOBS=("$@") + for j in "${JOBS[@]}"; do + if [[ ! " ${ALL_JOBS[*]} " == *" ${j} "* ]]; then + echo "error: unknown job '${j}'; try --list" >&2 + exit 2 + fi + done +else + JOBS=("${ALL_JOBS[@]}") +fi + +# Colour only when a terminal is watching; a redirected log stays plain. +if [[ -t 1 ]]; then + B=$'\e[1m'; R=$'\e[31m'; G=$'\e[32m'; Y=$'\e[33m'; Z=$'\e[0m' +else + B=''; R=''; G=''; Y=''; Z='' +fi + +RESULTS=() +FAILED=0 + +step() { printf '\n%s==> %s%s\n' "${B}" "$*" "${Z}"; } + +# A step's failure is recorded and the job carries on, the way separate CI +# steps would not — but one local run that reports every problem beats four +# runs each finding the next one. +record() { + local name="$1" code="$2" + if [[ "${code}" -eq 0 ]]; then + RESULTS+=("${G}pass${Z} ${name}") + else + RESULTS+=("${R}FAIL${Z} ${name}") + FAILED=1 + fi +} + +run() { + local name="$1"; shift + step "${name}: $*" + "$@" + record "${name}" "$?" +} + +# CI pins the toolchain so an unrelated push cannot fail on a compiler that +# moved under it. A local run on a different rustc is measuring a different +# thing, and silently so. +check_toolchain() { + local have + have="$(rustc --version | awk '{print $2}')" + if [[ "${have}" != "${RUST_PIN}" ]]; then + printf '%swarning: rustc %s, CI pins %s — clippy and fmt may disagree%s\n' \ + "${Y}" "${have}" "${RUST_PIN}" "${Z}" >&2 + fi +} + +job_desktop() { + check_toolchain + run "desktop/fmt" cargo fmt --all -- --check + run "desktop/clippy" cargo clippy --workspace --all-targets -- -D warnings + run "desktop/test" cargo test --workspace + run "desktop/build" cargo build --workspace --release +} + +# The one job that genuinely needs the container: it is a cross-compile, and +# the NDK, the linker and MIN_API all live in the image. +job_android() { + if ! command -v podman >/dev/null 2>&1 && ! command -v docker >/dev/null 2>&1; then + RESULTS+=("${Y}skip${Z} android (no podman or docker)") + return + fi + + step "android: cross-compile core" + ./docker/android/build.sh \ + cargo check -p dr-types -p dr-gpu -p dr-sync --target aarch64-linux-android + record "android/check" "$?" + + step "android: verify minimum API level" + ./docker/android/build.sh cargo ndk -t arm64-v8a build -p dr-gpu --release + record "android/ndk-build" "$?" + + # Asserted, not reported — and looked up under the target triple. + # + # The workflow this mirrors searches the whole target dir for the first + # `*.so` and reads its .comment. Under target-android/debug/deps sit the + # host proc-macro libraries, which are x86-64 ELF built by the Debian gcc, + # so the step happily prints a comment section that says nothing about + # Android and passes. A wrong MIN_API is invisible until a device refuses + # to install, which is exactly the failure the step exists to catch, so it + # is worth failing on here. + local triple_dir so api + triple_dir="${XDG_CACHE_HOME:-${HOME}/.cache}/darkroom-android/target/aarch64-linux-android/release" + so="$(find "${triple_dir}" -maxdepth 1 -name '*.so' -print -quit 2>/dev/null)" + + if [[ -z "${so}" ]]; then + echo "no aarch64 .so under ${triple_dir}" + record "android/min-api" 1 + return + fi + + # MIN_API is the Dockerfile's, read rather than repeated: two copies of the + # number would drift and the drift is the bug being checked for. + local min_api + min_api="$(sed -n 's/^ARG MIN_API=\([0-9]*\).*/\1/p' docker/android/Dockerfile)" + # Both sides must be non-empty before they are compared: two failed parses + # would otherwise satisfy `"" == ""` and report a pass, which is the same + # silent success this check was rewritten to remove. + if [[ -z "${min_api}" ]]; then + echo "FAIL: no ARG MIN_API= in docker/android/Dockerfile" + record "android/min-api" 1 + return + fi + + echo "checking ${so}" + file "${so}" + api="$(file "${so}" | sed -n 's/.*for Android \([0-9]*\).*/\1/p')" + if [[ -n "${api}" && "${api}" == "${min_api}" ]]; then + record "android/min-api" 0 + else + echo "FAIL: linked for Android '${api:-unknown}', expected ${min_api}" + record "android/min-api" 1 + fi +} + +# ARCH §6.5a. Builds nothing: `cargo tree` only resolves the graph. +job_layering() { + step "layering: core crates must not depend on the UI" + local failed=0 + for crate in dr-types dr-gpu dr-sync; do + if cargo tree -p "${crate}" -e normal 2>/dev/null | grep -qE '\bslint\b|\bi-slint'; then + echo "FAIL: ${crate} depends on Slint (ARCH §6.5a)" + failed=1 + else + echo "ok: ${crate}" + fi + done + record "layering" "${failed}" +} + +job_traceability() { + run "traceability/self-test" cargo test -p traceability + run "traceability/gate" cargo run -q -p traceability -- check + + # Compared against the working tree, not against HEAD. + # + # CI asks `git diff --quiet docs/traceability.md` after regenerating, which + # is right there and wrong here: CI starts from a clean checkout, so the + # only diff it can see is the one regeneration introduced. Locally the file + # is usually already modified for honest reasons — an uncommitted feature + # brings new TRACES tags with it — and the git form would then report the + # matrix as stale on every run, including the runs where it is perfectly + # up to date. Snapshotting first asks the question CI means to ask: does + # regenerating change anything? + step "traceability: matrix is up to date" + local before + before="$(mktemp)" + cp docs/traceability.md "${before}" + cargo run -q -p traceability -- report + if diff -q "${before}" docs/traceability.md >/dev/null; then + record "traceability/matrix" 0 + else + echo "docs/traceability.md was stale; regenerating changed it:" + diff --stat "${before}" docs/traceability.md 2>/dev/null \ + || diff "${before}" docs/traceability.md | head -20 + record "traceability/matrix" 1 + fi + rm -f "${before}" +} + +for job in "${JOBS[@]}"; do + printf '\n%s──────── %s ────────%s\n' "${B}" "${job}" "${Z}" + "job_${job}" +done + +printf '\n%s──────── summary ────────%s\n' "${B}" "${Z}" +printf '%s\n' "${RESULTS[@]}" +exit "${FAILED}" diff --git a/ui/dr-ui/build.rs b/ui/dr-ui/build.rs index 7f8d039..33cd207 100644 --- a/ui/dr-ui/build.rs +++ b/ui/dr-ui/build.rs @@ -148,8 +148,24 @@ fn generate(source: &Path, live: bool) -> Result { out.push_str("export global Theme {\n"); let mut names = BTreeSet::new(); - emit_group(&mut out, doc, "colors", "color", direction, &mut names, parse_color)?; - emit_group(&mut out, doc, "lengths", "length", direction, &mut names, parse_length)?; + emit_group( + &mut out, + doc, + "colors", + "color", + direction, + &mut names, + parse_color, + )?; + emit_group( + &mut out, + doc, + "lengths", + "length", + direction, + &mut names, + parse_length, + )?; if names.is_empty() { return Err("defines no tokens; expected `colors:` and `lengths:` maps".into()); @@ -230,7 +246,11 @@ fn emit_group( } let value = token_value(spec, name, key, parse)?; - writeln!(out, " {direction} property <{slint_type}> {name}: {value};").unwrap(); + writeln!( + out, + " {direction} property <{slint_type}> {name}: {value};" + ) + .unwrap(); first = false; } Ok(()) @@ -278,9 +298,9 @@ fn parse_color(value: &Value) -> Result { let hex = value .as_str() .ok_or("must be a quoted hex colour such as \"#1B1C1E\"")?; - let digits = hex.strip_prefix('#').ok_or_else(|| { - format!("`{hex}` is not a hex colour; expected a leading `#`") - })?; + let digits = hex + .strip_prefix('#') + .ok_or_else(|| format!("`{hex}` is not a hex colour; expected a leading `#`"))?; if !matches!(digits.len(), 3 | 4 | 6 | 8) || !digits.chars().all(|c| c.is_ascii_hexdigit()) { return Err(format!( "`{hex}` is not a hex colour; expected #RGB, #RGBA, #RRGGBB or #RRGGBBAA" diff --git a/ui/dr-ui/src/collections_ui.rs b/ui/dr-ui/src/collections_ui.rs index b54e21f..e6dd064 100644 --- a/ui/dr-ui/src/collections_ui.rs +++ b/ui/dr-ui/src/collections_ui.rs @@ -219,7 +219,11 @@ pub fn apply_press( selection.clear(); } - let (lo, hi) = if from <= row { (from, row) } else { (row, from) }; + let (lo, hi) = if from <= row { + (from, row) + } else { + (row, from) + }; let hi = hi.min(ids.len().saturating_sub(1)); for id in &ids[lo..=hi] { selection.insert(*id); @@ -290,7 +294,8 @@ pub fn refresh_tree(window: &AppWindow, ctl: &Rc, catalog // Deep counts are one query per row. That is fine at sidebar scale and // wrong at grid scale, which is why the grid does not do this. - let deep = coll::deep_count(catalog.connection(), id).unwrap_or(row.collection.direct_count); + let deep = + coll::deep_count(catalog.connection(), id).unwrap_or(row.collection.direct_count); ids.push(id); smart.push(row.collection.kind == CollectionKind::Smart); @@ -348,8 +353,7 @@ fn compose_drag_image(thumbs: &[slint::Image]) -> slint::Image { let tw = ((size.width as f32 * scale) as u32).max(1); let th = ((size.height as f32 * scale) as u32).max(1); - let mut canvas = - slint::SharedPixelBuffer::::new(tw + offset, th + offset); + let mut canvas = slint::SharedPixelBuffer::::new(tw + offset, th + offset); let cw = canvas.width(); let stride = cw as usize; let pixels = canvas.make_mut_slice(); @@ -670,7 +674,12 @@ fn start_trash( window: &AppWindow, ctl: &Rc, catalog: &Rc>>, - session: &Rc Option<(dr_sync_nextcloud::AppCredentials, dr_sync_nextcloud::Session)>>, + session: &Rc< + dyn Fn() -> Option<( + dr_sync_nextcloud::AppCredentials, + dr_sync_nextcloud::Session, + )>, + >, images: &[ImageId], reload: &Rc, ) { @@ -735,7 +744,12 @@ fn start_restore( window: &AppWindow, ctl: &Rc, catalog: &Rc>>, - session: &Rc Option<(dr_sync_nextcloud::AppCredentials, dr_sync_nextcloud::Session)>>, + session: &Rc< + dyn Fn() -> Option<( + dr_sync_nextcloud::AppCredentials, + dr_sync_nextcloud::Session, + )>, + >, images: &[ImageId], reload: &Rc, ) { @@ -823,7 +837,11 @@ fn drain_trash( }; match msg { - TrashMessage::Progress { done, total, failed } => { + TrashMessage::Progress { + done, + total, + failed, + } => { let status = if failed > 0 { format!("{done} / {total} · {failed} failed") } else { @@ -925,7 +943,10 @@ pub fn wire( ) where S: Fn() + 'static, R: Fn() -> Vec + 'static, - C: Fn() -> Option<(dr_sync_nextcloud::AppCredentials, dr_sync_nextcloud::Session)> + 'static, + C: Fn() -> Option<( + dr_sync_nextcloud::AppCredentials, + dr_sync_nextcloud::Session, + )> + 'static, { // Coerced to trait objects here rather than at each use: `start_trash` and // `drain_trash` are shared by three callbacks, and a generic parameter would @@ -933,7 +954,10 @@ pub fn wire( let on_scope_changed: Rc = Rc::new(on_scope_changed); let visible_ids = Rc::new(visible_ids); let session: Rc< - dyn Fn() -> Option<(dr_sync_nextcloud::AppCredentials, dr_sync_nextcloud::Session)>, + dyn Fn() -> Option<( + dr_sync_nextcloud::AppCredentials, + dr_sync_nextcloud::Session, + )>, > = Rc::new(session); // --- selection --------------------------------------------------------- @@ -1310,7 +1334,13 @@ pub fn wire( crate::library::thumbs_dir(&sess.server, &sess.user_id), ); - drain_trash(w.as_weak(), ctl.clone(), catalog.clone(), rx, reload.clone()); + drain_trash( + w.as_weak(), + ctl.clone(), + catalog.clone(), + rx, + reload.clone(), + ); }); } @@ -1702,7 +1732,7 @@ fn unique_name(conn: &rusqlite::Connection, parent: Option) -> Str } for n in 2..1000 { let candidate = format!("{base} {n}"); - if !taken.iter().any(|t| *t == candidate) { + if !taken.contains(&candidate) { return candidate; } } @@ -1948,7 +1978,11 @@ mod tests { } /// The spring's inputs: rows, which have children, and which are collapsed. - fn spring_fixture() -> (Vec, Vec, std::collections::HashSet) { + fn spring_fixture() -> ( + Vec, + Vec, + std::collections::HashSet, + ) { let ids = vec![CollectionId(1), CollectionId(2), CollectionId(3)]; // 1 is a collapsed parent, 2 an expanded parent, 3 a leaf. let has_children = vec![true, true, false]; @@ -2000,11 +2034,16 @@ mod tests { // A tree that flaps open under every passing pointer is worse than one // that never opens. This pins the intent rather than the number: a // reflex-speed value here would be a regression, not a tuning choice. - assert!( + // Asserted in a const item rather than at runtime: the condition is + // constant either way, and clippy is right that a runtime assert on it + // is theatre. This form fails the build instead of a test run, which is + // strictly earlier, and keeps the bound where a reader of the constant + // will look for it. + const _: () = assert!( SPRING_DELAY_MS >= 300, "a pointer crossing a parent must not open it" ); - assert!( + const _: () = assert!( SPRING_DELAY_MS <= 900, "and a deliberate dwell must not feel like a hang" ); diff --git a/ui/dr-ui/src/derived_sync.rs b/ui/dr-ui/src/derived_sync.rs index b05f4f1..fdd14db 100644 --- a/ui/dr-ui/src/derived_sync.rs +++ b/ui/dr-ui/src/derived_sync.rs @@ -225,7 +225,7 @@ async fn sync_shards( let have: std::collections::HashSet = local.iter().map(|s| s.id).collect(); let mut store = store; - for (name, _) in &remote { + for name in remote.keys() { let Some(id) = shard_id(name) else { continue }; if have.contains(&id) { continue; diff --git a/ui/dr-ui/src/develop.rs b/ui/dr-ui/src/develop.rs index edd24b2..e035fda 100644 --- a/ui/dr-ui/src/develop.rs +++ b/ui/dr-ui/src/develop.rs @@ -491,20 +491,29 @@ impl DevelopSession { /// Mirror horizontally, about the frame's vertical centre line. pub fn toggle_flip_h(&mut self) { let (h, _) = self.graph.framing().flips(); - self.graph - .set_param(dr_pipeline::framing::ID, dr_pipeline::framing::FLIP_H, f32::from(u8::from(!h))); + self.graph.set_param( + dr_pipeline::framing::ID, + dr_pipeline::framing::FLIP_H, + f32::from(u8::from(!h)), + ); } pub fn toggle_flip_v(&mut self) { let (_, v) = self.graph.framing().flips(); - self.graph - .set_param(dr_pipeline::framing::ID, dr_pipeline::framing::FLIP_V, f32::from(u8::from(!v))); + self.graph.set_param( + dr_pipeline::framing::ID, + dr_pipeline::framing::FLIP_V, + f32::from(u8::from(!v)), + ); } /// Set the straightening angle, in degrees. pub fn set_angle(&mut self, degrees: f32) { - self.graph - .set_param(dr_pipeline::framing::ID, dr_pipeline::framing::ANGLE, degrees); + self.graph.set_param( + dr_pipeline::framing::ID, + dr_pipeline::framing::ANGLE, + degrees, + ); } /// Whether the framing currently changes the image — what lights the @@ -586,7 +595,11 @@ impl DevelopSession { /// Pan by a fraction of the *visible* area — what a drag reports. pub fn pan_by(&mut self, dx: f32, dy: f32) { let view = self.graph.framing().view(); - self.set_view_clamped(view.x + dx * view.width, view.y + dy * view.height, view.width); + self.set_view_clamped( + view.x + dx * view.width, + view.y + dy * view.height, + view.width, + ); } /// Back to fitting the whole frame. @@ -923,8 +936,9 @@ mod tests { "group at {head} reaches past the model" ); // Every row the group spans must agree it belongs to that group. - for span in head..head + len { - assert_eq!(rows[span].0, head, "row {span} disagrees about its group"); + for (offset, spanned) in rows[head..head + len].iter().enumerate() { + let span = head + offset; + assert_eq!(spanned.0, head, "row {span} disagrees about its group"); } } } diff --git a/ui/dr-ui/src/launch_ui.rs b/ui/dr-ui/src/launch_ui.rs index f0e57ef..247494a 100644 --- a/ui/dr-ui/src/launch_ui.rs +++ b/ui/dr-ui/src/launch_ui.rs @@ -36,7 +36,6 @@ impl LaunchController { poll_timer: RefCell::new(None), }) } - } /// Push the model into the window's properties. diff --git a/ui/dr-ui/src/library.rs b/ui/dr-ui/src/library.rs index 1731e46..ded33d5 100644 --- a/ui/dr-ui/src/library.rs +++ b/ui/dr-ui/src/library.rs @@ -109,7 +109,10 @@ pub enum ThumbnailMessage { Ready(Box), /// No preview could be extracted. The cell stays a placeholder rather than /// silently retrying forever. - Unavailable { row: usize, reason: String }, + Unavailable { + row: usize, + reason: String, + }, /// How the batch split between the store and the network. /// /// Sent once, before any fetch. Without it there is no way to tell a @@ -139,7 +142,9 @@ pub enum ThumbnailMessage { /// connection. Sent at most once per batch, because a dropped connection /// produces one of these per *cell* otherwise and the banner would be /// rewritten sixty times. - Offline { reason: String }, + Offline { + reason: String, + }, } /// TRACES: FR-CAT-15 | FR-CAT-11 @@ -337,8 +342,7 @@ pub fn spawn_sidecar_writes( let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { - let rt = match crate::net_runtime::build() - { + let rt = match crate::net_runtime::build() { Ok(rt) => rt, Err(e) => { let _ = tx.send(SidecarMessage::Finished { @@ -402,10 +406,7 @@ pub enum SidecarMessage { } /// Read-modify-write one sidecar. -async fn write_one_sidecar( - backend: &NextcloudBackend, - w: &JudgementWrite, -) -> Result<(), String> { +async fn write_one_sidecar(backend: &NextcloudBackend, w: &JudgementWrite) -> Result<(), String> { let path = RemotePath::new(sidecar_path(&w.image_path)); let id = RemoteId::Path(path.clone()); @@ -421,7 +422,10 @@ async fn write_one_sidecar( // destroy an edit this build merely failed to understand. The // judgement stays in the catalog and the file is left alone. Err(e) => { - log::warn!("sidecar at {} is unreadable ({e}); not overwriting", path.as_str()); + log::warn!( + "sidecar at {} is unreadable ({e}); not overwriting", + path.as_str() + ); None } }) @@ -565,8 +569,7 @@ fn run_scan( } let catalog = Catalog::open(&catalog_path).map_err(ScanFailure::local)?; - let rt = crate::net_runtime::build() - .map_err(ScanFailure::local)?; + let rt = crate::net_runtime::build().map_err(ScanFailure::local)?; rt.block_on(async { let backend = NextcloudBackend::new(&creds, &user_id).map_err(ScanFailure::local)?; @@ -576,19 +579,13 @@ fn run_scan( // it is what keeps cost proportional to what changed (ARCH §8.4). let known = load_folder_etags(&catalog, &root); - let result = dr_sync::scan( - &backend, - &RemotePath::new(&root), - &filter, - &known, - |p| { - let _ = tx.send(ScanMessage::Progress { - directories: p.directories_listed, - pruned: p.directories_pruned, - images: p.images_found, - }); - }, - ) + let result = dr_sync::scan(&backend, &RemotePath::new(&root), &filter, &known, |p| { + let _ = tx.send(ScanMessage::Progress { + directories: p.directories_listed, + pruned: p.directories_pruned, + images: p.images_found, + }); + }) .await?; persist(&catalog, &root, &result).map_err(ScanFailure::local)?; @@ -818,12 +815,22 @@ impl std::fmt::Display for FetchFailure { #[derive(Debug)] pub enum PinMessage { /// How many originals the pin still needs. Sent once, before any transfer. - Planned { total: usize }, + Planned { + total: usize, + }, /// One original landed. - Stored { done: usize }, + Stored { + done: usize, + }, /// The pin is fully downloaded. - Done { stored: usize, bytes: u64 }, - Failed { message: String, offline: bool }, + Done { + stored: usize, + bytes: u64, + }, + Failed { + message: String, + offline: bool, + }, } /// TRACES: FR-NC-6a @@ -890,8 +897,7 @@ pub fn spawn_pin_fetch( return; } - let rt = match crate::net_runtime::build() - { + let rt = match crate::net_runtime::build() { Ok(rt) => rt, Err(e) => { let _ = tx.send(PinMessage::Failed { @@ -1056,8 +1062,7 @@ pub fn spawn_full_fetch( } } - let rt = match crate::net_runtime::build() - { + let rt = match crate::net_runtime::build() { Ok(rt) => rt, Err(e) => { let _ = tx.send(Err(FetchFailure::local(e))); @@ -1202,8 +1207,7 @@ pub fn spawn_thumbnails( return; } - let rt = match crate::net_runtime::build() - { + let rt = match crate::net_runtime::build() { Ok(rt) => rt, Err(e) => { for req in &to_fetch { @@ -1487,10 +1491,7 @@ async fn read_metadata_only( // Backing off at all matters more than the exact interval: the // contention that produced the lock is our own lanes, so any // pause lets the holder finish. - tokio::time::sleep(std::time::Duration::from_millis( - 200 * attempt as u64, - )) - .await; + tokio::time::sleep(std::time::Duration::from_millis(200 * attempt as u64)).await; } Err(e) => { // Not surfaced: a missing date leaves the image off the @@ -1560,11 +1561,15 @@ pub enum SweepMessage { /// How many images still need work, counted once at the start. Total(usize), /// Another chunk finished. Carries cumulative counts. - Progress { done: usize, dated: usize }, - Finished { dated: usize }, + Progress { + done: usize, + dated: usize, + }, + Finished { + dated: usize, + }, } - /// Await every future concurrently, returning results in order. /// /// A hand-rolled `join_all` rather than a `futures` dependency for one @@ -1658,7 +1663,10 @@ pub fn spawn_sweep( // Silent failure here left the sweep looking like it had run // and found nothing: no progress, no error, 17,397 images // still unindexed. - log::warn!("sweep: cannot open catalog at {}: {e}", catalog_path.display()); + log::warn!( + "sweep: cannot open catalog at {}: {e}", + catalog_path.display() + ); let _ = tx.send(SweepMessage::Finished { dated: 0 }); return; } @@ -1674,8 +1682,7 @@ pub fn spawn_sweep( return; } - let rt = match crate::net_runtime::build() - { + let rt = match crate::net_runtime::build() { Ok(rt) => rt, Err(e) => { log::warn!("sweep: no runtime: {e}"); @@ -1759,18 +1766,21 @@ pub fn spawn_sweep( // Only files actually read. One that could not be fetched is // left alone so the next pass retries it, rather than being // written off over a lock or a dropped connection. - found.extend(chunk.iter().filter(|r| { - reached.contains(&r.image_id) && !answered.contains(&r.image_id) - }).map( - |r| MetadataFound { - image_id: r.image_id, - captured_at: None, - captured_offset: None, - camera: None, - lens: None, - iso: None, - }, - )); + found.extend( + chunk + .iter() + .filter(|r| { + reached.contains(&r.image_id) && !answered.contains(&r.image_id) + }) + .map(|r| MetadataFound { + image_id: r.image_id, + captured_at: None, + captured_offset: None, + camera: None, + lens: None, + iso: None, + }), + ); dated += found.iter().filter(|m| m.captured_at.is_some()).count(); let read = answered.len(); @@ -1865,10 +1875,9 @@ fn flush_sweep(catalog: &Catalog, found: &mut Vec) { .map(|m| m.image_id) .collect(); for id in ids { - let _ = catalog.connection().execute( - "UPDATE images SET metadata_state = 2 WHERE id = ?1", - [id], - ); + let _ = catalog + .connection() + .execute("UPDATE images SET metadata_state = 2 WHERE id = ?1", [id]); } found.clear(); } @@ -2090,11 +2099,12 @@ pub fn total_images_scoped( .iter() .map(|c| rusqlite::types::Value::Integer(c.0 as i64)) .collect(); - let n: i64 = catalog.connection().query_row( - &sql, - rusqlite::params_from_iter(params.iter()), - |r| r.get(0), - )?; + let n: i64 = + catalog + .connection() + .query_row(&sql, rusqlite::params_from_iter(params.iter()), |r| { + r.get(0) + })?; Ok(n as usize) } @@ -2138,13 +2148,11 @@ pub fn local_original_count(catalog: &Catalog) -> Result Result { - let n: i64 = catalog - .connection() - .query_row( - &format!("SELECT count(*) FROM images WHERE {VISIBLE_UNALIASED}"), - [], - |r| r.get(0), - )?; + let n: i64 = catalog.connection().query_row( + &format!("SELECT count(*) FROM images WHERE {VISIBLE_UNALIASED}"), + [], + |r| r.get(0), + )?; Ok(n as usize) } @@ -2160,14 +2168,12 @@ mod tests { use super::*; use dr_sync::RemoteEntry; - #[test] fn join_all_preserves_order_regardless_of_completion() { // The ordering guarantee is what lets a caller pair results back to // their inputs; without it a lane's dates could be attributed to the // wrong images. - let rt = crate::net_runtime::build() - .unwrap(); + let rt = crate::net_runtime::build().unwrap(); let out = rt.block_on(async { futures_join_all(vec![ @@ -2190,8 +2196,7 @@ mod tests { #[test] fn join_all_of_nothing_completes() { - let rt = crate::net_runtime::build() - .unwrap(); + let rt = crate::net_runtime::build().unwrap(); let out: Vec = rt.block_on(async { futures_join_all(Vec::>::new()).await }); assert!(out.is_empty()); @@ -2226,7 +2231,6 @@ mod tests { assert_eq!(seen, chunk); } - #[test] fn a_scrub_ordinal_matches_the_grid_position() { // The scrub's count and the grid's window must use *identical* @@ -2319,9 +2323,11 @@ mod tests { // Folder ETags must persist or the next scan prunes nothing. let etag: String = catalog .connection() - .query_row("SELECT etag FROM folders WHERE path = 'PhotosRaw'", [], |r| { - r.get(0) - }) + .query_row( + "SELECT etag FROM folders WHERE path = 'PhotosRaw'", + [], + |r| r.get(0), + ) .unwrap(); assert_eq!(etag, "e1"); } @@ -2428,7 +2434,10 @@ mod tests { images: vec![], directories: vec![ (RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e1")), - (RemotePath::new("PhotosRaw/2026"), dr_sync::Validator::new("e2")), + ( + RemotePath::new("PhotosRaw/2026"), + dr_sync::Validator::new("e2"), + ), ], progress: Default::default(), }; @@ -2437,7 +2446,9 @@ mod tests { let known = load_folder_etags(&catalog, "PhotosRaw"); assert_eq!(known.len(), 2); assert_eq!( - known.get(&RemotePath::new("PhotosRaw/2026")).map(|v| v.as_str()), + known + .get(&RemotePath::new("PhotosRaw/2026")) + .map(|v| v.as_str()), Some("e2") ); } @@ -2492,15 +2503,26 @@ mod tests { let catalog = with_images(10); let ids = image_ids(&catalog); - let c = coll::create(catalog.connection(), "Selects", None, CollectionKind::Manual) - .unwrap(); + let c = coll::create( + catalog.connection(), + "Selects", + None, + CollectionKind::Manual, + ) + .unwrap(); coll::add_images(catalog.connection(), c, &ids[2..5]).unwrap(); let cells = read_cells_scoped(&catalog, Some(c), &RatingFilter::default(), 0, 120).unwrap(); assert_eq!(cells.len(), 3); - assert_eq!(total_images_scoped(&catalog, Some(c), &RatingFilter::default()).unwrap(), 3); + assert_eq!( + total_images_scoped(&catalog, Some(c), &RatingFilter::default()).unwrap(), + 3 + ); // Unscoped is still the whole library. - assert_eq!(total_images_scoped(&catalog, None, &RatingFilter::default()).unwrap(), 10); + assert_eq!( + total_images_scoped(&catalog, None, &RatingFilter::default()).unwrap(), + 10 + ); } #[test] @@ -2523,9 +2545,13 @@ mod tests { coll::add_images(catalog.connection(), iceland, &ids[0..4]).unwrap(); // The parent itself has no direct members at all. - let cells = read_cells_scoped(&catalog, Some(trips), &RatingFilter::default(), 0, 120).unwrap(); + let cells = + read_cells_scoped(&catalog, Some(trips), &RatingFilter::default(), 0, 120).unwrap(); assert_eq!(cells.len(), 4, "the set shows what its children hold"); - assert_eq!(total_images_scoped(&catalog, Some(trips), &RatingFilter::default()).unwrap(), 4); + assert_eq!( + total_images_scoped(&catalog, Some(trips), &RatingFilter::default()).unwrap(), + 4 + ); } #[test] @@ -2548,9 +2574,13 @@ mod tests { coll::add_images(catalog.connection(), trips, &ids[0..2]).unwrap(); coll::add_images(catalog.connection(), iceland, &ids[0..3]).unwrap(); - let cells = read_cells_scoped(&catalog, Some(trips), &RatingFilter::default(), 0, 120).unwrap(); + let cells = + read_cells_scoped(&catalog, Some(trips), &RatingFilter::default(), 0, 120).unwrap(); assert_eq!(cells.len(), 3, "images 0..3, each once"); - assert_eq!(total_images_scoped(&catalog, Some(trips), &RatingFilter::default()).unwrap(), 3); + assert_eq!( + total_images_scoped(&catalog, Some(trips), &RatingFilter::default()).unwrap(), + 3 + ); } #[test] @@ -2578,8 +2608,15 @@ mod tests { let catalog = with_images(10); let c = coll::create(catalog.connection(), "Empty", None, CollectionKind::Manual).unwrap(); - assert!(read_cells_scoped(&catalog, Some(c), &RatingFilter::default(), 0, 120).unwrap().is_empty()); - assert_eq!(total_images_scoped(&catalog, Some(c), &RatingFilter::default()).unwrap(), 0); + assert!( + read_cells_scoped(&catalog, Some(c), &RatingFilter::default(), 0, 120) + .unwrap() + .is_empty() + ); + assert_eq!( + total_images_scoped(&catalog, Some(c), &RatingFilter::default()).unwrap(), + 0 + ); } #[test] @@ -2612,7 +2649,10 @@ mod tests { let mut store = ThumbStore::open(&dir).unwrap(); let bytes = dr_thumbs::encode_rgba(32, 32, &rgba).unwrap(); store - .put(4242, dr_thumbs::ThumbSize::Grid, &dr_thumbs::Thumbnail { + .put( + 4242, + dr_thumbs::ThumbSize::Grid, + &dr_thumbs::Thumbnail { width: 32, height: 32, bytes, @@ -2622,7 +2662,10 @@ mod tests { } let store = ThumbStore::open(&dir).unwrap(); - let stored = store.get(4242, dr_thumbs::ThumbSize::Grid).unwrap().expect("persisted"); + let stored = store + .get(4242, dr_thumbs::ThumbSize::Grid) + .unwrap() + .expect("persisted"); let (w, h, out) = dr_thumbs::decode_rgba(&stored.bytes).unwrap(); assert_eq!((w, h), (32, 32)); // Lossy, so compare approximately — a blue-ish pixel must stay blue. @@ -2645,17 +2688,24 @@ mod tests { let mut store = ThumbStore::open(&dir).unwrap(); let bytes = dr_thumbs::encode_rgba(8, 8, &rgba).unwrap(); store - .put(99, dr_thumbs::ThumbSize::Grid, &dr_thumbs::Thumbnail { - width: 8, - height: 8, - bytes, - }) + .put( + 99, + dr_thumbs::ThumbSize::Grid, + &dr_thumbs::Thumbnail { + width: 8, + height: 8, + bytes, + }, + ) .unwrap(); // The split in `spawn_thumbnails`: a hit decodes off local disk and is // reported with `from_cache` set, which is what the reachability gate // keys on. - let stored = store.get(99, dr_thumbs::ThumbSize::Grid).unwrap().expect("stored"); + let stored = store + .get(99, dr_thumbs::ThumbSize::Grid) + .unwrap() + .expect("stored"); let (width, height, rgba) = dr_thumbs::decode_rgba(&stored.bytes).unwrap(); let hit = ThumbnailReady { row: 0, @@ -2824,7 +2874,11 @@ mod tests { // must not try to draw itself in a single query. let catalog = scanned(6); for i in 1..=6 { - trash_at(&catalog, &format!("PhotosRaw/IMG_{i:04}.CR2"), 100 + i as i64); + trash_at( + &catalog, + &format!("PhotosRaw/IMG_{i:04}.CR2"), + 100 + i as i64, + ); } let first = read_trashed_cells(&catalog, 0, 2).unwrap(); @@ -2832,7 +2886,9 @@ mod tests { assert_eq!(first.len(), 2); assert_eq!(second.len(), 2); assert!( - first.iter().all(|a| !second.iter().any(|b| b.image_id == a.image_id)), + first + .iter() + .all(|a| !second.iter().any(|b| b.image_id == a.image_id)), "pages must not overlap" ); } diff --git a/ui/dr-ui/src/library_ui.rs b/ui/dr-ui/src/library_ui.rs index ae287a2..d8b72d8 100644 --- a/ui/dr-ui/src/library_ui.rs +++ b/ui/dr-ui/src/library_ui.rs @@ -268,7 +268,9 @@ impl LibraryController { // applies to every fetch from now on. let Some(dir) = self.cache_dir() else { return }; let borrow = self.catalog.borrow(); - let Some(catalog) = borrow.as_ref() else { return }; + let Some(catalog) = borrow.as_ref() else { + return; + }; match dr_catalog::Cache::open(&dir, budget) .and_then(|cache| cache.enforce(catalog.connection())) { @@ -568,9 +570,7 @@ fn drain_scan( // off, and hiding them makes an incremental rescan look // identical to a full one. let status = if pruned > 0 { - format!( - "{directories} folders · {pruned} unchanged · {images} images" - ) + format!("{directories} folders · {pruned} unchanged · {images} images") } else { format!("{directories} folders · {images} images") }; @@ -628,9 +628,7 @@ fn drain_scan( // than the part that was scrolled past. start_sweep(&w, ctl); } - Err(e) => { - w.set_library_error(format!("opening catalog: {e}").into()) - } + Err(e) => w.set_library_error(format!("opening catalog: {e}").into()), } stop(&ctl.scan_timer); return; @@ -1166,8 +1164,7 @@ fn load_window(window: &AppWindow, ctl: &Rc) { *ctl.file_ids.borrow_mut() = cells.iter().map(|c| c.file_id).collect(); *ctl.sizes.borrow_mut() = cells.iter().map(|c| c.size).collect(); *ctl.image_ids.borrow_mut() = cells.iter().map(|c| c.image_id).collect(); - *ctl.needs_metadata.borrow_mut() = - cells.iter().map(|c| c.metadata_state < 2).collect(); + *ctl.needs_metadata.borrow_mut() = cells.iter().map(|c| c.metadata_state < 2).collect(); ctl.requested.borrow_mut().clear(); // The model is about to be replaced, so every thumbnail still in flight // addresses a window that no longer exists. Bumping here — before the swap, @@ -1320,11 +1317,7 @@ fn apply_judgement( } /// What the status line says about a judgement that just landed. -fn judgement_summary( - n: usize, - rating: Option, - flag: Option, -) -> String { +fn judgement_summary(n: usize, rating: Option, flag: Option) -> String { let what = match (rating, flag) { (Some(0), _) => "unrated".to_string(), (Some(r), _) => format!("{r} star{}", if r == 1 { "" } else { "s" }), @@ -1613,9 +1606,7 @@ fn drain_thumbnails( // Date reads produce no cell, so they are counted into // the bar's denominator or it finishes while work is // still running. - w.set_library_thumbs_total( - w.get_library_thumbs_total() + dating as i32, - ); + w.set_library_thumbs_total(w.get_library_thumbs_total() + dating as i32); let mut parts = Vec::new(); if cached > 0 { @@ -1820,7 +1811,11 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc) { report.shards_uploaded, report.shards_downloaded, report.thumbnails_adopted, - if report.catalog_uploaded { "pushed" } else { "not pushed" }, + if report.catalog_uploaded { + "pushed" + } else { + "not pushed" + }, if report.collections_gained > 0 { format!(", {} collection(s) gained", report.collections_gained) } else { @@ -2045,9 +2040,8 @@ fn refresh_timeline(window: &AppWindow, catalog: &Catalog, ctl: &Rc( ctl: Rc, coll_ctl: Rc, on_open_image: F, -) -where +) where F: Fn(String) + 'static, { { @@ -2435,8 +2428,8 @@ where // Reacting to every scroll event would re-query and re-fetch // continuously during a drag; this fires a few times per screenful. let margin = window_size / 4; - let inside = first_visible >= current + margin - && first_visible + margin < current + window_size; + let inside = + first_visible >= current + margin && first_visible + margin < current + window_size; if inside { return; } @@ -2453,8 +2446,12 @@ where window.on_library_timeline_pan(move |fraction| { let Some(w) = weak.upgrade() else { return }; let borrow = ctl.catalog.borrow(); - let Some(catalog) = borrow.as_ref() else { return }; - let Some(full) = catalog_span(catalog) else { return }; + let Some(catalog) = borrow.as_ref() else { + return; + }; + let Some(full) = catalog_span(catalog) else { + return; + }; // A fraction of the *visible* span, so dragging half the axis // moves half a span's worth of time whatever the zoom — and a @@ -2466,11 +2463,7 @@ where return; } - let centre = ctl - .timeline_centre - .borrow() - .unwrap_or((from + to) / 2) - + shift; + let centre = ctl.timeline_centre.borrow().unwrap_or((from + to) / 2) + shift; *ctl.timeline_centre.borrow_mut() = Some(centre.clamp(full.0, full.1)); refresh_timeline(&w, catalog, &ctl); }); @@ -2501,8 +2494,12 @@ where window.on_library_scrub_fraction(move |f| { let Some(w) = weak.upgrade() else { return }; let borrow = ctl.catalog.borrow(); - let Some(catalog) = borrow.as_ref() else { return }; - let Some(full) = catalog_span(catalog) else { return }; + let Some(catalog) = borrow.as_ref() else { + return; + }; + let Some(full) = catalog_span(catalog) else { + return; + }; let (from, to) = zoomed_span( full, @@ -2784,8 +2781,6 @@ fn stop(slot: &RefCell>) { mod tests { use super::*; - - /// The instant a scrub fraction names within a span, as the handler /// computes it. fn instant_at(span: (i64, i64), f: f32) -> i64 { @@ -2903,7 +2898,6 @@ mod tests { assert_eq!(pinch_step(&mut accum, 0.72), 0, "back roughly to 1.0"); } - /// One zoom step, as the handler applies it. fn zoom_cell(current: f32, delta: i32) -> f32 { let next = if delta > 0 { @@ -2920,7 +2914,10 @@ mod tests { // step is imperceptible at 400px and violent at 90px. let a = zoom_cell(180.0, 1); assert!((a - 225.0).abs() < 0.01); - assert!((zoom_cell(a, -1) - 180.0).abs() < 0.01, "in then out returns"); + assert!( + (zoom_cell(a, -1) - 180.0).abs() < 0.01, + "in then out returns" + ); } #[test] @@ -2943,12 +2940,17 @@ mod tests { // The point of the second class: past 256px a grid thumbnail is being // upscaled, and the softness shows. assert_eq!(ThumbSize::for_cell(180), ThumbSize::Grid); - assert_eq!(ThumbSize::for_cell(zoom_cell(225.0, 1) as u32), ThumbSize::Large); + assert_eq!( + ThumbSize::for_cell(zoom_cell(225.0, 1) as u32), + ThumbSize::Large + ); // And zooming back down does not keep paying for it. - assert_eq!(ThumbSize::for_cell(zoom_cell(281.0, -1) as u32), ThumbSize::Grid); + assert_eq!( + ThumbSize::for_cell(zoom_cell(281.0, -1) as u32), + ThumbSize::Grid + ); } - #[test] fn a_request_key_survives_the_window_moving() { use dr_thumbs::ThumbSize; diff --git a/ui/dr-ui/src/settings_ui.rs b/ui/dr-ui/src/settings_ui.rs index d91716b..c676b8d 100644 --- a/ui/dr-ui/src/settings_ui.rs +++ b/ui/dr-ui/src/settings_ui.rs @@ -151,10 +151,7 @@ pub fn render(window: &AppWindow, controller: &SettingsController) { window.set_settings_allow_upscaling(s.export.allow_upscaling); window.set_settings_sharpening_labels(labels(OutputSharpening::ALL.iter().map(|x| x.label()))); - window.set_settings_sharpening_selected(index_of( - &OutputSharpening::ALL, - &s.export.sharpening, - )); + window.set_settings_sharpening_selected(index_of(&OutputSharpening::ALL, &s.export.sharpening)); window.set_settings_filename_template(s.export.filename_template.clone().into()); window.set_settings_collision_labels(labels(CollisionPolicy::ALL.iter().map(|c| c.label()))); @@ -163,14 +160,7 @@ pub fn render(window: &AppWindow, controller: &SettingsController) { window.set_settings_destination(s.export.destination.clone().into()); - window.set_settings_error( - controller - .error - .borrow() - .clone() - .unwrap_or_default() - .into(), - ); + window.set_settings_error(controller.error.borrow().clone().unwrap_or_default().into()); } /// A label list as a Slint model. diff --git a/ui/dr-ui/src/trash.rs b/ui/dr-ui/src/trash.rs index 488c570..4ebeb23 100644 --- a/ui/dr-ui/src/trash.rs +++ b/ui/dr-ui/src/trash.rs @@ -48,10 +48,7 @@ pub enum TrashMessage { failed: usize, }, /// The batch finished. `failed` names what did not work, for the status line. - Done { - moved: usize, - failed: Vec, - }, + Done { moved: usize, failed: Vec }, } /// Which way an image is being moved. @@ -175,8 +172,7 @@ pub fn spawn_move( std::thread::spawn(move || { let total = moves.len(); - let rt = match crate::net_runtime::build() - { + let rt = match crate::net_runtime::build() { Ok(rt) => rt, Err(e) => { let _ = tx.send(TrashMessage::Done { @@ -289,8 +285,7 @@ pub fn spawn_purge( std::thread::spawn(move || { let total = paths.len(); - let rt = match crate::net_runtime::build() - { + let rt = match crate::net_runtime::build() { Ok(rt) => rt, Err(e) => { let _ = tx.send(TrashMessage::Done { @@ -486,7 +481,9 @@ mod tests { fn a_missing_image_is_skipped_rather_than_planned_against_nothing() { // The grid's selection can outlive a rescan that removed a row. let cat = seeded(); - assert!(plan_trash(&cat, "PhotosRaw", &[img(999)]).unwrap().is_empty()); + assert!(plan_trash(&cat, "PhotosRaw", &[img(999)]) + .unwrap() + .is_empty()); assert!(plan_restore(&cat, &[img(999)]).unwrap().is_empty()); }