Skip to main content

difflore_core/skills/
cloud_sync.rs

1use sha2::{Digest, Sha256};
2use uuid::Uuid;
3
4use crate::errors::CoreError;
5use crate::models::{
6    AddExampleInput, ListExamplesInput, RemoveExampleInput, RuleExampleRecord, SkillRecord,
7    SkillRepoAddInput, SkillRepoRecord, SkillRepoRemoveInput, UpdateConfidenceInput,
8};
9
10use super::{SkillRepoRow, SkillRow};
11
12/// Pick the cloud-side text the local `skills.description` column should
13/// hold. Cloud `rules_cloud` has both `description` (a summary) and
14/// `content` (the full body), but the local schema only stores
15/// `description`. Store the full `content` when present so the desktop
16/// hash matches cloud's `/rules/sync` contract (`sha256(rule.content)`)
17/// and retrieval/indexing gets the richest rule text.
18fn effective_description(rule: &crate::cloud::sync::SyncedRule) -> String {
19    if !rule.content.trim().is_empty() {
20        return rule.content.clone();
21    }
22    rule.description.clone()
23}
24
25/// Derive the local `confidence_score` for a synced cloud rule from its
26/// `cluster-size:N` / `severity:X` tags. Returns `None` when neither
27/// signal is present so the caller leaves the column at the DB default
28/// (0.7). See `crate::context::rule_source::confidence_from_tags` for
29/// the actual mapping (singletons → 0.55, big clusters → 0.9).
30fn effective_confidence(rule: &crate::cloud::sync::SyncedRule) -> Option<f64> {
31    let tags_json = serde_json::to_string(&rule.tags).ok()?;
32    crate::context::rule_source::confidence_from_tags(&tags_json)
33}
34
35pub(crate) fn cloud_rule_directory_name(rule_id: &str) -> String {
36    let mut slug = String::with_capacity(rule_id.len().min(96));
37    let mut last_dash = false;
38    for ch in rule_id.trim().chars() {
39        let safe = if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
40            ch.to_ascii_lowercase()
41        } else {
42            '-'
43        };
44        if safe == '-' {
45            if !last_dash {
46                slug.push('-');
47                last_dash = true;
48            }
49        } else {
50            slug.push(safe);
51            last_dash = false;
52        }
53    }
54    let slug = slug.trim_matches('-');
55    let needs_hash = slug.is_empty()
56        || slug != rule_id
57        || slug.chars().count() > 96
58        || is_windows_reserved_path_name(slug);
59    let base = if slug.is_empty() { "rule" } else { slug };
60    if !needs_hash {
61        return base.to_owned();
62    }
63
64    let head: String = base.chars().take(80).collect();
65    format!("{head}-{}", short_rule_id_hash(rule_id))
66}
67
68fn short_rule_id_hash(rule_id: &str) -> String {
69    let digest = Sha256::digest(rule_id.as_bytes());
70    let mut hex = String::with_capacity(12);
71    for byte in digest.iter().take(6) {
72        hex.push_str(&format!("{byte:02x}"));
73    }
74    hex
75}
76
77fn is_windows_reserved_path_name(name: &str) -> bool {
78    let lower = name.trim_end_matches('.').to_ascii_lowercase();
79    matches!(
80        lower.as_str(),
81        "con"
82            | "prn"
83            | "aux"
84            | "nul"
85            | "com1"
86            | "com2"
87            | "com3"
88            | "com4"
89            | "com5"
90            | "com6"
91            | "com7"
92            | "com8"
93            | "com9"
94            | "lpt1"
95            | "lpt2"
96            | "lpt3"
97            | "lpt4"
98            | "lpt5"
99            | "lpt6"
100            | "lpt7"
101            | "lpt8"
102            | "lpt9"
103    )
104}
105
106/// Re-stamp `skills.confidence_score` for every row whose tags carry a
107/// `cluster-size:N` / `severity:X` evidence signal but whose score is
108/// still at the historic flat default (0.7). Returns the number of
109/// rows updated. Tight tolerance (`±0.001`) so user-customized scores
110/// like 0.65 / 0.75 are left alone.
111pub async fn backfill_skills_confidence_from_tags(db: &sqlx::SqlitePool) -> crate::Result<i64> {
112    let rows =
113        sqlx::query!("SELECT id, tags, confidence_score FROM skills WHERE status = 'active'")
114            .fetch_all(db)
115            .await?;
116
117    let mut updated = 0_i64;
118    for row in rows {
119        let id = row.id;
120        let tags = row.tags;
121        let current = row.confidence_score;
122        if (current - 0.7).abs() > 0.001 {
123            continue;
124        }
125        let Some(new_score) = crate::context::rule_source::confidence_from_tags(&tags) else {
126            continue;
127        };
128        if (new_score - current).abs() < 0.001 {
129            continue;
130        }
131        let _ = sqlx::query!(
132            "UPDATE skills SET confidence_score = ?1 WHERE id = ?2",
133            new_score,
134            id
135        )
136        .execute(db)
137        .await;
138        updated += 1;
139    }
140    Ok(updated)
141}
142
143/// Cloud review memory is agent-agnostic: once a team rule is synced
144/// locally, Codex/Cursor/Gemini should get the same protection Claude
145/// gets. Older syncs wrote only `enabled_for_claude=1`, which made the
146/// CLI look full of rules while Codex MCP recall returned nothing.
147pub async fn backfill_cloud_rules_enabled_for_all_agents(
148    db: &sqlx::SqlitePool,
149) -> crate::Result<u64> {
150    let result = sqlx::query!(
151        "UPDATE skills \
152         SET enabled_for_codex = 1, \
153             enabled_for_claude = 1, \
154             enabled_for_gemini = 1, \
155             enabled_for_cursor = 1, \
156             updated_at = datetime('now') \
157         WHERE source = 'cloud' \
158           AND status = 'active' \
159           AND (enabled_for_codex = 0 \
160                OR enabled_for_claude = 0 \
161                OR enabled_for_gemini = 0 \
162                OR enabled_for_cursor = 0)",
163    )
164    .execute(db)
165    .await?;
166    Ok(result.rows_affected())
167}
168
169async fn apply_cloud_source_repo(
170    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
171    skill_id: &str,
172    incoming_repo: Option<&str>,
173) -> crate::Result<()> {
174    let Some(incoming_repo) = incoming_repo.map(str::trim).filter(|repo| !repo.is_empty()) else {
175        return Ok(());
176    };
177
178    let updated = sqlx::query(
179        "UPDATE skills
180         SET source_repo = ?1
181         WHERE id = ?2
182           AND source = 'cloud'
183           AND (source_repo IS NULL OR trim(source_repo) = '' OR source_repo = ?1)",
184    )
185    .bind(incoming_repo)
186    .bind(skill_id)
187    .execute(&mut **tx)
188    .await?;
189    if updated.rows_affected() > 0 {
190        return Ok(());
191    }
192
193    let existing = sqlx::query_as::<_, (Option<String>, String)>(
194        "SELECT source_repo, source FROM skills WHERE id = ?1",
195    )
196    .bind(skill_id)
197    .fetch_optional(&mut **tx)
198    .await?;
199    let Some((Some(existing_repo), source)) = existing else {
200        return Ok(());
201    };
202    if source != "cloud" || existing_repo.trim().is_empty() || existing_repo == incoming_repo {
203        return Ok(());
204    }
205
206    let event_id = format!("rule-event-{}", Uuid::new_v4());
207    let reason = format!(
208        "cloud sync kept existing source_repo '{existing_repo}' and ignored incoming '{incoming_repo}'"
209    );
210    let metadata = serde_json::json!({
211        "existingSourceRepo": existing_repo,
212        "incomingSourceRepo": incoming_repo,
213    })
214    .to_string();
215    sqlx::query(
216        "INSERT INTO rule_events
217         (id, skill_id, kind, source, reason, metadata)
218         VALUES (?1, ?2, 'source_repo_conflict', 'cloud_sync', ?3, ?4)",
219    )
220    .bind(event_id)
221    .bind(skill_id)
222    .bind(reason)
223    .bind(metadata)
224    .execute(&mut **tx)
225    .await?;
226
227    Ok(())
228}
229
230async fn refresh_rule_index_after_sync(db: &sqlx::SqlitePool) {
231    let project_hash = crate::db::project_hash_from_root(&crate::db::current_project_root());
232    let index_pool = match crate::context::index_db::get_pool_for_project(&project_hash).await {
233        Ok(pool) => pool,
234        Err(e) => {
235            eprintln!("[difflore] cloud sync rule-index refresh skipped: {e}");
236            return;
237        }
238    };
239    if let Err(e) = crate::context::orchestrator::ensure_rules_indexed(db, &index_pool).await {
240        eprintln!("[difflore] cloud sync rule-index refresh failed: {e}");
241    }
242}
243
244pub async fn apply_sync_result(
245    db: &sqlx::SqlitePool,
246    result: &crate::cloud::sync::SyncResult,
247) -> crate::Result<()> {
248    let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
249    let sync_changed =
250        !result.created.is_empty() || !result.updated.is_empty() || !result.deleted.is_empty();
251    let mut tx = db.begin().await?;
252
253    for rule in &result.created {
254        let engines_json = serde_json::to_string(&rule.engines)?;
255        let tags_json = serde_json::to_string(&rule.tags)?;
256        let directory = cloud_rule_directory_name(&rule.id);
257        // Iter-9: persist file_patterns so the local cascade can use them.
258        let file_patterns_json = if rule.file_patterns.is_empty() {
259            None
260        } else {
261            Some(serde_json::to_string(&rule.file_patterns)?)
262        };
263        let description = effective_description(rule);
264        // 2026-04-20: capture cloud-side origin when present. Falls back
265        // to "cloud" so audit pages still distinguish remotely-fetched
266        // rules from locally-typed ones — the migration default of
267        // `manual` would lie in this case.
268        let origin = rule.origin.clone().unwrap_or_else(|| "cloud".to_owned());
269        let directory_param = directory.as_str();
270        sqlx::query(
271            "INSERT INTO skills
272             (id, name, source, directory, version, description, type, engines, tags,
273              trigger, check_prompt, file_patterns,
274              enabled_for_codex, enabled_for_claude, enabled_for_gemini, enabled_for_cursor,
275              installed_at, updated_at, origin)
276             VALUES (?1, ?2, 'cloud', ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11,
277                     1, 1, 1, 1, ?12, ?12, ?13)
278             ON CONFLICT(id) DO UPDATE SET
279                name = excluded.name,
280                directory = excluded.directory,
281                version = excluded.version,
282                description = excluded.description,
283                type = excluded.type,
284                engines = excluded.engines,
285                tags = excluded.tags,
286                trigger = excluded.trigger,
287                check_prompt = excluded.check_prompt,
288                file_patterns = excluded.file_patterns,
289                updated_at = excluded.updated_at,
290                origin = excluded.origin,
291                status = 'active'
292             WHERE skills.source = 'cloud'",
293        )
294        .bind(&rule.id)
295        .bind(&rule.name)
296        .bind(directory_param)
297        .bind(&rule.version)
298        .bind(&description)
299        .bind(&rule.r#type)
300        .bind(&engines_json)
301        .bind(&tags_json)
302        .bind(rule.trigger.as_deref())
303        .bind(rule.check_prompt.as_deref())
304        .bind(file_patterns_json.as_deref())
305        .bind(&now)
306        .bind(&origin)
307        .execute(&mut *tx)
308        .await?;
309        apply_cloud_source_repo(&mut tx, &rule.id, rule.source_repo.as_deref()).await?;
310        // 2026-04-27: stamp confidence_score from cluster-size/severity
311        // tags. Only when tags carry an evidence signal AND the row is
312        // still at the historic DB default (0.7 ± 0.001) — leaves
313        // user-customized scores like 0.1 (rejected) or 0.85 (boosted)
314        // untouched on resync.
315        if let Some(conf) = effective_confidence(rule) {
316            let _ = sqlx::query!(
317                "UPDATE skills SET confidence_score = ?1 \
318                 WHERE id = ?2 AND ABS(confidence_score - 0.7) < 0.001",
319                conf,
320                rule.id
321            )
322            .execute(&mut *tx)
323            .await;
324        }
325    }
326
327    for rule in &result.updated {
328        let engines_json = serde_json::to_string(&rule.engines)?;
329        let tags_json = serde_json::to_string(&rule.tags)?;
330        let directory = cloud_rule_directory_name(&rule.id);
331        let file_patterns_json = if rule.file_patterns.is_empty() {
332            None
333        } else {
334            Some(serde_json::to_string(&rule.file_patterns)?)
335        };
336        let description = effective_description(rule);
337        sqlx::query(
338            "UPDATE skills SET name = ?1, description = ?2, type = ?3, version = ?4,
339             engines = ?5, tags = ?6, trigger = ?7, check_prompt = ?8, file_patterns = ?9,
340             directory = ?10,
341             updated_at = ?11,
342             origin = COALESCE(?12, origin),
343             status = 'active'
344             WHERE id = ?13 AND source = 'cloud'",
345        )
346        .bind(&rule.name)
347        .bind(&description)
348        .bind(&rule.r#type)
349        .bind(&rule.version)
350        .bind(&engines_json)
351        .bind(&tags_json)
352        .bind(rule.trigger.as_deref())
353        .bind(rule.check_prompt.as_deref())
354        .bind(file_patterns_json.as_deref())
355        .bind(&directory)
356        .bind(&now)
357        .bind(rule.origin.as_deref())
358        .bind(&rule.id)
359        .execute(&mut *tx)
360        .await?;
361        apply_cloud_source_repo(&mut tx, &rule.id, rule.source_repo.as_deref()).await?;
362        // Same default-only guard as the INSERT path: don't clobber
363        // user-customized scores like 0.1 (rejected) or 0.85 (boosted)
364        // when re-syncing rules.
365        if let Some(conf) = effective_confidence(rule) {
366            let _ = sqlx::query!(
367                "UPDATE skills SET confidence_score = ?1 \
368                 WHERE id = ?2 AND ABS(confidence_score - 0.7) < 0.001",
369                conf,
370                rule.id
371            )
372            .execute(&mut *tx)
373            .await;
374        }
375    }
376
377    for id in &result.deleted {
378        sqlx::query("DELETE FROM skills WHERE id = ?1 AND source = 'cloud'")
379            .bind(id)
380            .execute(&mut *tx)
381            .await?;
382    }
383
384    tx.commit().await?;
385
386    // Self-heal: re-stamp confidence_score on rows still at the
387    // historic 0.7 default but with cluster-size/severity tags. Cheap,
388    // idempotent, runs once per sync — gives users with already-synced
389    // corpora the new ranking weights without waiting for cloud-side
390    // updated_at to bump.
391    let _ = backfill_skills_confidence_from_tags(db).await;
392    if sync_changed {
393        refresh_rule_index_after_sync(db).await;
394    }
395
396    Ok(())
397}
398
399/// Lightweight metadata for recall/search flows. Returns just enough to
400/// explain why a rule surfaced
401/// (`file_patterns` + canonical `source_repo`). Uses dynamic SQL so it doesn't pin
402/// the sqlx offline cache to current schema.
403#[derive(Debug, Clone, Default)]
404pub struct SearchSkillMeta {
405    pub file_patterns: Vec<String>,
406    pub source_repo: Option<String>,
407}
408
409pub async fn fetch_search_meta(
410    pool: &sqlx::SqlitePool,
411    ids: &[String],
412) -> std::collections::HashMap<String, SearchSkillMeta> {
413    let mut out = std::collections::HashMap::new();
414    if ids.is_empty() {
415        return out;
416    }
417    let Ok(ids_json) = serde_json::to_string(ids) else {
418        return out;
419    };
420    let Ok(rows) = sqlx::query_as::<_, (String, Option<String>, Option<String>)>(
421        "SELECT id, file_patterns, source_repo
422           FROM skills WHERE id IN (SELECT value FROM json_each(?1))",
423    )
424    .bind(ids_json)
425    .fetch_all(pool)
426    .await
427    else {
428        return out;
429    };
430    for (id, file_patterns_raw, source_repo) in rows {
431        let file_patterns: Vec<String> = file_patterns_raw
432            .as_deref()
433            .and_then(|s| serde_json::from_str(s).ok())
434            .unwrap_or_default();
435        out.insert(
436            id,
437            SearchSkillMeta {
438                file_patterns,
439                source_repo,
440            },
441        );
442    }
443    out
444}
445
446pub async fn list_review_standards(db: &sqlx::SqlitePool) -> crate::Result<Vec<SkillRecord>> {
447    let rows = sqlx::query_as!(
448        SkillRow,
449        "SELECT id, name, source, directory, version, description, type, \
450         engines, tags, trigger, check_prompt, repo_owner, repo_name, repo_branch, readme_url, \
451         enabled_for_codex, enabled_for_claude, enabled_for_gemini, enabled_for_cursor, \
452         installed_at, updated_at, origin FROM skills \
453         WHERE type = 'review_standard' AND status = 'active' \
454         ORDER BY installed_at DESC",
455    )
456    .fetch_all(db)
457    .await?;
458    Ok(rows.into_iter().map(SkillRecord::from).collect())
459}
460
461/// List skills across **all types** but **active status only** —
462/// pending candidates and soft-deleted rows are filtered out at the
463/// SQL level.
464///
465/// "all" means all `type`s, not all statuses. Several callers rely on this
466/// active-only filter and pair it with their own pending checks; if you change
467/// the WHERE clause, audit those call sites.
468pub async fn list_all_skills(db: &sqlx::SqlitePool) -> crate::Result<Vec<SkillRecord>> {
469    let rows = sqlx::query_as!(
470        SkillRow,
471        "SELECT id, name, source, directory, version, description, type, \
472         engines, tags, trigger, check_prompt, repo_owner, repo_name, repo_branch, readme_url, \
473         enabled_for_codex, enabled_for_claude, enabled_for_gemini, enabled_for_cursor, \
474         installed_at, updated_at, origin FROM skills WHERE status = 'active' \
475         ORDER BY installed_at DESC",
476    )
477    .fetch_all(db)
478    .await?;
479    Ok(rows.into_iter().map(SkillRecord::from).collect())
480}
481
482pub async fn export_rules_markdown(db: &sqlx::SqlitePool) -> crate::Result<String> {
483    let skills = list_all_skills(db).await?;
484    if skills.is_empty() {
485        return Ok("# Project Rules\n\n_No rules found._\n".to_owned());
486    }
487
488    let skill_ids: Vec<String> = skills.iter().map(|s| s.id.clone()).collect();
489    let examples_map =
490        crate::context::rule_source::load_rule_examples_batch(db, &skill_ids).await?;
491
492    let mut md = String::from("# Project Rules\n");
493
494    for (i, skill) in skills.iter().enumerate() {
495        md.push_str(&format!("\n## {}\n\n", skill.name));
496        md.push_str(&format!("{}\n", skill.description));
497
498        if let Some(cp) = &skill.check_prompt
499            && !cp.is_empty()
500        {
501            md.push_str(&format!("\n**Check prompt:** {cp}\n"));
502        }
503
504        if let Some(examples) = examples_map.get(&skill.id)
505            && !examples.is_empty()
506        {
507            md.push_str("\n### Examples\n");
508            for ex in examples {
509                md.push_str("\n❌ Bad:\n```\n");
510                md.push_str(&ex.bad_code);
511                md.push_str("\n```\n\n✅ Good:\n```\n");
512                md.push_str(&ex.good_code);
513                md.push_str("\n```\n");
514                if let Some(desc) = &ex.description
515                    && !desc.is_empty()
516                {
517                    md.push_str(&format!("\n{desc}\n"));
518                }
519            }
520        }
521
522        if i < skills.len() - 1 {
523            md.push_str("\n---\n");
524        }
525    }
526
527    Ok(md)
528}
529
530pub async fn repos_list(db: &sqlx::SqlitePool) -> crate::Result<Vec<SkillRepoRecord>> {
531    let rows = sqlx::query_as!(SkillRepoRow,
532        "SELECT id, owner, name, branch, enabled, created_at FROM skill_repos ORDER BY created_at DESC"
533    )
534    .fetch_all(db)
535    .await?;
536    Ok(rows.into_iter().map(SkillRepoRecord::from).collect())
537}
538
539pub async fn repos_add(
540    db: &sqlx::SqlitePool,
541    input: SkillRepoAddInput,
542) -> crate::Result<SkillRepoRecord> {
543    let id = format!("repo-{}-{}", Uuid::new_v4(), input.name);
544    let branch = input.branch.unwrap_or_else(|| "main".into());
545    let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
546
547    sqlx::query!(
548        "INSERT INTO skill_repos (id, owner, name, branch, enabled, created_at) VALUES (?1, ?2, ?3, ?4, 1, ?5)",
549        id, input.owner, input.name, branch, now
550    )
551    .execute(db)
552    .await?;
553
554    Ok(SkillRepoRecord {
555        id,
556        owner: input.owner,
557        name: input.name,
558        branch,
559        enabled: true,
560        created_at: now,
561    })
562}
563
564pub async fn repos_remove(db: &sqlx::SqlitePool, input: SkillRepoRemoveInput) -> crate::Result<()> {
565    let result = sqlx::query!("DELETE FROM skill_repos WHERE id = ?1", input.id)
566        .execute(db)
567        .await?;
568    if result.rows_affected() == 0 {
569        return Err(CoreError::NotFound(format!(
570            "skill repo '{}' not found.",
571            input.id
572        )));
573    }
574    Ok(())
575}
576
577/// Return value of `update_confidence` so the caller can render a
578/// before/after message ("rule X: 0.65 → 0.70"). 2026-04-25: previously
579/// returned `()`, which made the feedback loop feel inert from the
580/// user's POV. The before/name fields piggy-back on the SELECT we now
581/// do anyway to compute the clamped after value.
582#[derive(Debug, Clone)]
583pub struct ConfidenceChange {
584    pub before: f64,
585    pub after: f64,
586    pub name: String,
587}
588
589#[derive(Debug, Clone, Copy, PartialEq, Eq)]
590enum ConfidenceSignal {
591    Accept,
592    Reject,
593}
594
595impl ConfidenceSignal {
596    const ALLOWED: &'static [&'static str] = &["accept", "reject"];
597
598    fn parse(raw: &str) -> crate::Result<Self> {
599        match raw {
600            "accept" => Ok(Self::Accept),
601            "reject" => Ok(Self::Reject),
602            _ => Err(CoreError::Validation(format!(
603                "signal must be one of: {}",
604                Self::ALLOWED.join(", ")
605            ))),
606        }
607    }
608
609    const fn delta(self) -> f64 {
610        match self {
611            Self::Accept => 0.05,
612            Self::Reject => -0.1,
613        }
614    }
615
616    const fn event_kind(self) -> &'static str {
617        match self {
618            Self::Accept => "feedback_accept",
619            Self::Reject => "feedback_dismiss",
620        }
621    }
622}
623
624pub async fn update_confidence(
625    db: &sqlx::SqlitePool,
626    input: UpdateConfidenceInput,
627) -> crate::Result<ConfidenceChange> {
628    let signal = ConfidenceSignal::parse(input.signal.as_str())?;
629    let delta = signal.delta();
630
631    let mut tx = db.begin().await?;
632    let existing = sqlx::query!(
633        "SELECT confidence_score, name FROM skills WHERE id = ?1",
634        input.skill_id
635    )
636    .fetch_optional(&mut *tx)
637    .await?;
638    let row = existing.ok_or_else(|| {
639        CoreError::NotFound(format!(
640            "rule '{}' not found — cannot apply {} feedback. Run `difflore status --json` to inspect current local memory ids.",
641            input.skill_id, input.signal
642        ))
643    })?;
644    let before = row.confidence_score;
645    let name = row.name;
646    let after = (before + delta).clamp(0.0, 1.0);
647
648    sqlx::query!(
649        "UPDATE skills SET confidence_score = ?1 WHERE id = ?2",
650        after,
651        input.skill_id
652    )
653    .execute(&mut *tx)
654    .await?;
655
656    let event_id = format!("rule-event-{}", Uuid::new_v4());
657    let kind = signal.event_kind();
658    let metadata = serde_json::json!({
659        "signal": input.signal,
660        "delta": delta,
661    })
662    .to_string();
663    sqlx::query!(
664        "INSERT INTO rule_events
665         (id, skill_id, kind, source, confidence_before, confidence_after, reason, metadata)
666         VALUES (?1, ?2, ?3, 'local_feedback', ?4, ?5, NULL, ?6)",
667        event_id,
668        input.skill_id,
669        kind,
670        before,
671        after,
672        metadata
673    )
674    .execute(&mut *tx)
675    .await?;
676
677    tx.commit().await?;
678
679    Ok(ConfidenceChange {
680        before,
681        after,
682        name,
683    })
684}
685
686pub async fn add_example(
687    db: &sqlx::SqlitePool,
688    input: AddExampleInput,
689) -> crate::Result<RuleExampleRecord> {
690    let id = format!("example-{}", Uuid::new_v4());
691    let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
692    let source = input.source.unwrap_or_else(|| "manual".into());
693
694    sqlx::query!(
695        "INSERT INTO rule_examples (id, skill_id, bad_code, good_code, description, source, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
696        id,
697        input.skill_id,
698        input.bad_code,
699        input.good_code,
700        input.description,
701        source,
702        now
703    )
704    .execute(db)
705    .await?;
706
707    Ok(RuleExampleRecord {
708        id,
709        skill_id: input.skill_id,
710        bad_code: input.bad_code,
711        good_code: input.good_code,
712        description: input.description,
713        source,
714        created_at: now,
715    })
716}
717
718pub async fn list_examples(
719    db: &sqlx::SqlitePool,
720    input: ListExamplesInput,
721) -> crate::Result<Vec<RuleExampleRecord>> {
722    let rows = sqlx::query_as!(
723        ExampleRow,
724        "SELECT id, skill_id, bad_code, good_code, description, source, created_at FROM rule_examples WHERE skill_id = ?1 ORDER BY created_at DESC",
725        input.skill_id
726    )
727    .fetch_all(db)
728    .await?;
729    Ok(rows
730        .into_iter()
731        .map(|r| RuleExampleRecord {
732            id: r.id,
733            skill_id: r.skill_id,
734            bad_code: r.bad_code,
735            good_code: r.good_code,
736            description: r.description,
737            source: r.source,
738            created_at: r.created_at,
739        })
740        .collect())
741}
742
743pub async fn remove_example(db: &sqlx::SqlitePool, input: RemoveExampleInput) -> crate::Result<()> {
744    let result = sqlx::query!("DELETE FROM rule_examples WHERE id = ?1", input.id)
745        .execute(db)
746        .await?;
747    // SQLite's DELETE silently succeeds with 0 rows affected when the id
748    // doesn't exist. Surface that as NotFound so the CLI can tell the
749    // user their id was wrong instead of claiming a phantom success.
750    if result.rows_affected() == 0 {
751        return Err(CoreError::NotFound(format!(
752            "example '{}' not found. Run `difflore status --json` to inspect current local memory ids.",
753            input.id
754        )));
755    }
756    Ok(())
757}
758
759#[derive(sqlx::FromRow)]
760struct ExampleRow {
761    id: String,
762    skill_id: String,
763    bad_code: String,
764    good_code: String,
765    description: Option<String>,
766    source: String,
767    created_at: String,
768}