1mod 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}