Skip to content

Commit 310524d

Browse files
committed
fix: scan rules from disk; dedup flat-md scanners; round-trip paths/tags
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.
1 parent e32d638 commit 310524d

5 files changed

Lines changed: 710 additions & 139 deletions

File tree

src-tauri/src/commands/rules.rs

Lines changed: 95 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -139,13 +139,36 @@ pub fn update_rule(
139139
stmt.query_row([id], row_to_rule).map_err(|e| e.to_string())
140140
}
141141

142+
/// Inner helper for [`delete_rule`]: drops the DB row and best-effort
143+
/// removes the backing `.md` file under `{home}/.claude/rules/<name>.md`.
144+
/// Factored out so tests can pass a temp dir in place of `~`.
145+
fn delete_rule_inner(conn: &rusqlite::Connection, id: i64, home: &Path) -> Result<(), String> {
146+
let query = format!("SELECT {} FROM rules WHERE id = ?", RULE_SELECT_FIELDS);
147+
let rule: Option<Rule> = conn
148+
.prepare(&query)
149+
.ok()
150+
.and_then(|mut s| s.query_row([id], row_to_rule).ok());
151+
152+
conn.execute("DELETE FROM rules WHERE id = ?", [id])
153+
.map_err(|e| e.to_string())?;
154+
155+
if let Some(rule) = rule {
156+
// Best-effort — if the file is already gone (user deleted by hand) we
157+
// still want the DB delete to succeed.
158+
let _ = rule_writer::delete_rule_file(home, &rule);
159+
}
160+
161+
Ok(())
162+
}
163+
142164
#[tauri::command]
143165
pub fn delete_rule(db: State<'_, Arc<Mutex<Database>>>, id: i64) -> Result<(), String> {
144166
let db = db.lock().map_err(|e| e.to_string())?;
145-
db.conn()
146-
.execute("DELETE FROM rules WHERE id = ?", [id])
147-
.map_err(|e| e.to_string())?;
148-
Ok(())
167+
let base_dirs =
168+
directories::BaseDirs::new().ok_or_else(|| "Could not find home directory".to_string())?;
169+
// Without the disk delete, the next startup scan would resurrect the rule
170+
// as `source='auto-detected'`.
171+
delete_rule_inner(db.conn(), id, base_dirs.home_dir())
149172
}
150173

151174
#[tauri::command]
@@ -548,4 +571,72 @@ mod tests {
548571
assert!(glob_match("src/*.ts", "src/index.ts"));
549572
assert!(!glob_match("src/*.ts", "src/deep/index.ts"));
550573
}
574+
575+
// =========================================================================
576+
// delete_rule_inner tests
577+
// =========================================================================
578+
579+
fn setup_test_db() -> Database {
580+
Database::in_memory().unwrap()
581+
}
582+
583+
#[test]
584+
fn test_delete_rule_removes_disk_file() {
585+
let db = setup_test_db();
586+
let temp_dir = tempfile::TempDir::new().unwrap();
587+
588+
db.conn()
589+
.execute(
590+
"INSERT INTO rules (name, content) VALUES (?, ?)",
591+
params!["my-rule", "body"],
592+
)
593+
.unwrap();
594+
let id = db.conn().last_insert_rowid();
595+
596+
let rules_dir = temp_dir.path().join(".claude").join("rules");
597+
std::fs::create_dir_all(&rules_dir).unwrap();
598+
let file_path = rules_dir.join("my-rule.md");
599+
std::fs::write(&file_path, "body").unwrap();
600+
assert!(file_path.exists());
601+
602+
delete_rule_inner(db.conn(), id, temp_dir.path()).unwrap();
603+
604+
assert!(!file_path.exists(), "disk file should be removed");
605+
let row_count: i64 = db
606+
.conn()
607+
.query_row("SELECT COUNT(*) FROM rules WHERE id = ?", [id], |r| {
608+
r.get(0)
609+
})
610+
.unwrap();
611+
assert_eq!(row_count, 0, "db row should be removed");
612+
}
613+
614+
#[test]
615+
fn test_delete_rule_succeeds_when_file_absent() {
616+
let db = setup_test_db();
617+
let temp_dir = tempfile::TempDir::new().unwrap();
618+
619+
db.conn()
620+
.execute(
621+
"INSERT INTO rules (name, content) VALUES (?, ?)",
622+
params!["ghost", "body"],
623+
)
624+
.unwrap();
625+
let id = db.conn().last_insert_rowid();
626+
627+
// No disk file created — just the DB row.
628+
let result = delete_rule_inner(db.conn(), id, temp_dir.path());
629+
assert!(
630+
result.is_ok(),
631+
"delete must succeed when disk file is absent"
632+
);
633+
634+
let row_count: i64 = db
635+
.conn()
636+
.query_row("SELECT COUNT(*) FROM rules WHERE id = ?", [id], |r| {
637+
r.get(0)
638+
})
639+
.unwrap();
640+
assert_eq!(row_count, 0);
641+
}
551642
}

