difflore-core 0.2.0

Core library for the difflore CLI — rule store, retrieval, MCP server, hooks, cloud sync. Not intended for direct use; depend on `difflore-cli` instead.
use crate::domain::models::{CreateLocalSkillInput, SkillRecord};
use crate::error::CoreError;
use uuid::Uuid;

use super::fetch_skill_row_by_id;

async fn record_engine_link_failure(
    db: &sqlx::SqlitePool,
    skill_id: &str,
    engine: &str,
    error: &std::io::Error,
) {
    let event_id = format!("rule-event-{}", Uuid::new_v4());
    let reason = format!("sync_engine_link failed for engine {engine}: {error}");
    let metadata = serde_json::json!({
        "engine": engine,
        "enabled": true,
        "error": error.to_string(),
    })
    .to_string();
    if let Err(insert_err) = sqlx::query(
        "INSERT INTO rule_events
         (id, skill_id, kind, source, reason, metadata)
         VALUES (?1, ?2, 'engine_link_failed', 'local_rule_create', ?3, ?4)",
    )
    .bind(event_id)
    .bind(skill_id)
    .bind(reason)
    .bind(metadata)
    .execute(db)
    .await
    {
        eprintln!("warning: failed to audit sync_engine_link failure: {insert_err}");
    }
}

pub async fn create_local(
    db: &sqlx::SqlitePool,
    input: CreateLocalSkillInput,
) -> crate::Result<SkillRecord> {
    let Some(slug) = crate::skills::fs::safe_slug(&input.name) else {
        return Err(CoreError::Internal(
            "skill name produces an empty slug after sanitization".into(),
        ));
    };
    let id = format!("local-{slug}");
    let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
    let engines = input.engines.unwrap_or_default();
    let tags = input.tags.unwrap_or_default();
    let engines_json = serde_json::to_string(&engines)?;
    let tags_json = serde_json::to_string(&tags)?;
    let skill_type = input.r#type.unwrap_or_else(|| "skill".into());
    let description = input.description.unwrap_or_default();
    let writes_native_skill = crate::skills::fs::skill_type_allows_engine_link(&skill_type);

    // Friendly duplicate check before writing a native SKILL.md / hitting the
    // UNIQUE constraint. Review-standard rules are DB-only, but both paths are
    // keyed by `id = local-<slug>`.
    let existing_id = sqlx::query_scalar!("SELECT id FROM skills WHERE id = ?1", id)
        .fetch_optional(db)
        .await?;
    if existing_id.is_some() {
        return Err(CoreError::Validation(format!(
            "a rule with id '{id}' already exists. Remove it first with \
             the memory management UI or pick a different name."
        )));
    }

    let skill_dir = if writes_native_skill {
        let base_dir = crate::skills::fs::skills_base_dir()?.join("local");
        let skill_dir = base_dir.join(&slug);

        // Create base_dir BEFORE canonicalize so both sides share a prefix form.
        // On Windows `canonicalize()` returns `\\?\C:\...` only when the dir
        // exists, and that asymmetry makes `starts_with` spuriously fail.
        std::fs::create_dir_all(&base_dir).map_err(|e| {
            CoreError::Internal(format!("failed to create skills base directory: {e}"))
        })?;

        let canonical_base = base_dir
            .canonicalize()
            .map_err(|e| CoreError::Internal(format!("failed to resolve skills base dir: {e}")))?;
        let skill_dir_for_check = canonical_base.join(&slug);
        if !skill_dir_for_check.starts_with(&canonical_base) {
            return Err(CoreError::Internal("invalid skill name".into()));
        }

        let mut skill_md = String::new();
        skill_md.push_str("---\n");
        skill_md.push_str(&format!("type: {}\n", &skill_type));
        if !engines.is_empty() {
            skill_md.push_str(&format!("engines: [{}]\n", engines.join(", ")));
        }
        if !tags.is_empty() {
            skill_md.push_str(&format!("tags: [{}]\n", tags.join(", ")));
        }
        if let Some(ref trigger) = input.trigger
            && !trigger.is_empty()
        {
            skill_md.push_str(&format!("trigger: {trigger}\n"));
        }
        skill_md.push_str("---\n\n");
        skill_md.push_str(&format!("# {}\n\n", &input.name));
        if !description.is_empty() {
            skill_md.push_str(&format!("{}\n", &description));
        }
        if let Some(ref content) = input.content
            && !content.is_empty()
        {
            skill_md.push_str(&format!("\n{content}\n"));
        }

        std::fs::create_dir_all(&skill_dir)
            .map_err(|e| CoreError::Internal(format!("failed to create skill directory: {e}")))?;
        let canonical_skill = skill_dir
            .canonicalize()
            .map_err(|e| CoreError::Internal(format!("failed to resolve skill directory: {e}")))?;
        if !canonical_skill.starts_with(&canonical_base) {
            return Err(CoreError::Internal("invalid skill name".into()));
        }

        std::fs::write(skill_dir.join("SKILL.md"), &skill_md)
            .map_err(|e| CoreError::Internal(format!("failed to write SKILL.md: {e}")))?;
        Some(skill_dir)
    } else {
        None
    };

    let insert_result = sqlx::query!(
        "INSERT INTO skills
         (id, name, source, directory, version, description, type, engines, tags,
          trigger, check_prompt, enabled_for_claude, installed_at, updated_at)
         VALUES (?1, ?2, 'local', ?3, '1.0.0', ?4, ?5, ?6, ?7, ?8, ?9, 1, ?10, ?10)",
        id,
        input.name,
        slug,
        description,
        skill_type,
        engines_json,
        tags_json,
        input.trigger,
        input.check_prompt,
        now
    )
    .execute(db)
    .await;
    if let Err(e) = insert_result {
        if let Some(skill_dir) = skill_dir {
            let _ = std::fs::remove_dir_all(skill_dir);
        }
        return Err(e.into());
    }

    if writes_native_skill {
        for engine_name in &engines {
            if let Err(e) = crate::skills::fs::sync_engine_link("local", &slug, engine_name, true) {
                eprintln!("warning: sync_engine_link failed for engine {engine_name}: {e}");
                record_engine_link_failure(db, &id, engine_name, &e).await;
            }
        }
    }

    fetch_skill_row_by_id(db, &id).await
}