Skip to content

Commit 1dfb4ec

Browse files
utofclaude
andcommitted
feat(cli): perima metadata command + ls --with-metadata flag
New metadata subcommand re-extracts metadata for a specific file (debugging / one-off fixes). ls gains an optional --with-metadata flag that adds captured_at + dimensions + camera_model columns via the new LEFT JOIN query path. Default ls output is unchanged for v0.3.x script compatibility. Scanner now enqueues freshly hashed files (Inserted | Updated) into a MetadataQueue after each successful upsert_file. Scan performs a bounded drain (default 30s, --no-wait-metadata bypasses) so CLI callers can rely on metadata rows being present by exit — the integration test scan_with_metadata_test asserts this directly. Watcher still does not enqueue on Modified events in v0.4.0 — content changes mark status=stale; next scan re-extracts. Avoids Modified-storm pathology during file-in-flight downloads. Adds perima-media workspace dep to crates/cli so release-plz's dep graph propagates version bumps when feat(media): commits land. Hardens scripts/pre-commit by exporting PKG_CONFIG_PATH / LIBRARY_PATH / RUSTFLAGS for the Tauri toolchain so `just ci` succeeds inside git's non-interactive hook shell. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent cd30fce commit 1dfb4ec

10 files changed

Lines changed: 780 additions & 44 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ module_name_repetitions = "allow"
2727
missing_errors_doc = "warn"
2828

2929
[workspace.dependencies]
30+
# WHY workspace-path dep for perima-media: release-plz + other crates
31+
# (perima CLI) need it reachable via `workspace = true` so bumps
32+
# propagate correctly via the workspace graph.
33+
perima-media = { path = "crates/media", version = "0.3.2" }
3034
anyhow = "1"
3135
thiserror = "2"
3236
serde = { version = "1", features = ["derive"] }

crates/cli/Cargo.toml

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,15 @@ license.workspace = true
77
repository.workspace = true
88

99
[dependencies]
10-
perima-core = { path = "../core" }
11-
perima-hash = { path = "../hash" }
12-
perima-fs = { path = "../fs" }
13-
perima-db = { path = "../db" }
10+
perima-core = { path = "../core" }
11+
perima-hash = { path = "../hash" }
12+
perima-fs = { path = "../fs" }
13+
perima-db = { path = "../db" }
14+
# WHY `workspace = true` (not path-local): release-plz reads the workspace
15+
# dep graph to decide which crates need version bumps. A `feat(media):`
16+
# commit must propagate to `perima` so the CLI rev matches the media rev
17+
# it links against.
18+
perima-media = { workspace = true }
1419
chrono.workspace = true
1520
serde_json.workspace = true
1621
serde.workspace = true
@@ -32,6 +37,10 @@ tempfile.workspace = true
3237
insta.workspace = true
3338
rusqlite.workspace = true
3439
serde_json.workspace = true
40+
# WHY image + blake3 in dev-deps: `scan_with_metadata_test.rs`
41+
# synthesises PNG/JPEG fixtures at runtime (no binary blobs in git),
42+
# matching the pattern already used in `crates/media/tests`.
43+
image.workspace = true
3544

3645
[target.'cfg(unix)'.dev-dependencies]
3746
# WHY: nix provides POSIX signal utilities (kill/SIGTERM) without unsafe libc.

crates/cli/src/cmd/ls.rs

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
33
use std::io::Write;
44

5-
use perima_core::{CoreError, FileLocationRecord, FileRepository, VolumeId};
5+
use perima_core::{
6+
CoreError, FileLocationRecord, FileRepository, MediaMetadata, MetadataRepository, VolumeId,
7+
};
68

79
/// Arguments for the ls command.
810
#[derive(Debug, Clone)]
@@ -13,18 +15,46 @@ pub struct LsArgs {
1315
pub limit: usize,
1416
/// Output as JSON instead of a human-readable table.
1517
pub json: bool,
18+
/// Include media metadata columns (`captured_at`, dimensions, `camera_model`).
19+
///
20+
/// WHY opt-in flag: the `--with-metadata` path uses a LEFT JOIN
21+
/// against `file_metadata`, which is slightly more expensive than
22+
/// the base `list_file_locations` query and returns extra columns
23+
/// that older scripts do not expect. Keeping the default narrow
24+
/// preserves v0.3.x output stability.
25+
pub with_metadata: bool,
1626
}
1727

