Skip to content

Commit 0927b9d

Browse files
authored
Merge pull request #200 from prefrontalsys/fix/scan-rules-and-dedup-flat-md-scanners
fix: scan rules from disk + dedup flat-md scanners
2 parents e32d638 + 1c9f2a4 commit 0927b9d

5 files changed

Lines changed: 732 additions & 139 deletions

File tree

src-tauri/src/commands/rules.rs

Lines changed: 117 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -139,13 +139,46 @@ 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 removes the
143+
/// backing `.md` file under `{home}/.claude/rules/<name>.md`.
144+
/// Factored out so tests can pass a temp dir in place of `~`.
145+
///
146+
/// Error handling: a missing row is not an error (the rule is effectively
147+
/// gone — DB delete no-ops, disk delete is skipped). Any other lookup error
148+
/// (lock contention, corruption, etc.) is surfaced *before* the DB delete,
149+
/// so we never orphan the disk file by nulling out the DB row without
150+
/// knowing the filename.
151+
fn delete_rule_inner(conn: &rusqlite::Connection, id: i64, home: &Path) -> Result<(), String> {
152+
let query = format!("SELECT {} FROM rules WHERE id = ?", RULE_SELECT_FIELDS);
153+
let rule: Option<Rule> = match conn
154+
.prepare(&query)
155+
.and_then(|mut s| s.query_row([id], row_to_rule))
156+
{
157+
Ok(r) => Some(r),
158+
Err(rusqlite::Error::QueryReturnedNoRows) => None,
159+
Err(e) => return Err(e.to_string()),
160+
};
161+
162+
conn.execute("DELETE FROM rules WHERE id = ?", [id])
163+
.map_err(|e| e.to_string())?;
164+
165+
if let Some(rule) = rule {
166+
// `delete_rule_file` already treats a missing file as success; any
167+
// error returned here is a real IO/permission failure worth surfacing.
168+
rule_writer::delete_rule_file(home, &rule).map_err(|e| e.to_string())?;
169+
}
170+
171+
Ok(())
172+
}
173+
142174
#[tauri::command]
143175
pub fn delete_rule(db: State<'_, Arc<Mutex<Database>>>, id: i64) -> Result<(), String> {
144176
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(())
177+
let base_dirs =
178+
directories::BaseDirs::new().ok_or_else(|| "Could not find home directory".to_string())?;
179+
// Without the disk delete, the next startup scan would resurrect the rule
180+
// as `source='auto-detected'`.
181+
delete_rule_inner(db.conn(), id, base_dirs.home_dir())
149182
}
150183

151184
#[tauri::command]
@@ -548,4 +581,84 @@ mod tests {
548581
assert!(glob_match("src/*.ts", "src/index.ts"));
549582
assert!(!glob_match("src/*.ts", "src/deep/index.ts"));
550583
}
584+
585+
// =========================================================================
586+
// delete_rule_inner tests
587+
// =========================================================================
588+
589+
fn setup_test_db() -> Database {
590+
Database::in_memory().unwrap()
591+
}
592+
593+
#[test]
594+
fn test_delete_rule_removes_disk_file() {
595+
let db = setup_test_db();
596+
let temp_dir = tempfile::TempDir::new().unwrap();
597+
598+
db.conn()
599+
.execute(
600+
"INSERT INTO rules (name, content) VALUES (?, ?)",
601+
params!["my-rule", "body"],
602+
)
603+
.unwrap();
604+
let id = db.conn().last_insert_rowid();
605+
606+
let rules_dir = temp_dir.path().join(".claude").join("rules");
607+
std::fs::create_dir_all(&rules_dir).unwrap();
608+
let file_path = rules_dir.join("my-rule.md");
609+
std::fs::write(&file_path, "body").unwrap();
610+
assert!(file_path.exists());
611+
612+
delete_rule_inner(db.conn(), id, temp_dir.path()).unwrap();
613+
614+
assert!(!file_path.exists(), "disk file should be removed");
615+
let row_count: i64 = db
616+
.conn()
617+
.query_row("SELECT COUNT(*) FROM rules WHERE id = ?", [id], |r| {
618+
r.get(0)
619+
})
620+
.unwrap();
621+
assert_eq!(row_count, 0, "db row should be removed");
622+
}
623+
624+
#[test]
625+
fn test_delete_rule_missing_id_is_not_an_error() {
626+
// A delete targeting an ID that isn't in the DB must return Ok —
627+
// the "rule is gone" end state is already satisfied. This exercises
628+
// the `QueryReturnedNoRows` arm of delete_rule_inner.
629+
let db = setup_test_db();
630+
let temp_dir = tempfile::TempDir::new().unwrap();
631+
632+
let result = delete_rule_inner(db.conn(), 99_999, temp_dir.path());
633+
assert!(result.is_ok(), "missing row should not error");
634+
}
635+
636+
#[test]
637+
fn test_delete_rule_succeeds_when_file_absent() {
638+
let db = setup_test_db();
639+
let temp_dir = tempfile::TempDir::new().unwrap();
640+
641+
db.conn()
642+
.execute(
643+
"INSERT INTO rules (name, content) VALUES (?, ?)",
644+
params!["ghost", "body"],
645+
)
646+
.unwrap();
647+
let id = db.conn().last_insert_rowid();
648+
649+
// No disk file created — just the DB row.
650+
let result = delete_rule_inner(db.conn(), id, temp_dir.path());
651+
assert!(
652+
result.is_ok(),
653+
"delete must succeed when disk file is absent"
654+
);
655+
656+
let row_count: i64 = db
657+
.conn()
658+
.query_row("SELECT COUNT(*) FROM rules WHERE id = ?", [id], |r| {
659+
r.get(0)
660+
})
661+
.unwrap();
662+
assert_eq!(row_count, 0);
663+
}
551664
}

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)