Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions benches/support/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ pub fn make_sounds(n: usize) -> Vec<SoundEntry> {
} else {
None
},
modified_ms: None,
category: category.to_owned(),
}
})
Expand Down
75 changes: 75 additions & 0 deletions docs/superpowers/specs/2026-07-20-issue-195-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Issue #195 — Library Modified and First-Seen Timestamps

Date: 2026-07-20 · Status: approved

## Why

The list-controls framework needs stable modified and date-added sort keys. Modified time comes
from the filesystem during the existing scan. Date added is HonkHonk state: it is stamped once
when a sound ID is first observed and survives later scans.

The scan must also distinguish a complete view of the library from a partial one. A missing mount,
unreadable subtree, or other walk error must not look like intentional deletion and erase date-added
history.

## Data Shapes

- `SoundEntry.modified_ms: Option<u64>` stores filesystem modification time in Unix epoch
milliseconds. It is `None` when metadata, modification time, epoch conversion, or `u64`
conversion is unavailable.
- `LibraryScan` owns the scanned entries and a completeness flag. It replaces the ambiguous bare
`Vec<SoundEntry>` result at startup and rescan boundaries.
- `SoundMetaStore` becomes a named struct containing the existing custom metadata map plus
`added: BTreeMap<String, u64>`.
- The persisted file becomes an explicit versioned envelope containing custom metadata and added
timestamps. Loading retains compatibility with the legacy top-level sound-ID map.

No new crate is needed; `std::fs::Metadata`, `std::time::SystemTime`, and the existing Serde stack
cover the feature.

## Interfaces and Contracts

- `Library::scan(dirs) -> Result<LibraryScan, ConfigError>` continues collecting accessible audio
entries. `complete` is false if a configured root is absent or any directory-walk/metadata
observation needed for membership fails.
- Timestamp conversion is a small pure helper returning `Option<u64>`; it never unwraps or truncates
`u128` milliseconds.
- `SoundMetaStore::added_ms(id) -> Option<u64>` exposes the persisted sort key.
- `SoundMetaStore::reconcile_added(ids, observed_at_ms, complete) -> bool` inserts missing IDs with
the single scan timestamp, never overwrites existing timestamps, and prunes absent IDs only when
`complete` is true. The boolean reports whether persistence is needed.
- Startup and `Message::RescanLibrary` reconcile immediately after scanning and save once when the
store changed. Save failures are logged with context and do not discard the usable in-memory
library.

## Persistence Compatibility

The loader first recognizes the new versioned envelope; otherwise it interprets the JSON object as
the legacy `HashMap<String, SoundMeta>`. Legacy customizations must survive the first new-version
save. Default `SoundMeta` entries remain pruned exactly as before; first-seen timestamps live only
in the dedicated map and do not create fake customization entries.

## Invariants

1. A successfully observed modification time is represented in epoch milliseconds without panic or
lossy integer casting.
2. Every observed sound ID receives one first-seen timestamp, shared from the scan boundary.
3. Re-observing an ID never changes its first-seen timestamp, including after persistence reload.
4. A complete scan prunes IDs no longer present; a partial scan never prunes unseen IDs.
5. Existing `sound_meta.json` customizations load unchanged and migrate on the next save.
6. Default custom metadata still occupies no entry in the customization map.

## TDD and Change Map

1. Add failing library tests for populated `modified_ms` and partial-scan completeness.
2. Add failing metadata-store tests for first stamp, stable restamp, complete-scan pruning, and
partial-scan preservation.
3. Add failing persistence tests for legacy loading and new-envelope round trips.
4. Introduce the data shapes and timestamp helpers, then make the focused state tests green.
5. Wire `LibraryScan` and reconciliation through startup and rescan, with app-boundary regression
tests.
6. Update existing `SoundEntry` fixtures mechanically with `modified_ms: None`, refactor, and run the
full Rust verification suite.

Likely files: `src/state/library.rs`, `src/state/sound_meta.rs`, `src/state/mod.rs`, `src/main.rs`,
and narrow startup/rescan/test sites in `src/app/mod.rs`.
1 change: 1 addition & 0 deletions src/app/filtering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ mod tests {
format: AudioFormat::Wav,
duration_ms: None,
category: "Animals".into(),
modified_ms: None,
}];
app.sound_meta
.set_display_name("goose", Some("Angry Bird".into()));
Expand Down
147 changes: 147 additions & 0 deletions src/app/library_scan.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
use std::time::SystemTime;

