1use 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
26pub struct IndexDb {
28 conn: Connection,
29}
30
31impl IndexDb {
32 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 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 pub fn connection(&self) -> &Connection {
54 &self.conn
55 }
56
57 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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(¬e).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(¬e).unwrap();
817 note.title = "Updated Title".to_string();
818 let id2 = db.upsert_note(¬e).unwrap();
819
820 assert_eq!(id1, id2); 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(¬e1).unwrap();
851 let id2 = db.insert_note(¬e2).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(¬e1).unwrap();
879 let id2 = db.insert_note(¬e2).unwrap();
880
881 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}