src-tauri/src/services/config_writer.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,7 @@ mod tests {
697697
commands_dir: dir.path().join("commands"),
698698
skills_dir: dir.path().join("skills"),
699699
agents_dir: dir.path().join("agents"),
700+
rules_dir: dir.path().join("rules"),
700701
};
701702

702703
let mcps = vec![sample_stdio_mcp()];
@@ -720,6 +721,7 @@ mod tests {
720721
commands_dir: dir.path().join("commands"),
721722
skills_dir: dir.path().join("skills"),
722723
agents_dir: dir.path().join("agents"),
724+
rules_dir: dir.path().join("rules"),
723725
};
724726

725727
// Write existing config
@@ -751,6 +753,7 @@ mod tests {
751753
commands_dir: dir.path().join("commands"),
752754
skills_dir: dir.path().join("skills"),
753755
agents_dir: dir.path().join("agents"),
756+
rules_dir: dir.path().join("rules"),
754757
};
755758

756759
std::fs::write(&paths.claude_json, "not valid json").unwrap();
@@ -780,6 +783,7 @@ mod tests {
780783
commands_dir: dir.path().join("commands"),
781784
skills_dir: dir.path().join("skills"),
782785
agents_dir: dir.path().join("agents"),
786+
rules_dir: dir.path().join("rules"),
783787
};
784788

785789
std::fs::write(&paths.claude_json, "{}").unwrap();
@@ -815,6 +819,7 @@ mod tests {
815819
commands_dir: dir.path().join("commands"),
816820
skills_dir: dir.path().join("skills"),
817821
agents_dir: dir.path().join("agents"),
822+
rules_dir: dir.path().join("rules"),
818823
};
819824

820825
std::fs::write(&paths.claude_json, "{}").unwrap();
@@ -854,6 +859,7 @@ mod tests {
854859
commands_dir: dir.path().join("commands"),
855860
skills_dir: dir.path().join("skills"),
856861
agents_dir: dir.path().join("agents"),
862+
rules_dir: dir.path().join("rules"),
857863
};
858864

859865
std::fs::write(&paths.claude_json, "{}").unwrap();
@@ -894,6 +900,7 @@ mod tests {
894900
commands_dir: dir.path().join("commands"),
895901
skills_dir: dir.path().join("skills"),
896902
agents_dir: dir.path().join("agents"),
903+
rules_dir: dir.path().join("rules"),
897904
};
898905

899906
std::fs::write(&paths.claude_json, "{}").unwrap();
@@ -933,6 +940,7 @@ mod tests {
933940
commands_dir: dir.path().join("commands"),
934941
skills_dir: dir.path().join("skills"),
935942
agents_dir: dir.path().join("agents"),
943+
rules_dir: dir.path().join("rules"),
936944
};
937945

938946
std::fs::write(&paths.claude_json, "{}").unwrap();
@@ -971,6 +979,7 @@ mod tests {
971979
commands_dir: dir.path().join("commands"),
972980
skills_dir: dir.path().join("skills"),
973981
agents_dir: dir.path().join("agents"),
982+
rules_dir: dir.path().join("rules"),
974983
};
975984

