Make the CI checks say what they mean, and format the workspace
Build and test / Desktop (Linux) (push) Successful in 1h23m26s
Build and test / Android (aarch64) (push) Failing after 2s
Build and test / Layer separation (push) Successful in 50s
Traceability / Requirement traces (push) Failing after 1m9s

The Android job's "Verify minimum API level" step has never verified the
minimum API level. It took the first `*.so` anywhere under the target
directory, which is a host proc-macro from debug/deps — an x86-64 object
built by the runner's gcc, whose .comment section cannot mention Android
and so can never contradict the expected value. It now reads the artifact
under the target triple, compares against MIN_API parsed from the
Dockerfile rather than a second copy of the number, and fails on a
mismatch. Both sides are checked non-empty first: two failed parses would
otherwise compare equal and pass, which is the same silent success in a
new costume.

The Android image installs one SDK package per layer and keeps the
output. sdkmanager is a JVM program that aborts when it cannot get memory,
and the single `> /dev/null` step reported that as a bare "exit code 134"
while a retry re-downloaded everything that had already succeeded.

tools/ci-local.sh runs all four jobs — desktop, android, layering,
traceability — against the host toolchain, which is pinned to the same
1.92.0 CI installs. Its matrix check compares regeneration against the
working tree rather than against HEAD: CI starts from a clean checkout, so
git's answer is the right one there and reports every local run stale here.

The rest is rustfmt across the workspace, and the clippy findings that
surfaced once it did: manual_contains in dr-thumbs and collections_ui, a
map iterated as pairs for its keys, an index loop over a slice, and two
runtime assertions on a constant now made at compile time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 12:02:51 +02:00
co-authored by Claude Opus 5
parent 94a2686dcb
commit 03326242a1
31 changed files with 927 additions and 387 deletions
+159 -103
View File
@@ -109,7 +109,10 @@ pub enum ThumbnailMessage {
Ready(Box<ThumbnailReady>),
/// 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<MetadataFound>) {
.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<usize, dr_catalog::Cata
/// What the scan reports and what the sidebar's "all images" row shows — the
/// size of the library itself, not of the current view.
pub fn total_images(catalog: &Catalog) -> Result<usize, dr_catalog::CatalogError> {
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<i32> =
rt.block_on(async { futures_join_all(Vec::<std::future::Ready<i32>>::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"
);
}