1use crate::errors::CoreError;
2use crate::models::SkillRecord;
3use uuid::Uuid;
4
5use super::SkillRow;
6
7#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
8#[serde(rename_all = "camelCase")]
9pub struct CandidateSourceProof {
10 pub source: Option<String>,
11 pub comment_url: Option<String>,
12 pub file: Option<String>,
13 pub excerpt: Option<String>,
14}
15
16impl CandidateSourceProof {
17 pub const fn has_any(&self) -> bool {
18 self.source.is_some()
19 || self.comment_url.is_some()
20 || self.file.is_some()
21 || self.excerpt.is_some()
22 }
23}
24
25#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
29#[serde(rename_all = "camelCase")]
30pub struct CandidateRule {
31 pub id: String,
32 pub name: String,
33 pub description: String,
34 pub origin: String,
35 pub installed_at: String,
36 pub file_patterns: Vec<String>,
37 pub drafted_rule: Option<String>,
38 pub source_proof: Option<CandidateSourceProof>,
39}
40
41#[derive(sqlx::FromRow)]
42struct CandidateRuleRow {
43 id: String,
44 name: String,
45 description: String,
46 origin: String,
47 installed_at: String,
48 file_patterns: Option<String>,
49}
50
51impl crate::domain::rule_view::RuleView for CandidateRule {
52 fn id(&self) -> &str {
53 &self.id
54 }
55 fn content(&self) -> &str {
56 self.drafted_rule.as_deref().unwrap_or(&self.description)
57 }
58 fn origin(&self) -> &str {
59 &self.origin
60 }
61 fn confidence(&self) -> Option<f64> {
62 None
63 }
64}
65
66impl From<CandidateRuleRow> for CandidateRule {
67 fn from(row: CandidateRuleRow) -> Self {
68 let file_patterns = parse_candidate_file_patterns(row.file_patterns.as_deref());
69 let drafted_rule = parse_candidate_drafted_rule(&row.description);
70 let source_proof = parse_candidate_source_proof(&row.description);
71 Self {
72 id: row.id,
73 name: row.name,
74 description: row.description,
75 origin: row.origin,
76 installed_at: row.installed_at,
77 file_patterns,
78 drafted_rule,
79 source_proof,
80 }
81 }
82}
83
84fn candidate_actionability_rank(file_patterns: Option<&str>) -> u8 {
85 let Some(patterns) = file_patterns
86 .map(str::trim)
87 .filter(|v| !v.is_empty() && *v != "[]")
88 else {
89 return 2;
90 };
91 let lower = patterns.to_ascii_lowercase();
92 u8::from(
93 !(lower.contains(".github/")
94 || lower.contains("go.mod")
95 || lower.contains("go.sum")
96 || lower.contains("cargo.toml")
97 || lower.contains("cargo.lock")
98 || lower.contains("package.json")
99 || lower.contains("package-lock.json")
100 || lower.contains("pnpm-lock.yaml")
101 || lower.contains("yarn.lock")
102 || lower.contains("dockerfile")),
103 )
104}
105
106pub async fn list_candidates(
114 db: &sqlx::SqlitePool,
115 repo: Option<&str>,
116 limit: Option<usize>,
117) -> crate::Result<Vec<CandidateRule>> {
118 let mut rows: Vec<CandidateRuleRow> = if let Some(r) = repo {
121 sqlx::query_as(
122 "SELECT id, name, description, origin, installed_at, file_patterns FROM skills \
123 WHERE status = 'pending' \
124 AND source_repo = ?1 \
125 ORDER BY installed_at DESC",
126 )
127 .bind(r)
128 .fetch_all(db)
129 .await?
130 } else {
131 sqlx::query_as!(
132 CandidateRuleRow,
133 "SELECT id, name, description, origin, installed_at, file_patterns FROM skills \
134 WHERE status = 'pending' ORDER BY installed_at DESC",
135 )
136 .fetch_all(db)
137 .await?
138 };
139 rows.sort_by(|a, b| {
140 candidate_actionability_rank(a.file_patterns.as_deref())
141 .cmp(&candidate_actionability_rank(b.file_patterns.as_deref()))
142 .then_with(|| b.installed_at.cmp(&a.installed_at))
143 .then_with(|| a.id.cmp(&b.id))
144 });
145 let mut out: Vec<CandidateRule> = rows.into_iter().map(Into::into).collect();
146 if let Some(cap) = limit {
147 out.truncate(cap);
148 }
149 Ok(out)
150}
151
152pub async fn count_pending_candidates(
156 db: &sqlx::SqlitePool,
157 repo: Option<&str>,
158) -> crate::Result<u64> {
159 let count: i64 = if let Some(r) = repo {
160 sqlx::query_scalar(
161 "SELECT COUNT(*) FROM skills \
162 WHERE status = 'pending' \
163 AND source_repo = ?1",
164 )
165 .bind(r)
166 .fetch_one(db)
167 .await?
168 } else {
169 sqlx::query_scalar!("SELECT COUNT(*) FROM skills WHERE status = 'pending'")
170 .fetch_one(db)
171 .await?
172 };
173 Ok(u64::try_from(count.max(0)).unwrap_or(0))
174}
175
176pub async fn list_candidate_ids(db: &sqlx::SqlitePool) -> crate::Result<Vec<String>> {
177 let ids = sqlx::query_scalar!("SELECT id FROM skills WHERE status = 'pending'")
178 .fetch_all(db)
179 .await?;
180 Ok(ids)
181}
182
183pub async fn rule_status(db: &sqlx::SqlitePool, id: &str) -> crate::Result<Option<String>> {
184 let status = sqlx::query_scalar!("SELECT status FROM skills WHERE id = ?1", id)
185 .fetch_optional(db)
186 .await?;
187 Ok(status)
188}
189
190pub async fn promote_candidate(db: &sqlx::SqlitePool, id: &str) -> crate::Result<SkillRecord> {
191 let candidate_description = sqlx::query_scalar!(
192 "SELECT description FROM skills WHERE id = ?1 AND status = 'pending'",
193 id,
194 )
195 .fetch_optional(db)
196 .await?;
197 let Some(candidate_description) = candidate_description else {
198 let existing = rule_status(db, id).await?;
199 return match existing.as_deref() {
200 Some("active") => Err(CoreError::Validation(format!(
201 "rule '{id}' is already active — nothing to promote. Inspect local memory with `difflore status --json`."
202 ))),
203 _ => Err(CoreError::NotFound(format!(
204 "memory draft '{id}' not found. Run `difflore status` for the next action."
205 ))),
206 };
207 };
208
209 let source_proof = parse_candidate_source_proof(&candidate_description);
210 let mut tx = db.begin().await?;
211 let updated = sqlx::query!(
212 "UPDATE skills SET status = 'active' WHERE id = ?1 AND status = 'pending'",
213 id
214 )
215 .execute(&mut *tx)
216 .await?;
217 if updated.rows_affected() == 0 {
218 tx.rollback().await?;
219 let existing = rule_status(db, id).await?;
223 return match existing.as_deref() {
224 Some("active") => Err(CoreError::Validation(format!(
225 "rule '{id}' is already active — nothing to promote. Inspect local memory with `difflore status --json`."
226 ))),
227 _ => Err(CoreError::NotFound(format!(
228 "memory draft '{id}' not found. Run `difflore status` for the next action."
229 ))),
230 };
231 }
232 if let Some(proof) = source_proof {
233 record_candidate_source_proof(&mut tx, id, &proof).await?;
234 }
235 let row = sqlx::query_as!(
236 SkillRow,
237 "SELECT id, name, source, directory, version, description, type, \
238 engines, tags, trigger, check_prompt, repo_owner, repo_name, repo_branch, readme_url, \
239 enabled_for_codex, enabled_for_claude, enabled_for_gemini, enabled_for_cursor, \
240 installed_at, updated_at, origin FROM skills WHERE id = ?1",
241 id
242 )
243 .fetch_one(&mut *tx)
244 .await?;
245 tx.commit().await?;
246 Ok(SkillRecord::from(row))
247}
248
249pub async fn reject_candidate(db: &sqlx::SqlitePool, id: &str) -> crate::Result<()> {
250 let result = sqlx::query!(
251 "DELETE FROM skills WHERE id = ?1 AND status = 'pending'",
252 id
253 )
254 .execute(db)
255 .await?;
256 if result.rows_affected() == 0 {
257 let existing = rule_status(db, id).await?;
262 return match existing.as_deref() {
263 Some("active") => Err(CoreError::Validation(format!(
264 "rule '{id}' is already an active rule, not a pending memory draft."
265 ))),
266 _ => Err(CoreError::NotFound(format!(
267 "memory draft '{id}' not found. Run `difflore status` for the next action."
268 ))),
269 };
270 }
271 Ok(())
272}
273
274pub fn parse_candidate_source_proof(description: &str) -> Option<CandidateSourceProof> {
275 let proof = CandidateSourceProof {
276 source: description_field(description, "Source:"),
277 comment_url: description_field(description, "Comment:"),
278 file: description_field(description, "File:"),
279 excerpt: reviewer_excerpt(description),
280 };
281 proof.has_any().then_some(proof)
282}
283
284pub fn parse_candidate_drafted_rule(description: &str) -> Option<String> {
285 let after = description_section_after(description, "Rule:")?;
286 let drafted = after
287 .split_once("Source evidence:")
288 .map_or(after, |(drafted, _)| drafted)
289 .trim();
290 (!drafted.is_empty()).then(|| drafted.lines().collect::<Vec<_>>().join(" "))
291}
292
293fn description_section_after<'a>(description: &'a str, label: &str) -> Option<&'a str> {
294 if let Some(rest) = description.trim_start().strip_prefix(label) {
295 return Some(rest);
296 }
297 let needle = format!("\n{label}");
298 description
299 .split_once(&needle)
300 .map(|(_, after)| after.trim_start())
301}
302
303fn parse_candidate_file_patterns(raw: Option<&str>) -> Vec<String> {
304 let Some(raw) = raw.map(str::trim).filter(|raw| !raw.is_empty()) else {
305 return Vec::new();
306 };
307 serde_json::from_str::<Vec<String>>(raw).unwrap_or_default()
308}
309
310async fn record_candidate_source_proof(
311 tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
312 skill_id: &str,
313 proof: &CandidateSourceProof,
314) -> crate::Result<()> {
315 let event_id = format!("rule-event-{}", Uuid::new_v4());
316 let metadata = serde_json::json!({
317 "sourceProof": proof,
318 })
319 .to_string();
320 let reason = source_proof_reason(proof);
321 sqlx::query!(
322 "INSERT INTO rule_events
323 (id, skill_id, kind, source, confidence_before, confidence_after, reason, metadata)
324 VALUES (?1, ?2, 'source_proof', 'candidate_promotion', NULL, NULL, ?3, ?4)",
325 event_id,
326 skill_id,
327 reason,
328 metadata,
329 )
330 .execute(&mut **tx)
331 .await?;
332 Ok(())
333}
334
335fn source_proof_reason(proof: &CandidateSourceProof) -> String {
336 match (
337 proof.source.as_deref(),
338 proof.comment_url.as_deref(),
339 proof.file.as_deref(),
340 ) {
341 (Some(source), _, Some(file)) => {
342 format!("Promoted review-memory candidate from {source} on {file}")
343 }
344 (Some(source), _, None) => {
345 format!("Promoted review-memory candidate from {source}")
346 }
347 (None, Some(comment_url), Some(file)) => {
348 format!("Promoted review-memory candidate from {comment_url} on {file}")
349 }
350 (None, Some(comment_url), None) => {
351 format!("Promoted review-memory candidate from {comment_url}")
352 }
353 (None, None, Some(file)) => {
354 format!("Promoted review-memory candidate for {file}")
355 }
356 (None, None, None) => "Promoted review-memory candidate with source proof".to_owned(),
357 }
358}
359
360fn description_field(description: &str, prefix: &str) -> Option<String> {
361 description
362 .lines()
363 .find_map(|line| line.trim().strip_prefix(prefix).map(str::trim))
364 .filter(|value| !value.is_empty())
365 .map(ToOwned::to_owned)
366}
367
368fn reviewer_excerpt(description: &str) -> Option<String> {
369 let excerpt = description
370 .split_once("Reviewer said:")
371 .map(|(_, body)| body.trim())
372 .filter(|body| !body.is_empty())?;
373 Some(truncate_chars(excerpt, 500))
374}
375
376fn truncate_chars(value: &str, limit: usize) -> String {
377 let mut chars = value.chars();
378 let truncated: String = chars.by_ref().take(limit).collect();
379 if chars.next().is_some() {
380 format!("{truncated}...")
381 } else {
382 truncated
383 }
384}
385
386#[cfg(test)]
387mod tests {
388 use super::{CandidateRule, parse_candidate_drafted_rule, parse_candidate_file_patterns};
389 use crate::domain::rule_view::RuleView;
390
391 #[test]
392 fn candidate_rule_implements_rule_view() {
393 let c = CandidateRule {
394 id: "c1".into(),
395 name: "n".into(),
396 description: "desc".into(),
397 origin: "agent-memory".into(),
398 installed_at: String::new(),
399 file_patterns: vec![],
400 drafted_rule: None,
401 source_proof: None,
402 };
403 assert_eq!(c.id(), "c1");
404 assert_eq!(c.content(), "desc");
405 assert_eq!(c.origin(), "agent-memory");
406 assert_eq!(c.confidence(), None);
407
408 let c2 = CandidateRule {
409 drafted_rule: Some("the drafted body".into()),
410 ..c
411 };
412 assert_eq!(c2.content(), "the drafted body");
413 }
414
415 #[test]
416 fn drafted_rule_is_extracted_without_source_evidence() {
417 let body = "Rule:\nWhen touching `src/**/*.rs`, prefer structured parsing.\n\nSource evidence:\nSource: acme/widgets#7\n\nReviewer said:\nPlease prefer structured parsing.";
418
419 assert_eq!(
420 parse_candidate_drafted_rule(body).as_deref(),
421 Some("When touching `src/**/*.rs`, prefer structured parsing.")
422 );
423 }
424
425 #[test]
426 fn drafted_rule_parser_rejects_retired_label() {
427 let body = "Imported from review.\n\nDrafted rule:\nWhen touching `src/**/*.rs`, prefer structured parsing.\n\nSource evidence:\nSource: acme/widgets#7\n\nReviewer said:\nPlease prefer structured parsing.";
428
429 assert_eq!(parse_candidate_drafted_rule(body).as_deref(), None);
430 }
431
432 #[test]
433 fn candidate_file_patterns_parse_json_list() {
434 assert_eq!(
435 parse_candidate_file_patterns(Some(r#"["src/**/*.rs","**/go.mod"]"#)),
436 vec!["src/**/*.rs".to_owned(), "**/go.mod".to_owned()]
437 );
438 assert!(parse_candidate_file_patterns(Some("not-json")).is_empty());
439 }
440}