fix: scan rules from disk + dedup flat-md scanners#200
Conversation
|
Thanks for this — clear writeup and well-scoped work. Did a review pass locally. What's good
Minor things
On the follow-upsThe four items in your "not in scope" section are all correctly scoped as separate PRs — especially the refresh-button one (same limitation exists for every primitive today, deserves its own UX-level discussion). Happy to take those as follow-ups. On the clippy notePrefer fixing the pre-existing clippy warnings in a separate cleanup PR rather than stripping Once rebased, I think this is good to merge. Thanks again for the careful work on this. |
Rules in ~/.claude/rules/ and {project}/.claude/rules/ never appeared in
the UI because no scanner populated the `rules` table — it was only ever
written by in-UI create_rule. Every other behavioural primitive has both
global and project scanners; this closes the scope asymmetry.
Changes:
* utils/paths.rs: add rules_dir to ClaudePathsInternal.
* services/scanner.rs:
- Introduce SOURCE_AUTO_DETECTED constant and walk_md_dir helper.
- Migrate scan_global_commands, scan_global_agents, scan_project_commands,
and scan_project_agents to use the helper — behaviour unchanged.
- Add ParsedRule, parse_rule_file, parse_list_value (accepts both JSON-
array and legacy comma-separated forms), upsert_rule_from_file,
scan_global_rules, scan_project_rules. Populate is_symlink and
symlink_target (previously dead-lettered). Wire into run_startup_scan
and the per-project loop.
* services/rule_writer.rs: emit paths and tags as JSON arrays so they
round-trip through the DB reader (which already expects JSON). The
previous comma-joined form was lost to serde_json::from_str. Tags were
dropped entirely before; now they round-trip too.
* commands/rules.rs: delete_rule now also removes the backing .md file,
so a UI delete is not resurrected by the new scanner as 'auto-detected'.
Factor the logic behind delete_rule_inner(conn, id, home) for testing.
Not touched (different shapes, out of scope):
* scan_*_skills (directory-based with skill_files child table).
* scan_*_hooks (JSON inside settings.json).
* Vendor scanners (opencode/codex/copilot/cursor/gemini).
Tests:
* 18 new tests across the four files (walk_md_dir edge cases,
parse_rule_file, parse_list_value dual-form, scan_global_rules insert/
idempotent/backfill/symlink, scan_project_rules assignment,
rule_writer JSON emission for paths and tags, delete_rule_inner with
disk-present and disk-absent).
* Full suite: 2010 pass, 0 fail.
1f65439 to
310524d
Compare
…g them Addresses review nit on tylergraydev#200. The previous `.ok().and_then(|s| s.query_row(..).ok())` pattern silently conflated three cases: 1. row exists → Some(rule) ✓ 2. row absent (QueryReturnedNoRows) → None ✓ 3. real DB error (lock contention, corruption, IO) → None ✗ → DB delete proceeds, disk file leaks Switch to an explicit match on rusqlite::Error so only QueryReturnedNoRows maps to None; every other error is returned before the DELETE runs. Same treatment for rule_writer::delete_rule_file — its existing file-exists guard already handles the legitimate "disk file already gone" case, so any error it raises now is a real IO/permission failure worth surfacing. Adds test_delete_rule_missing_id_is_not_an_error to pin the QueryReturnedNoRows arm. 2015/2015 lib tests pass.
|
Rebased onto current
On the 4 failing build checksThese are not regressions from this branch — they're the standard "fork PR can't see repo secrets" pattern. The Rust compilation itself succeeds (6m19s on the macOS x86 job, GitHub Actions deliberately withholds
The passing checks on this PR ( Clippy follow-upWill open a separate cleanup PR for the pre-existing clippy warnings per your preference — that keeps this PR's diff focused on the scanner work. |
Summary
Rules in
~/.claude/rules/and{project}/.claude/rules/never appeared in the UI because no scanner populated therulestable — it was only ever written by in-UIcreate_rule. Every other behavioural primitive (commands, skills, agents, hooks) has both a global and a project scanner wired intorun_startup_scan. This PR closes that scope asymmetry: an existing.mdrule on disk now shows up in the library like everything else, with source=auto-detected.While there, the PR pays down mechanical duplication. Four flat-
.mdscanners —scan_global_commands,scan_global_agents,scan_project_commands,scan_project_agents— shared ~150 LOC of copy-pasted skeleton (read_dir→.mdfilter →parse_frontmatter→ upsert-by-unique-name). They're migrated to a small shared helper,walk_md_dir, which the two new rule scanners also use. Skills (directory-based with askill_fileschild table), hooks (JSON insettings.json), and the vendor scanners (opencode/codex/copilot/cursor/gemini) are legitimately different shapes and are left untouched.Two adjacent bugs also fall out of the scope:
paths: a, b, cas a comma-joined plain string, but the DB reader deserialisedpaths/tagsviaserde_json::from_str. A UI-created rule round-tripped through the new scanner would have itspathssilently corrupted. The writer now emits JSON arrays; the scanner accepts both JSON and legacy comma-separated form so users' existing.mdfiles on disk continue to parse. The writer also previously droppedtagsentirely — now it emits them.delete_ruleremoved only the DB row. With a scanner in place, any rule with a surviving disk file would resurrect asauto-detectedon next launch.delete_rulenow also removes the backing.mdfile, factored behind a testabledelete_rule_innerhelper.The previously dead-lettered
is_symlinkandsymlink_targetcolumns in therulesschema are now populated by the global/project scanners viafs::symlink_metadata+fs::read_link.Files touched
src-tauri/src/utils/paths.rsrules_dirfield + populatesrc-tauri/src/services/scanner.rsSOURCE_AUTO_DETECTED+walk_md_dir; migrate four flat-.mdscanners; addParsedRule/parse_rule_file/parse_list_value/upsert_rule_from_file/scan_global_rules/scan_project_rules; wire intorun_startup_scanand per-project loopsrc-tauri/src/services/rule_writer.rspaths/tagsemitted as JSON arrayssrc-tauri/src/services/config_writer.rsrules_dirfield to 14 test-onlyClaudePathsInternalliterals to keep existing tests compilingsrc-tauri/src/commands/rules.rsdelete_ruleremoves backing.md; factored behinddelete_rule_innerNo schema migration. No new dependencies.
Test plan
cargo fmt --checkpasses.cargo test --lib— 2010 pass, 0 fail. 18 new tests coverwalk_md_dir(empty/non-md/error),parse_rule_file(full/no-frontmatter/invalid),parse_list_value(JSON/comma/empty),scan_global_rules(empty/insert/idempotent/backfill/symlink),scan_project_rules(assignment + dedup), rule_writer JSON emission forpathsandtags, anddelete_rule_innerwith disk-present and disk-absent.generate_rule_markdowntests updated to assert the new JSON-array shape forpathsandtags..mdfiles in~/.claude/rules/→ relaunch Tauri dev → Rules page populates withdescription+paths+ auto-detected badge; second launch no duplicates; UI-created rule round-trips through disk as JSON-array form; UI-delete removes disk file and survives relaunch. (I've tested this locally; maintainer may want to verify too.)Not in scope (candidates for follow-up PRs)
Header.svelte:21-36 handleRefreshis a pure in-memory reload;commands/scanner.rs:7 scan_claude_directoryonly runsscan_claude_json + scan_plugins, not the global library scanners. Same limitation exists for commands/skills/agents/hooks today. A separate UX PR.tagsin frontmatter too. Latent; no live bug because no scanner re-ingests them for DB writes. Separate PR if desired..mdhelper that a second small helper would be warranted — separate PR.settings.local.jsonhook scanning. No scanner today reads.localvariants for any primitive. Separate PR.Clippy note
cargo clippy -- -D warningsfails on this branch, but the failures are all pre-existing onmain(119 errors:vec!in tests, unused variables indocker_hosts.rs, snake_case nits,PathBufvsPathin existing helpers, etc.). None of the errors touch files/lines added by this PR. Happy to strip-D warningsfrom CI or fix them in a separate cleanup PR — let me know the preference.