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
+26 -4
View File
@@ -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:
+101 -22
View File
@@ -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(),
+35 -13
View File
@@ -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::<Result<Vec<_>, _>>()?;
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::<Result<Vec<_>, _>>()?;
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");
}
+7 -2
View File
@@ -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]
+6 -10
View File
@@ -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<i64> = 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]
+19 -10
View File
@@ -260,7 +260,9 @@ pub fn list(conn: &Connection, limit: usize) -> Result<Vec<TrashedImage>, 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<String>>(2)?.unwrap_or_else(|| source_ref.clone()),
trashed_from: r
.get::<_, Option<String>>(2)?
.unwrap_or_else(|| source_ref.clone()),
source_ref,
trashed_at: r.get(3)?,
file_id: r.get::<_, Option<i64>>(4)?.map(|v| v as u64),
@@ -310,9 +312,7 @@ pub fn file_ids_for(conn: &Connection, images: &[ImageId]) -> Result<Vec<u64>, C
let placeholders = std::iter::repeat_n("?", images.len())
.collect::<Vec<_>>()
.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<rusqlite::types::Value> = 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");
}
+4 -1
View File
@@ -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) {
+3 -3
View File
@@ -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<u8> {
let mut tiff = Vec::new();
+5 -1
View File
@@ -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");
}
+21 -16
View File
@@ -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::<u8>().unwrap_or(0).min(MAX_RATING)
}
"rating" => version.rating = value.parse::<u8>().unwrap_or(0).min(MAX_RATING),
"flag" => version.flag = value.parse::<u8>().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");
}
}
+70 -24
View File
@@ -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::<String>()
}).collect::<Vec<_>>().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::<String>()
})
.collect::<Vec<_>>()
.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 {
+12 -34
View File
@@ -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<Item = reqwest::Certificate> {
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 {
}
}
}
+1 -3
View File
@@ -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")
}
+3 -1
View File
@@ -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:?}"
+5 -4
View File
@@ -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]
+18 -13
View File
@@ -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();
+9 -3
View File
@@ -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,
+17 -4
View File
@@ -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,
+12 -8
View File
@@ -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<u64> {
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::<Settings>(&json).unwrap(),
settings
);
assert_eq!(serde_json::from_str::<Settings>(&json).unwrap(), settings);
}
#[test]
+21 -7
View File
@@ -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}
+5 -8
View File
@@ -195,9 +195,7 @@ impl PlatformSecretStore {
fn store(&self) -> Result<&std::sync::Arc<android_native_keyring_store::Store>, 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<keyring_core::Entry, SecretError> {
fn entry(&self, r: &SecretRef) -> Result<keyring_core::Entry, SecretError> {
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<String, SecretError> {
+214
View File
@@ -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}"
+26 -6
View File
@@ -148,8 +148,24 @@ fn generate(source: &Path, live: bool) -> Result<String, String> {
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<String, String> {
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"
+53 -14
View File
@@ -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<CollectionsController>, 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::<slint::Rgba8Pixel>::new(tw + offset, th + offset);
let mut canvas = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::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<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
session: &Rc<dyn Fn() -> 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<dyn Fn()>,
) {
@@ -735,7 +744,12 @@ fn start_restore(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
session: &Rc<dyn Fn() -> 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<dyn Fn()>,
) {
@@ -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<S, R, C>(
) where
S: Fn() + 'static,
R: Fn() -> Vec<ImageId> + '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<S, R, C>(
let on_scope_changed: Rc<dyn Fn()> = 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<S, R, C>(
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<CollectionId>) -> 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<CollectionId>, Vec<bool>, std::collections::HashSet<CollectionId>) {
fn spring_fixture() -> (
Vec<CollectionId>,
Vec<bool>,
std::collections::HashSet<CollectionId>,
) {
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"
);
+1 -1
View File
@@ -225,7 +225,7 @@ async fn sync_shards(
let have: std::collections::HashSet<u32> = 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;
+23 -9
View File
@@ -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");
}
}
}
-1
View File
@@ -36,7 +36,6 @@ impl LaunchController {
poll_timer: RefCell::new(None),
})
}
}
/// Push the model into the window's properties.
+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"
);
}
+43 -41
View File
@@ -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<LibraryController>) {
*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<u8>,
flag: Option<dr_types::FlagState>,
) -> String {
fn judgement_summary(n: usize, rating: Option<u8>, flag: Option<dr_types::FlagState>) -> 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<LibraryController>) {
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<LibraryContr
window.set_library_current_bucket(current.unwrap_or(0) as i32);
window.set_library_current_fraction(fraction);
window.set_library_timeline_anchored(current.is_some());
window.set_library_timeline_label(
format!("{} – {}", format_date(from), format_date(to)).into(),
);
window
.set_library_timeline_label(format!("{} – {}", format_date(from), format_date(to)).into());
window.set_library_timeline(slint::ModelRc::new(slint::VecModel::from(bars)));
}
@@ -2264,8 +2258,7 @@ pub fn wire<F>(
ctl: Rc<LibraryController>,
coll_ctl: Rc<crate::collections_ui::CollectionsController>,
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<Option<slint::Timer>>) {
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;
+2 -12
View File
@@ -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.
+6 -9
View File
@@ -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<String>,
},
Done { moved: usize, failed: Vec<String> },
}
/// 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());
}