Skip to main content

grepdown_lib/
lint.rs

1use crate::error::Result;
2use rusqlite::Connection;
3use serde::Serialize;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
6pub enum LintId {
7    StaleRef,
8    Orphan,
9    BrokenLink,
10    BrokenAnchor,
11}
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
14pub enum Severity {
15    Error,
16    Warning,
17}
18
19#[derive(Debug, Clone, PartialEq, Serialize)]
20pub enum LintData {
21    StaleRef {
22        pinned_version: i64,
23        current_version: i64,
24    },
25    Orphan,
26    BrokenLink {
27        raw_target: String,
28    },
29    BrokenAnchor {
30        anchor: String,
31    },
32}
33
34#[derive(Debug, Clone, PartialEq, Serialize)]
35pub struct Diagnostic {
36    pub lint_id: LintId,
37    pub severity: Severity,
38    pub from_path: String,
39    pub to_path: String,
40    pub data: LintData,
41}
42
43impl LintId {
44    pub fn title(self) -> &'static str {
45        match self {
46            LintId::StaleRef => "STALE REFERENCES DETECTED",
47            LintId::Orphan => "ORPHAN DOCUMENTS DETECTED",
48            LintId::BrokenLink => "BROKEN LINKS DETECTED",
49            LintId::BrokenAnchor => "BROKEN ANCHORS DETECTED",
50        }
51    }
52
53    pub fn suggestions(self) -> &'static str {
54        match self {
55            LintId::StaleRef => {
56                "💡 Suggested actions:\n    1. Update them if needed\n    2. Run `grepdown approve-edits <filenames>` to mark them as reviewed"
57            }
58            LintId::Orphan => {
59                "💡 These documents have no links. Consider:\n   \
60                 1. Adding links to related documents\n   \
61                 2. Linking from other documents to these\n   \
62                 3. Deleting if they're no longer needed"
63            }
64            LintId::BrokenLink => {
65                "💡 These links point to documents that don't exist. Consider:\n   \
66                 1. Creating the missing documents\n   \
67                 2. Fixing the link targets\n   \
68                 3. Removing the broken links"
69            }
70            LintId::BrokenAnchor => {
71                "💡 These anchors don't exist in the target document. Consider:\n   \
72                 1. Adding the missing heading to the target document\n   \
73                 2. Fixing the anchor to match an existing heading\n   \
74                 3. Removing the anchor from the link"
75            }
76        }
77    }
78
79    pub fn format_group(self, diags: &[&Diagnostic]) -> String {
80        match self {
81            LintId::StaleRef => format_stale_ref(diags),
82            LintId::Orphan => format_orphan(diags),
83            LintId::BrokenLink => format_broken_link(diags),
84            LintId::BrokenAnchor => format_broken_anchor(diags),
85        }
86    }
87}
88
89fn format_stale_ref(diags: &[&Diagnostic]) -> String {
90    let mut out = String::new();
91    out.push_str("The following files were updated, but their dependents may need review:\n\n");
92
93    let mut by_updated: std::collections::HashMap<&str, Vec<&&Diagnostic>> =
94        std::collections::HashMap::new();
95    for d in diags {
96        by_updated.entry(d.to_path.as_str()).or_default().push(d);
97    }
98
99    for (updated_file, deps) in &by_updated {
100        let current_version = match &deps[0].data {
101            LintData::StaleRef {
102                current_version, ..
103            } => *current_version,
104            _ => unreachable!(),
105        };
106        out.push_str(&format!(
107            "📄 {} (version {})\n",
108            updated_file, current_version
109        ));
110        out.push_str("   └─ Referenced by:\n");
111        for dep in deps {
112            let pinned_version = match &dep.data {
113                LintData::StaleRef { pinned_version, .. } => *pinned_version,
114                _ => unreachable!(),
115            };
116            out.push_str(&format!(
117                "      • {} (pinned at version {})\n",
118                dep.from_path, pinned_version
119            ));
120        }
121        out.push('\n');
122    }
123
124    out
125}
126
127fn format_orphan(diags: &[&Diagnostic]) -> String {
128    let mut out = String::new();
129    for d in diags {
130        out.push_str(&format!("  - {}\n", d.from_path));
131    }
132    out
133}
134
135fn format_broken_link(diags: &[&Diagnostic]) -> String {
136    let mut out = String::new();
137
138    let mut by_source: std::collections::HashMap<&str, Vec<&&Diagnostic>> =
139        std::collections::HashMap::new();
140    for d in diags {
141        by_source.entry(d.from_path.as_str()).or_default().push(d);
142    }
143
144    for (source, deps) in &by_source {
145        out.push_str(&format!("📄 {}\n", source));
146        out.push_str("   └─ Broken links:\n");
147        for dep in deps {
148            match &dep.data {
149                LintData::BrokenLink { raw_target } => {
150                    out.push_str(&format!("      • {} → {}\n", dep.from_path, raw_target));
151                }
152                _ => unreachable!(),
153            }
154        }
155        out.push('\n');
156    }
157
158    out
159}
160
161fn format_broken_anchor(diags: &[&Diagnostic]) -> String {
162    let mut out = String::new();
163
164    let mut by_source: std::collections::HashMap<&str, Vec<&&Diagnostic>> =
165        std::collections::HashMap::new();
166    for d in diags {
167        by_source.entry(d.from_path.as_str()).or_default().push(d);
168    }
169
170    for (source, deps) in &by_source {
171        out.push_str(&format!("📄 {}\n", source));
172        out.push_str("   └─ Broken anchors:\n");
173        for dep in deps {
174            match &dep.data {
175                LintData::BrokenAnchor { anchor } => {
176                    out.push_str(&format!("      • {} → #{}\n", dep.from_path, anchor));
177                }
178                _ => unreachable!(),
179            }
180        }
181        out.push('\n');
182    }
183
184    out
185}
186
187fn check_stale_ref(conn: &Connection) -> Result<Vec<Diagnostic>> {
188    let mut stmt = conn.prepare(
189        "SELECT l.from_id, l.to_id, l.pinned_version, d.version
190         FROM links l
191         JOIN documents d ON l.to_id = d.path
192         WHERE l.pinned_version < d.version",
193    )?;
194
195    stmt.query_map([], |row| {
196        Ok(Diagnostic {
197            lint_id: LintId::StaleRef,
198            severity: Severity::Warning,
199            from_path: row.get(0)?,
200            to_path: row.get(1)?,
201            data: LintData::StaleRef {
202                pinned_version: row.get(2)?,
203                current_version: row.get(3)?,
204            },
205        })
206    })?
207    .map(|r| r.map_err(Into::into))
208    .collect()
209}
210
211fn check_orphan(conn: &Connection) -> Result<Vec<Diagnostic>> {
212    let mut stmt = conn.prepare(
213        "SELECT d.path
214         FROM documents d
215         WHERE NOT EXISTS (SELECT 1 FROM links l WHERE l.from_id = d.path)
216           AND NOT EXISTS (SELECT 1 FROM links l WHERE l.to_id = d.path)",
217    )?;
218
219    stmt.query_map([], |row| {
220        let path: String = row.get(0)?;
221        Ok(Diagnostic {
222            lint_id: LintId::Orphan,
223            severity: Severity::Warning,
224            from_path: path.clone(),
225            to_path: path,
226            data: LintData::Orphan,
227        })
228    })?
229    .map(|r| r.map_err(Into::into))
230    .collect()
231}
232
233fn check_broken_link(conn: &Connection) -> Result<Vec<Diagnostic>> {
234    let mut stmt = conn.prepare("SELECT from_id, raw_target FROM broken_links")?;
235
236    stmt.query_map([], |row| {
237        let raw_target: String = row.get(1)?;
238        Ok(Diagnostic {
239            lint_id: LintId::BrokenLink,
240            severity: Severity::Error,
241            from_path: row.get(0)?,
242            to_path: raw_target.clone(),
243            data: LintData::BrokenLink { raw_target },
244        })
245    })?
246    .map(|r| r.map_err(Into::into))
247    .collect()
248}
249
250fn check_broken_anchor(conn: &Connection) -> Result<Vec<Diagnostic>> {
251    let mut stmt = conn.prepare(
252        "SELECT l.from_id, l.to_id, l.anchor
253         FROM links l
254         WHERE l.anchor IS NOT NULL
255           AND NOT EXISTS (
256               SELECT 1 FROM headings h
257               WHERE h.path = l.to_id AND h.anchor = l.anchor
258           )",
259    )?;
260
261    stmt.query_map([], |row| {
262        let anchor: String = row.get(2)?;
263        Ok(Diagnostic {
264            lint_id: LintId::BrokenAnchor,
265            severity: Severity::Error,
266            from_path: row.get(0)?,
267            to_path: row.get(1)?,
268            data: LintData::BrokenAnchor { anchor },
269        })
270    })?
271    .map(|r| r.map_err(Into::into))
272    .collect()
273}
274
275pub fn run_lints(conn: &Connection) -> Result<Vec<Diagnostic>> {
276    let mut all = Vec::new();
277    all.extend(check_stale_ref(conn)?);
278    all.extend(check_orphan(conn)?);
279    all.extend(check_broken_link(conn)?);
280    all.extend(check_broken_anchor(conn)?);
281    Ok(all)
282}
283
284pub fn approve_edits(conn: &Connection, paths: &[String]) -> Result<usize> {
285    let rows = if paths.is_empty() {
286        conn.execute(
287            "WITH stale AS (
288                SELECT l.rowid as link_rowid, d.version as current_version
289                FROM links l
290                JOIN documents d ON l.to_id = d.path
291                WHERE l.pinned_version < d.version
292            )
293            UPDATE links SET pinned_version = (SELECT current_version FROM stale WHERE stale.link_rowid = links.rowid)
294            WHERE rowid IN (SELECT link_rowid FROM stale)",
295            []
296        )?
297    } else {
298        let placeholders: Vec<String> = paths
299            .iter()
300            .enumerate()
301            .map(|(i, _)| format!("?{}", i + 1))
302            .collect();
303        let sql = format!(
304            "WITH stale AS (
305                SELECT l.rowid as link_rowid, d.version as current_version
306                FROM links l
307                JOIN documents d ON l.to_id = d.path
308                WHERE l.pinned_version < d.version
309                AND l.to_id IN ({})
310            )
311            UPDATE links SET pinned_version = (SELECT current_version FROM stale WHERE stale.link_rowid = links.rowid)
312            WHERE rowid IN (SELECT link_rowid FROM stale)",
313            placeholders.join(", ")
314        );
315        let params: Vec<&dyn rusqlite::ToSql> =
316            paths.iter().map(|p| p as &dyn rusqlite::ToSql).collect();
317        conn.execute(&sql, params.as_slice())?
318    };
319    Ok(rows)
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use crate::db::bootstrap;
326
327    fn setup_test_db() -> Connection {
328        let conn = Connection::open_in_memory().unwrap();
329        bootstrap(&conn).unwrap();
330        conn
331    }
332
333    fn insert_test_document(conn: &Connection, path: &str, version: i64) {
334        conn.execute(
335            "INSERT INTO documents (path, mtime, content_hash, version) VALUES (?1, 0, X'00', ?2)",
336            rusqlite::params![path, version],
337        )
338        .unwrap();
339    }
340
341    fn insert_test_link(conn: &Connection, from_path: &str, to_path: &str, pinned_version: i64) {
342        conn.execute(
343            "INSERT INTO links (from_id, to_id, pinned_version) VALUES (?1, ?2, ?3)",
344            rusqlite::params![from_path, to_path, pinned_version],
345        )
346        .unwrap();
347    }
348
349    fn insert_broken_link(conn: &Connection, from_path: &str, raw_target: &str) {
350        conn.execute(
351            "INSERT INTO broken_links (from_id, raw_target) VALUES (?1, ?2)",
352            rusqlite::params![from_path, raw_target],
353        )
354        .unwrap();
355    }
356
357    fn diags_for(diags: &[Diagnostic], id: LintId) -> Vec<&Diagnostic> {
358        diags.iter().filter(|d| d.lint_id == id).collect()
359    }
360
361    #[test]
362    fn test_stale_ref_detection() {
363        let conn = setup_test_db();
364        insert_test_document(&conn, "/a.md", 1);
365        insert_test_document(&conn, "/b.md", 2);
366        insert_test_link(&conn, "/a.md", "/b.md", 1);
367
368        let diags = check_stale_ref(&conn).unwrap();
369        assert_eq!(diags.len(), 1);
370        assert_eq!(diags[0].from_path, "/a.md");
371        assert_eq!(diags[0].to_path, "/b.md");
372        match &diags[0].data {
373            LintData::StaleRef {
374                pinned_version,
375                current_version,
376            } => {
377                assert_eq!(*pinned_version, 1);
378                assert_eq!(*current_version, 2);
379            }
380            _ => unreachable!(),
381        }
382    }
383
384    #[test]
385    fn test_no_stale_refs_when_up_to_date() {
386        let conn = setup_test_db();
387        insert_test_document(&conn, "/a.md", 1);
388        insert_test_document(&conn, "/b.md", 2);
389        insert_test_link(&conn, "/a.md", "/b.md", 2);
390
391        let diags = check_stale_ref(&conn).unwrap();
392        assert_eq!(diags.len(), 0);
393    }
394
395    #[test]
396    fn test_approve_edits_all() {
397        let conn = setup_test_db();
398        insert_test_document(&conn, "/a.md", 1);
399        insert_test_document(&conn, "/b.md", 2);
400        insert_test_link(&conn, "/a.md", "/b.md", 1);
401
402        let rows = approve_edits(&conn, &[]).unwrap();
403        assert_eq!(rows, 1);
404
405        let pinned: i64 = conn
406            .query_row(
407                "SELECT pinned_version FROM links WHERE from_id = '/a.md'",
408                [],
409                |row| row.get(0),
410            )
411            .unwrap();
412        assert_eq!(pinned, 2);
413    }
414
415    #[test]
416    fn test_approve_edits_specific_path() {
417        let conn = setup_test_db();
418        insert_test_document(&conn, "/a.md", 1);
419        insert_test_document(&conn, "/b.md", 2);
420        insert_test_document(&conn, "/c.md", 3);
421        insert_test_link(&conn, "/a.md", "/b.md", 1);
422        insert_test_link(&conn, "/a.md", "/c.md", 1);
423
424        let paths = vec!["/b.md".to_string()];
425        let rows = approve_edits(&conn, &paths).unwrap();
426        assert_eq!(rows, 1);
427
428        let pinned_b: i64 = conn
429            .query_row(
430                "SELECT pinned_version FROM links WHERE to_id = '/b.md'",
431                [],
432                |row| row.get(0),
433            )
434            .unwrap();
435        assert_eq!(pinned_b, 2);
436
437        let pinned_c: i64 = conn
438            .query_row(
439                "SELECT pinned_version FROM links WHERE to_id = '/c.md'",
440                [],
441                |row| row.get(0),
442            )
443            .unwrap();
444        assert_eq!(pinned_c, 1);
445    }
446
447    #[test]
448    fn orphan_detection() {
449        let conn = setup_test_db();
450        insert_test_document(&conn, "orphan.md", 1);
451        insert_test_document(&conn, "another-orphan.md", 1);
452
453        let diags = run_lints(&conn).unwrap();
454        let orphans = diags_for(&diags, LintId::Orphan);
455        assert_eq!(orphans.len(), 2);
456        let mut paths: Vec<&str> = orphans.iter().map(|d| d.from_path.as_str()).collect();
457        paths.sort();
458        assert_eq!(paths, vec!["another-orphan.md", "orphan.md"]);
459        match &orphans[0].data {
460            LintData::Orphan => {}
461            _ => panic!("expected Orphan data"),
462        }
463    }
464
465    #[test]
466    fn non_orphan_with_outgoing_link() {
467        let conn = setup_test_db();
468        insert_test_document(&conn, "doc-a.md", 1);
469        insert_test_document(&conn, "doc-b.md", 1);
470        insert_test_link(&conn, "doc-a.md", "doc-b.md", 1);
471
472        let diags = run_lints(&conn).unwrap();
473        assert!(diags_for(&diags, LintId::Orphan).is_empty());
474    }
475
476    #[test]
477    fn non_orphan_with_incoming_link() {
478        let conn = setup_test_db();
479        insert_test_document(&conn, "source.md", 1);
480        insert_test_document(&conn, "target.md", 1);
481        insert_test_link(&conn, "source.md", "target.md", 1);
482
483        let diags = run_lints(&conn).unwrap();
484        assert!(diags_for(&diags, LintId::Orphan).is_empty());
485    }
486
487    #[test]
488    fn broken_link_detection() {
489        let conn = setup_test_db();
490        insert_test_document(&conn, "doc-a.md", 1);
491        insert_broken_link(&conn, "doc-a.md", "nonexistent.md");
492
493        let diags = check_broken_link(&conn).unwrap();
494        assert_eq!(diags.len(), 1);
495        assert_eq!(diags[0].from_path, "doc-a.md");
496        assert_eq!(diags[0].severity, Severity::Error);
497        match &diags[0].data {
498            LintData::BrokenLink { raw_target } => {
499                assert_eq!(raw_target, "nonexistent.md");
500            }
501            _ => panic!("expected BrokenLink data"),
502        }
503    }
504}