976985
// No file exists, should create new
@@ -1096,6 +1105,7 @@ mod tests {
10961105
commands_dir: dir.path().join("commands"),
10971106
skills_dir: dir.path().join("skills"),
10981107
agents_dir: dir.path().join("agents"),
1108+
rules_dir: dir.path().join("rules"),
10991109
};
11001110

11011111
// Create existing project with an MCP
@@ -1150,6 +1160,7 @@ mod tests {
11501160
commands_dir: dir.path().join("commands"),
11511161
skills_dir: dir.path().join("skills"),
11521162
agents_dir: dir.path().join("agents"),
1163+
rules_dir: dir.path().join("rules"),
11531164
};
11541165

11551166
std::fs::write(&paths.claude_json, r#"{"original": true}"#).unwrap();
@@ -1187,6 +1198,7 @@ mod tests {
11871198
commands_dir: dir.path().join("commands"),
11881199
skills_dir: dir.path().join("skills"),
11891200
agents_dir: dir.path().join("agents"),
1201+
rules_dir: dir.path().join("rules"),
11901202
};
11911203

11921204
std::fs::write(&paths.claude_json, "{}").unwrap();
@@ -1255,6 +1267,7 @@ mod tests {
12551267
commands_dir: dir.path().join("commands"),
12561268
skills_dir: dir.path().join("skills"),
12571269
agents_dir: dir.path().join("agents"),
1270+
rules_dir: dir.path().join("rules"),
12581271
};
12591272

12601273
std::fs::write(&paths.claude_json, r#"{"existing": true}"#).unwrap();
@@ -1340,6 +1353,7 @@ mod tests {
13401353
commands_dir: dir.path().join("commands"),
13411354
skills_dir: dir.path().join("skills"),
13421355
agents_dir: dir.path().join("agents"),
1356+
rules_dir: dir.path().join("rules"),
13431357
};
13441358

13451359
std::fs::write(&paths.claude_json, "not valid json").unwrap();

src-tauri/src/services/rule_writer.rs

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,16 @@ pub(crate) fn generate_rule_markdown(rule: &Rule) -> String {
1515

1616
if let Some(ref paths) = rule.paths {
1717
if !paths.is_empty() {
18-
frontmatter.push_str(&format!("paths: {}\n", paths.join(", ")));
18+
frontmatter.push_str(&format!(
19+
"paths: {}\n",
20+
serde_json::to_string(paths).unwrap()
21+
));
22+
}
23+
}
24+
25+
if let Some(ref tags) = rule.tags {
26+
if !tags.is_empty() {
27+
frontmatter.push_str(&format!("tags: {}\n", serde_json::to_string(tags).unwrap()));
1928
}
2029
}
2130

@@ -138,16 +147,24 @@ mod tests {
138147
}
139148

140149
#[test]
141-
fn test_generate_rule_markdown_full() {
150+
fn test_generate_rule_markdown_emits_json_paths() {
142151
let rule = sample_rule();
143152
let md = generate_rule_markdown(&rule);
144153

145154
assert!(md.starts_with("---\n"));
146155
assert!(md.contains("description: Enforce TypeScript strict mode\n"));
147-
assert!(md.contains("paths: src/**/*.ts, tests/**/*.ts\n"));
156+
assert!(md.contains(r#"paths: ["src/**/*.ts","tests/**/*.ts"]"#));
148157
assert!(md.contains("---\n\nAlways use strict TypeScript"));
149158
}
150159

160+
#[test]
161+
fn test_generate_rule_markdown_emits_json_tags() {
162+
let rule = sample_rule();
163+
let md = generate_rule_markdown(&rule);
164+
165+
assert!(md.contains(r#"tags: ["typescript","quality"]"#));
166+
}
167+
151168
#[test]
152169
fn test_generate_rule_markdown_minimal() {
153170
let rule = sample_minimal_rule();
@@ -156,6 +173,7 @@ mod tests {
156173
assert!(md.starts_with("---\n"));
157174
assert!(!md.contains("description:"));
158175
assert!(!md.contains("paths:"));
176+
assert!(!md.contains("tags:"));
159177
assert!(md.contains("Be concise."));
160178
}
161179

0 commit comments

Comments
 (0)