1use std::path::Path;
7
8use color_eyre::eyre::eyre;
9use llmy_types::error::LLMYError;
10use sqlx::Row;
11use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
12
13use crate::builder::BuildResult;
14use crate::model::CodeGraph;
15
16const STORE_SCHEMA_SQL: &str = r#"
17CREATE TABLE IF NOT EXISTS codegraph_snapshot (
18 root TEXT PRIMARY KEY,
19 fingerprint TEXT NOT NULL,
20 graph_json TEXT NOT NULL,
21 files INTEGER NOT NULL,
22 parse_errors INTEGER NOT NULL,
23 created_at TEXT NOT NULL
24);
25"#;
26
27#[derive(Debug, Clone)]
28pub struct CodeGraphStore {
29 pool: SqlitePool,
30 path: String,
31}
32
33impl CodeGraphStore {
34 pub async fn open(path: &str) -> Result<Self, LLMYError> {
35 let fs_path = Path::new(path);
36 if fs_path.is_dir() {
37 return Err(eyre!(
38 "codegraph db path {} is a directory; sqlite needs a file",
39 path
40 )
41 .into());
42 }
43 if let Some(parent) = fs_path.parent()
44 && !parent.as_os_str().is_empty()
45 {
46 tokio::fs::create_dir_all(parent).await?;
47 }
48 let opts = SqliteConnectOptions::new()
49 .filename(path)
50 .create_if_missing(true)
51 .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal);
52 let pool = SqlitePoolOptions::new()
53 .max_connections(2)
54 .connect_with(opts)
55 .await
56 .map_err(|e| eyre!("failed to open codegraph db at {}: {}", path, e))?;
57 for stmt in STORE_SCHEMA_SQL.split(';') {
58 let trimmed = stmt.trim();
59 if trimmed.is_empty() {
60 continue;
61 }
62 sqlx::query(trimmed)
63 .execute(&pool)
64 .await
65 .map_err(|e| eyre!("failed to apply codegraph schema: {}", e))?;
66 }
67 Ok(Self {
68 pool,
69 path: path.to_string(),
70 })
71 }
72
73 pub fn path(&self) -> &str {
74 &self.path
75 }
76
77 pub async fn save(&self, root: &str, result: &BuildResult) -> Result<(), LLMYError> {
78 let graph_json = serde_json::to_string(&result.graph)
79 .map_err(|e| eyre!("failed to serialize code graph: {}", e))?;
80 sqlx::query(
81 "INSERT INTO codegraph_snapshot (root, fingerprint, graph_json, files, parse_errors, created_at) \
82 VALUES (?, ?, ?, ?, ?, ?) \
83 ON CONFLICT(root) DO UPDATE SET \
84 fingerprint = excluded.fingerprint, graph_json = excluded.graph_json, \
85 files = excluded.files, parse_errors = excluded.parse_errors, created_at = excluded.created_at",
86 )
87 .bind(root)
88 .bind(&result.fingerprint)
89 .bind(graph_json)
90 .bind(result.files.len() as i64)
91 .bind(result.total_parse_errors() as i64)
92 .bind(chrono::Utc::now().to_rfc3339())
93 .execute(&self.pool)
94 .await
95 .map_err(|e| eyre!("failed to save codegraph snapshot: {}", e))?;
96 Ok(())
97 }
98
99 pub async fn load_fresh(
102 &self,
103 root: &str,
104 fingerprint: &str,
105 ) -> Result<Option<CodeGraph>, LLMYError> {
106 let row =
107 sqlx::query("SELECT fingerprint, graph_json FROM codegraph_snapshot WHERE root = ?")
108 .bind(root)
109 .fetch_optional(&self.pool)
110 .await
111 .map_err(|e| eyre!("failed to load codegraph snapshot: {}", e))?;
112 let Some(row) = row else {
113 return Ok(None);
114 };
115 let stored_fingerprint: String = row.try_get("fingerprint").map_err(|e| eyre!("{}", e))?;
116 if stored_fingerprint != fingerprint {
117 tracing::info!("codegraph cache for {} is stale, rebuilding", root);
118 return Ok(None);
119 }
120 let graph_json: String = row.try_get("graph_json").map_err(|e| eyre!("{}", e))?;
121 let graph = serde_json::from_str::<CodeGraph>(&graph_json)
122 .map_err(|e| eyre!("failed to deserialize cached code graph: {}", e))?;
123 Ok(Some(graph))
124 }
125}