Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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`.
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