Skip to main content

mdvault_core/index/
db.rs

1//! Database connection and operations.
2
3use std::path::Path;
4
5use rusqlite::{Connection, OptionalExtension, params};
6use thiserror::Error;
7
8use super::schema::{SchemaError, init_schema};
9use super::types::{IndexedLink, IndexedNote, LinkType, NoteQuery, NoteType};
10
11#[derive(Debug, Error)]
12pub enum IndexError {
13    #[error("Database error: {0}")]
14    Database(#[from] rusqlite::Error),
15
16    #[error("Schema error: {0}")]
17    Schema(#[from] SchemaError),
18
19    #[error("Note not found: {0}")]
20    NoteNotFound(String),
21
22    #[error("Invalid data: {0}")]
23    InvalidData(String),
24}
25
26/// Vault index database handle.
27pub struct IndexDb {
28    conn: Connection,
29}
30
31impl IndexDb {
32    /// Open or create an index database at the given path.
33    pub fn open(path: &Path) -> Result<Self, IndexError> {
34        let conn = Connection::open(path)?;
35        conn.execute_batch(
36            "PRAGMA journal_mode = WAL;
37             PRAGMA foreign_keys = ON;
38             PRAGMA busy_timeout = 5000;",
39        )?;
40        init_schema(&conn)?;
41        Ok(Self { conn })
42    }
43
44    /// Create an in-memory database (for testing).
45    pub fn open_in_memory() -> Result<Self, IndexError> {
46        let conn = Connection::open_in_memory()?;
47        conn.execute_batch("PRAGMA foreign_keys = ON;")?;
48        init_schema(&conn)?;
49        Ok(Self { conn })
50    }
51
52    /// Get the underlying connection (for transactions).
53    pub fn connection(&self) -> &Connection {
54        &self.conn
55    }
56
57    // ─────────────────────────────────────────────────────────────────────────
58    // Notes CRUD
59    // ─────────────────────────────────────────────────────────────────────────
60
61    /// Insert a new note into the index.
62    pub fn insert_note(&self, note: &IndexedNote) -> Result<i64, IndexError> {
63        self.conn.execute(
64            "INSERT INTO notes (path, note_type, title, created_at, modified_at, frontmatter_json, content_hash)
65             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
66            params![
67                note.path.to_string_lossy(),
68                note.note_type.as_str(),
69                note.title,
70                note.created.map(|d| d.to_rfc3339()),
71                note.modified.to_rfc3339(),
72                note.frontmatter_json,
73                note.content_hash,
74            ],
75        )?;
76        Ok(self.conn.last_insert_rowid())
77    }
78
79    /// Update an existing note in the index.
80    pub fn update_note(&self, note: &IndexedNote) -> Result<(), IndexError> {
81        let id = note.id.ok_or_else(|| {
82            IndexError::InvalidData("Note must have an ID for update".to_string())
83        })?;
84
85        let rows = self.conn.execute(
86            "UPDATE notes SET
87                path = ?1, note_type = ?2, title = ?3,
88                created_at = ?4, modified_at = ?5,
89                frontmatter_json = ?6, content_hash = ?7
90             WHERE id = ?8",
91            params![
92                note.path.to_string_lossy(),
93                note.note_type.as_str(),
94                note.title,
95                note.created.map(|d| d.to_rfc3339()),
96                note.modified.to_rfc3339(),
97                note.frontmatter_json,
98                note.content_hash,
99                id,
100            ],
101        )?;
102
103        if rows == 0 {
104            return Err(IndexError::NoteNotFound(format!("ID {}", id)));
105        }
106        Ok(())
107    }
108
109    /// Upsert a note (insert or update based on path).
110    pub fn upsert_note(&self, note: &IndexedNote) -> Result<i64, IndexError> {
111        self.conn.execute(
112            "INSERT INTO notes (path, note_type, title, created_at, modified_at, frontmatter_json, content_hash)
113             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
114             ON CONFLICT(path) DO UPDATE SET
115                note_type = excluded.note_type,
116                title = excluded.title,
117                created_at = excluded.created_at,
118                modified_at = excluded.modified_at,
119                frontmatter_json = excluded.frontmatter_json,
120                content_hash = excluded.content_hash",
121            params![
122                note.path.to_string_lossy(),
123                note.note_type.as_str(),
124                note.title,
125                note.created.map(|d| d.to_rfc3339()),
126                note.modified.to_rfc3339(),
127                note.frontmatter_json,
128                note.content_hash,
129            ],
130        )?;
131
132        // Get the ID (either new or existing)
133        let id: i64 = self.conn.query_row(
134            "SELECT id FROM notes WHERE path = ?1",
135            [note.path.to_string_lossy()],
136            |row| row.get(0),
137        )?;
138
139        Ok(id)
140    }
141
142    /// Get a note by its path.
143    pub fn get_note_by_path(
144        &self,
145        path: &Path,
146    ) -> Result<Option<IndexedNote>, IndexError> {
147        self.conn
148            .query_row(
149                "SELECT id, path, note_type, title, created_at, modified_at, frontmatter_json, content_hash
150                 FROM notes WHERE path = ?1",
151                [path.to_string_lossy()],
152                Self::row_to_note,
153            )
154            .optional()
155            .map_err(Into::into)
156    }
157
158    /// Get a note by its ID.
159    pub fn get_note_by_id(&self, id: i64) -> Result<Option<IndexedNote>, IndexError> {
160        self.conn
161            .query_row(
162                "SELECT id, path, note_type, title, created_at, modified_at, frontmatter_json, content_hash
163                 FROM notes WHERE id = ?1",
164                [id],
165                Self::row_to_note,
166            )
167            .optional()
168            .map_err(Into::into)
169    }
170
171    /// Query notes with filters.
172    pub fn query_notes(&self, query: &NoteQuery) -> Result<Vec<IndexedNote>, IndexError> {
173        let mut sql = String::from(
174            "SELECT id, path, note_type, title, created_at, modified_at, frontmatter_json, content_hash
175             FROM notes WHERE 1=1",
176        );
177        let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
178
179        if let Some(note_type) = &query.note_type {
180            sql.push_str(" AND note_type = ?");
181            params_vec.push(Box::new(note_type.as_str().to_string()));
182        }
183
184        if let Some(prefix) = &query.path_prefix {
185            sql.push_str(" AND path LIKE ?");
186            params_vec.push(Box::new(format!("{}%", prefix.to_string_lossy())));
187        }
188
189        if let Some(after) = &query.modified_after {
190            sql.push_str(" AND modified_at >= ?");
191            params_vec.push(Box::new(after.to_rfc3339()));
192        }
193
194        if let Some(before) = &query.modified_before {
195            sql.push_str(" AND modified_at <= ?");
196            params_vec.push(Box::new(before.to_rfc3339()));
197        }
198
199        sql.push_str(" ORDER BY modified_at DESC");
200
201        if let Some(limit) = query.limit {
202            sql.push_str(&format!(" LIMIT {}", limit));
203        }
204
205        if let Some(offset) = query.offset {
206            sql.push_str(&format!(" OFFSET {}", offset));
207        }
208
209        let params_refs: Vec<&dyn rusqlite::ToSql> =
210            params_vec.iter().map(|p| p.as_ref()).collect();
211
212        let mut stmt = self.conn.prepare(&sql)?;
213        let notes = stmt
214            .query_map(params_refs.as_slice(), Self::row_to_note)?
215            .filter_map(|r| r.ok())
216            .collect();
217
218        Ok(notes)
219    }
220
221    /// Delete a note by path (also deletes associated links via CASCADE).
222    pub fn delete_note(&self, path: &Path) -> Result<bool, IndexError> {
223        let rows = self
224            .conn
225            .execute("DELETE FROM notes WHERE path = ?1", [path.to_string_lossy()])?;
226        Ok(rows > 0)
227    }
228
229    /// Get content hash for a note path (for change detection).
230    pub fn get_content_hash(&self, path: &Path) -> Result<Option<String>, IndexError> {
231        self.conn
232            .query_row(
233                "SELECT content_hash FROM notes WHERE path = ?1",
234                [path.to_string_lossy()],
235                |row| row.get(0),
236            )
237            .optional()
238            .map_err(Into::into)
239    }
240
241    /// Get all indexed note paths (for detecting deletions during incremental reindex).
242    pub fn get_all_paths(&self) -> Result<Vec<std::path::PathBuf>, IndexError> {
243        let mut stmt = self.conn.prepare("SELECT path FROM notes")?;
244        let paths = stmt
245            .query_map([], |row| {
246                let path_str: String = row.get(0)?;
247                Ok(std::path::PathBuf::from(path_str))
248            })?
249            .filter_map(|r| r.ok())
250            .collect();
251        Ok(paths)
252    }
253
254    fn row_to_note(row: &rusqlite::Row) -> Result<IndexedNote, rusqlite::Error> {
255        let path_str: String = row.get(1)?;
256        let type_str: String = row.get(2)?;
257        let created_str: Option<String> = row.get(4)?;
258        let modified_str: String = row.get(5)?;
259
260        Ok(IndexedNote {
261            id: Some(row.get(0)?),
262            path: path_str.into(),
263            note_type: type_str.parse().unwrap(),
264            title: row.get(3)?,
265            created: created_str.and_then(|s| {
266                chrono::DateTime::parse_from_rfc3339(&s)
267                    .ok()
268                    .map(|d| d.with_timezone(&chrono::Utc))
269            }),
270            modified: chrono::DateTime::parse_from_rfc3339(&modified_str)
271                .map(|d| d.with_timezone(&chrono::Utc))
272                .unwrap_or_else(|_| chrono::Utc::now()),
273            frontmatter_json: row.get(6)?,
274            content_hash: row.get(7)?,
275        })
276    }
277
278    // ─────────────────────────────────────────────────────────────────────────
279    // Links CRUD
280    // ─────────────────────────────────────────────────────────────────────────
281
282    /// Insert a link between notes.
283    pub fn insert_link(&self, link: &IndexedLink) -> Result<i64, IndexError> {
284        self.conn.execute(
285            "INSERT INTO links (source_id, target_id, target_path, link_text, link_type, context, line_number)
286             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
287            params![
288                link.source_id,
289                link.target_id,
290                link.target_path,
291                link.link_text,
292                link.link_type.as_str(),
293                link.context,
294                link.line_number,
295            ],
296        )?;
297        Ok(self.conn.last_insert_rowid())
298    }
299
300    /// Delete all links from a source note.
301    pub fn delete_links_from(&self, source_id: i64) -> Result<usize, IndexError> {
302        let rows =
303            self.conn.execute("DELETE FROM links WHERE source_id = ?1", [source_id])?;
304        Ok(rows)
305    }
306
307    /// Get outgoing links from a note.
308    pub fn get_outgoing_links(
309        &self,
310        source_id: i64,
311    ) -> Result<Vec<IndexedLink>, IndexError> {
312        let mut stmt = self.conn.prepare(
313            "SELECT id, source_id, target_id, target_path, link_text, link_type, context, line_number
314             FROM links WHERE source_id = ?1",
315        )?;
316
317        let links = stmt
318            .query_map([source_id], Self::row_to_link)?
319            .filter_map(|r| r.ok())
320            .collect();
321
322        Ok(links)
323    }
324
325    /// Get incoming links (backlinks) to a note.
326    pub fn get_backlinks(&self, target_id: i64) -> Result<Vec<IndexedLink>, IndexError> {
327        let mut stmt = self.conn.prepare(
328            "SELECT id, source_id, target_id, target_path, link_text, link_type, context, line_number
329             FROM links WHERE target_id = ?1",
330        )?;
331
332        let links = stmt
333            .query_map([target_id], Self::row_to_link)?
334            .filter_map(|r| r.ok())
335            .collect();
336
337        Ok(links)
338    }
339
340    /// Find orphan notes (no incoming links).
341    pub fn find_orphans(&self) -> Result<Vec<IndexedNote>, IndexError> {
342        let mut stmt = self.conn.prepare(
343            "SELECT n.id, n.path, n.note_type, n.title, n.created_at, n.modified_at, n.frontmatter_json, n.content_hash
344             FROM notes n
345             LEFT JOIN links l ON l.target_id = n.id
346             WHERE l.id IS NULL",
347        )?;
348
349        let notes =
350            stmt.query_map([], Self::row_to_note)?.filter_map(|r| r.ok()).collect();
351
352        Ok(notes)
353    }
354
355    /// Resolve target_id for links by matching target_path to notes.
356    /// Returns the number of links that were successfully resolved.
357    pub fn resolve_link_targets(&self) -> Result<usize, IndexError> {
358        self.conn.execute(
359            "UPDATE links SET target_id = (
360                SELECT n.id FROM notes n
361                WHERE links.target_path = n.path
362                   OR links.target_path || '.md' = n.path
363                   OR links.target_path = REPLACE(n.path, '.md', '')
364             )
365             WHERE target_id IS NULL",
366            [],
367        )?;
368
369        // Count how many links now have a resolved target
370        let resolved: i64 = self.conn.query_row(
371            "SELECT COUNT(*) FROM links WHERE target_id IS NOT NULL",
372            [],
373            |row| row.get(0),
374        )?;
375        Ok(resolved as usize)
376    }
377
378    /// Count links that have no resolved target (broken links).
379    pub fn count_broken_links(&self) -> Result<i64, IndexError> {
380        let count: i64 = self.conn.query_row(
381            "SELECT COUNT(*) FROM links WHERE target_id IS NULL",
382            [],
383            |row| row.get(0),
384        )?;
385        Ok(count)
386    }
387
388    fn row_to_link(row: &rusqlite::Row) -> Result<IndexedLink, rusqlite::Error> {
389        let type_str: String = row.get(5)?;
390        Ok(IndexedLink {
391            id: Some(row.get(0)?),
392            source_id: row.get(1)?,
393            target_id: row.get(2)?,
394            target_path: row.get(3)?,
395            link_text: row.get(4)?,
396            link_type: LinkType::parse(&type_str).unwrap_or(LinkType::Wikilink),
397            context: row.get(6)?,
398            line_number: row.get(7)?,
399        })
400    }
401
402    // ─────────────────────────────────────────────────────────────────────────
403    // Statistics
404    // ─────────────────────────────────────────────────────────────────────────
405
406    /// Get count of notes by type.
407    pub fn count_by_type(&self) -> Result<Vec<(NoteType, i64)>, IndexError> {
408        let mut stmt = self
409            .conn
410            .prepare("SELECT note_type, COUNT(*) FROM notes GROUP BY note_type")?;
411
412        let counts = stmt
413            .query_map([], |row| {
414                let type_str: String = row.get(0)?;
415                let count: i64 = row.get(1)?;
416                Ok((type_str.parse().unwrap(), count))
417            })?
418            .filter_map(|r| r.ok())
419            .collect();
420
421        Ok(counts)
422    }
423
424    /// Get total note count.
425    pub fn count_notes(&self) -> Result<i64, IndexError> {
426        let count: i64 =
427            self.conn.query_row("SELECT COUNT(*) FROM notes", [], |row| row.get(0))?;
428        Ok(count)
429    }
430
431    /// Get total link count.
432    pub fn count_links(&self) -> Result<i64, IndexError> {
433        let count: i64 =
434            self.conn.query_row("SELECT COUNT(*) FROM links", [], |row| row.get(0))?;
435        Ok(count)
436    }
437
438    /// Clear all data from the index (for full reindex).
439    pub fn clear_all(&self) -> Result<(), IndexError> {
440        self.conn.execute_batch(
441            "DELETE FROM links;
442             DELETE FROM temporal_activity;
443             DELETE FROM activity_summary;
444             DELETE FROM note_cooccurrence;
445             DELETE FROM notes;",
446        )?;
447        Ok(())
448    }
449
450    // ─────────────────────────────────────────────────────────────────────────
451    // Derived Index Operations
452    // ─────────────────────────────────────────────────────────────────────────
453
454    /// Clear derived tables (temporal_activity, activity_summary, note_cooccurrence).
455    pub fn clear_derived_tables(&self) -> Result<(), IndexError> {
456        self.conn.execute_batch(
457            "DELETE FROM temporal_activity;
458             DELETE FROM activity_summary;
459             DELETE FROM note_cooccurrence;",
460        )?;
461        Ok(())
462    }
463
464    /// Get notes filtered by type.
465    pub fn get_notes_by_type(
466        &self,
467        type_str: &str,
468    ) -> Result<Vec<IndexedNote>, IndexError> {
469        let mut stmt = self.conn.prepare(
470            "SELECT id, path, note_type, title, created_at, modified_at, frontmatter_json, content_hash
471             FROM notes WHERE note_type = ?1",
472        )?;
473
474        let notes = stmt
475            .query_map([type_str], Self::row_to_note)?
476            .filter_map(|r| r.ok())
477            .collect();
478
479        Ok(notes)
480    }
481
482    /// Count temporal activity records.
483    pub fn count_temporal_activity(&self) -> Result<i64, IndexError> {
484        let count: i64 =
485            self.conn.query_row("SELECT COUNT(*) FROM temporal_activity", [], |row| {
486                row.get(0)
487            })?;
488        Ok(count)
489    }
490
491    /// Insert a temporal activity record.
492    pub fn insert_temporal_activity(
493        &self,
494        note_id: i64,
495        daily_id: i64,
496        activity_date: &str,
497        context: Option<&str>,
498    ) -> Result<i64, IndexError> {
499        self.conn.execute(
500            "INSERT INTO temporal_activity (note_id, daily_id, activity_date, context)
501             VALUES (?1, ?2, ?3, ?4)",
502            params![note_id, daily_id, activity_date, context],
503        )?;
504        Ok(self.conn.last_insert_rowid())
505    }
506
507    /// Aggregate activity data for computing summaries.
508    ///
509    /// Returns aggregated activity counts for each note that has temporal activity.
510    pub fn aggregate_activity(
511        &self,
512        thirty_days_ago: &str,
513        ninety_days_ago: &str,
514    ) -> Result<Vec<super::types::AggregateActivity>, IndexError> {
515        let mut stmt = self.conn.prepare(
516            "SELECT
517                note_id,
518                MAX(activity_date) as last_seen,
519                SUM(CASE WHEN activity_date >= ?1 THEN 1 ELSE 0 END) as count_30d,
520                SUM(CASE WHEN activity_date >= ?2 THEN 1 ELSE 0 END) as count_90d
521             FROM temporal_activity
522             GROUP BY note_id",
523        )?;
524
525        let results = stmt
526            .query_map([thirty_days_ago, ninety_days_ago], |row| {
527                Ok(super::types::AggregateActivity {
528                    note_id: row.get(0)?,
529                    last_seen: row.get(1)?,
530                    access_count_30d: row.get(2)?,
531                    access_count_90d: row.get(3)?,
532                })
533            })?
534            .filter_map(|r| r.ok())
535            .collect();
536
537        Ok(results)
538    }
539
540    /// Upsert an activity summary for a note.
541    pub fn upsert_activity_summary(
542        &self,
543        note_id: i64,
544        last_seen: Option<&str>,
545        access_count_30d: i32,
546        access_count_90d: i32,
547        staleness_score: f64,
548    ) -> Result<(), IndexError> {
549        self.conn.execute(
550            "INSERT INTO activity_summary (note_id, last_seen, access_count_30d, access_count_90d, staleness_score)
551             VALUES (?1, ?2, ?3, ?4, ?5)
552             ON CONFLICT(note_id) DO UPDATE SET
553                last_seen = excluded.last_seen,
554                access_count_30d = excluded.access_count_30d,
555                access_count_90d = excluded.access_count_90d,
556                staleness_score = excluded.staleness_score",
557            params![note_id, last_seen, access_count_30d, access_count_90d, staleness_score],
558        )?;
559        Ok(())
560    }
561
562    /// Compute cooccurrence pairs from temporal activity.
563    ///
564    /// Finds pairs of notes that are referenced in the same daily notes.
565    pub fn compute_cooccurrence_pairs(
566        &self,
567    ) -> Result<Vec<super::types::CooccurrencePair>, IndexError> {
568        let mut stmt = self.conn.prepare(
569            "SELECT
570                a.note_id as note_a,
571                b.note_id as note_b,
572                COUNT(DISTINCT a.daily_id) as shared_count,
573                MAX(a.activity_date) as most_recent
574             FROM temporal_activity a
575             JOIN temporal_activity b ON a.daily_id = b.daily_id
576             WHERE a.note_id < b.note_id
577             GROUP BY a.note_id, b.note_id
578             HAVING shared_count > 0",
579        )?;
580
581        let pairs = stmt
582            .query_map([], |row| {
583                Ok(super::types::CooccurrencePair {
584                    note_a_id: row.get(0)?,
585                    note_b_id: row.get(1)?,
586                    shared_count: row.get(2)?,
587                    most_recent: row.get(3)?,
588                })
589            })?
590            .filter_map(|r| r.ok())
591            .collect();
592
593        Ok(pairs)
594    }
595
596    /// Upsert a note cooccurrence record.
597    pub fn upsert_cooccurrence(
598        &self,
599        note_a: i64,
600        note_b: i64,
601        shared_count: i32,
602        most_recent: Option<&str>,
603    ) -> Result<(), IndexError> {
604        self.conn.execute(
605            "INSERT INTO note_cooccurrence (note_a_id, note_b_id, shared_daily_count, most_recent)
606             VALUES (?1, ?2, ?3, ?4)
607             ON CONFLICT(note_a_id, note_b_id) DO UPDATE SET
608                shared_daily_count = excluded.shared_daily_count,
609                most_recent = excluded.most_recent",
610            params![note_a, note_b, shared_count, most_recent],
611        )?;
612        Ok(())
613    }
614
615    /// Get activity summary for a note.
616    pub fn get_activity_summary(
617        &self,
618        note_id: i64,
619    ) -> Result<Option<super::types::ActivitySummary>, IndexError> {
620        self.conn
621            .query_row(
622                "SELECT note_id, last_seen, access_count_30d, access_count_90d, staleness_score
623                 FROM activity_summary WHERE note_id = ?1",
624                [note_id],
625                |row| {
626                    let last_seen_str: Option<String> = row.get(1)?;
627                    Ok(super::types::ActivitySummary {
628                        note_id: row.get(0)?,
629                        last_seen: last_seen_str.and_then(|s| {
630                            chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()
631                        }),
632                        access_count_30d: row.get::<_, i32>(2)? as u32,
633                        access_count_90d: row.get::<_, i32>(3)? as u32,
634                        staleness_score: row.get(4)?,
635                    })
636                },
637            )
638            .optional()
639            .map_err(Into::into)
640    }
641
642    /// Get cooccurrent notes for a given note.
643    ///
644    /// Returns notes that frequently appear together with the given note in dailies.
645    pub fn get_cooccurrent_notes(
646        &self,
647        note_id: i64,
648        limit: u32,
649    ) -> Result<Vec<(IndexedNote, i32)>, IndexError> {
650        let mut stmt = self.conn.prepare(
651            "SELECT n.id, n.path, n.note_type, n.title, n.created_at, n.modified_at,
652                    n.frontmatter_json, n.content_hash, c.shared_daily_count
653             FROM note_cooccurrence c
654             JOIN notes n ON (
655                 CASE WHEN c.note_a_id = ?1 THEN c.note_b_id ELSE c.note_a_id END = n.id
656             )
657             WHERE c.note_a_id = ?1 OR c.note_b_id = ?1
658             ORDER BY c.shared_daily_count DESC
659             LIMIT ?2",
660        )?;
661
662        let results = stmt
663            .query_map(params![note_id, limit], |row| {
664                let note = Self::row_to_note(row)?;
665                let count: i32 = row.get(8)?;
666                Ok((note, count))
667            })?
668            .filter_map(|r| r.ok())
669            .collect();
670
671        Ok(results)
672    }
673
674    /// Get stale notes (high staleness score).
675    ///
676    /// Returns notes ordered by staleness score descending.
677    pub fn get_stale_notes(
678        &self,
679        min_staleness: f64,
680        note_type: Option<&str>,
681        limit: Option<u32>,
682    ) -> Result<Vec<(IndexedNote, f64)>, IndexError> {
683        let mut sql = String::from(
684            "SELECT n.id, n.path, n.note_type, n.title, n.created_at, n.modified_at,
685                    n.frontmatter_json, n.content_hash, s.staleness_score
686             FROM notes n
687             LEFT JOIN activity_summary s ON n.id = s.note_id
688             WHERE COALESCE(s.staleness_score, 1.0) >= ?1",
689        );
690
691        if note_type.is_some() {
692            sql.push_str(" AND n.note_type = ?2");
693        }
694
695        sql.push_str(" ORDER BY COALESCE(s.staleness_score, 1.0) DESC");
696
697        if let Some(limit) = limit {
698            sql.push_str(&format!(" LIMIT {}", limit));
699        }
700
701        let mut stmt = self.conn.prepare(&sql)?;
702
703        let results = if let Some(nt) = note_type {
704            stmt.query_map(params![min_staleness, nt], |row| {
705                let note = Self::row_to_note(row)?;
706                let staleness: Option<f64> = row.get(8)?;
707                Ok((note, staleness.unwrap_or(1.0)))
708            })?
709            .filter_map(|r| r.ok())
710            .collect()
711        } else {
712            stmt.query_map([min_staleness], |row| {
713                let note = Self::row_to_note(row)?;
714                let staleness: Option<f64> = row.get(8)?;
715                Ok((note, staleness.unwrap_or(1.0)))
716            })?
717            .filter_map(|r| r.ok())
718            .collect()
719        };
720
721        Ok(results)
722    }
723
724    /// Get notes not seen in a number of days.
725    pub fn get_notes_not_seen_in_days(
726        &self,
727        days: u32,
728        note_type: Option<&str>,
729        limit: Option<u32>,
730    ) -> Result<Vec<(IndexedNote, Option<String>)>, IndexError> {
731        let cutoff_date = (chrono::Utc::now() - chrono::Duration::days(days as i64))
732            .format("%Y-%m-%d")
733            .to_string();
734
735        let mut sql = String::from(
736            "SELECT n.id, n.path, n.note_type, n.title, n.created_at, n.modified_at,
737                    n.frontmatter_json, n.content_hash, s.last_seen
738             FROM notes n
739             LEFT JOIN activity_summary s ON n.id = s.note_id
740             WHERE s.last_seen IS NULL OR s.last_seen < ?1",
741        );
742
743        if note_type.is_some() {
744            sql.push_str(" AND n.note_type = ?2");
745        }
746
747        sql.push_str(" ORDER BY s.last_seen ASC NULLS FIRST");
748
749        if let Some(limit) = limit {
750            sql.push_str(&format!(" LIMIT {}", limit));
751        }
752
753        let mut stmt = self.conn.prepare(&sql)?;
754
755        let results = if let Some(nt) = note_type {
756            stmt.query_map(params![&cutoff_date, nt], |row| {
757                let note = Self::row_to_note(row)?;
758                let last_seen: Option<String> = row.get(8)?;
759                Ok((note, last_seen))
760            })?
761            .filter_map(|r| r.ok())
762            .collect()
763        } else {
764            stmt.query_map([&cutoff_date], |row| {
765                let note = Self::row_to_note(row)?;
766                let last_seen: Option<String> = row.get(8)?;
767                Ok((note, last_seen))
768            })?
769            .filter_map(|r| r.ok())
770            .collect()
771        };
772
773        Ok(results)
774    }
775}
776
777#[cfg(test)]
778mod tests {
779    use super::*;
780    use chrono::Utc;
781    use std::path::PathBuf;
782
783    fn sample_note(path: &str) -> IndexedNote {
784        IndexedNote {
785            id: None,
786            path: PathBuf::from(path),
787            note_type: NoteType::Zettel,
788            title: "Test Note".to_string(),
789            created: Some(Utc::now()),
790            modified: Utc::now(),
791            frontmatter_json: Some(r#"{"tags": ["test"]}"#.to_string()),
792            content_hash: "abc123".to_string(),
793        }
794    }
795
796    #[test]
797    fn test_insert_and_get_note() {
798        let db = IndexDb::open_in_memory().unwrap();
799        let note = sample_note("test/note.md");
800
801        let id = db.insert_note(&note).unwrap();
802        assert!(id > 0);
803
804        let retrieved = db.get_note_by_path(Path::new("test/note.md")).unwrap();
805        assert!(retrieved.is_some());
806        let retrieved = retrieved.unwrap();
807        assert_eq!(retrieved.title, "Test Note");
808        assert_eq!(retrieved.note_type, NoteType::Zettel);
809    }
810
811    #[test]
812    fn test_upsert_note() {
813        let db = IndexDb::open_in_memory().unwrap();
814        let mut note = sample_note("test/note.md");
815
816        let id1 = db.upsert_note(&note).unwrap();
817        note.title = "Updated Title".to_string();
818        let id2 = db.upsert_note(&note).unwrap();
819
820        assert_eq!(id1, id2); // Same ID after upsert
821
822        let retrieved = db.get_note_by_id(id1).unwrap().unwrap();
823        assert_eq!(retrieved.title, "Updated Title");
824    }
825
826    #[test]
827    fn test_query_by_type() {
828        let db = IndexDb::open_in_memory().unwrap();
829
830        let mut zettel = sample_note("knowledge/note1.md");
831        zettel.note_type = NoteType::Zettel;
832        db.insert_note(&zettel).unwrap();
833
834        let mut task = sample_note("tasks/task1.md");
835        task.note_type = NoteType::Task;
836        db.insert_note(&task).unwrap();
837
838        let query = NoteQuery { note_type: Some(NoteType::Zettel), ..Default::default() };
839        let results = db.query_notes(&query).unwrap();
840        assert_eq!(results.len(), 1);
841        assert_eq!(results[0].note_type, NoteType::Zettel);
842    }
843
844    #[test]
845    fn test_links() {
846        let db = IndexDb::open_in_memory().unwrap();
847
848        let note1 = sample_note("note1.md");
849        let note2 = sample_note("note2.md");
850        let id1 = db.insert_note(&note1).unwrap();
851        let id2 = db.insert_note(&note2).unwrap();
852
853        let link = IndexedLink {
854            id: None,
855            source_id: id1,
856            target_id: Some(id2),
857            target_path: "note2.md".to_string(),
858            link_text: Some("Note 2".to_string()),
859            link_type: LinkType::Wikilink,
860            context: None,
861            line_number: Some(10),
862        };
863        db.insert_link(&link).unwrap();
864
865        let outgoing = db.get_outgoing_links(id1).unwrap();
866        assert_eq!(outgoing.len(), 1);
867
868        let backlinks = db.get_backlinks(id2).unwrap();
869        assert_eq!(backlinks.len(), 1);
870    }
871
872    #[test]
873    fn test_orphans() {
874        let db = IndexDb::open_in_memory().unwrap();
875
876        let note1 = sample_note("note1.md");
877        let note2 = sample_note("note2.md");
878        let id1 = db.insert_note(&note1).unwrap();
879        let id2 = db.insert_note(&note2).unwrap();
880
881        // Link note1 -> note2, so note1 is orphan (no incoming)
882        let link = IndexedLink {
883            id: None,
884            source_id: id1,
885            target_id: Some(id2),
886            target_path: "note2.md".to_string(),
887            link_text: None,
888            link_type: LinkType::Wikilink,
889            context: None,
890            line_number: None,
891        };
892        db.insert_link(&link).unwrap();
893
894        let orphans = db.find_orphans().unwrap();
895        assert_eq!(orphans.len(), 1);
896        assert_eq!(orphans[0].path, PathBuf::from("note1.md"));
897    }
898}