Skip to main content

mana_core/sqlite/
mod.rs

1//! Rebuildable SQLite index for canonical mana unit files.
2//!
3//! This module owns only derived state. Canonical mana data remains in the
4//! human-editable Markdown/YAML unit files; the SQLite database can be deleted
5//! and rebuilt from those files.
6
7mod freshness;
8mod query;
9mod rebuild;
10mod schema;
11
12use std::fs;
13use std::path::{Path, PathBuf};
14
15use anyhow::{anyhow, Context, Result};
16use rusqlite::{params, Connection, OptionalExtension};
17
18pub use freshness::{
19    source_file_metadata, Freshness, SourceFileKind, SourceFileMetadata, SourceFileStatus,
20};
21pub use rebuild::RebuildReport;
22
23pub const SCHEMA_VERSION: i64 = 2;
24const INDEX_FILENAME: &str = "index.sqlite";
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct DiagnosticRow {
28    pub severity: String,
29    pub kind: String,
30    pub source_path: Option<String>,
31    pub unit_id: Option<String>,
32    pub field: Option<String>,
33    pub message: String,
34}
35
36pub struct Index {
37    conn: Connection,
38}
39
40impl Index {
41    pub fn open(mana_dir: &Path) -> Result<Self> {
42        fs::create_dir_all(mana_dir)
43            .with_context(|| format!("failed to create mana dir: {}", mana_dir.display()))?;
44        let db_path = database_path(mana_dir);
45        let conn = Connection::open(&db_path)
46            .with_context(|| format!("failed to open SQLite index: {}", db_path.display()))?;
47        let index = Self { conn };
48        index.initialize(mana_dir)?;
49        Ok(index)
50    }
51
52    pub fn rebuild(mana_dir: &Path) -> Result<RebuildReport> {
53        let mut index = Self::open(mana_dir)?;
54        index.rebuild_from_canonical_files(mana_dir)
55    }
56
57    pub fn database_path(mana_dir: &Path) -> PathBuf {
58        database_path(mana_dir)
59    }
60
61    pub fn schema_version(&self) -> Result<i64> {
62        let version = self
63            .get_meta("schema_version")?
64            .ok_or_else(|| anyhow!("SQLite index schema_version metadata is missing"))?;
65        version
66            .parse::<i64>()
67            .context("SQLite index schema_version metadata is invalid")
68    }
69
70    pub fn is_stale(&self) -> Result<bool> {
71        Ok(self.get_meta("stale")?.is_some_and(|value| value == "true"))
72    }
73
74    pub fn mark_stale(&self, reason: &str) -> Result<()> {
75        self.set_meta("stale", "true")?;
76        self.set_meta("stale_reason", reason)
77    }
78
79    pub fn mark_fresh(&self) -> Result<()> {
80        self.set_meta("stale", "false")?;
81        self.set_meta("stale_reason", "")
82    }
83
84    pub fn record_source_file(&self, metadata: &SourceFileMetadata) -> Result<()> {
85        freshness::record_source_file(&self.conn, metadata)
86    }
87
88    pub fn source_freshness(
89        &self,
90        path: &str,
91        hash: Option<&str>,
92        mtime: Option<i64>,
93        size: Option<i64>,
94    ) -> Result<Freshness> {
95        freshness::source_freshness(&self.conn, path, hash, mtime, size)
96    }
97
98    pub fn source_status(&self, path: &str) -> Result<Option<String>> {
99        self.conn
100            .query_row(
101                "SELECT status FROM source_files WHERE path = ?1",
102                [path],
103                |row| row.get(0),
104            )
105            .optional()
106            .with_context(|| format!("failed to read source status: {path}"))
107    }
108
109    pub fn unit_exists(&self, id: &str) -> Result<bool> {
110        let count: i64 =
111            self.conn
112                .query_row("SELECT COUNT(*) FROM units WHERE id = ?1", [id], |row| {
113                    row.get(0)
114                })?;
115        Ok(count > 0)
116    }
117
118    pub fn diagnostic_count(&self) -> Result<usize> {
119        let count: i64 =
120            self.conn
121                .query_row("SELECT COUNT(*) FROM index_diagnostics", [], |row| {
122                    row.get(0)
123                })?;
124        usize::try_from(count).context("diagnostic count overflow")
125    }
126
127    pub fn diagnostics(&self) -> Result<Vec<DiagnosticRow>> {
128        let mut statement = self.conn.prepare(
129            "SELECT severity, kind, source_path, unit_id, field, message FROM index_diagnostics ORDER BY id",
130        )?;
131        let rows = statement.query_map([], |row| {
132            Ok(DiagnosticRow {
133                severity: row.get(0)?,
134                kind: row.get(1)?,
135                source_path: row.get(2)?,
136                unit_id: row.get(3)?,
137                field: row.get(4)?,
138                message: row.get(5)?,
139            })
140        })?;
141
142        rows.collect::<rusqlite::Result<Vec<_>>>()
143            .context("failed to collect SQLite diagnostics")
144    }
145
146    fn initialize(&self, mana_dir: &Path) -> Result<()> {
147        self.conn.execute_batch(schema::SCHEMA_SQL)?;
148        self.apply_lightweight_migrations()?;
149        self.set_meta("schema_version", &SCHEMA_VERSION.to_string())?;
150        self.set_meta("mana_root", &mana_dir.display().to_string())?;
151        self.set_meta("stale", "false")?;
152        self.set_meta("stale_reason", "")?;
153        Ok(())
154    }
155
156    fn apply_lightweight_migrations(&self) -> Result<()> {
157        let has_handle_column: bool = self.conn.query_row(
158            "SELECT EXISTS(SELECT 1 FROM pragma_table_info('units') WHERE name = 'handle')",
159            [],
160            |row| row.get(0),
161        )?;
162        if !has_handle_column {
163            self.conn
164                .execute("ALTER TABLE units ADD COLUMN handle TEXT", [])?;
165        }
166        Ok(())
167    }
168
169    fn get_meta(&self, key: &str) -> Result<Option<String>> {
170        self.conn
171            .query_row(
172                "SELECT value FROM index_meta WHERE key = ?1",
173                [key],
174                |row| row.get(0),
175            )
176            .optional()
177            .with_context(|| format!("failed to read SQLite index metadata: {key}"))
178    }
179
180    fn set_meta(&self, key: &str, value: &str) -> Result<()> {
181        self.conn.execute(
182            r#"
183            INSERT INTO index_meta (key, value) VALUES (?1, ?2)
184            ON CONFLICT(key) DO UPDATE SET value = excluded.value
185            "#,
186            params![key, value],
187        )?;
188        Ok(())
189    }
190
191    pub(crate) fn connection(&self) -> &Connection {
192        &self.conn
193    }
194
195    pub(crate) fn connection_mut(&mut self) -> &mut Connection {
196        &mut self.conn
197    }
198}
199
200pub(crate) fn timestamp_now() -> String {
201    chrono::Utc::now().to_rfc3339()
202}
203
204pub fn database_path(mana_dir: &Path) -> PathBuf {
205    mana_dir.join(INDEX_FILENAME)
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::unit::{Status, Unit};
212    use std::fs;
213    use tempfile::tempdir;
214
215    #[test]
216    fn initializes_schema_and_metadata() {
217        let dir = tempdir().unwrap();
218        let index = Index::open(dir.path()).unwrap();
219
220        assert_eq!(index.schema_version().unwrap(), SCHEMA_VERSION);
221        assert!(!index.is_stale().unwrap());
222        assert!(Index::database_path(dir.path()).exists());
223    }
224
225    #[test]
226    fn marks_index_stale_and_fresh() {
227        let dir = tempdir().unwrap();
228        let index = Index::open(dir.path()).unwrap();
229
230        index.mark_stale("test failure").unwrap();
231        assert!(index.is_stale().unwrap());
232
233        index.mark_fresh().unwrap();
234        assert!(!index.is_stale().unwrap());
235    }
236
237    #[test]
238    fn records_source_metadata_and_detects_freshness() {
239        let dir = tempdir().unwrap();
240        let unit_path = dir.path().join("1-test.md");
241        fs::write(&unit_path, "---\nid: '1'\ntitle: Test\n---\n").unwrap();
242
243        let index = Index::open(dir.path()).unwrap();
244        let metadata = source_file_metadata(&unit_path, Some("1".to_string())).unwrap();
245        index.record_source_file(&metadata).unwrap();
246
247        assert_eq!(
248            index
249                .source_freshness(
250                    &metadata.path,
251                    metadata.hash.as_deref(),
252                    metadata.mtime,
253                    metadata.size,
254                )
255                .unwrap(),
256            Freshness::Fresh
257        );
258        assert_eq!(
259            index
260                .source_freshness(
261                    &metadata.path,
262                    Some("different"),
263                    metadata.mtime,
264                    metadata.size
265                )
266                .unwrap(),
267            Freshness::Stale
268        );
269        assert_eq!(
270            index
271                .source_freshness("missing.md", None, None, None)
272                .unwrap(),
273            Freshness::Missing
274        );
275    }
276    #[test]
277    fn rebuilds_valid_units_and_child_tables() {
278        let dir = tempdir().unwrap();
279        let unit_path = dir.path().join("1-test.md");
280        fs::write(
281            &unit_path,
282            r#"---
283id: 1
284title: Test unit
285status: open
286priority: 2
287kind: task
288created_at: "2026-01-01T00:00:00Z"
289updated_at: "2026-01-01T00:00:00Z"
290labels: [alpha, beta]
291paths: [src/lib.rs]
292dependencies: [0]
293produces: [artifact-a]
294requires: [artifact-b]
295decisions:
296  - Choose SQLite as derived index
297---
298Human-readable body.
299"#,
300        )
301        .unwrap();
302
303        let mut index = Index::open(dir.path()).unwrap();
304        let report = index.rebuild_from_canonical_files(dir.path()).unwrap();
305
306        assert_eq!(report.valid_units, 1);
307        assert_eq!(report.invalid_files, 0);
308        assert!(index.unit_exists("1").unwrap());
309        assert_eq!(index.diagnostic_count().unwrap(), 0);
310        assert_eq!(
311            index
312                .source_status(unit_path.display().to_string().as_str())
313                .unwrap(),
314            Some("valid".to_string())
315        );
316    }
317
318    #[test]
319    fn rebuild_records_invalid_yaml_without_valid_unit_row() {
320        let dir = tempdir().unwrap();
321        let unit_path = dir.path().join("1-bad.md");
322        fs::write(
323            &unit_path,
324            "---\nid: 1\ntitle: [unterminated\n---\nBroken body.\n",
325        )
326        .unwrap();
327
328        let mut index = Index::open(dir.path()).unwrap();
329        let report = index.rebuild_from_canonical_files(dir.path()).unwrap();
330
331        assert_eq!(report.valid_units, 0);
332        assert_eq!(report.invalid_files, 1);
333        assert!(!index.unit_exists("1").unwrap());
334        assert_eq!(index.diagnostic_count().unwrap(), 1);
335        assert_eq!(
336            index
337                .source_status(unit_path.display().to_string().as_str())
338                .unwrap(),
339            Some("invalid_parse".to_string())
340        );
341    }
342
343    #[test]
344    fn rebuild_removes_stale_rows_after_source_becomes_invalid() {
345        let dir = tempdir().unwrap();
346        let unit_path = dir.path().join("1-test.md");
347        let mut unit = Unit::new("1".to_string(), "Test".to_string());
348        unit.status = Status::Open;
349        unit.to_file(&unit_path).unwrap();
350
351        let mut index = Index::open(dir.path()).unwrap();
352        let first_report = index.rebuild_from_canonical_files(dir.path()).unwrap();
353        assert_eq!(first_report.valid_units, 1);
354        assert!(index.unit_exists("1").unwrap());
355
356        fs::write(
357            &unit_path,
358            "---\nid: 1\ntitle: Test\ncreated_at: \"not-a-date\"\nupdated_at: \"2026-01-01T00:00:00Z\"\n---\n",
359        )
360        .unwrap();
361        let second_report = index.rebuild_from_canonical_files(dir.path()).unwrap();
362
363        assert_eq!(second_report.valid_units, 0);
364        assert_eq!(second_report.invalid_files, 1);
365        assert!(!index.unit_exists("1").unwrap());
366        assert_eq!(index.diagnostic_count().unwrap(), 1);
367    }
368}