//! Thin read-only client over `~/.claude/registry.sqlite`. //! //! Fetches rule-fragment content by logical name (`rule::section`). //! The registry stores the real filesystem path; this module reads that path. //! //! Constructor Pattern: one responsibility — lookup + read fragment body. //! No writes. No schema migration. Opens DB read-only. use rusqlite::{Connection, OpenFlags}; use std::path::{Path, PathBuf}; /// Open the registry at `db_path` in read-only mode. pub fn open_read_only(db_path: &Path) -> Result { Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY) .map_err(|e| format!("open registry {}: {e}", db_path.display())) } /// Default path: `$KEI_REGISTRY_DB` (if set) or `~/.claude/registry.sqlite`. pub fn default_db_path() -> PathBuf { if let Some(v) = std::env::var_os("KEI_REGISTRY_DB") { return PathBuf::from(v); } let home = std::env::var_os("HOME").unwrap_or_default(); PathBuf::from(home).join(".claude/registry.sqlite") } /// Look up a rule fragment by `name` (e.g. `"karpathy-behavioral::1-think-before-coding"`). /// /// Returns: /// - `Ok(Some(body))` — fragment found and file readable. /// - `Ok(None)` — name not in registry, or registry path does not exist on disk. /// Caller should warn-and-skip. /// - `Err(msg)` — DB query failure (not a missing-path issue). Propagate. pub fn find_rule(conn: &Connection, name: &str) -> Result, String> { let path = match query_path(conn, name)? { Some(p) => p, None => return Ok(None), }; read_fragment_body(name, &path) } /// Query the `path` column for the active row with `name` and `block_type='rule'`. fn query_path(conn: &Connection, name: &str) -> Result, String> { let mut stmt = conn .prepare( "SELECT path FROM blocks \ WHERE name = ?1 AND block_type = 'rule' AND superseded_by IS NULL \ LIMIT 1", ) .map_err(|e| format!("prepare query for {name}: {e}"))?; let row: Option = stmt .query_row(rusqlite::params![name], |r| r.get(0)) .optional() .map_err(|e| format!("query registry for {name}: {e}"))?; Ok(row) } /// Read the fragment body from `path`. Returns `Ok(None)` when the file is absent. fn read_fragment_body(name: &str, path: &str) -> Result, String> { match std::fs::read_to_string(path) { Ok(body) => Ok(Some(body)), Err(e) if e.kind() == std::io::ErrorKind::NotFound => { eprintln!( "warn [assembler]: registry fragment for '{name}' has path '{path}' but file is missing — skipping. \ Run `kei-decompose decompose-rules --rebuild-fragments` to restore." ); Ok(None) } Err(e) => Err(format!("read fragment for {name} at {path}: {e}")), } } trait OptionalExt: Sized { fn optional(self) -> rusqlite::Result>; } impl OptionalExt for rusqlite::Result { fn optional(self) -> rusqlite::Result> { match self { Ok(v) => Ok(Some(v)), Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), Err(e) => Err(e), } } } /// Check if `name` is a registered path-atom. /// /// Convention: a path-atom is an atom whose source file is /// `_blocks/path-.md` and whose YAML frontmatter declares /// `kind: path`. The DB stores only the file path (not body), so this /// function uses the filename convention as a fast first check, then /// reads the file and parses the frontmatter to confirm `kind: path`. /// /// Returns: /// - `Ok(true)` — atom registered under `name`, file exists, frontmatter /// declares `kind: path`. Caller may emit an opaque resolved reference. /// - `Ok(false)` — atom not found, or found but not a path-atom. Caller /// should pass the original reference through unchanged (with optional /// warn-and-skip in caller). /// - `Err(msg)` — DB query failure. Propagate. pub fn is_path_atom(conn: &Connection, name: &str) -> Result { let mut stmt = conn .prepare( "SELECT path FROM blocks \ WHERE name = ?1 AND block_type = 'atom' AND superseded_by IS NULL \ LIMIT 1", ) .map_err(|e| format!("prepare path-atom query for {name}: {e}"))?; let path: Option = stmt .query_row(rusqlite::params![name], |r| r.get(0)) .optional() .map_err(|e| format!("query path-atom {name}: {e}"))?; let Some(p) = path else { return Ok(false) }; // Filename convention check: `_blocks/path-.md`. Cheap O(1) string // contains, avoids the file read on the common non-path-atom case. let expected_suffix = format!("/_blocks/path-{name}.md"); if !p.ends_with(&expected_suffix) { return Ok(false); } // Read frontmatter to confirm `kind: path`. Defensive — convention is // not authoritative on its own; explicit declaration is. let body = match std::fs::read_to_string(&p) { Ok(b) => b, Err(_) => return Ok(false), }; Ok(frontmatter_has_kind_path(&body)) } /// Return true if `body` starts with a YAML frontmatter block (`---\n...---\n`) /// containing a line whose key is `kind` and value is `path`. Tolerates /// `---\r\n`, surrounding whitespace, and YAML quoting. fn frontmatter_has_kind_path(body: &str) -> bool { let stripped = match body .strip_prefix("---\n") .or_else(|| body.strip_prefix("---\r\n")) { Some(s) => s, None => return false, }; let end = match stripped .find("\n---\n") .or_else(|| stripped.find("\r\n---\r\n")) { Some(i) => i, None => return false, }; let frontmatter = &stripped[..end]; for line in frontmatter.lines() { let line = line.trim(); if let Some(rest) = line.strip_prefix("kind:") { let val = rest.trim().trim_matches(&['\'', '"'][..]); return val == "path"; } } false } #[cfg(test)] mod tests { use super::frontmatter_has_kind_path; #[test] fn detects_kind_path_in_frontmatter() { let body = "---\ntype: atom\nkind: path\nname: foo\n---\n\n# body\n"; assert!(frontmatter_has_kind_path(body)); } #[test] fn rejects_kind_other() { let body = "---\ntype: atom\nkind: other\n---\n"; assert!(!frontmatter_has_kind_path(body)); } #[test] fn rejects_no_frontmatter() { let body = "# just markdown\n"; assert!(!frontmatter_has_kind_path(body)); } #[test] fn tolerates_quoted_value() { let body = "---\nkind: \"path\"\n---\n"; assert!(frontmatter_has_kind_path(body)); } #[test] fn rejects_kind_path_substring() { // `kind: pathological` must NOT match `kind: path`. let body = "---\nkind: pathological\n---\n"; assert!(!frontmatter_has_kind_path(body)); } }