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}