Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
121 changes: 117 additions & 4 deletions src-tauri/src/commands/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,13 +139,46 @@ pub fn update_rule(
stmt.query_row([id], row_to_rule).map_err(|e| e.to_string())
}

/// Inner helper for [`delete_rule`]: drops the DB row and removes the
/// backing `.md` file under `{home}/.claude/rules/<name>.md`.
/// Factored out so tests can pass a temp dir in place of `~`.
///
/// Error handling: a missing row is not an error (the rule is effectively
/// gone — DB delete no-ops, disk delete is skipped). Any other lookup error
/// (lock contention, corruption, etc.) is surfaced *before* the DB delete,
/// so we never orphan the disk file by nulling out the DB row without
/// knowing the filename.
fn delete_rule_inner(conn: &rusqlite::Connection, id: i64, home: &Path) -> Result<(), String> {
let query = format!("SELECT {} FROM rules WHERE id = ?", RULE_SELECT_FIELDS);
let rule: Option<Rule> = match conn
.prepare(&query)
.and_then(|mut s| s.query_row([id], row_to_rule))
{
Ok(r) => Some(r),
Err(rusqlite::Error::QueryReturnedNoRows) => None,
Err(e) => return Err(e.to_string()),
};

conn.execute("DELETE FROM rules WHERE id = ?", [id])
.map_err(|e| e.to_string())?;

if let Some(rule) = rule {
// `delete_rule_file` already treats a missing file as success; any
// error returned here is a real IO/permission failure worth surfacing.
rule_writer::delete_rule_file(home, &rule).map_err(|e| e.to_string())?;
}

Ok(())
}

#[tauri::command]
pub fn delete_rule(db: State<'_, Arc<Mutex<Database>>>, id: i64) -> Result<(), String> {
let db = db.lock().map_err(|e| e.to_string())?;
db.conn()
.execute("DELETE FROM rules WHERE id = ?", [id])
.map_err(|e| e.to_string())?;
Ok(())
let base_dirs =
directories::BaseDirs::new().ok_or_else(|| "Could not find home directory".to_string())?;
// Without the disk delete, the next startup scan would resurrect the rule
// as `source='auto-detected'`.
delete_rule_inner(db.conn(), id, base_dirs.home_dir())
}

#[tauri::command]
Expand Down Expand Up @@ -548,4 +581,84 @@ mod tests {
assert!(glob_match("src/*.ts", "src/index.ts"));
assert!(!glob_match("src/*.ts", "src/deep/index.ts"));
}

// =========================================================================
// delete_rule_inner tests
// =========================================================================

fn setup_test_db() -> Database {
Database::in_memory().unwrap()
}

#[test]
fn test_delete_rule_removes_disk_file() {
let db = setup_test_db();
let temp_dir = tempfile::TempDir::new().unwrap();

db.conn()
.execute(
"INSERT INTO rules (name, content) VALUES (?, ?)",
params!["my-rule", "body"],
)
.unwrap();
let id = db.conn().last_insert_rowid();

let rules_dir = temp_dir.path().join(".claude").join("rules");
std::fs::create_dir_all(&rules_dir).unwrap();
let file_path = rules_dir.join("my-rule.md");
std::fs::write(&file_path, "body").unwrap();
assert!(file_path.exists());

delete_rule_inner(db.conn(), id, temp_dir.path()).unwrap();

assert!(!file_path.exists(), "disk file should be removed");
let row_count: i64 = db
.conn()
.query_row("SELECT COUNT(*) FROM rules WHERE id = ?", [id], |r| {
r.get(0)
})
.unwrap();
assert_eq!(row_count, 0, "db row should be removed");
}

#[test]
fn test_delete_rule_missing_id_is_not_an_error() {
// A delete targeting an ID that isn't in the DB must return Ok —
// the "rule is gone" end state is already satisfied. This exercises
// the `QueryReturnedNoRows` arm of delete_rule_inner.
let db = setup_test_db();
let temp_dir = tempfile::TempDir::new().unwrap();

let result = delete_rule_inner(db.conn(), 99_999, temp_dir.path());
assert!(result.is_ok(), "missing row should not error");
}

#[test]
fn test_delete_rule_succeeds_when_file_absent() {
let db = setup_test_db();
let temp_dir = tempfile::TempDir::new().unwrap();

db.conn()
.execute(
"INSERT INTO rules (name, content) VALUES (?, ?)",
params!["ghost", "body"],
)
.unwrap();
let id = db.conn().last_insert_rowid();

// No disk file created — just the DB row.
let result = delete_rule_inner(db.conn(), id, temp_dir.path());
assert!(
result.is_ok(),
"delete must succeed when disk file is absent"
);

let row_count: i64 = db
.conn()
.query_row("SELECT COUNT(*) FROM rules WHERE id = ?", [id], |r| {
r.get(0)
})
.unwrap();
assert_eq!(row_count, 0);
}
}
14 changes: 14 additions & 0 deletions src-tauri/src/services/config_writer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,7 @@ mod tests {
commands_dir: dir.path().join("commands"),
skills_dir: dir.path().join("skills"),
agents_dir: dir.path().join("agents"),
rules_dir: dir.path().join("rules"),
};

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

