1use std::time::Duration;
2
3use chrono::{DateTime, Utc};
4use rusqlite::{params, Connection};
5use serde::{Deserialize, Serialize};
6
7use crate::error::{Result, SqzError};
8
9pub trait ContentFetcher: Send + Sync {
14 fn fetch(&self, url: &str) -> Result<String>;
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct IndexedChunk {
20 pub url: String,
21 pub heading: String,
22 pub body: String,
23 pub rank: f64,
24}
25
26#[derive(Debug, Clone)]
28pub struct IndexResult {
29 pub url: String,
30 pub chunks_indexed: usize,
31 pub cached: bool,
32}
33
34const URL_SCHEMA: &str = r#"
37CREATE TABLE IF NOT EXISTS url_cache (
38 url TEXT PRIMARY KEY,
39 fetched_at TEXT NOT NULL,
40 ttl_secs INTEGER NOT NULL,
41 markdown TEXT NOT NULL
42);
43
44CREATE TABLE IF NOT EXISTS url_chunks (
45 id INTEGER PRIMARY KEY AUTOINCREMENT,
46 url TEXT NOT NULL,
47 heading TEXT NOT NULL,
48 body TEXT NOT NULL
49);
50
51CREATE VIRTUAL TABLE IF NOT EXISTS url_chunks_fts USING fts5(
52 url,
53 heading,
54 body,
55 content='url_chunks',
56 content_rowid='id',
57 tokenize='porter ascii'
58);
59
60CREATE TRIGGER IF NOT EXISTS url_chunks_ai AFTER INSERT ON url_chunks BEGIN
61 INSERT INTO url_chunks_fts(rowid, url, heading, body)
62 VALUES (new.id, new.url, new.heading, new.body);
63END;
64
65CREATE TRIGGER IF NOT EXISTS url_chunks_ad AFTER DELETE ON url_chunks BEGIN
66 INSERT INTO url_chunks_fts(url_chunks_fts, rowid, url, heading, body)
67 VALUES ('delete', old.id, old.url, old.heading, old.body);
68END;
69"#;
70
71pub(crate) fn apply_url_schema(conn: &Connection) -> rusqlite::Result<()> {
72 conn.execute_batch(URL_SCHEMA)
73}
74
75fn html_to_markdown(html: &str) -> String {
80 let mut out = String::with_capacity(html.len());
81 let mut chars = html.chars().peekable();
82 let mut in_tag = false;
83 let mut tag_buf = String::new();
84 let mut skip_content = false;
85
86 while let Some(ch) = chars.next() {
87 if ch == '<' {
88 in_tag = true;
89 tag_buf.clear();
90 continue;
91 }
92 if in_tag {
93 if ch == '>' {
94 in_tag = false;
95 let tag_lower = tag_buf.to_ascii_lowercase();
96 let tag_name = tag_lower.split_whitespace().next().unwrap_or("");
97
98 match tag_name {
99 "h1" => out.push_str("\n# "),
100 "h2" => out.push_str("\n## "),
101 "h3" => out.push_str("\n### "),
102 "h4" => out.push_str("\n#### "),
103 "h5" => out.push_str("\n##### "),
104 "h6" => out.push_str("\n###### "),
105 "/h1" | "/h2" | "/h3" | "/h4" | "/h5" | "/h6" => {
106 out.push('\n');
107 }
108 "p" | "/p" | "br" | "br/" => out.push('\n'),
109 "li" => out.push_str("\n- "),
110 "pre" | "code" => out.push_str("\n```\n"),
111 "/pre" | "/code" => out.push_str("\n```\n"),
112 "strong" | "b" => out.push_str("**"),
113 "/strong" | "/b" => out.push_str("**"),
114 "em" | "i" => out.push('*'),
115 "/em" | "/i" => out.push('*'),
116 "script" | "style" | "noscript" => skip_content = true,
117 "/script" | "/style" | "/noscript" => skip_content = false,
118 _ => {} }
120 } else {
121 tag_buf.push(ch);
122 }
123 continue;
124 }
125 if skip_content {
126 continue;
127 }
128 if ch == '&' {
130 let mut entity = String::new();
131 for ec in chars.by_ref() {
132 if ec == ';' {
133 break;
134 }
135 entity.push(ec);
136 if entity.len() > 8 {
137 break;
138 }
139 }
140 match entity.as_str() {
141 "amp" => out.push('&'),
142 "lt" => out.push('<'),
143 "gt" => out.push('>'),
144 "quot" => out.push('"'),
145 "apos" => out.push('\''),
146 "nbsp" => out.push(' '),
147 _ => {
148 out.push('&');
149 out.push_str(&entity);
150 out.push(';');
151 }
152 }
153 continue;
154 }
155 out.push(ch);
156 }
157
158 collapse_blank_lines(&out)
160}
161
162fn collapse_blank_lines(s: &str) -> String {
163 let mut result = String::with_capacity(s.len());
164 let mut blank_count = 0u32;
165 for line in s.lines() {
166 if line.trim().is_empty() {
167 blank_count += 1;
168 if blank_count <= 2 {
169 result.push('\n');
170 }
171 } else {
172 blank_count = 0;
173 result.push_str(line);
174 result.push('\n');
175 }
176 }
177 result.trim().to_string()
178}
179
180
181#[derive(Debug, Clone)]
186struct MdChunk {
187 heading: String,
188 body: String,
189}
190
191fn chunk_by_headings(markdown: &str) -> Vec<MdChunk> {
192 let mut chunks: Vec<MdChunk> = Vec::new();
193 let mut current_heading = String::new();
194 let mut current_body = String::new();
195
196 for line in markdown.lines() {
197 let trimmed = line.trim_start();
198 if trimmed.starts_with('#') {
199 if !current_heading.is_empty() || !current_body.trim().is_empty() {
201 chunks.push(MdChunk {
202 heading: current_heading.clone(),
203 body: current_body.trim().to_string(),
204 });
205 }
206 current_heading = trimmed.trim_start_matches('#').trim().to_string();
208 current_body.clear();
209 } else {
210 current_body.push_str(line);
211 current_body.push('\n');
212 }
213 }
214
215 if !current_heading.is_empty() || !current_body.trim().is_empty() {
217 chunks.push(MdChunk {
218 heading: current_heading,
219 body: current_body.trim().to_string(),
220 });
221 }
222
223 chunks
224}
225
226pub struct UrlIndexer {
231 db: Connection,
232 ttl: Duration,
233}
234
235impl UrlIndexer {
236 #[cfg(test)]
239 pub(crate) fn in_memory() -> Result<Self> {
240 let conn = Connection::open_in_memory()?;
241 apply_url_schema(&conn)?;
242 Ok(Self {
243 db: conn,
244 ttl: Duration::from_secs(24 * 3600),
245 })
246 }
247
248 pub fn from_connection(conn: Connection, ttl: Duration) -> Result<Self> {
251 apply_url_schema(&conn)?;
252 Ok(Self { db: conn, ttl })
253 }
254
255 #[cfg(test)]
257 pub(crate) fn in_memory_with_ttl(ttl: Duration) -> Result<Self> {
258 let conn = Connection::open_in_memory()?;
259 apply_url_schema(&conn)?;
260 Ok(Self { db: conn, ttl })
261 }
262
263 pub fn fetch_and_index(
270 &self,
271 url: &str,
272 force: bool,
273 fetcher: &dyn ContentFetcher,
274 ) -> Result<IndexResult> {
275 if !force {
277 if let Some(fetched_at) = self.get_cache_time(url)? {
278 let age = Utc::now().signed_duration_since(fetched_at);
279 if age.num_seconds() < self.ttl.as_secs() as i64 {
280 let count = self.chunk_count(url)?;
281 return Ok(IndexResult {
282 url: url.to_string(),
283 chunks_indexed: count,
284 cached: true,
285 });
286 }
287 }
288 }
289
290 let html = fetcher.fetch(url)?;
292 let markdown = html_to_markdown(&html);
293 let chunks = chunk_by_headings(&markdown);
294
295 self.delete_url(url)?;
297
298 let now = Utc::now().to_rfc3339();
300 self.db.execute(
301 "INSERT OR REPLACE INTO url_cache (url, fetched_at, ttl_secs, markdown) VALUES (?1, ?2, ?3, ?4)",
302 params![url, now, self.ttl.as_secs() as i64, markdown],
303 )?;
304
305 let chunk_count = chunks.len();
307 for chunk in &chunks {
308 self.db.execute(
309 "INSERT INTO url_chunks (url, heading, body) VALUES (?1, ?2, ?3)",
310 params![url, chunk.heading, chunk.body],
311 )?;
312 }
313
314 Ok(IndexResult {
315 url: url.to_string(),
316 chunks_indexed: chunk_count,
317 cached: false,
318 })
319 }
320
321 pub fn search(&self, query: &str) -> Result<Vec<IndexedChunk>> {
324 let mut stmt = self.db.prepare(
325 r#"SELECT c.url, c.heading, c.body, rank
326 FROM url_chunks c
327 JOIN url_chunks_fts f ON c.id = f.rowid
328 WHERE url_chunks_fts MATCH ?1
329 ORDER BY rank"#,
330 )?;
331
332 let rows = stmt.query_map(params![query], |row| {
333 Ok(IndexedChunk {
334 url: row.get(0)?,
335 heading: row.get(1)?,
336 body: row.get(2)?,
337 rank: row.get(3)?,
338 })
339 })?;
340
341 let mut results = Vec::new();
342 for row in rows {
343 results.push(row?);
344 }
345 Ok(results)
346 }
347
348 fn get_cache_time(&self, url: &str) -> Result<Option<DateTime<Utc>>> {
351 let result: rusqlite::Result<String> = self.db.query_row(
352 "SELECT fetched_at FROM url_cache WHERE url = ?1",
353 params![url],
354 |row| row.get(0),
355 );
356 match result {
357 Ok(ts) => {
358 let dt = ts
359 .parse::<DateTime<Utc>>()
360 .map_err(|e| SqzError::Other(format!("invalid timestamp: {e}")))?;
361 Ok(Some(dt))
362 }
363 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
364 Err(e) => Err(SqzError::SessionStore(e)),
365 }
366 }
367
368 fn chunk_count(&self, url: &str) -> Result<usize> {
369 let count: i64 = self.db.query_row(
370 "SELECT COUNT(*) FROM url_chunks WHERE url = ?1",
371 params![url],
372 |row| row.get(0),
373 )?;
374 Ok(count as usize)
375 }
376
377 fn delete_url(&self, url: &str) -> Result<()> {
378 self.db.execute(
380 "DELETE FROM url_chunks WHERE url = ?1",
381 params![url],
382 )?;
383 self.db.execute(
384 "DELETE FROM url_cache WHERE url = ?1",
385 params![url],
386 )?;
387 Ok(())
388 }
389}
390
391
392#[cfg(test)]
395mod tests {
396 use super::*;
397 use std::collections::HashMap;
398 use std::sync::atomic::{AtomicUsize, Ordering};
399 use std::time::Duration;
400
401 struct MockFetcher {
403 pages: HashMap<String, String>,
404 call_count: AtomicUsize,
405 }
406
407 impl MockFetcher {
408 fn new() -> Self {
409 Self {
410 pages: HashMap::new(),
411 call_count: AtomicUsize::new(0),
412 }
413 }
414
415 fn add_page(&mut self, url: &str, html: &str) {
416 self.pages.insert(url.to_string(), html.to_string());
417 }
418
419 fn calls(&self) -> usize {
420 self.call_count.load(Ordering::SeqCst)
421 }
422 }
423
424 impl ContentFetcher for MockFetcher {
425 fn fetch(&self, url: &str) -> Result<String> {
426 self.call_count.fetch_add(1, Ordering::SeqCst);
427 self.pages
428 .get(url)
429 .cloned()
430 .ok_or_else(|| SqzError::Other(format!("URL not found: {url}")))
431 }
432 }
433
434 #[test]
437 fn test_html_to_markdown_headings() {
438 let html = "<h1>Title</h1><h2>Subtitle</h2><p>Body text</p>";
439 let md = html_to_markdown(html);
440 assert!(md.contains("# Title"));
441 assert!(md.contains("## Subtitle"));
442 assert!(md.contains("Body text"));
443 }
444
445 #[test]
446 fn test_html_to_markdown_strips_script_and_style() {
447 let html = "<p>visible</p><script>alert('x')</script><style>.a{}</style><p>also visible</p>";
448 let md = html_to_markdown(html);
449 assert!(md.contains("visible"));
450 assert!(md.contains("also visible"));
451 assert!(!md.contains("alert"));
452 assert!(!md.contains(".a{}"));
453 }
454
455 #[test]
456 fn test_html_to_markdown_entities() {
457 let html = "<p>A & B < C > D</p>";
458 let md = html_to_markdown(html);
459 assert!(md.contains("A & B < C > D"));
460 }
461
462 #[test]
463 fn test_html_to_markdown_lists() {
464 let html = "<ul><li>one</li><li>two</li></ul>";
465 let md = html_to_markdown(html);
466 assert!(md.contains("- one"));
467 assert!(md.contains("- two"));
468 }
469
470 #[test]
471 fn test_html_to_markdown_bold_italic() {
472 let html = "<strong>bold</strong> and <em>italic</em>";
473 let md = html_to_markdown(html);
474 assert!(md.contains("**bold**"));
475 assert!(md.contains("*italic*"));
476 }
477
478 #[test]
481 fn test_chunk_by_headings_basic() {
482 let md = "# Intro\nSome text\n## Details\nMore text\n## Conclusion\nFinal words";
483 let chunks = chunk_by_headings(md);
484 assert_eq!(chunks.len(), 3);
485 assert_eq!(chunks[0].heading, "Intro");
486 assert!(chunks[0].body.contains("Some text"));
487 assert_eq!(chunks[1].heading, "Details");
488 assert_eq!(chunks[2].heading, "Conclusion");
489 }
490
491 #[test]
492 fn test_chunk_by_headings_no_headings() {
493 let md = "Just plain text\nwith multiple lines";
494 let chunks = chunk_by_headings(md);
495 assert_eq!(chunks.len(), 1);
496 assert!(chunks[0].heading.is_empty());
497 assert!(chunks[0].body.contains("Just plain text"));
498 }
499
500 #[test]
503 fn test_fetch_and_index_basic() {
504 let indexer = UrlIndexer::in_memory().unwrap();
505 let mut fetcher = MockFetcher::new();
506 fetcher.add_page(
507 "https://example.com/docs",
508 "<h1>API Reference</h1><p>This is the API docs.</p>\
509 <h2>Authentication</h2><p>Use bearer tokens for auth.</p>\
510 <h2>Endpoints</h2><p>GET /users returns user list.</p>",
511 );
512
513 let result = indexer
514 .fetch_and_index("https://example.com/docs", false, &fetcher)
515 .unwrap();
516
517 assert_eq!(result.url, "https://example.com/docs");
518 assert_eq!(result.chunks_indexed, 3);
519 assert!(!result.cached);
520 assert_eq!(fetcher.calls(), 1);
521 }
522
523 #[test]
524 fn test_cache_prevents_refetch() {
525 let indexer = UrlIndexer::in_memory().unwrap();
526 let mut fetcher = MockFetcher::new();
527 fetcher.add_page("https://example.com", "<h1>Hello</h1><p>World</p>");
528
529 let r1 = indexer
531 .fetch_and_index("https://example.com", false, &fetcher)
532 .unwrap();
533 assert!(!r1.cached);
534 assert_eq!(fetcher.calls(), 1);
535
536 let r2 = indexer
538 .fetch_and_index("https://example.com", false, &fetcher)
539 .unwrap();
540 assert!(r2.cached);
541 assert_eq!(fetcher.calls(), 1); }
543
544 #[test]
545 fn test_force_bypasses_cache() {
546 let indexer = UrlIndexer::in_memory().unwrap();
547 let mut fetcher = MockFetcher::new();
548 fetcher.add_page("https://example.com", "<h1>Hello</h1><p>World</p>");
549
550 indexer
551 .fetch_and_index("https://example.com", false, &fetcher)
552 .unwrap();
553 assert_eq!(fetcher.calls(), 1);
554
555 let r2 = indexer
557 .fetch_and_index("https://example.com", true, &fetcher)
558 .unwrap();
559 assert!(!r2.cached);
560 assert_eq!(fetcher.calls(), 2);
561 }
562
563 #[test]
564 fn test_expired_ttl_refetches() {
565 let indexer = UrlIndexer::in_memory_with_ttl(Duration::from_secs(0)).unwrap();
567 let mut fetcher = MockFetcher::new();
568 fetcher.add_page("https://example.com", "<h1>Hello</h1><p>World</p>");
569
570 indexer
571 .fetch_and_index("https://example.com", false, &fetcher)
572 .unwrap();
573 assert_eq!(fetcher.calls(), 1);
574
575 let r2 = indexer
577 .fetch_and_index("https://example.com", false, &fetcher)
578 .unwrap();
579 assert!(!r2.cached);
580 assert_eq!(fetcher.calls(), 2);
581 }
582
583 #[test]
584 fn test_search_returns_matching_sections_only() {
585 let indexer = UrlIndexer::in_memory().unwrap();
586 let mut fetcher = MockFetcher::new();
587 fetcher.add_page(
588 "https://example.com/docs",
589 "<h1>Overview</h1><p>General introduction to the system.</p>\
590 <h2>Authentication</h2><p>Use bearer tokens for authentication.</p>\
591 <h2>Rate Limiting</h2><p>Requests are limited to 100 per minute.</p>",
592 );
593
594 indexer
595 .fetch_and_index("https://example.com/docs", false, &fetcher)
596 .unwrap();
597
598 let results = indexer.search("authentication").unwrap();
599 assert!(!results.is_empty());
601 assert!(results.iter().any(|r| r.heading == "Authentication"));
602 assert!(results.iter().all(|r| r.heading != "Overview"
604 || r.body.to_lowercase().contains("authentication")));
605 }
606
607 #[test]
608 fn test_search_across_multiple_urls() {
609 let indexer = UrlIndexer::in_memory().unwrap();
610 let mut fetcher = MockFetcher::new();
611 fetcher.add_page(
612 "https://a.com",
613 "<h1>Rust Guide</h1><p>Learn about ownership and borrowing.</p>",
614 );
615 fetcher.add_page(
616 "https://b.com",
617 "<h1>Python Guide</h1><p>Learn about decorators and generators.</p>",
618 );
619
620 indexer
621 .fetch_and_index("https://a.com", false, &fetcher)
622 .unwrap();
623 indexer
624 .fetch_and_index("https://b.com", false, &fetcher)
625 .unwrap();
626
627 let results = indexer.search("ownership").unwrap();
628 assert_eq!(results.len(), 1);
629 assert_eq!(results[0].url, "https://a.com");
630
631 let results = indexer.search("decorators").unwrap();
632 assert_eq!(results.len(), 1);
633 assert_eq!(results[0].url, "https://b.com");
634 }
635
636 #[test]
637 fn test_search_empty_query_returns_empty() {
638 let indexer = UrlIndexer::in_memory().unwrap();
639 let mut fetcher = MockFetcher::new();
640 fetcher.add_page("https://example.com", "<h1>Hello</h1><p>World</p>");
641 indexer
642 .fetch_and_index("https://example.com", false, &fetcher)
643 .unwrap();
644
645 let results = indexer.search("nonexistentxyzterm");
647 assert!(results.is_ok());
648 assert!(results.unwrap().is_empty());
649 }
650
651 #[test]
652 fn test_reindex_replaces_old_chunks() {
653 let indexer = UrlIndexer::in_memory().unwrap();
654 let mut fetcher = MockFetcher::new();
655 fetcher.add_page(
656 "https://example.com",
657 "<h1>Version 1</h1><p>Old content about widgets.</p>",
658 );
659
660 indexer
661 .fetch_and_index("https://example.com", false, &fetcher)
662 .unwrap();
663
664 fetcher.pages.insert(
666 "https://example.com".to_string(),
667 "<h1>Version 2</h1><p>New content about gadgets.</p>".to_string(),
668 );
669
670 indexer
672 .fetch_and_index("https://example.com", true, &fetcher)
673 .unwrap();
674
675 let old = indexer.search("widgets").unwrap();
677 assert!(old.is_empty());
678
679 let new = indexer.search("gadgets").unwrap();
681 assert_eq!(new.len(), 1);
682 assert!(new[0].body.contains("gadgets"));
683 }
684
685 #[test]
686 fn test_fetch_error_propagates() {
687 let indexer = UrlIndexer::in_memory().unwrap();
688 let fetcher = MockFetcher::new(); let result = indexer.fetch_and_index("https://missing.com", false, &fetcher);
691 assert!(result.is_err());
692 }
693}