Skip to main content

difflore_core/skills/
local.rs

1use crate::errors::CoreError;
2use crate::models::{CreateLocalSkillInput, SkillRecord};
3use uuid::Uuid;
4
5use super::SkillRow;
6
7async fn record_engine_link_failure(
8    db: &sqlx::SqlitePool,
9    skill_id: &str,
10    engine: &str,
11    error: &std::io::Error,
12) {
13    let event_id = format!("rule-event-{}", Uuid::new_v4());
14    let reason = format!("sync_engine_link failed for engine {engine}: {error}");
15    let metadata = serde_json::json!({
16        "engine": engine,
17        "enabled": true,
18        "error": error.to_string(),
19    })
20    .to_string();
21    if let Err(insert_err) = sqlx::query(
22        "INSERT INTO rule_events
23         (id, skill_id, kind, source, reason, metadata)
24         VALUES (?1, ?2, 'engine_link_failed', 'local_rule_create', ?3, ?4)",
25    )
26    .bind(event_id)
27    .bind(skill_id)
28    .bind(reason)
29    .bind(metadata)
30    .execute(db)
31    .await
32    {
33        eprintln!("warning: failed to audit sync_engine_link failure: {insert_err}");
34    }
35}
36
37pub async fn create_local(
38    db: &sqlx::SqlitePool,
39    input: CreateLocalSkillInput,
40) -> crate::Result<SkillRecord> {
41    let slug: String = input
42        .name
43        .to_lowercase()
44        .chars()
45        .map(|c| {
46            if c.is_ascii_alphanumeric() || c == '_' {
47                c
48            } else {
49                '-'
50            }
51        })
52        .collect::<String>()
53        .split('-')
54        .filter(|s| !s.is_empty())
55        .collect::<Vec<_>>()
56        .join("-");
57    if slug.is_empty() {
58        return Err(CoreError::Internal(
59            "skill name produces an empty slug after sanitization".into(),
60        ));
61    }
62    let id = format!("local-{slug}");
63    let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
64    let engines = input.engines.unwrap_or_default();
65    let tags = input.tags.unwrap_or_default();
66    let engines_json = serde_json::to_string(&engines)?;
67    let tags_json = serde_json::to_string(&tags)?;
68    let skill_type = input.r#type.unwrap_or_else(|| "skill".into());
69    let description = input.description.unwrap_or_default();
70
71    let base_dir = crate::skill_fs::skills_base_dir()
72        .map_err(CoreError::Internal)?
73        .join("local");
74    let skill_dir = base_dir.join(&slug);
75
76    // Ensure base_dir exists BEFORE canonicalize so both sides end up with
77    // the same prefix form (on Windows, `canonicalize()` returns `\\?\C:\...`
78    // when the dir exists, but falls back to the relative form when it
79    // doesn't — the asymmetry caused `starts_with` to spuriously fail).
80    std::fs::create_dir_all(&base_dir)
81        .map_err(|e| CoreError::Internal(format!("failed to create skills base directory: {e}")))?;
82
83    let canonical_base = base_dir
84        .canonicalize()
85        .map_err(|e| CoreError::Internal(format!("failed to resolve skills base dir: {e}")))?;
86    let skill_dir_for_check = canonical_base.join(&slug);
87    if !skill_dir_for_check.starts_with(&canonical_base) {
88        return Err(CoreError::Internal("invalid skill name".into()));
89    }
90
91    let mut skill_md = String::new();
92    skill_md.push_str("---\n");
93    skill_md.push_str(&format!("type: {}\n", &skill_type));
94    if !engines.is_empty() {
95        skill_md.push_str(&format!("engines: [{}]\n", engines.join(", ")));
96    }
97    if !tags.is_empty() {
98        skill_md.push_str(&format!("tags: [{}]\n", tags.join(", ")));
99    }
100    if let Some(ref trigger) = input.trigger
101        && !trigger.is_empty()
102    {
103        skill_md.push_str(&format!("trigger: {trigger}\n"));
104    }
105    skill_md.push_str("---\n\n");
106    skill_md.push_str(&format!("# {}\n\n", &input.name));
107    if !description.is_empty() {
108        skill_md.push_str(&format!("{}\n", &description));
109    }
110    if let Some(ref content) = input.content
111        && !content.is_empty()
112    {
113        skill_md.push_str(&format!("\n{content}\n"));
114    }
115
116    // Friendly duplicate check BEFORE writing SKILL.md / hitting SQLite's
117    // UNIQUE constraint. Skills are keyed by `id = local-<slug>`, so two
118    // rules with the same sanitized name collide on `skills.id`. Raw sqlx
119    // errors ("UNIQUE constraint failed: skills.id (code: 1555)") confuse
120    // end users — give them the actionable version.
121    let existing_id = sqlx::query_scalar!("SELECT id FROM skills WHERE id = ?1", id)
122        .fetch_optional(db)
123        .await?;
124    if existing_id.is_some() {
125        return Err(CoreError::Validation(format!(
126            "a rule with id '{id}' already exists. Remove it first with \
127             the memory management UI or pick a different name."
128        )));
129    }
130
131    std::fs::create_dir_all(&skill_dir)
132        .map_err(|e| CoreError::Internal(format!("failed to create skill directory: {e}")))?;
133    let canonical_skill = skill_dir
134        .canonicalize()
135        .map_err(|e| CoreError::Internal(format!("failed to resolve skill directory: {e}")))?;
136    if !canonical_skill.starts_with(&canonical_base) {
137        return Err(CoreError::Internal("invalid skill name".into()));
138    }
139
140    std::fs::write(skill_dir.join("SKILL.md"), &skill_md)
141        .map_err(|e| CoreError::Internal(format!("failed to write SKILL.md: {e}")))?;
142
143    let insert_result = sqlx::query!(
144        "INSERT INTO skills
145         (id, name, source, directory, version, description, type, engines, tags,
146          trigger, check_prompt, enabled_for_claude, installed_at, updated_at)
147         VALUES (?1, ?2, 'local', ?3, '1.0.0', ?4, ?5, ?6, ?7, ?8, ?9, 1, ?10, ?10)",
148        id,
149        input.name,
150        slug,
151        description,
152        skill_type,
153        engines_json,
154        tags_json,
155        input.trigger,
156        input.check_prompt,
157        now
158    )
159    .execute(db)
160    .await;
161    if let Err(e) = insert_result {
162        let _ = std::fs::remove_dir_all(&skill_dir);
163        return Err(e.into());
164    }
165
166    for engine_name in &engines {
167        if let Err(e) = crate::skill_fs::sync_engine_link("local", &slug, engine_name, true) {
168            eprintln!("warning: sync_engine_link failed for engine {engine_name}: {e}");
169            record_engine_link_failure(db, &id, engine_name, &e).await;
170        }
171    }
172
173    let row = sqlx::query_as!(
174        SkillRow,
175        "SELECT id, name, source, directory, version, description, type, \
176         engines, tags, trigger, check_prompt, repo_owner, repo_name, repo_branch, readme_url, \
177         enabled_for_codex, enabled_for_claude, enabled_for_gemini, enabled_for_cursor, \
178         installed_at, updated_at, origin FROM skills WHERE id = ?1",
179        id
180    )
181    .fetch_one(db)
182    .await?;
183    Ok(SkillRecord::from(row))
184}