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
12fn 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
25fn 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
106pub 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
143pub 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 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 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 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 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 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#[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
461pub 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#[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 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}