Skip to main content

difflore_core/skills/
crud.rs

1use std::collections::{HashMap, HashSet};
2
3use crate::errors::CoreError;
4use crate::models::{
5    DiscoverSkillsInput, DiscoveredSkillRecord, InstallSkillInput, RemoveSkillInput, SkillRecord,
6    ToggleSkillEngineInput,
7};
8
9use super::{SkillRow, decode_base64_lossy, parse_skill_frontmatter};
10
11/// Sidecar query: `(skill_id → source_repo)` for every rule in the local
12/// `skills` table.
13///
14/// Used by the TUI to default the rules tab to the current repo without
15/// widening `SkillRecord` (which is a stable serde surface).
16pub async fn list_source_repos(
17    db: &sqlx::SqlitePool,
18) -> crate::Result<HashMap<String, Option<String>>> {
19    let rows = sqlx::query!("SELECT id, source_repo FROM skills WHERE status = 'active'")
20        .fetch_all(db)
21        .await?;
22    let mut out = HashMap::with_capacity(rows.len());
23    for row in rows {
24        out.insert(row.id, row.source_repo);
25    }
26    Ok(out)
27}
28
29/// Expand detected git remotes with a conservative source-repo alias.
30///
31/// A common fork setup only has `origin` configured locally, e.g.
32/// `difflore-fixtures/fastapi`, while the imported review memory is scoped to
33/// the upstream repo, e.g. `fastapi/fastapi`. If the local rule store has
34/// exactly one active source repo with the same repository name, include that
35/// source repo as an additional recall scope. When there are zero or multiple
36/// possible aliases, keep the original remotes only so unrelated repos never
37/// receive global memory.
38pub async fn expand_repo_scopes_with_source_aliases(
39    db: &sqlx::SqlitePool,
40    repo_full_names: &[String],
41) -> crate::Result<Vec<String>> {
42    let mut scopes = Vec::new();
43    let mut seen = HashSet::new();
44    let mut repo_names = Vec::new();
45
46    for raw in repo_full_names {
47        let Some(repo) = crate::git::normalize_github_repo_full_name(raw) else {
48            continue;
49        };
50        if seen.insert(repo.clone()) {
51            if let Some((_, name)) = repo.rsplit_once('/')
52                && !repo_names.iter().any(|existing| existing == name)
53            {
54                repo_names.push(name.to_owned());
55            }
56            scopes.push(repo);
57        }
58    }
59
60    for repo_name in repo_names {
61        let pattern = format!("%/{repo_name}");
62        let rows: Vec<String> = sqlx::query_scalar(
63            "SELECT LOWER(source_repo)
64             FROM skills
65             WHERE status = 'active'
66               AND source_repo IS NOT NULL
67               AND TRIM(source_repo) <> ''
68               AND LOWER(source_repo) LIKE ?1
69             GROUP BY LOWER(source_repo)",
70        )
71        .bind(pattern)
72        .fetch_all(db)
73        .await?;
74
75        let candidates: Vec<String> = rows
76            .into_iter()
77            .filter_map(|repo| crate::git::normalize_github_repo_full_name(&repo))
78            .filter(|repo| {
79                repo.rsplit_once('/')
80                    .is_some_and(|(_, name)| name == repo_name)
81                    && !seen.contains(repo)
82            })
83            .collect();
84
85        if candidates.len() == 1 {
86            let Some(alias) = candidates.into_iter().next() else {
87                continue;
88            };
89            seen.insert(alias.clone());
90            scopes.push(alias);
91        }
92    }
93
94    Ok(scopes)
95}
96
97pub async fn list(db: &sqlx::SqlitePool) -> crate::Result<Vec<SkillRecord>> {
98    let rows = sqlx::query_as!(
99        SkillRow,
100        "SELECT id, name, source, directory, version, description, type, \
101         engines, tags, trigger, check_prompt, repo_owner, repo_name, repo_branch, readme_url, \
102         enabled_for_codex, enabled_for_claude, enabled_for_gemini, enabled_for_cursor, \
103         installed_at, updated_at, origin FROM skills WHERE status = 'active' \
104         ORDER BY installed_at DESC",
105    )
106    .fetch_all(db)
107    .await?;
108    Ok(rows.into_iter().map(SkillRecord::from).collect())
109}
110
111/// Same SELECT as `list()` but without the `status='active'` filter.
112/// Used by review-memory surfaces that need to see pending rows alongside
113/// active ones.
114pub async fn list_all(db: &sqlx::SqlitePool) -> crate::Result<Vec<SkillRecord>> {
115    let rows = sqlx::query_as!(
116        SkillRow,
117        "SELECT id, name, source, directory, version, description, type, \
118         engines, tags, trigger, check_prompt, repo_owner, repo_name, repo_branch, readme_url, \
119         enabled_for_codex, enabled_for_claude, enabled_for_gemini, enabled_for_cursor, \
120         installed_at, updated_at, origin FROM skills ORDER BY installed_at DESC",
121    )
122    .fetch_all(db)
123    .await?;
124    Ok(rows.into_iter().map(SkillRecord::from).collect())
125}
126
127pub async fn add(db: &sqlx::SqlitePool, input: InstallSkillInput) -> crate::Result<SkillRecord> {
128    let id = format!("skill-{}-{}", input.owner, input.directory);
129    let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
130    let engines_json = serde_json::to_string(&["claude"])?;
131    let tags_json = serde_json::to_string(&["github", "imported"])?;
132    let name = input.directory.replace('-', " ");
133    let description = format!("Imported from {}/{}", input.owner, input.repo);
134
135    sqlx::query!(
136        "INSERT OR IGNORE INTO skills
137         (id, name, source, directory, version, description, type, engines, tags,
138          repo_owner, repo_name, repo_branch, enabled_for_claude, installed_at, updated_at)
139         VALUES (?1, ?2, 'github', ?3, '1.0.0', ?4, 'skill', ?5, ?6,
140                 ?7, ?8, ?9, 1, ?10, ?10)",
141        id,
142        name,
143        input.directory,
144        description,
145        engines_json,
146        tags_json,
147        input.owner,
148        input.repo,
149        input.branch,
150        now
151    )
152    .execute(db)
153    .await?;
154
155    let row = sqlx::query_as!(
156        SkillRow,
157        "SELECT id, name, source, directory, version, description, type, \
158         engines, tags, trigger, check_prompt, repo_owner, repo_name, repo_branch, readme_url, \
159         enabled_for_codex, enabled_for_claude, enabled_for_gemini, enabled_for_cursor, \
160         installed_at, updated_at, origin FROM skills WHERE id = ?1",
161        id
162    )
163    .fetch_one(db)
164    .await?;
165    Ok(SkillRecord::from(row))
166}
167
168pub async fn remove(db: &sqlx::SqlitePool, input: RemoveSkillInput) -> crate::Result<()> {
169    let skill: Option<SkillRecord> = sqlx::query_as!(
170        SkillRow,
171        "SELECT id, name, source, directory, version, description, type, \
172             engines, tags, trigger, check_prompt, repo_owner, repo_name, repo_branch, readme_url, \
173             enabled_for_codex, enabled_for_claude, enabled_for_gemini, enabled_for_cursor, \
174             installed_at, updated_at, origin FROM skills WHERE id = ?1",
175        input.id
176    )
177    .fetch_optional(db)
178    .await?
179    .map(SkillRecord::from);
180
181    // Fail loud when the id doesn't exist — otherwise we return a phantom
182    // "Removed rule: X" for typos, which makes debugging impossible.
183    let Some(skill) = skill else {
184        return Err(CoreError::NotFound(format!(
185            "rule '{}' not found. Inspect local memory with `difflore status --json`.",
186            input.id
187        )));
188    };
189
190    sqlx::query!("DELETE FROM skills WHERE id = ?1", input.id)
191        .execute(db)
192        .await?;
193
194    {
195        for engine in &["codex", "claude", "gemini", "cursor"] {
196            if let Err(e) =
197                crate::skill_fs::sync_engine_link(&skill.source, &skill.directory, engine, false)
198            {
199                eprintln!("warning: sync_engine_link failed for engine {engine}: {e}");
200            }
201        }
202        let skill_dir = crate::skill_fs::skills_base_dir()
203            .map_err(CoreError::Internal)?
204            .join(&skill.source)
205            .join(&skill.directory);
206        if skill_dir.exists() {
207            let _ = std::fs::remove_dir_all(&skill_dir);
208        }
209    }
210
211    Ok(())
212}
213
214pub async fn toggle_engine(
215    db: &sqlx::SqlitePool,
216    input: ToggleSkillEngineInput,
217) -> crate::Result<()> {
218    let val = i32::from(input.enabled);
219    match input.engine.as_str() {
220        "codex" => {
221            sqlx::query!("UPDATE skills SET enabled_for_codex = ?1, updated_at = datetime('now') WHERE id = ?2",
222                val, input.id).execute(db).await?;
223        }
224        "claude" => {
225            sqlx::query!("UPDATE skills SET enabled_for_claude = ?1, updated_at = datetime('now') WHERE id = ?2",
226                val, input.id).execute(db).await?;
227        }
228        "gemini" => {
229            sqlx::query!("UPDATE skills SET enabled_for_gemini = ?1, updated_at = datetime('now') WHERE id = ?2",
230                val, input.id).execute(db).await?;
231        }
232        "cursor" => {
233            sqlx::query!("UPDATE skills SET enabled_for_cursor = ?1, updated_at = datetime('now') WHERE id = ?2",
234                val, input.id).execute(db).await?;
235        }
236        other => return Err(CoreError::Internal(format!("unknown engine: {other}"))),
237    }
238
239    // Sync the symlink on disk to match the new DB state.
240    if let Some(row) = sqlx::query_as!(
241        SkillRow,
242        "SELECT id, name, source, directory, version, description, type, \
243         engines, tags, trigger, check_prompt, repo_owner, repo_name, repo_branch, readme_url, \
244         enabled_for_codex, enabled_for_claude, enabled_for_gemini, enabled_for_cursor, \
245         installed_at, updated_at, origin FROM skills WHERE id = ?1",
246        input.id
247    )
248    .fetch_optional(db)
249    .await?
250    {
251        let skill = SkillRecord::from(row);
252        if let Err(e) = crate::skill_fs::sync_engine_link(
253            &skill.source,
254            &skill.directory,
255            &input.engine,
256            input.enabled,
257        ) {
258            eprintln!(
259                "warning: sync_engine_link failed for engine {}: {e}",
260                input.engine
261            );
262        }
263    }
264
265    Ok(())
266}
267
268pub async fn discover(
269    db: &sqlx::SqlitePool,
270    input: DiscoverSkillsInput,
271) -> crate::Result<Vec<DiscoveredSkillRecord>> {
272    let branch = input.branch.unwrap_or_else(|| "main".into());
273    let repo_slug = format!("{}/{}", input.owner, input.repo);
274
275    let dirs: Vec<String> = if which::which("gh").is_ok() {
276        let output = std::process::Command::new("gh")
277            .args([
278                "api",
279                &format!("repos/{repo_slug}/contents"),
280                "--jq",
281                ".[].name",
282            ])
283            .output();
284
285        match output {
286            Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
287                .lines()
288                .map(|l| l.trim().to_owned())
289                .filter(|l| !l.is_empty() && !l.starts_with('.'))
290                .collect(),
291            _ => vec![],
292        }
293    } else {
294        vec![]
295    };
296
297    let installed_ids: Vec<String> =
298        sqlx::query_scalar!("SELECT id FROM skills WHERE source = 'github'")
299            .fetch_all(db)
300            .await?;
301
302    if dirs.is_empty() {
303        return Ok(vec![DiscoveredSkillRecord {
304            name: format!("{} skills", input.repo),
305            description: format!("Skills from {repo_slug}"),
306            r#type: "skill".into(),
307            engines: vec!["claude".into()],
308            tags: vec!["remote".into()],
309            version: "1.0.0".into(),
310            directory: input.repo.clone(),
311            repo_owner: input.owner.clone(),
312            repo_name: input.repo,
313            repo_branch: branch,
314            installed: installed_ids.iter().any(|id| id.contains(&input.owner)),
315        }]);
316    }
317
318    let mut results = Vec::new();
319    for dir in dirs {
320        let skill_id = format!("skill-{}-{}", input.owner, dir);
321        let installed = installed_ids.contains(&skill_id);
322
323        let (name, description, fm) = if which::which("gh").is_ok() {
324            let md_output = std::process::Command::new("gh")
325                .args([
326                    "api",
327                    &format!("repos/{repo_slug}/contents/{dir}/SKILL.md"),
328                    "--jq",
329                    ".content",
330                ])
331                .output();
332
333            match md_output {
334                Ok(o) if o.status.success() => {
335                    let raw = String::from_utf8_lossy(&o.stdout).trim().to_owned();
336                    let decoded = decode_base64_lossy(&raw);
337                    let fm = parse_skill_frontmatter(&decoded);
338                    let first_line = fm
339                        .body
340                        .lines()
341                        .find(|l| !l.trim().is_empty())
342                        .unwrap_or(&dir)
343                        .trim_start_matches('#')
344                        .trim()
345                        .to_owned();
346                    let desc = fm
347                        .body
348                        .lines()
349                        .skip(1)
350                        .find(|l| !l.trim().is_empty())
351                        .unwrap_or("Remote skill")
352                        .trim()
353                        .to_owned();
354                    (first_line, desc, fm)
355                }
356                _ => (
357                    dir.replace('-', " "),
358                    format!("Skill from {repo_slug}/{dir}"),
359                    parse_skill_frontmatter(""),
360                ),
361            }
362        } else {
363            (
364                dir.replace('-', " "),
365                format!("Skill from {repo_slug}/{dir}"),
366                parse_skill_frontmatter(""),
367            )
368        };
369
370        results.push(DiscoveredSkillRecord {
371            name,
372            description,
373            r#type: fm.r#type.unwrap_or_else(|| "skill".into()),
374            engines: fm.engines.unwrap_or_else(|| vec!["claude".into()]),
375            tags: fm
376                .tags
377                .unwrap_or_else(|| vec!["remote".into(), "github".into()]),
378            version: fm.version.unwrap_or_else(|| "1.0.0".into()),
379            directory: dir,
380            repo_owner: input.owner.clone(),
381            repo_name: input.repo.clone(),
382            repo_branch: branch.clone(),
383            installed,
384        });
385    }
386
387    Ok(results)
388}
389
390pub async fn sync_links(db: &sqlx::SqlitePool) -> crate::Result<()> {
391    let rows = sqlx::query_as!(
392        SkillRow,
393        "SELECT id, name, source, directory, version, description, type, \
394         engines, tags, trigger, check_prompt, repo_owner, repo_name, repo_branch, readme_url, \
395         enabled_for_codex, enabled_for_claude, enabled_for_gemini, enabled_for_cursor, \
396         installed_at, updated_at, origin FROM skills WHERE status = 'active'",
397    )
398    .fetch_all(db)
399    .await?;
400    let skills: Vec<SkillRecord> = rows.into_iter().map(SkillRecord::from).collect();
401
402    for skill in &skills {
403        let engines = [
404            ("codex", skill.enabled_for_codex),
405            ("claude", skill.enabled_for_claude),
406            ("gemini", skill.enabled_for_gemini),
407            ("cursor", skill.enabled_for_cursor),
408        ];
409        for (engine, enabled) in engines {
410            if let Err(e) =
411                crate::skill_fs::sync_engine_link(&skill.source, &skill.directory, engine, enabled)
412            {
413                eprintln!("warning: sync_engine_link failed for engine {engine}: {e}");
414            }
415        }
416    }
417
418    Ok(())
419}