Skip to main content

difflore_core/skills/
candidates.rs

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/// One row in the local candidate queue. Mirrors `SkillRecord` but
26/// drops the engine flags that aren't actionable for a pending rule
27/// and adds the ingest-time provenance we surface in the UI.
28#[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
106/// List pending candidates, with high-leverage file-scoped work first.
107///
108/// `repo` filters to rows whose canonical `source_repo` matches the given
109/// `owner/repo` slug. `limit` caps the number of returned rows after that
110/// filter; `None` means no cap. The filter happens at the SQL layer where it's
111/// cheap and the cap is applied in Rust so a missing cap doesn't change the
112/// generated query shape.
113pub async fn list_candidates(
114    db: &sqlx::SqlitePool,
115    repo: Option<&str>,
116    limit: Option<usize>,
117) -> crate::Result<Vec<CandidateRule>> {
118    // Two static SQL variants — the repo filter must be conditional, and
119    // sqlx macros need a literal SQL string, so we branch at the call site.
120    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
152/// Count pending candidates, optionally filtered to a repo. Cheaper than
153/// `list_candidates(...).len()` when the caller only needs the total
154/// (e.g. the "+N more — `--limit 0` to see all" hint in `candidates list`).
155pub 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        // Same disambiguation as `reject_candidate`: tell the user when
220        // they're trying to promote something already active so they
221        // don't go looking for it on the candidates list.
222        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        // Disambiguate "doesn't exist" vs "is already active" — both
258        // hit `rows_affected == 0` but the user-facing fix differs.
259        // The previous wording told both cases to look in
260        // `candidates list`, where an active rule won't appear.
261        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}