asurada 0.2.0

Asurada — a memory + cognition daemon that grows with the user. Local-first, BYOK, shared by Devist/Webchemist Core/etc.
//! ClaudeCodeAdapter — Asurada 의 intent + harnesses 를 Claude Code 형식으로 컴파일.
//!
//! 라우팅 전략 (CLAUDE.md 비대화 방지):
//!   짧은 preference (≤ 120자, 1줄) → CLAUDE.md preferences 블록
//!   긴 preference            → ~/.claude/skills/asurada-preferences/SKILL.md
//!   principle                → ~/.asurada/gates.json (능동 게이트) +
//!                              CLAUDE.md principles 블록 (1줄 transparency)
//!   짧은 context             → CLAUDE.md context 블록
//!   긴 context               → ~/.claude/skills/asurada-context/SKILL.md
//!   harnesses (active)       → CLAUDE.md harnesses 블록 (사용량 상위 N개 1줄씩)
//!
//! 각 블록은 ASURADA_BLOCK_MAX_LINES 를 넘으면 초과분을 자동으로 skill 파일로 demote.

use anyhow::{Context, Result};
use std::path::PathBuf;

use super::limits::*;
use super::*;
use crate::db::harness::Harness;
use crate::db::intent::{Intent, Strength};

pub struct ClaudeCodeAdapter {
    pub global_claude_md: PathBuf,
    pub gates_path: PathBuf,
    pub global_skills_dir: PathBuf,
}

impl ClaudeCodeAdapter {
    pub fn default() -> Result<Self> {
        let home = dirs::home_dir().context("home directory not found")?;
        Ok(Self {
            global_claude_md: home.join(".claude/CLAUDE.md"),
            gates_path: home.join(".asurada/gates.json"),
            global_skills_dir: home.join(".claude/skills"),
        })
    }
}

#[derive(Debug)]
enum Route {
    /// CLAUDE.md 의 짧은 한 줄.
    ClaudeMdLine { block_id: &'static str, line: String },
    /// ~/.claude/skills/<slug>/SKILL.md 의 일부 (배치 단위로 합쳐짐).
    SkillBullet {
        slug: &'static str,
        title: &'static str,
        description: &'static str,
        bullet: String,
    },
    /// gates.json 으로 — hook 이 PreToolUse 시점에 평가.
    Gate(GateRule),
}

fn route_intent(intent: &Intent) -> Vec<Route> {
    let line_text = intent.intent_text.trim();
    let scope_suffix = intent
        .project
        .as_deref()
        .map(|p| format!(" *(scope: {})*", p))
        .unwrap_or_default();
    let one_liner = format!("- {}{}", line_text, scope_suffix);
    let is_short = line_text.chars().count() <= SHORT_INTENT_CHAR_LIMIT
        && !line_text.contains('\n');

    match intent.strength {
        Strength::Preference if is_short => vec![Route::ClaudeMdLine {
            block_id: "preferences",
            line: one_liner,
        }],
        Strength::Preference => vec![Route::SkillBullet {
            slug: "asurada-preferences",
            title: "사용자 선호 (Asurada)",
            description: "사용자가 Asurada 에 등록한 작업 선호. 코드 작성/응답 스타일/도구 사용 \
                         규약을 다룰 때 이 skill 의 항목들을 적극 반영하라.",
            bullet: format!("- {}{}", line_text, scope_suffix),
        }],
        Strength::Principle => {
            let mut out = vec![];
            if let Some(rule) = principle_to_gate_rule(intent) {
                out.push(Route::Gate(rule));
            }
            // 사용자 투명성 — CLAUDE.md 에 1줄. 게이트가 차단 사유를 *왜* 적용하는지 노출.
            out.push(Route::ClaudeMdLine {
                block_id: "principles",
                line: format!(
                    "- {} *(자동 차단/확인 적용)*",
                    line_text
                ),
            });
            out
        }
        Strength::Context if is_short => vec![Route::ClaudeMdLine {
            block_id: "context",
            line: one_liner,
        }],
        Strength::Context => vec![Route::SkillBullet {
            slug: "asurada-context",
            title: "프로젝트 맥락 (Asurada)",
            description: "Asurada 가 누적한 프로젝트별 맥락. 사용자 작업이 이 항목들과 \
                         관련될 때 이 skill 을 사용해 기존 결정/제약을 회상하라.",
            bullet: format!("- {}{}", line_text, scope_suffix),
        }],
    }
}

impl RuntimeAdapter for ClaudeCodeAdapter {
    fn name(&self) -> &'static str {
        "claude-code"
    }

