1use rusqlite::{Connection, OptionalExtension, Result};
2
3#[derive(Debug, Clone)]
4pub struct LinkResult {
5 pub note_id: i64,
6 pub note_path: String,
7 pub note_title: String,
8 pub is_embed: bool,
9 pub alias: Option<String>,
10 pub heading_ref: Option<String>,
11 pub block_ref: Option<String>,
12}
13
14#[derive(Debug, Clone)]
16pub struct DiagnoseResult {
17 pub note_id: i64,
18 pub note_path: String,
19 pub note_title: String,
20 pub incoming_count: i64,
21 pub outgoing_count: i64,
22}
23
24#[derive(Debug, Clone)]
26pub struct BrokenLinkResult {
27 pub src_path: String,
28 pub src_title: String,
29 pub raw_link: String,
30 pub target: String,
31 pub status: String, pub candidates: Vec<String>, }
34
35pub fn diagnose_broken_links(conn: &Connection) -> Result<Vec<BrokenLinkResult>> {
39 let mut results = Vec::new();
40
41 let mut stmt = conn.prepare(
43 "SELECT
44 src.path as src_path,
45 src.title as src_title,
46 l.alias as raw_link,
47 l.dst_text as target
48 FROM links l
49 JOIN notes src ON l.src_note_id = src.id
50 WHERE l.dst_note_id IS NULL
51 ORDER BY src.path, l.dst_text",
52 )?;
53
54 let unresolved_iter = stmt.query_map([], |row| {
55 Ok(BrokenLinkResult {
56 src_path: row.get(0)?,
57 src_title: row.get(1)?,
58 raw_link: row.get::<_, Option<String>>(2)?.unwrap_or_default(),
59 target: row.get(3)?,
60 status: "unresolved".to_string(),
61 candidates: Vec::new(),
62 })
63 })?;
64
65 for result in unresolved_iter {
66 results.push(result?);
67 }
68
69 let mut stmt = conn.prepare(
72 "SELECT
73 l.id as link_id,
74 src.path as src_path,
75 src.title as src_title,
76 l.alias as raw_link,
77 l.dst_text as target
78 FROM links l
79 JOIN notes src ON l.src_note_id = src.id
80 WHERE l.dst_note_id IS NOT NULL",
81 )?;
82
83 let links_iter = stmt.query_map([], |row| {
84 Ok((
85 row.get::<_, i64>(0)?,
86 row.get::<_, String>(1)?,
87 row.get::<_, String>(2)?,
88 row.get::<_, Option<String>>(3)?.unwrap_or_default(),
89 row.get::<_, String>(4)?,
90 ))
91 })?;
92
93 let mut stmt_candidates =
95 conn.prepare("SELECT path FROM notes WHERE path = ?1 OR title = ?1 LIMIT 2")?;
96
97 for link_result in links_iter {
98 let (_link_id, src_path, src_title, raw_link, target) = link_result?;
99
100 let candidates: Vec<String> = stmt_candidates
102 .query_map([&target], |row| row.get(0))?
103 .filter_map(|r| r.ok())
104 .collect();
105
106 if candidates.len() > 1 {
108 results.push(BrokenLinkResult {
109 src_path,
110 src_title,
111 raw_link,
112 target,
113 status: "ambiguous".to_string(),
114 candidates,
115 });
116 }
117 }
118
119 Ok(results)
120}
121
122pub fn get_orphans(
125 conn: &Connection,
126 exclude_templates: bool,
127 exclude_daily: bool,
128) -> Result<Vec<DiagnoseResult>> {
129 let mut query = String::from(
130 "SELECT
131 n.id,
132 n.path,
133 n.title,
134 (SELECT COUNT(*) FROM links l WHERE l.dst_note_id = n.id) as incoming_count,
135 (SELECT COUNT(*) FROM links l WHERE l.src_note_id = n.id) as outgoing_count
136 FROM notes n
137 WHERE (SELECT COUNT(*) FROM links l WHERE l.dst_note_id = n.id) = 0
138 AND (SELECT COUNT(*) FROM links l WHERE l.src_note_id = n.id) = 0",
139 );
140
141 if exclude_templates {
142 query.push_str(" AND n.path NOT LIKE 'templates/%' AND n.path NOT LIKE '%/templates/%' AND n.path NOT LIKE '%/template%'");
143 }
144
145 if exclude_daily {
146 query.push_str(" AND n.path NOT LIKE 'daily/%' AND n.path NOT LIKE '%/daily/%' AND n.title NOT LIKE '%Daily%'");
147 }
148
149 query.push_str(" ORDER BY n.path");
150
151 let mut stmt = conn.prepare(&query)?;
152 let results = stmt.query_map([], |row| {
153 Ok(DiagnoseResult {
154 note_id: row.get(0)?,
155 note_path: row.get(1)?,
156 note_title: row.get(2)?,
157 incoming_count: row.get(3)?,
158 outgoing_count: row.get(4)?,
159 })
160 })?;
161
162 let mut orphans = Vec::new();
163 for result in results {
164 orphans.push(result?);
165 }
166
167 Ok(orphans)
168}
169
170pub fn get_dead_ends(
173 conn: &Connection,
174 exclude_templates: bool,
175 exclude_daily: bool,
176) -> Result<Vec<DiagnoseResult>> {
177 let mut query = String::from(
178 "SELECT
179 n.id,
180 n.path,
181 n.title,
182 (SELECT COUNT(*) FROM links l WHERE l.dst_note_id = n.id) as incoming_count,
183 (SELECT COUNT(*) FROM links l WHERE l.src_note_id = n.id) as outgoing_count
184 FROM notes n
185 WHERE (SELECT COUNT(*) FROM links l WHERE l.dst_note_id = n.id) > 0
186 AND (SELECT COUNT(*) FROM links l WHERE l.src_note_id = n.id) = 0",
187 );
188
189 if exclude_templates {
190 query.push_str(" AND n.path NOT LIKE 'templates/%' AND n.path NOT LIKE '%/templates/%' AND n.path NOT LIKE '%/template%'");
191 }
192
193 if exclude_daily {
194 query.push_str(" AND n.path NOT LIKE 'daily/%' AND n.path NOT LIKE '%/daily/%' AND n.title NOT LIKE '%Daily%'");
195 }
196
197 query.push_str(" ORDER BY n.path");
198
199 let mut stmt = conn.prepare(&query)?;
200 let results = stmt.query_map([], |row| {
201 Ok(DiagnoseResult {
202 note_id: row.get(0)?,
203 note_path: row.get(1)?,
204 note_title: row.get(2)?,
205 incoming_count: row.get(3)?,
206 outgoing_count: row.get(4)?,
207 })
208 })?;
209
210 let mut dead_ends = Vec::new();
211 for result in results {
212 dead_ends.push(result?);
213 }
214
215 Ok(dead_ends)
216}
217
218pub fn get_backlinks(conn: &Connection, note_path: &str) -> Result<Vec<LinkResult>> {
220 let target_note_id: Option<i64> = conn
222 .query_row("SELECT id FROM notes WHERE path = ?1", [note_path], |row| {
223 row.get(0)
224 })
225 .optional()?;
226
227 if target_note_id.is_none() {
228 return Ok(Vec::new());
229 }
230
231 let target_note_id = target_note_id.unwrap();
232
233 let mut stmt = conn.prepare(
235 "SELECT
236 src.id,
237 src.path,
238 src.title,
239 l.is_embed,
240 l.alias,
241 l.heading_ref,
242 l.block_ref
243 FROM links l
244 JOIN notes src ON l.src_note_id = src.id
245 WHERE l.dst_note_id = ?1
246 ORDER BY src.path",
247 )?;
248
249 let results = stmt.query_map([target_note_id], |row| {
250 Ok(LinkResult {
251 note_id: row.get(0)?,
252 note_path: row.get(1)?,
253 note_title: row.get(2)?,
254 is_embed: row.get::<_, i32>(3)? != 0,
255 alias: row.get(4)?,
256 heading_ref: row.get(5)?,
257 block_ref: row.get(6)?,
258 })
259 })?;
260
261 let mut backlinks = Vec::new();
262 for result in results {
263 backlinks.push(result?);
264 }
265
266 Ok(backlinks)
267}
268
269pub fn get_forward_links(conn: &Connection, note_path: &str) -> Result<Vec<LinkResult>> {
271 let src_note_id: Option<i64> = conn
273 .query_row("SELECT id FROM notes WHERE path = ?1", [note_path], |row| {
274 row.get(0)
275 })
276 .optional()?;
277
278 if src_note_id.is_none() {
279 return Ok(Vec::new());
280 }
281
282 let src_note_id = src_note_id.unwrap();
283
284 let mut stmt = conn.prepare(
286 "SELECT
287 COALESCE(dst.id, -1),
288 COALESCE(dst.path, l.dst_text),
289 COALESCE(dst.title, l.dst_text),
290 l.is_embed,
291 l.alias,
292 l.heading_ref,
293 l.block_ref
294 FROM links l
295 LEFT JOIN notes dst ON l.dst_note_id = dst.id
296 WHERE l.src_note_id = ?1
297 ORDER BY l.dst_text",
298 )?;
299
300 let results = stmt.query_map([src_note_id], |row| {
301 Ok(LinkResult {
302 note_id: row.get(0)?,
303 note_path: row.get(1)?,
304 note_title: row.get(2)?,
305 is_embed: row.get::<_, i32>(3)? != 0,
306 alias: row.get(4)?,
307 heading_ref: row.get(5)?,
308 block_ref: row.get(6)?,
309 })
310 })?;
311
312 let mut forward_links = Vec::new();
313 for result in results {
314 forward_links.push(result?);
315 }
316
317 Ok(forward_links)
318}
319
320pub fn get_unresolved_links(conn: &Connection) -> Result<Vec<LinkResult>> {
322 let mut stmt = conn.prepare(
323 "SELECT
324 src.id,
325 src.path,
326 src.title,
327 l.is_embed,
328 l.alias,
329 l.heading_ref,
330 l.block_ref,
331 l.dst_text
332 FROM links l
333 JOIN notes src ON l.src_note_id = src.id
334 WHERE l.dst_note_id IS NULL
335 ORDER BY l.dst_text, src.path",
336 )?;
337
338 let results = stmt.query_map([], |row| {
339 Ok(LinkResult {
340 note_id: row.get(0)?,
341 note_path: row.get(1)?,
342 note_title: row.get(2)?,
343 is_embed: row.get::<_, i32>(3)? != 0,
344 alias: row.get(4)?,
345 heading_ref: row.get(5)?,
346 block_ref: row.get(6)?,
347 })
348 })?;
349
350 let mut unresolved = Vec::new();
351 for result in results {
352 unresolved.push(result?);
353 }
354
355 Ok(unresolved)
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361 use rusqlite::Connection;
362
363 fn setup_test_db(conn: &Connection) {
364 conn.execute(
365 "CREATE TABLE notes (id INTEGER PRIMARY KEY, path TEXT, title TEXT)",
366 [],
367 )
368 .unwrap();
369 conn.execute(
370 "CREATE TABLE links (id INTEGER PRIMARY KEY, src_note_id INTEGER, dst_note_id INTEGER, dst_text TEXT, is_embed INTEGER, alias TEXT, heading_ref TEXT, block_ref TEXT)",
371 [],
372 ).unwrap();
373
374 conn.execute(
376 "INSERT INTO notes (path, title) VALUES ('test1.md', 'Test 1')",
377 [],
378 )
379 .unwrap();
380 conn.execute(
381 "INSERT INTO notes (path, title) VALUES ('test2.md', 'Test 2')",
382 [],
383 )
384 .unwrap();
385 conn.execute(
386 "INSERT INTO notes (path, title) VALUES ('test3.md', 'Test 3')",
387 [],
388 )
389 .unwrap();
390 }
391
392 #[test]
393 fn test_link_result_creation() {
394 let link = LinkResult {
395 note_id: 1,
396 note_path: "test.md".to_string(),
397 note_title: "Test".to_string(),
398 is_embed: false,
399 alias: Some("alias".to_string()),
400 heading_ref: Some("heading".to_string()),
401 block_ref: Some("block".to_string()),
402 };
403
404 assert_eq!(link.note_id, 1);
405 assert!(!link.is_embed);
406 assert!(link.alias.is_some());
407 }
408
409 #[test]
410 fn test_link_result_no_optionals() {
411 let link = LinkResult {
412 note_id: 1,
413 note_path: "test.md".to_string(),
414 note_title: "Test".to_string(),
415 is_embed: true,
416 alias: None,
417 heading_ref: None,
418 block_ref: None,
419 };
420
421 assert!(link.is_embed);
422 assert!(link.alias.is_none());
423 }
424
425 #[test]
426 fn test_get_backlinks_with_results() {
427 let conn = Connection::open_in_memory().unwrap();
428 setup_test_db(&conn);
429
430 conn.execute(
432 "INSERT INTO links (src_note_id, dst_note_id, dst_text, is_embed) VALUES (1, 2, 'test2.md', 0)",
433 [],
434 ).unwrap();
435
436 let backlinks = get_backlinks(&conn, "test2.md").unwrap();
437 assert_eq!(backlinks.len(), 1);
438 assert_eq!(backlinks[0].note_path, "test1.md");
439 }
440
441 #[test]
442 fn test_get_backlinks_no_results() {
443 let conn = Connection::open_in_memory().unwrap();
444 setup_test_db(&conn);
445
446 let backlinks = get_backlinks(&conn, "test2.md").unwrap();
447 assert!(backlinks.is_empty());
448 }
449
450 #[test]
451 fn test_get_forward_links_with_results() {
452 let conn = Connection::open_in_memory().unwrap();
453 setup_test_db(&conn);
454
455 conn.execute(
457 "INSERT INTO links (src_note_id, dst_note_id, dst_text, is_embed) VALUES (1, 2, 'test2.md', 0)",
458 [],
459 ).unwrap();
460
461 let forward_links = get_forward_links(&conn, "test1.md").unwrap();
462 assert_eq!(forward_links.len(), 1);
463 }
464
465 #[test]
466 fn test_get_forward_links_unresolved() {
467 let conn = Connection::open_in_memory().unwrap();
468 setup_test_db(&conn);
469
470 conn.execute(
472 "INSERT INTO links (src_note_id, dst_note_id, dst_text, is_embed) VALUES (1, NULL, 'nonexistent.md', 0)",
473 [],
474 ).unwrap();
475
476 let forward_links = get_forward_links(&conn, "test1.md").unwrap();
477 assert_eq!(forward_links.len(), 1);
478 assert_eq!(forward_links[0].note_id, -1); }
480
481 #[test]
482 fn test_get_unresolved_links() {
483 let conn = Connection::open_in_memory().unwrap();
484 setup_test_db(&conn);
485
486 conn.execute(
488 "INSERT INTO links (src_note_id, dst_note_id, dst_text, is_embed) VALUES (1, NULL, 'nonexistent.md', 0)",
489 [],
490 ).unwrap();
491
492 let unresolved = get_unresolved_links(&conn).unwrap();
493 assert_eq!(unresolved.len(), 1);
494 }
495
496 #[test]
498 fn test_broken_link_result_creation() {
499 let result = BrokenLinkResult {
500 src_path: "source.md".to_string(),
501 src_title: "Source".to_string(),
502 raw_link: "[[target|alias]]".to_string(),
503 target: "target".to_string(),
504 status: "unresolved".to_string(),
505 candidates: vec![],
506 };
507
508 assert_eq!(result.status, "unresolved");
509 assert!(result.candidates.is_empty());
510 }
511
512 #[test]
513 fn test_broken_link_result_ambiguous() {
514 let result = BrokenLinkResult {
515 src_path: "source.md".to_string(),
516 src_title: "Source".to_string(),
517 raw_link: "[[duplicate]]".to_string(),
518 target: "duplicate".to_string(),
519 status: "ambiguous".to_string(),
520 candidates: vec![
521 "folder1/duplicate.md".to_string(),
522 "folder2/duplicate.md".to_string(),
523 ],
524 };
525
526 assert_eq!(result.status, "ambiguous");
527 assert_eq!(result.candidates.len(), 2);
528 }
529
530 #[test]
531 fn test_diagnose_broken_links_unresolved() {
532 let conn = Connection::open_in_memory().unwrap();
533 setup_test_db(&conn);
534
535 conn.execute(
537 "INSERT INTO links (src_note_id, dst_note_id, dst_text, is_embed) VALUES (1, NULL, 'nonexistent.md', 0)",
538 [],
539 ).unwrap();
540
541 let broken = diagnose_broken_links(&conn).unwrap();
542 assert_eq!(broken.len(), 1);
543 assert_eq!(broken[0].status, "unresolved");
544 assert_eq!(broken[0].target, "nonexistent.md");
545 }
546
547 #[test]
548 fn test_diagnose_broken_links_resolved() {
549 let conn = Connection::open_in_memory().unwrap();
550 setup_test_db(&conn);
551
552 conn.execute(
554 "INSERT INTO links (src_note_id, dst_note_id, dst_text, is_embed) VALUES (1, 2, 'test2.md', 0)",
555 [],
556 ).unwrap();
557
558 let broken = diagnose_broken_links(&conn).unwrap();
559 assert!(broken.is_empty());
560 }
561
562 #[test]
563 fn test_diagnose_broken_links_empty() {
564 let conn = Connection::open_in_memory().unwrap();
565 setup_test_db(&conn);
566
567 let broken = diagnose_broken_links(&conn).unwrap();
569 assert!(broken.is_empty());
570 }
571
572 #[test]
574 fn test_diagnose_result_creation() {
575 let result = DiagnoseResult {
576 note_id: 1,
577 note_path: "test.md".to_string(),
578 note_title: "Test".to_string(),
579 incoming_count: 5,
580 outgoing_count: 3,
581 };
582
583 assert_eq!(result.note_id, 1);
584 assert_eq!(result.incoming_count, 5);
585 assert_eq!(result.outgoing_count, 3);
586 }
587
588 #[test]
590 fn test_get_orphans_with_orphans() {
591 let conn = Connection::open_in_memory().unwrap();
592 conn.execute(
593 "CREATE TABLE notes (id INTEGER PRIMARY KEY, path TEXT, title TEXT)",
594 [],
595 )
596 .unwrap();
597 conn.execute(
598 "CREATE TABLE links (id INTEGER PRIMARY KEY, src_note_id INTEGER, dst_note_id INTEGER, dst_text TEXT, is_embed INTEGER, alias TEXT, heading_ref TEXT, block_ref TEXT)",
599 [],
600 ).unwrap();
601
602 conn.execute(
604 "INSERT INTO notes (path, title) VALUES ('test1.md', 'Test 1')",
605 [],
606 )
607 .unwrap();
608 conn.execute(
609 "INSERT INTO notes (path, title) VALUES ('test2.md', 'Test 2')",
610 [],
611 )
612 .unwrap();
613 conn.execute(
614 "INSERT INTO notes (path, title) VALUES ('orphan.md', 'Orphan')",
615 [],
616 )
617 .unwrap();
618
619 conn.execute(
621 "INSERT INTO links (src_note_id, dst_note_id, dst_text, is_embed) VALUES (1, 2, 'test2.md', 0)",
622 [],
623 ).unwrap();
624
625 let orphans = get_orphans(&conn, false, false).unwrap();
627 assert_eq!(orphans.len(), 1);
628 assert_eq!(orphans[0].note_path, "orphan.md");
629 }
630
631 #[test]
632 fn test_get_orphans_no_orphans() {
633 let conn = Connection::open_in_memory().unwrap();
634 setup_test_db(&conn);
635
636 conn.execute(
638 "INSERT INTO links (src_note_id, dst_note_id, dst_text, is_embed) VALUES (1, 2, 'test2.md', 0)",
639 [],
640 ).unwrap();
641 conn.execute(
642 "INSERT INTO links (src_note_id, dst_note_id, dst_text, is_embed) VALUES (2, 3, 'test3.md', 0)",
643 [],
644 ).unwrap();
645 conn.execute(
646 "INSERT INTO links (src_note_id, dst_note_id, dst_text, is_embed) VALUES (3, 1, 'test1.md', 0)",
647 [],
648 ).unwrap();
649
650 let orphans = get_orphans(&conn, false, false).unwrap();
651 assert!(orphans.is_empty());
652 }
653
654 #[test]
655 fn test_get_orphans_exclude_templates() {
656 let conn = Connection::open_in_memory().unwrap();
657 conn.execute(
658 "CREATE TABLE notes (id INTEGER PRIMARY KEY, path TEXT, title TEXT)",
659 [],
660 )
661 .unwrap();
662 conn.execute(
663 "CREATE TABLE links (id INTEGER PRIMARY KEY, src_note_id INTEGER, dst_note_id INTEGER, dst_text TEXT, is_embed INTEGER, alias TEXT, heading_ref TEXT, block_ref TEXT)",
664 [],
665 ).unwrap();
666
667 conn.execute(
669 "INSERT INTO notes (path, title) VALUES ('regular.md', 'Regular')",
670 [],
671 )
672 .unwrap();
673 conn.execute(
675 "INSERT INTO notes (path, title) VALUES ('templates/template1.md', 'Template1')",
676 [],
677 )
678 .unwrap();
679
680 let orphans = get_orphans(&conn, false, false).unwrap();
682 assert_eq!(orphans.len(), 2);
683
684 let orphans = get_orphans(&conn, true, false).unwrap();
686 assert_eq!(orphans.len(), 1);
687 assert_eq!(orphans[0].note_path, "regular.md");
688 }
689
690 #[test]
691 fn test_get_orphans_exclude_daily() {
692 let conn = Connection::open_in_memory().unwrap();
693 conn.execute(
694 "CREATE TABLE notes (id INTEGER PRIMARY KEY, path TEXT, title TEXT)",
695 [],
696 )
697 .unwrap();
698 conn.execute(
699 "CREATE TABLE links (id INTEGER PRIMARY KEY, src_note_id INTEGER, dst_note_id INTEGER, dst_text TEXT, is_embed INTEGER, alias TEXT, heading_ref TEXT, block_ref TEXT)",
700 [],
701 ).unwrap();
702
703 conn.execute(
705 "INSERT INTO notes (path, title) VALUES ('regular.md', 'Regular')",
706 [],
707 )
708 .unwrap();
709 conn.execute(
711 "INSERT INTO notes (path, title) VALUES ('daily/2024-01-01.md', 'Daily Notes')",
712 [],
713 )
714 .unwrap();
715
716 let orphans = get_orphans(&conn, false, false).unwrap();
718 assert_eq!(orphans.len(), 2);
719
720 let orphans = get_orphans(&conn, false, true).unwrap();
722 assert_eq!(orphans.len(), 1);
723 assert_eq!(orphans[0].note_path, "regular.md");
724 }
725
726 #[test]
728 fn test_get_dead_ends_with_dead_ends() {
729 let conn = Connection::open_in_memory().unwrap();
730 setup_test_db(&conn);
731
732 conn.execute(
734 "INSERT INTO links (src_note_id, dst_note_id, dst_text, is_embed) VALUES (1, 3, 'test3.md', 0)",
735 [],
736 ).unwrap();
737
738 let dead_ends = get_dead_ends(&conn, false, false).unwrap();
739 assert_eq!(dead_ends.len(), 1);
740 assert_eq!(dead_ends[0].note_path, "test3.md");
741 }
742
743 #[test]
744 fn test_get_dead_ends_no_dead_ends() {
745 let conn = Connection::open_in_memory().unwrap();
746 setup_test_db(&conn);
747
748 conn.execute(
750 "INSERT INTO links (src_note_id, dst_note_id, dst_text, is_embed) VALUES (1, 2, 'test2.md', 0)",
751 [],
752 ).unwrap();
753 conn.execute(
754 "INSERT INTO links (src_note_id, dst_note_id, dst_text, is_embed) VALUES (2, 3, 'test3.md', 0)",
755 [],
756 ).unwrap();
757 conn.execute(
758 "INSERT INTO links (src_note_id, dst_note_id, dst_text, is_embed) VALUES (3, 1, 'test1.md', 0)",
759 [],
760 ).unwrap();
761
762 let dead_ends = get_dead_ends(&conn, false, false).unwrap();
763 assert!(dead_ends.is_empty());
764 }
765
766 #[test]
767 fn test_get_dead_ends_exclude_templates() {
768 let conn = Connection::open_in_memory().unwrap();
769 conn.execute(
770 "CREATE TABLE notes (id INTEGER PRIMARY KEY, path TEXT, title TEXT)",
771 [],
772 )
773 .unwrap();
774 conn.execute(
775 "CREATE TABLE links (id INTEGER PRIMARY KEY, src_note_id INTEGER, dst_note_id INTEGER, dst_text TEXT, is_embed INTEGER, alias TEXT, heading_ref TEXT, block_ref TEXT)",
776 [],
777 ).unwrap();
778
779 conn.execute(
781 "INSERT INTO notes (id, path, title) VALUES (1, 'source.md', 'Source')",
782 [],
783 )
784 .unwrap();
785 conn.execute(
786 "INSERT INTO notes (id, path, title) VALUES (2, 'regular.md', 'Regular')",
787 [],
788 )
789 .unwrap();
790 conn.execute(
791 "INSERT INTO notes (id, path, title) VALUES (3, 'templates/tmpl.md', 'Template')",
792 [],
793 )
794 .unwrap();
795
796 conn.execute(
798 "INSERT INTO links (src_note_id, dst_note_id, dst_text, is_embed) VALUES (1, 2, 'regular.md', 0)",
799 [],
800 ).unwrap();
801 conn.execute(
802 "INSERT INTO links (src_note_id, dst_note_id, dst_text, is_embed) VALUES (1, 3, 'tmpl.md', 0)",
803 [],
804 ).unwrap();
805
806 let dead_ends = get_dead_ends(&conn, false, false).unwrap();
808 assert_eq!(dead_ends.len(), 2);
809
810 let dead_ends = get_dead_ends(&conn, true, false).unwrap();
812 assert_eq!(dead_ends.len(), 1);
813 assert_eq!(dead_ends[0].note_path, "regular.md");
814 }
815
816 #[test]
817 fn test_get_dead_ends_exclude_daily() {
818 let conn = Connection::open_in_memory().unwrap();
819 conn.execute(
820 "CREATE TABLE notes (id INTEGER PRIMARY KEY, path TEXT, title TEXT)",
821 [],
822 )
823 .unwrap();
824 conn.execute(
825 "CREATE TABLE links (id INTEGER PRIMARY KEY, src_note_id INTEGER, dst_note_id INTEGER, dst_text TEXT, is_embed INTEGER, alias TEXT, heading_ref TEXT, block_ref TEXT)",
826 [],
827 ).unwrap();
828
829 conn.execute(
831 "INSERT INTO notes (id, path, title) VALUES (1, 'source.md', 'Source')",
832 [],
833 )
834 .unwrap();
835 conn.execute(
836 "INSERT INTO notes (id, path, title) VALUES (2, 'regular.md', 'Regular')",
837 [],
838 )
839 .unwrap();
840 conn.execute(
841 "INSERT INTO notes (id, path, title) VALUES (3, 'daily/2024-01-01.md', 'Daily Notes')",
842 [],
843 )
844 .unwrap();
845
846 conn.execute(
848 "INSERT INTO links (src_note_id, dst_note_id, dst_text, is_embed) VALUES (1, 2, 'regular.md', 0)",
849 [],
850 ).unwrap();
851 conn.execute(
852 "INSERT INTO links (src_note_id, dst_note_id, dst_text, is_embed) VALUES (1, 3, '2024-01-01.md', 0)",
853 [],
854 ).unwrap();
855
856 let dead_ends = get_dead_ends(&conn, false, false).unwrap();
858 assert_eq!(dead_ends.len(), 2);
859
860 let dead_ends = get_dead_ends(&conn, false, true).unwrap();
862 assert_eq!(dead_ends.len(), 1);
863 assert_eq!(dead_ends[0].note_path, "regular.md");
864 }
865
866 #[test]
868 fn test_get_orphans_empty_database() {
869 let conn = Connection::open_in_memory().unwrap();
870 conn.execute(
871 "CREATE TABLE notes (id INTEGER PRIMARY KEY, path TEXT, title TEXT)",
872 [],
873 )
874 .unwrap();
875 conn.execute(
876 "CREATE TABLE links (id INTEGER PRIMARY KEY, src_note_id INTEGER, dst_note_id INTEGER, dst_text TEXT, is_embed INTEGER, alias TEXT, heading_ref TEXT, block_ref TEXT)",
877 [],
878 ).unwrap();
879
880 let orphans = get_orphans(&conn, false, false).unwrap();
881 assert!(orphans.is_empty());
882 }
883
884 #[test]
885 fn test_get_dead_ends_empty_database() {
886 let conn = Connection::open_in_memory().unwrap();
887 conn.execute(
888 "CREATE TABLE notes (id INTEGER PRIMARY KEY, path TEXT, title TEXT)",
889 [],
890 )
891 .unwrap();
892 conn.execute(
893 "CREATE TABLE links (id INTEGER PRIMARY KEY, src_note_id INTEGER, dst_note_id INTEGER, dst_text TEXT, is_embed INTEGER, alias TEXT, heading_ref TEXT, block_ref TEXT)",
894 [],
895 ).unwrap();
896
897 let dead_ends = get_dead_ends(&conn, false, false).unwrap();
898 assert!(dead_ends.is_empty());
899 }
900
901 #[test]
902 fn test_diagnose_broken_links_multiple_unresolved() {
903 let conn = Connection::open_in_memory().unwrap();
904 setup_test_db(&conn);
905
906 conn.execute(
908 "INSERT INTO links (src_note_id, dst_note_id, dst_text, is_embed) VALUES (1, NULL, 'nonexistent1.md', 0)",
909 [],
910 ).unwrap();
911 conn.execute(
912 "INSERT INTO links (src_note_id, dst_note_id, dst_text, is_embed) VALUES (1, NULL, 'nonexistent2.md', 0)",
913 [],
914 ).unwrap();
915 conn.execute(
916 "INSERT INTO links (src_note_id, dst_note_id, dst_text, is_embed) VALUES (2, NULL, 'nonexistent3.md', 0)",
917 [],
918 ).unwrap();
919
920 let broken = diagnose_broken_links(&conn).unwrap();
921 assert_eq!(broken.len(), 3);
922
923 for link in &broken {
925 assert_eq!(link.status, "unresolved");
926 }
927 }
928}