Skip to main content

obsidian_cli_inspector/query/
links.rs

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/// Result for orphan and dead-end analysis
15#[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/// Result for broken link diagnosis
25#[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,          // "unresolved" or "ambiguous"
32    pub candidates: Vec<String>, // list of candidate note paths
33}
34
35/// Get all broken links (unresolved and ambiguous)
36/// Unresolved: links where dst_note_id is NULL (target doesn't exist)
37/// Ambiguous: links where dst_text could match multiple notes
38pub fn diagnose_broken_links(conn: &Connection) -> Result<Vec<BrokenLinkResult>> {
39    let mut results = Vec::new();
40
41    // First, get unresolved links (dst_note_id IS NULL)
42    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    // Now find ambiguous links - links where dst_text could match multiple notes
70    // This happens when the link target matches multiple notes (e.g., same basename in different folders)
71    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    // For each resolved link, check if the target is ambiguous
94    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        // Check how many notes match this target
101        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 there are multiple matches, mark as ambiguous
107        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
122/// Get orphan notes (no incoming AND no outgoing links)
123/// Optionally exclude templates and daily notes
124pub 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
170/// Get dead-end notes (has incoming but no outgoing links)
171/// Optionally exclude templates and daily notes
172pub 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
218/// Get all notes that link to a given note (backlinks)
219pub fn get_backlinks(conn: &Connection, note_path: &str) -> Result<Vec<LinkResult>> {
220    // First find the target note
221    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    // Get all links pointing to this note
234    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
269/// Get all notes that a given note links to (forward links)
270pub fn get_forward_links(conn: &Connection, note_path: &str) -> Result<Vec<LinkResult>> {
271    // First find the source note
272    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    // Get all links from this note
285    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
320/// Get all unresolved links (links pointing to non-existent notes)
321pub 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        // Insert test notes
375        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        // Insert a link from test1 to test2
431        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        // Insert a link from test1 to test2
456        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        // Insert an unresolved link from test1 to nonexistent
471        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); // Unresolved
479    }
480
481    #[test]
482    fn test_get_unresolved_links() {
483        let conn = Connection::open_in_memory().unwrap();
484        setup_test_db(&conn);
485
486        // Insert an unresolved link
487        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    // Tests for diagnose_broken_links
497    #[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        // Insert an unresolved link from test1 to nonexistent
536        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        // Insert a resolved link from test1 to test2
553        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        // No links at all
568        let broken = diagnose_broken_links(&conn).unwrap();
569        assert!(broken.is_empty());
570    }
571
572    // Tests for DiagnoseResult
573    #[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    // Tests for get_orphans
589    #[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        // Insert notes - only test1 and test2 will have links
603        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        // Add a link between test1 and test2
620        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        // orphan.md has no links - should be orphan
626        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        // Add links to make all notes connected
637        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        // Insert regular orphan
668        conn.execute(
669            "INSERT INTO notes (path, title) VALUES ('regular.md', 'Regular')",
670            [],
671        )
672        .unwrap();
673        // Insert template orphan
674        conn.execute(
675            "INSERT INTO notes (path, title) VALUES ('templates/template1.md', 'Template1')",
676            [],
677        )
678        .unwrap();
679
680        // Without exclude
681        let orphans = get_orphans(&conn, false, false).unwrap();
682        assert_eq!(orphans.len(), 2);
683
684        // With exclude templates
685        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        // Insert regular orphan
704        conn.execute(
705            "INSERT INTO notes (path, title) VALUES ('regular.md', 'Regular')",
706            [],
707        )
708        .unwrap();
709        // Insert daily orphan
710        conn.execute(
711            "INSERT INTO notes (path, title) VALUES ('daily/2024-01-01.md', 'Daily Notes')",
712            [],
713        )
714        .unwrap();
715
716        // Without exclude
717        let orphans = get_orphans(&conn, false, false).unwrap();
718        assert_eq!(orphans.len(), 2);
719
720        // With exclude daily
721        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    // Tests for get_dead_ends
727    #[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        // test3 has incoming from test1 but no outgoing
733        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        // Create a fully connected graph
749        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        // Insert regular dead-end (has incoming, no outgoing)
780        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        // Source links to both regular and template
797        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        // Without exclude - both regular and template are dead-ends
807        let dead_ends = get_dead_ends(&conn, false, false).unwrap();
808        assert_eq!(dead_ends.len(), 2);
809
810        // With exclude templates - only regular should be returned
811        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        // Insert notes
830        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        // Source links to both
847        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        // Without exclude
857        let dead_ends = get_dead_ends(&conn, false, false).unwrap();
858        assert_eq!(dead_ends.len(), 2);
859
860        // With exclude daily
861        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    // Edge case tests
867    #[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        // Insert multiple unresolved links
907        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        // All should be unresolved
924        for link in &broken {
925            assert_eq!(link.status, "unresolved");
926        }
927    }
928}