    fn compile(
        &self,
        intents: &[Intent],
        harnesses: &[Harness],
    ) -> Result<CompiledArtifacts> {
        let mut artifacts = CompiledArtifacts::default();

        // 1. 라우팅
        let mut claude_md_lines: std::collections::BTreeMap<&'static str, Vec<String>> =
            Default::default();
        let mut skill_buckets: std::collections::BTreeMap<&'static str, SkillBucket> =
            Default::default();

        for it in intents {
            for r in route_intent(it) {
                match r {
                    Route::ClaudeMdLine { block_id, line } => {
                        claude_md_lines.entry(block_id).or_default().push(line);
                    }
                    Route::SkillBullet {
                        slug,
                        title,
                        description,
                        bullet,
                    } => {
                        skill_buckets
                            .entry(slug)
                            .or_insert_with(|| SkillBucket {
                                slug,
                                title,
                                description,
                                bullets: vec![],
                            })
                            .bullets
                            .push(bullet);
                    }
                    Route::Gate(rule) => {
                        artifacts.gate_rules.push(rule);
                    }
                }
            }
        }

        // 2. CLAUDE.md 블록 — per-block cap 강제. 초과는 skill 로 demote.
        let block_titles: &[(&str, &str)] = &[
            ("preferences", "사용자 선호 (Asurada)"),
            ("context", "프로젝트 맥락 (Asurada)"),
            ("principles", "원칙 (Asurada — 자동 차단/확인 적용)"),
        ];

        for (block_id, title) in block_titles {
            let lines = claude_md_lines.remove(block_id).unwrap_or_default();
            if lines.is_empty() {
                continue;
            }

            let head = ASURADA_BLOCK_MAX_LINES.saturating_sub(3); // title 줄 + 빈줄 고려.
            let (kept, overflow): (Vec<_>, Vec<_>) = lines
                .into_iter()
                .enumerate()
                .partition(|(i, _)| *i < head);
            let kept: Vec<String> = kept.into_iter().map(|(_, s)| s).collect();
            let overflow: Vec<String> = overflow.into_iter().map(|(_, s)| s).collect();

            artifacts.text_blocks.push(TextBlock {
                id: (*block_id).to_string(),
                target: BlockTarget::GlobalClaudeMd,
                body: format!("## {}\n\n{}\n", title, kept.join("\n")),
            });

            // 초과분 → skill demote. block 별 demote slug 매핑.
            if !overflow.is_empty() {
                let (demote_slug, demote_title, demote_desc) = match *block_id {
                    "preferences" => (
                        "asurada-preferences",
                        "사용자 선호 (Asurada)",
                        "사용자가 등록한 선호 — CLAUDE.md 에서 demote 된 항목.",
                    ),
                    "context" => (
                        "asurada-context",
                        "프로젝트 맥락 (Asurada)",
                        "Asurada 가 누적한 맥락 — CLAUDE.md 에서 demote 된 항목.",
                    ),
                    _ => continue,
                };
                skill_buckets
                    .entry(demote_slug)
                    .or_insert_with(|| SkillBucket {
                        slug: demote_slug,
                        title: demote_title,
                        description: demote_desc,
                        bullets: vec![],
                    })
                    .bullets
                    .extend(overflow);
            }
        }

        // 3. 하네스 레지스트리 블록 — 사용량 상위 N개 1줄씩.
        let mut sorted = harnesses
            .iter()
            .filter(|h| h.status == "active")
            .collect::<Vec<_>>();
        sorted.sort_by_key(|h| -h.usage_count);
        let top: Vec<&Harness> = sorted.into_iter().take(HARNESS_REGISTRY_MAX_ENTRIES).collect();
        if !top.is_empty() {
            let mut body = String::from("## 등록된 하네스 (Asurada — 자동 생성)\n\n");
            body.push_str("자세한 내용은 각 skill 파일 참조. 비슷한 작업 요청 시 자동 트리거됩니다.\n\n");
            for h in &top {
                body.push_str(&format!(
                    "- **{}** *(used {}x)* — {}\n",
                    h.title, h.usage_count, h.description
                ));
            }
            artifacts.text_blocks.push(TextBlock {
                id: "harnesses".into(),
                target: BlockTarget::GlobalClaudeMd,
                body,
            });
        }

        // 4. Skill 파일 생성 (~/.claude/skills/<slug>/SKILL.md).
        for (_, bucket) in skill_buckets {
            if bucket.bullets.is_empty() {
                continue;
            }
            let path = self.global_skills_dir.join(bucket.slug).join("SKILL.md");
            let body = render_aux_skill(&bucket);
            artifacts.files.push(GeneratedFile { path, body });
        }

        Ok(artifacts)
    }