1828
/// Execute `ls`.
1929
///
2030
/// Reads all file location records from `repo` (up to `args.limit`)
21-
/// and prints them either as a human-readable table or as JSON.
31+
/// and prints them either as a human-readable table or as JSON. When
32+
/// `args.with_metadata` is `true`, the listing routes through
33+
/// `metadata_repo` so each row is joined with its (optional)
34+
/// `file_metadata`.
2235
///
2336
/// # Errors
2437
/// Propagates `CoreError` from the repository.
25-
pub fn run<R: FileRepository + ?Sized>(repo: &R, args: &LsArgs) -> Result<(), CoreError> {
26-
let records = repo.list_file_locations(args.limit, args.volume)?;
38+
pub fn run<R, M>(repo: &R, metadata_repo: &M, args: &LsArgs) -> Result<(), CoreError>
39+
where
40+
R: FileRepository + ?Sized,
41+
M: MetadataRepository + ?Sized,
42+
{
43+
if args.with_metadata {
44+
let rows = metadata_repo.list_with_metadata(args.limit, args.volume)?;
45+
if args.json {
46+
let stdout = std::io::stdout();
47+
let mut handle = stdout.lock();
48+
serde_json::to_writer_pretty(&mut handle, &rows)
49+
.map_err(|e| CoreError::Internal(format!("json: {e}")))?;
50+
writeln!(handle).map_err(CoreError::Io)?;
51+
} else {
52+
print_table_with_metadata(&rows)?;
53+
}
54+
return Ok(());
55+
}
2756

57+
let records = repo.list_file_locations(args.limit, args.volume)?;
2858
if args.json {
2959
let stdout = std::io::stdout();
3060
let mut handle = stdout.lock();
@@ -61,3 +91,52 @@ fn print_table(records: &[FileLocationRecord]) -> Result<(), CoreError> {
6191
}
6292
Ok(())
6393
}
94+
95+
/// Render `ls --with-metadata` as a human-readable table.
96+
///
97+
/// WHY separate helper (not a branch inside [`print_table`]): the two
98+
/// tables have different column counts and different `writeln!` format
99+
/// strings; sharing the body would mean nullable placeholders for the
100+
/// metadata columns on plain `ls`, which is more confusing than a
101+
/// parallel function.
102+
fn print_table_with_metadata(
103+
rows: &[(FileLocationRecord, Option<MediaMetadata>)],
104+
) -> Result<(), CoreError> {
105+
let stdout = std::io::stdout();
106+
let mut handle = stdout.lock();
107+
writeln!(
108+
handle,
109+
"{:<10} {:<10} {:<10} {:<20} {:<10} {:<20} PATH",
110+
"HASH", "SIZE", "VOLUME", "CAPTURED_AT", "DIMS", "CAMERA",
111+
)
112+
.map_err(CoreError::Io)?;
113+
for (r, meta) in rows {
114+
let hash_hex = r.hash.to_hex();
115+
let hash_short = &hash_hex[..8];
116+
let vol_str = r.volume_id.0.to_string();
117+
let vol_short = &vol_str[..8];
118+
let size = super::format::format_size(r.size.0);
119+
let captured_at = meta
120+
.as_ref()
121+
.and_then(|m| m.captured_at.clone())
122+
.unwrap_or_else(|| "-".to_owned());
123+
let dims = meta
124+
.as_ref()
125+
.and_then(|m| match (m.width, m.height) {
126+
(Some(w), Some(h)) => Some(format!("{w}x{h}")),
127+
_ => None,
128+
})
129+
.unwrap_or_else(|| "-".to_owned());
130+
let camera = meta
131+
.as_ref()
132+
.and_then(|m| m.camera_model.clone())
133+
.unwrap_or_else(|| "-".to_owned());
134+
writeln!(
135+
handle,
136+
"{hash_short}… {size:<10} {vol_short}… {captured_at:<20} {dims:<10} {camera:<20} {}",
137+
r.relative_path.as_str()
138+
)
139+
.map_err(CoreError::Io)?;
140+
}
141+
Ok(())
142+
}

0 commit comments

Comments
 (0)