agentstatedeveloper_core/
doc_adapters.rs1use std::path::Path;
17
18use crate::search_fts::{DocKind, SearchDoc};
19
20pub 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 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
72pub 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
101fn 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(¤t_title, ¤t_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(¤t_title, ¤t_body, chunk_start, &mut docs);
142
143 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
160fn 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 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
184fn flatten_json_text(content: &str, _max_depth: usize) -> String {
187 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
202fn 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 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 ¤t_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 ¤t_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
266fn 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 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
298fn 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 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 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 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
382fn 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 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
423fn 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 if kind == DocKind::BuildScript {
435 let targets: Vec<String> = content
436 .lines()
437 .filter_map(|l| {
438 let t = l.trim();
439 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 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 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 #[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 assert_eq!(strip_html_tags("— <script>evil()</script> ok"), "— ok");
501 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 let _ = adapt_document(Path::new("doc.html"), html);
510 }
511}