    fn apply(&self, artifacts: &CompiledArtifacts) -> Result<ApplyReport> {
        let mut report = ApplyReport::default();

        // text blocks → CLAUDE.md
        for block in &artifacts.text_blocks {
            let target = match &block.target {
                BlockTarget::GlobalClaudeMd => self.global_claude_md.clone(),
                BlockTarget::ProjectClaudeMd(p) => p.clone(),
            };
            let changed = upsert_managed_block(&target, block)?;
            if changed {
                report.files_written.push(target);
                report.blocks_updated.push(block.id.clone());
            }
        }

        // 정리 — 라우팅 결과 빈 블록은 디스크에서도 제거.
        let present_ids: std::collections::HashSet<&str> =
            artifacts.text_blocks.iter().map(|b| b.id.as_str()).collect();
        let all_known_ids: &[&str] = &["preferences", "context", "principles", "harnesses"];
        for &id in all_known_ids {
            if !present_ids.contains(id) {
                let _ = remove_managed_block(&self.global_claude_md, id);
            }
        }

        // generated files
        for gf in &artifacts.files {
            if let Some(parent) = gf.path.parent() {
                std::fs::create_dir_all(parent)?;
            }
            let tmp = gf.path.with_extension(format!(
                "{}.asurada.tmp",
                gf.path
                    .extension()
                    .and_then(|e| e.to_str())
                    .unwrap_or("md")
            ));
            std::fs::write(&tmp, &gf.body)?;
            std::fs::rename(&tmp, &gf.path)?;
            report.files_written.push(gf.path.clone());
        }

        // gate rules → ~/.asurada/gates.json
        if let Some(parent) = self.gates_path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let body = serde_json::to_string_pretty(&artifacts.gate_rules)?;
        let tmp = self.gates_path.with_extension("json.asurada.tmp");
        std::fs::write(&tmp, body)?;
        std::fs::rename(&tmp, &self.gates_path)?;
        report.gate_rules_count = artifacts.gate_rules.len();
        report.files_written.push(self.gates_path.clone());

        Ok(report)
    }
}

struct SkillBucket {
    slug: &'static str,
    title: &'static str,
    description: &'static str,
    bullets: Vec<String>,
}

fn render_aux_skill(bucket: &SkillBucket) -> String {
    format!(
        "---\n\
         name: {slug}\n\
         description: {description}\n\
         asurada-managed: true\n\
         ---\n\
         \n\
         # {title}\n\
         \n\
         이 skill 은 Asurada 가 자동으로 관리합니다 (`asurada intent compile`).\n\
         사용자 직접 편집은 다음 컴파일 시 덮여 쓰일 수 있으니, 영구 변경은\n\
         `asurada intent add/archive` 로 진행하세요.\n\
         \n\
         ## 항목\n\
         \n\
         {bullets}\n",
        slug = bucket.slug,
        description = bucket.description,
        title = bucket.title,
        bullets = bucket.bullets.join("\n"),
    )
}

/// principle intent → GateRule. metadata 의 `trigger` 필드를 읽어 변환.
fn principle_to_gate_rule(intent: &Intent) -> Option<GateRule> {
    let trigger = intent.metadata.get("trigger")?;
    let tool = trigger
        .get("tool")
        .and_then(|v| v.as_str())
        .map(String::from);
    let contains = trigger
        .get("contains")
        .and_then(|v| v.as_str())
        .map(String::from);
    if tool.is_none() && contains.is_none() {
        return None;
    }
    let decision = intent
        .metadata
        .get("decision")
        .and_then(|v| v.as_str())
        .unwrap_or("ask")
        .to_string();
    Some(GateRule {
        intent_id: intent.id.clone(),
        tool,
        contains,
        decision,
        reason: intent.intent_text.clone(),
    })
}

/// PreToolUse hook 시점에 활성 게이트 규칙 평가.
pub fn load_gate_rules(path: &std::path::Path) -> Result<Vec<GateRule>> {
    if !path.exists() {
        return Ok(vec![]);
    }
    let body = std::fs::read_to_string(path)?;
    if body.trim().is_empty() {
        return Ok(vec![]);
    }
    Ok(serde_json::from_str(&body).unwrap_or_default())
}

pub fn match_gate(
    rules: &[GateRule],
    tool_name: Option<&str>,
    tool_input: &serde_json::Value,
) -> Option<GateRule> {
    let tool_input_str = tool_input.to_string();
    for rule in rules {
        let tool_ok = match (&rule.tool, tool_name) {
            (None, _) => true,
            (Some(want), Some(got)) => want == got,
            (Some(_), None) => false,
        };
        if !tool_ok {
            continue;
        }
        let contains_ok = match &rule.contains {
            None => true,
            Some(needle) => tool_input_str.contains(needle),
        };
        if !contains_ok {
            continue;
        }
        return Some(rule.clone());
    }
    None
}