core-dev
Core development guide for frename-core. Use when: adding or changing traits (StoredTagStore, AppStateStore), modifying TagList logic, working with OrderedCollection, adding Tag fields, changing FileS
SKILL.md.claude/skills/core-dev/SKILL.md
GitHubCore Development Guide — frename-core
WHEN to use this skill
- Adding or changing a
StoredTagStoreorAppStateStoremethod - Modifying
TagList(ordering, filtering, toggle, create, save, remove, reorder) - Working with
OrderedCollection(insert, insert_before, rebalance) - Adding fields to
Tag,File,Directory, orFileSnapshot - Changing file name parsing/saving (
FileTagger) - Writing core tests
- Adding database tables, columns, or migrations
For undo/redo infrastructure (crates/frename-core/src/undo/), use the undo-dev skill instead.
Crate boundary
frename-core is a pure library. No iced, no UI types. It exposes:
frename_core::
AppDatabase, LoggingAppStateStore, FakeAppStorage (test)
AppStateStore, StoredTagStore, Initializable (traits)
Directory<S>, File, FileSnapshot, FolderAndFile
TagList<S>, Tag, TagId, StoredTag, TagColorMapping
OrderedCollection<K,V>, OrderKey, OrderedThing
FileTagger, SaveAndReparse
Traits
StoredTagStore (db/traits.rs)
All methods return Result<_, Box<dyn Error + Send + Sync>>.
| Method | What it does |
|---|---|
get_stored_tags() |
Returns all stored tags ordered by sort_order |
get_tag_color_mapping() |
Returns tag-name → color-index mapping |
save_tag(tag, color) |
Upsert by tag id: insert or update name + sort_order + color |
remove_stored_tag_by_id(uuid) |
Delete tag and its color mapping by id |
update_tag_orders(&[(uuid, i64)]) |
Bulk-update sort_order after rebalance |
TagList always calls save_tag() (upsert). Never call add_stored_tag from production code.
Trait is Send + Sync — safe to hold in FileWorkspace<S> and clone.
AppStateStore (db/traits.rs)
| Method | What it does |
|---|---|
get_last_session() |
Returns Option<FolderAndFile> (last opened folder + file) |
set_last_folder_and_file(&FolderAndFile) |
Persist or update session; also records the folder as opened now in the recent folders |
get_recent_folders() |
The folders opened recently (#63): newest first, each once, at most MAX_RECENT_FOLDERS (10) |
record_recent_folder(&FolderAndFile, opened_at_ms) |
Move a folder to the top (the list logic is recent_folders::record); a session without a file keeps the file the folder had |
forget_recent_folder(&Path), clear_recent_folders() |
Remove one entry, or all |
Initializable (db/traits.rs)
fn initialize(&self) -> Result<(), rusqlite::Error> — run migrations once at startup.
Always call via LoggingAppStateStore::initialize().
Adding a method to StoredTagStore
- Add to trait in
db/traits.rs - Implement in
impl StoredTagStore for AppDatabaseindb/app_database.rs - Implement in
impl StoredTagStore for FakeAppStorageindb/fake_app_storage.rs - (If TagList needs to call it) add a wrapper to
TagList<S>intags/tag_list.rs - (If UI needs it) expose via
FileWorkspace<S>insrc/features/file_workspace/state.rs
TagList
TagList<S: StoredTagStore + Clone> is the source of truth for all tag state.
Internal structure
| Field | Type | Purpose |
|---|---|---|
tags_by_id |
HashMap<TagId, Tag> |
All tag data; only place tag fields are stored |
display_tag_ids |
OrderedCollection<TagId, ()> |
Display order (tag panel); set at init, only grows at front via create_new_tag; used for persistence |
selected_tag_ids |
OrderedCollection<TagId, ()> |
File-name-panel order; same set as display; reorderable via reorder_tag_to_index |
filtered_display_tag_ids |
Vec<TagId> |
Display after search filter; rebuilt by rebuild_filtered_display_tag_ids() after every mutation |
Initialization order (critical — don't change)
- Stored tags from DB →
tags_by_id(checked = has_tag in snapshot, order = DB sort_order) - Snapshot tags not in stored →
tags_by_id(checked = true, order = 0, stored = false) - Stored-but-not-in-snapshot tags →
display_tag_ids(order from DB) - Snapshot tags (reversed, deduped) →
display_tag_idsviainsert_beforeso they appear first in snapshot order selected_tag_ids = display_tag_ids.clone()rebuild_filtered_display_tag_ids()
Key methods
| Method | Effect on fields |
|---|---|
set_filter(query) |
Rebuilds filtered_display_tag_ids |
toggle_by_id(id) |
Flips tag.checked in tags_by_id |
create_new_tag(name) |
Inserts at front of both ordered collections; rebuilds filter |
save_tag(id) |
Calls store.save_tag(); sets tag.stored=true, assigns random color if new; if display_tags_rebalanced, also calls store.update_tag_orders() |
remove_stored_tag_by_id(id) |
Removes from store + both collections + map; rebuilds filter |
reorder_tag_to_index(moved_id, index) |
Moves in selected_tag_ids only (file name panel order) |
file_snapshot() |
Builds FileSnapshot from checked tags in selected_tag_ids order |
After any mutation that changes the display set: always call rebuild_filtered_display_tag_ids().
display_tags_rebalanced
Set to true when OrderedCollection::insert_before() triggers a rebalance.
Cleared in save_tag() after calling update_tag_orders(). Signals that all stored tag sort_orders must be re-persisted.
OrderedCollection
OrderedCollection<K, V> is an ordered dictionary backed by HashMap + BTreeSet.
OrderKey = i64.
| Method | Behavior |
|---|---|
insert(key, value, order) |
Insert or replace at exact order key |
insert_before(key, value, before_id) |
Insert immediately before before_id; if None, insert before the first element; returns true if rebalanced |
remove(key) |
Remove; returns value |
get_order(key) |
Returns current OrderKey for the key |
iter() |
Yields (id, value, order) in ascending order |
Rebalance: when no integer fits between two adjacent order keys, all items are respread across [1, i64::MAX-1] with equal gaps. Callers check the return value of insert_before to know if orders need re-persisting.
Tag
pub struct Tag {
id: TagId, // UUID wrapper
tag: String, // display name
checked: bool, // applied to current file
color_index: u8, // index into 16-color palette
stored: bool, // persisted in DB (false = snapshot-only or just created)
order: OrderKey, // matches display_tag_ids order at creation
}
stored = falsemeans the tag exists only in the UI / current file's snapshot.toggle()flipschecked.set_stored(true)+set_color_index(c)called insave_tag().
FileSnapshot
Built by TagList::file_snapshot(). Contains:
tags: Vec<String>— names of checked tags inselected_tag_idsordername_without_extension: Stringextension: Stringinitial_file_name: String
FileSnapshot::file_name() = tags.join(".") + "." + name + "." + ext
Parsing (loading a file): FileTagger::parse(path) splits the file name by .: last segment = extension, second-to-last = name, rest = tags.
Saving (writing to disk): snapshot.save_and_reparse(&path) renames the file and re-parses to get the canonical snapshot.
Database schema
Tables (defined in db/schema.rs, run by db/migrations.rs):
| Table | Columns | Key |
|---|---|---|
folder_history |
opened_at, folder_path, last_file_path |
folder_path unique; the last session is its newest row |
recent_folders |
position, folder_path, opened_at_ms, last_file_path |
position (0 = newest); rewritten whole from recent_folders::record |
stored_tags |
id (UUID text), name, sort_order |
id unique |
tag_color_mapping |
tag_name, color_index |
tag_name unique |
Colors are keyed by tag name (not id), so renaming a tag requires deleting the old mapping and inserting the new one — save_tag() handles this.
Migrations: numbers collide across branches
Migrations run in version order, once each (db/migrations.rs). Two branches that each took
"the next number" collide: a database already at that version never runs the second one, so its
columns never appear and reading the settings fails. Before merging, and after every rebase,
check migrations.rs on main and renumber yours to follow its last one (the migration
constant's name, the list entry, and the version asserted by running_migrations_twice_is_a_no_op).
Disk paths in a debug build
A debug build (or --debug) installs InMemoryFileTagger: renames and comment/metadata writes
happen in memory only, and File::file_path() is the name the file would have. Anything that
opens the real file — GStreamer, std::fs, another program — must go through
FileTagger::disk_path(path) first, as the media viewer and Describe with AI do; otherwise a file
renamed during the session (tag spacing, a tag toggle) is "not found". --production makes a
debug build write to disk.
Requests to paid APIs (Anthropic, Soniox) from a debug build are still real and billed; only their results stay in memory.
Tests
All core tests use FakeAppStorage (in db/fake_app_storage.rs).
Builder pattern:
let store = FakeAppStorage::new()
.add_stored_tag(StoredTag::with_sort_order(Uuid::new_v4(), "Action", 1), 0)
.add_stored_tag(StoredTag::with_sort_order(Uuid::new_v4(), "Comedy", 2), 1);
let list = TagList::new(store, FileSnapshot::default());
FakeAppStorage::add_stored_tag(self, tag, color) is a builder method (takes self by value), separate from the StoredTagStore trait.