// Write existing config
Expand Down Expand Up @@ -751,6 +753,7 @@ mod tests {
commands_dir: dir.path().join("commands"),
skills_dir: dir.path().join("skills"),
agents_dir: dir.path().join("agents"),
rules_dir: dir.path().join("rules"),
};

std::fs::write(&paths.claude_json, "not valid json").unwrap();
Expand Down Expand Up @@ -780,6 +783,7 @@ mod tests {
commands_dir: dir.path().join("commands"),
skills_dir: dir.path().join("skills"),
agents_dir: dir.path().join("agents"),
rules_dir: dir.path().join("rules"),
};

std::fs::write(&paths.claude_json, "{}").unwrap();
Expand Down Expand Up @@ -815,6 +819,7 @@ mod tests {
commands_dir: dir.path().join("commands"),
skills_dir: dir.path().join("skills"),
agents_dir: dir.path().join("agents"),
rules_dir: dir.path().join("rules"),
};

std::fs::write(&paths.claude_json, "{}").unwrap();
Expand Down Expand Up @@ -854,6 +859,7 @@ mod tests {
commands_dir: dir.path().join("commands"),
skills_dir: dir.path().join("skills"),
agents_dir: dir.path().join("agents"),
rules_dir: dir.path().join("rules"),
};

std::fs::write(&paths.claude_json, "{}").unwrap();
Expand Down Expand Up @@ -894,6 +900,7 @@ mod tests {
commands_dir: dir.path().join("commands"),
skills_dir: dir.path().join("skills"),
agents_dir: dir.path().join("agents"),
rules_dir: dir.path().join("rules"),
};

std::fs::write(&paths.claude_json, "{}").unwrap();
Expand Down Expand Up @@ -933,6 +940,7 @@ mod tests {
commands_dir: dir.path().join("commands"),
skills_dir: dir.path().join("skills"),
agents_dir: dir.path().join("agents"),
rules_dir: dir.path().join("rules"),
};

std::fs::write(&paths.claude_json, "{}").unwrap();
Expand Down Expand Up @@ -971,6 +979,7 @@ mod tests {
commands_dir: dir.path().join("commands"),
skills_dir: dir.path().join("skills"),
agents_dir: dir.path().join("agents"),
rules_dir: dir.path().join("rules"),
};

// No file exists, should create new
Expand Down Expand Up @@ -1096,6 +1105,7 @@ mod tests {
commands_dir: dir.path().join("commands"),
skills_dir: dir.path().join("skills"),
agents_dir: dir.path().join("agents"),
rules_dir: dir.path().join("rules"),
};

// Create existing project with an MCP
Expand Down Expand Up @@ -1150,6 +1160,7 @@ mod tests {
commands_dir: dir.path().join("commands"),
skills_dir: dir.path().join("skills"),
agents_dir: dir.path().join("agents"),
rules_dir: dir.path().join("rules"),
};

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

std::fs::write(&paths.claude_json, "{}").unwrap();
Expand Down Expand Up @@ -1255,6 +1267,7 @@ mod tests {
commands_dir: dir.path().join("commands"),
skills_dir: dir.path().join("skills"),
agents_dir: dir.path().join("agents"),
rules_dir: dir.path().join("rules"),
};

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

std::fs::write(&paths.claude_json, "not valid json").unwrap();
Expand Down
24 changes: 21 additions & 3 deletions src-tauri/src/services/rule_writer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,16 @@ pub(crate) fn generate_rule_markdown(rule: &Rule) -> String {

if let Some(ref paths) = rule.paths {
if !paths.is_empty() {
frontmatter.push_str(&format!("paths: {}\n", paths.join(", ")));
frontmatter.push_str(&format!(
"paths: {}\n",
serde_json::to_string(paths).unwrap()
));
}
}

if let Some(ref tags) = rule.tags {
if !tags.is_empty() {
frontmatter.push_str(&format!("tags: {}\n", serde_json::to_string(tags).unwrap()));
}
}

Expand Down Expand Up @@ -138,16 +147,24 @@ mod tests {
}

#[test]
fn test_generate_rule_markdown_full() {
fn test_generate_rule_markdown_emits_json_paths() {
let rule = sample_rule();
let md = generate_rule_markdown(&rule);

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

#[test]
fn test_generate_rule_markdown_emits_json_tags() {
let rule = sample_rule();
let md = generate_rule_markdown(&rule);

assert!(md.contains(r#"tags: ["typescript","quality"]"#));
}

#[test]
fn test_generate_rule_markdown_minimal() {
let rule = sample_minimal_rule();
Expand All @@ -156,6 +173,7 @@ mod tests {
assert!(md.starts_with("---\n"));
assert!(!md.contains("description:"));
assert!(!md.contains("paths:"));
assert!(!md.contains("tags:"));
assert!(md.contains("Be concise."));
}

Expand Down
Loading
Loading