Skip to main content

agentstatedeveloper_core/
doc_adapters.rs

1//! Document adapters: produce [`SearchDoc`] chunks from non-code files.
2//!
3//! Each adapter takes a file path + raw content and returns `Vec<SearchDoc>`.
4//! Adapters are intentionally thin — no deep structural parsing, just enough
5//! text extraction to make files discoverable in `asd search`.
6//!
7//! Supported file types:
8//!   - Markdown (.md, .markdown) — each H1/H2/H3 heading becomes a chunk
9//!   - Config (JSON, TOML, YAML) — key-path flattening for leaf values
10//!   - HTML (.html, .htm) — strip tags, extract title + heading text
11//!   - CSS (.css) — extract selector names and custom property names
12//!   - Manifests (Package.swift, Cargo.toml, pubspec.yaml, package.json) —
13//!     extract name + description + top-level keys
14//!   - Build scripts (Makefile, Fastfile, Podfile, Gemfile) — extract target/lane names
15
16use std::path::Path;
17
18use crate::search_fts::{DocKind, SearchDoc};
19
20// ---------------------------------------------------------------------------
21// Entry point
22// ---------------------------------------------------------------------------
23
24/// Detect the kind for a file path and, if recognised, parse it into chunks.
25/// Returns `None` when the file is not handled by any adapter.
26pub fn adapt_document(path: &Path, content: &str) -> Option<Vec<SearchDoc>> {
27    let name = path.file_name()?.to_string_lossy().to_lowercase();
28    let ext = path
29        .extension()
30        .map(|e| e.to_string_lossy().to_lowercase())
31        .unwrap_or_default();
32
33    // Manifests — check by specific filename before extension.
34    let is_manifest = matches!(
35        name.as_str(),
36        "package.swift"
37            | "cargo.toml"
38            | "pubspec.yaml"
39            | "pubspec.yml"
40            | "package.json"
41            | "package-lock.json"
42            | "podfile"
43            | "gemfile"
44            | "makefile"
45            | "fastfile"
46            | "appfile"
47            | "deliverfile"
48    );
49    if is_manifest {
50        let kind = if matches!(
51            name.as_str(),
52            "makefile" | "fastfile" | "podfile" | "gemfile"
53        ) {
54            DocKind::BuildScript
55        } else {
56            DocKind::Manifest
57        };
58        return Some(adapt_manifest(path, content, kind));
59    }
60
61    match ext.as_str() {
62        "md" | "markdown" => Some(adapt_markdown(path, content)),
63        "json" => Some(adapt_json(path, content)),
64        "toml" => Some(adapt_toml(path, content)),
65        "yaml" | "yml" => Some(adapt_yaml(path, content)),
66        "html" | "htm" => Some(adapt_html(path, content)),
67        "css" => Some(adapt_css(path, content)),
68        _ => None,
69    }
70}
71
72/// Returns true if this path should be offered to document adapters.
73/// Used to quickly gate file walking before reading content.
74pub fn is_doc_file(path: &Path) -> bool {
75    let name = path
76        .file_name()
77        .map(|n| n.to_string_lossy().to_lowercase())
78        .unwrap_or_default();
79    let ext = path
80        .extension()
81        .map(|e| e.to_string_lossy().to_lowercase())
82        .unwrap_or_default();
83
84    matches!(
85        name.as_str(),
86        "package.swift"
87            | "cargo.toml"
88            | "pubspec.yaml"
89            | "pubspec.yml"
90            | "package.json"
91            | "makefile"
92            | "fastfile"
93            | "podfile"
94            | "gemfile"
95    ) || matches!(
96        ext.as_str(),
97        "md" | "markdown" | "json" | "toml" | "yaml" | "yml" | "html" | "htm" | "css"
98    )
99}
100
101// ---------------------------------------------------------------------------
102// Markdown adapter
103// ---------------------------------------------------------------------------
104
105fn adapt_markdown(path: &Path, content: &str) -> Vec<SearchDoc> {
106    let path_str = path.to_string_lossy();
107    let mut docs: Vec<SearchDoc> = Vec::new();
108    let mut current_title = String::new();
109    let mut current_body: Vec<&str> = Vec::new();
110    let mut current_line: u32 = 0;
111    let mut chunk_start: u32 = 1;
112
113    let flush = |title: &str, body: &[&str], start: u32, docs: &mut Vec<SearchDoc>| {
114        let body_text = body.join(" ").trim().to_string();
115        if !title.is_empty() || !body_text.is_empty() {
116            docs.push(SearchDoc::new(
117                DocKind::Markdown,
118                path_str.as_ref(),
119                Some(start),
120                title,
121                body_text,
122            ));
123        }
124    };
125
126    for line in content.lines() {
127        current_line += 1;
128        let trimmed = line.trim();
129
130        if trimmed.starts_with("# ") || trimmed.starts_with("## ") || trimmed.starts_with("### ") {
131            // Flush previous chunk.
132            flush(&current_title, &current_body, chunk_start, &mut docs);
133            current_title = trimmed.trim_start_matches('#').trim().to_string();
134            current_body = Vec::new();
135            chunk_start = current_line;
136        } else if !trimmed.is_empty() && !trimmed.starts_with("```") {
137            current_body.push(line);
138        }
139    }
140    // Flush last chunk.
141    flush(&current_title, &current_body, chunk_start, &mut docs);
142
143    // If no headings found, emit a single whole-file chunk using the filename as title.
144    if docs.is_empty() {
145        let title = path
146            .file_stem()
147            .map(|s| s.to_string_lossy().to_string())
148            .unwrap_or_default();
149        docs.push(SearchDoc::new(
150            DocKind::Markdown,
151            path_str.as_ref(),
152            None,
153            title,
154            content.chars().take(2000).collect::<String>(),
155        ));
156    }
157    docs
158}
159
160// ---------------------------------------------------------------------------
161// JSON adapter
162// ---------------------------------------------------------------------------
163
164fn adapt_json(path: &Path, content: &str) -> Vec<SearchDoc> {
165    let path_str = path.to_string_lossy();
166    let name = path
167        .file_name()
168        .map(|n| n.to_string_lossy().to_string())
169        .unwrap_or_default();
170    // For small JSON files (≤4KB) emit a single chunk with flattened key paths.
171    let body = flatten_json_text(content, 3);
172    if body.is_empty() {
173        return vec![];
174    }
175    vec![SearchDoc::new(
176        DocKind::Config,
177        path_str.as_ref(),
178        None,
179        name,
180        body,
181    )]
182}
183
184/// Flatten up to `max_depth` levels of a JSON object into "key: value" lines.
185/// Avoids serde_json entirely — simple line-based heuristic for discovery text.
186fn flatten_json_text(content: &str, _max_depth: usize) -> String {
187    // Strip string values, keep keys + short scalar values for searchability.
188    content
189        .lines()
190        .map(|l| l.trim())
191        .filter(|l| l.contains(':') || l.contains('"'))
192        .take(100)
193        .map(|l| l.trim_matches(|c: char| c == ',' || c == '{' || c == '}' || c == '[' || c == ']'))
194        .filter(|l| !l.is_empty())
195        .collect::<Vec<_>>()
196        .join(" ")
197        .chars()
198        .take(2000)
199        .collect()
200}
201
202// ---------------------------------------------------------------------------
203// TOML adapter
204// ---------------------------------------------------------------------------
205
206fn adapt_toml(path: &Path, content: &str) -> Vec<SearchDoc> {
207    let path_str = path.to_string_lossy();
208    let name = path
209        .file_name()
210        .map(|n| n.to_string_lossy().to_string())
211        .unwrap_or_default();
212    // Emit key = value lines as body; section headers ([section]) become subchunks.
213    let mut docs: Vec<SearchDoc> = Vec::new();
214    let mut current_section = name.clone();
215    let mut body_lines: Vec<&str> = Vec::new();
216    let mut chunk_start: u32 = 0;
217    let mut line_no: u32 = 0;
218
219    for line in content.lines() {
220        line_no += 1;
221        let trimmed = line.trim();
222        if trimmed.starts_with('[') && trimmed.ends_with(']') {
223            let section_body = body_lines.join(" ");
224            if !section_body.trim().is_empty() {
225                docs.push(SearchDoc::new(
226                    DocKind::Manifest,
227                    path_str.as_ref(),
228                    Some(chunk_start),
229                    &current_section,
230                    section_body,
231                ));
232            }
233            current_section = format!(
234                "{} {}",
235                name,
236                trimmed.trim_matches(|c| c == '[' || c == ']')
237            );
238            body_lines = Vec::new();
239            chunk_start = line_no;
240        } else if !trimmed.is_empty() && !trimmed.starts_with('#') {
241            body_lines.push(line);
242        }
243    }
244    let section_body = body_lines.join(" ");
245    if !section_body.trim().is_empty() {
246        docs.push(SearchDoc::new(
247            DocKind::Manifest,
248            path_str.as_ref(),
249            Some(chunk_start),
250            &current_section,
251            section_body,
252        ));
253    }
254    if docs.is_empty() {
255        docs.push(SearchDoc::new(
256            DocKind::Config,
257            path_str.as_ref(),
258            None,
259            name,
260            content.chars().take(1000).collect::<String>(),
261        ));
262    }
263    docs
264}
265
266// ---------------------------------------------------------------------------
267// YAML adapter
268// ---------------------------------------------------------------------------
269
270fn adapt_yaml(path: &Path, content: &str) -> Vec<SearchDoc> {
271    let path_str = path.to_string_lossy();
272    let name = path
273        .file_name()
274        .map(|n| n.to_string_lossy().to_string())
275        .unwrap_or_default();
276    // Flatten top-level keys + values into a single searchable chunk.
277    let body: String = content
278        .lines()
279        .filter(|l| !l.trim_start().starts_with('#') && !l.trim().is_empty())
280        .take(80)
281        .collect::<Vec<_>>()
282        .join(" ")
283        .chars()
284        .take(2000)
285        .collect();
286    if body.is_empty() {
287        return vec![];
288    }
289    vec![SearchDoc::new(
290        DocKind::Config,
291        path_str.as_ref(),
292        None,
293        name,
294        body,
295    )]
296}
297
298// ---------------------------------------------------------------------------
299// HTML adapter
300// ---------------------------------------------------------------------------
301
302fn adapt_html(path: &Path, content: &str) -> Vec<SearchDoc> {
303    let path_str = path.to_string_lossy();
304    let name = path
305        .file_name()
306        .map(|n| n.to_string_lossy().to_string())
307        .unwrap_or_default();
308    // Strip HTML tags, collapse whitespace.
309    let text = strip_html_tags(content);
310    if text.trim().is_empty() {
311        return vec![];
312    }
313    vec![SearchDoc::new(
314        DocKind::Html,
315        path_str.as_ref(),
316        None,
317        name,
318        text.chars().take(2000).collect::<String>(),
319    )]
320}
321
322fn strip_html_tags(s: &str) -> String {
323    let mut out = String::with_capacity(s.len());
324    let mut in_tag = false;
325    let mut in_script = false;
326    let mut prev_space = true;
327    let mut i = 0;
328    let bytes = s.as_bytes();
329    while i < bytes.len() {
330        // Detect <script> blocks. Byte-wise ASCII comparison so a multi-byte
331        // char (em-dash, arrow, …) elsewhere in the string can never panic a
332        // string slice on a non-char boundary.
333        if !in_tag && i + 7 <= bytes.len() && bytes[i..i + 7].eq_ignore_ascii_case(b"<script") {
334            in_script = true;
335        }
336        if in_script {
337            if i + 9 <= bytes.len() && bytes[i..i + 9].eq_ignore_ascii_case(b"</script>") {
338                in_script = false;
339                i += 9;
340                continue;
341            }
342            i += 1;
343            continue;
344        }
345        match bytes[i] {
346            b'<' => {
347                in_tag = true;
348                i += 1;
349            }
350            b'>' => {
351                in_tag = false;
352                if !prev_space {
353                    out.push(' ');
354                    prev_space = true;
355                }
356                i += 1;
357            }
358            _ if !in_tag => {
359                // `i` is always on a char boundary here (text advances by the
360                // char's UTF-8 width; tags/markup are skipped byte-by-byte
361                // through ASCII delimiters), so this slice is safe.
362                let c = s[i..].chars().next().unwrap_or(' ');
363                if c.is_whitespace() {
364                    if !prev_space {
365                        out.push(' ');
366                        prev_space = true;
367                    }
368                } else {
369                    out.push(c);
370                    prev_space = false;
371                }
372                i += c.len_utf8();
373            }
374            _ => {
375                i += 1;
376            }
377        }
378    }
379    out
380}
381
382// ---------------------------------------------------------------------------
383// CSS adapter
384// ---------------------------------------------------------------------------
385
386fn adapt_css(path: &Path, content: &str) -> Vec<SearchDoc> {
387    let path_str = path.to_string_lossy();
388    let name = path
389        .file_name()
390        .map(|n| n.to_string_lossy().to_string())
391        .unwrap_or_default();
392    // Extract selector names and custom property declarations (--var-name).
393    let mut selectors: Vec<String> = Vec::new();
394    let mut custom_props: Vec<String> = Vec::new();
395    for line in content.lines() {
396        let t = line.trim();
397        if t.ends_with('{') && !t.starts_with("/*") {
398            let sel = t.trim_end_matches('{').trim();
399            if !sel.is_empty() {
400                selectors.push(sel.to_string());
401            }
402        }
403        if t.starts_with("--") {
404            let prop = t.split(':').next().unwrap_or("").trim();
405            if !prop.is_empty() {
406                custom_props.push(prop.to_string());
407            }
408        }
409    }
410    let body = format!("{} {}", selectors.join(" "), custom_props.join(" "));
411    if body.trim().is_empty() {
412        return vec![];
413    }
414    vec![SearchDoc::new(
415        DocKind::Css,
416        path_str.as_ref(),
417        None,
418        name,
419        body.chars().take(2000).collect::<String>(),
420    )]
421}
422
423// ---------------------------------------------------------------------------
424// Manifest / build-script adapter
425// ---------------------------------------------------------------------------
426
427fn adapt_manifest(path: &Path, content: &str, kind: DocKind) -> Vec<SearchDoc> {
428    let path_str = path.to_string_lossy();
429    let name = path
430        .file_name()
431        .map(|n| n.to_string_lossy().to_string())
432        .unwrap_or_default();
433    // For build scripts, extract target/lane names.
434    if kind == DocKind::BuildScript {
435        let targets: Vec<String> = content
436            .lines()
437            .filter_map(|l| {
438                let t = l.trim();
439                // Makefile targets: "target:"
440                if t.ends_with(':') && !t.starts_with('\t') && !t.starts_with('#') {
441                    let target = t.trim_end_matches(':').trim();
442                    if !target.is_empty() && !target.contains(' ') {
443                        return Some(target.to_string());
444                    }
445                }
446                // Fastlane lanes: "lane :name do"
447                if t.starts_with("lane :") || t.starts_with("desc \"") {
448                    return Some(t.to_string());
449                }
450                None
451            })
452            .take(50)
453            .collect();
454        let body = targets.join(" ");
455        if body.is_empty() {
456            return vec![SearchDoc::new(
457                kind,
458                path_str.as_ref(),
459                None,
460                name,
461                content.chars().take(500).collect::<String>(),
462            )];
463        }
464        return vec![SearchDoc::new(kind, path_str.as_ref(), None, name, body)];
465    }
466
467    // Generic manifest: flatten all key = value / "key": "value" lines.
468    let body: String = content
469        .lines()
470        .filter(|l| {
471            let t = l.trim();
472            !t.is_empty() && !t.starts_with("//") && !t.starts_with('#')
473        })
474        .take(60)
475        .collect::<Vec<_>>()
476        .join(" ")
477        .chars()
478        .take(2000)
479        .collect();
480    if body.is_empty() {
481        return vec![];
482    }
483    vec![SearchDoc::new(kind, path_str.as_ref(), None, name, body)]
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489
490    // Regression (Plan Q t-001): the indexer panicked on any doc containing a
491    // multi-byte char (em-dash, arrow, …) because `strip_html_tags` iterated
492    // bytes but sliced the string and advanced one byte past a wide char. These
493    // must not panic and must preserve the char.
494    #[test]
495    fn strip_html_tags_survives_multibyte_chars() {
496        assert_eq!(strip_html_tags("a — b"), "a — b");
497        assert_eq!(strip_html_tags("x ← y → z"), "x ← y → z");
498        assert_eq!(strip_html_tags("<b>café</b> — déjà"), "café — déjà");
499        // A wide char adjacent to a <script> block (the original crash shape).
500        assert_eq!(strip_html_tags("— <script>evil()</script> ok"), "— ok");
501        // Emoji (4-byte) must not desync the index either.
502        assert_eq!(strip_html_tags("ship 🚀 it"), "ship 🚀 it");
503    }
504
505    #[test]
506    fn adapt_document_does_not_panic_on_unicode_html() {
507        let html = "<html><body><h1>Title —</h1><p>arrow ← here</p></body></html>";
508        // Must return Some(..) without panicking.
509        let _ = adapt_document(Path::new("doc.html"), html);
510    }
511}