use crate::state::{LibraryScan, SoundEntry, SoundMetaStore};

use super::HonkHonk;

pub(super) fn load_sound_meta(scan: &LibraryScan) -> SoundMetaStore {
let mut store = SoundMetaStore::load();
reconcile_sound_meta(&mut store, &scan.entries, scan.complete, true);
store
}

fn reconcile_sound_meta(
store: &mut SoundMetaStore,
sounds: &[SoundEntry],
complete: bool,
persist: bool,
) {
let Some(observed_at_ms) = crate::state::library::system_time_to_epoch_ms(SystemTime::now())
else {
tracing::warn!("system clock is before Unix epoch; first-seen timestamps not reconciled");
return;
};
let changed = store.reconcile_added(
sounds.iter().map(|sound| &sound.id),
observed_at_ms,
complete,
);
save_reconciled_meta(store, changed && persist);
}

fn save_reconciled_meta(store: &SoundMetaStore, should_save: bool) {
if !should_save {
return;
}
if let Err(error) = store.save() {
tracing::warn!(error = %error, "failed to save reconciled sound metadata");
}
}

impl HonkHonk {
pub(super) fn apply_library_scan(&mut self, scan: LibraryScan) {
reconcile_sound_meta(
&mut self.sound_meta,
&scan.entries,
scan.complete,
self.persist,
);
self.duration_scan_pairs = std::sync::Arc::new(
scan.entries
.iter()
.map(|sound| (sound.id.clone(), sound.path.clone()))
.collect(),
);
self.sounds = scan.entries;
self.reconcile_playback_with_library();
self.durations_loaded = false;
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::app::Message;
use crate::state::AudioFormat;

fn sound(id: &str) -> SoundEntry {
SoundEntry {
id: id.to_owned(),
name: id.to_owned(),
path: format!("/sounds/{id}.wav").into(),
format: AudioFormat::Wav,
duration_ms: None,
modified_ms: None,
category: "Test".to_owned(),
}
}

#[test]
fn applying_complete_scan_reconciles_first_seen_ids_and_prunes_stale_ids() {
let mut app = HonkHonk::new_for_test();
app.sound_meta.reconcile_added(["stale"], 1, true);

app.apply_library_scan(LibraryScan {
entries: vec![sound("current")],
complete: true,
});

assert!(app.sound_meta.added_ms("current").is_some());
assert_eq!(app.sound_meta.added_ms("stale"), None);
}

#[test]
fn applying_partial_scan_preserves_unseen_first_seen_ids() {
let mut app = HonkHonk::new_for_test();
app.sound_meta.reconcile_added(["unseen"], 1, true);

app.apply_library_scan(LibraryScan {
entries: vec![sound("current")],
complete: false,
});

assert_eq!(app.sound_meta.added_ms("unseen"), Some(1));
assert!(app.sound_meta.added_ms("current").is_some());
}

#[test]
fn rescan_library_resets_durations_loaded() {
let mut app = HonkHonk::new_for_test();
app.durations_loaded = true;
let _ = app.update(Message::RescanLibrary);
assert!(!app.durations_loaded);
}

#[test]
fn remove_sound_directory_removes_path() {
let mut app = HonkHonk::new_for_test();
let path = std::path::PathBuf::from("/tmp/hh_test_sounds");
app.config.sound_directories.push(path.clone());

let _ = app.update(Message::RemoveSoundDirectory(path.clone()));

assert!(!app.config.sound_directories.contains(&path));
}

#[test]
fn sound_directory_pick_some_appends_to_config() {
let mut app = HonkHonk::new_for_test();
let path = std::path::PathBuf::from("/tmp/hh_new_sounds");
let before = app.config.sound_directories.len();

let _ = app.update(Message::SoundDirectoryPickResult(Some(path.clone())));

assert_eq!(app.config.sound_directories.len(), before + 1);
assert!(app.config.sound_directories.contains(&path));
}

#[test]
fn sound_directory_pick_none_is_noop() {
let mut app = HonkHonk::new_for_test();
let before = app.config.sound_directories.clone();

let _ = app.update(Message::SoundDirectoryPickResult(None));

assert_eq!(app.config.sound_directories, before);
}
}
1 change: 1 addition & 0 deletions src/app/macros/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ fn sound(id: &str, path: &str) -> SoundEntry {
path: path.into(),
format: AudioFormat::Wav,
duration_ms: Some(100),
modified_ms: None,
category: "Test".into(),
}
}
Expand Down
Loading
Loading