1use std::collections::HashSet;
26use std::path::{Path, PathBuf};
27
28use super::{ImportResult, ImportSource, RepoFact, SourceRefusal, push_repo_fact};
29use crate::core::knowledge::FactValidity;
30
31pub const INSTRUCTION_FILES: [&str; 3] = ["AGENTS.md", "CLAUDE.md", ".cursorrules"];
33pub const DOCS_DIR: &str = "docs";
35const MAX_FILES: usize = 400;
37const MAX_DOCS_DEPTH: usize = 6;
39const MIN_FACT_CHARS: usize = 12;
41
42#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Entry {
45 pub category: &'static str,
46 pub anchor: String,
48 pub text: String,
49 pub offset: usize,
51}
52
53const HEADING_CATEGORIES: [(&str, &[&str]); 7] = [
58 (
59 "dead_end",
60 &[
61 "dead end",
62 "dead-end",
63 "dead ends",
64 "abandoned",
65 "did not work",
66 "didn't work",
67 "tried",
68 ],
69 ),
70 (
71 "gotcha",
72 &[
73 "gotcha",
74 "pitfall",
75 "known issue",
76 "caveat",
77 "avoid",
78 "never",
79 "don't",
80 "do not",
81 "anti-pattern",
82 ],
83 ),
84 (
85 "experience",
86 &[
87 "experience",
88 "lesson",
89 "learned",
90 "postmortem",
91 "post-mortem",
92 "incident",
93 ],
94 ),
95 ("decision", &["decision", "adr", "rationale", "why we"]),
96 (
97 "procedure",
98 &[
99 "procedure",
100 "how to",
101 "how-to",
102 "steps",
103 "workflow",
104 "setup",
105 "set up",
106 "install",
107 "build",
108 "test",
109 "release",
110 "deploy",
111 "runbook",
112 "commands",
113 "development",
114 ],
115 ),
116 (
117 "architecture",
118 &[
119 "architecture",
120 "design",
121 "structure",
122 "overview",
123 "components",
124 "layout",
125 ],
126 ),
127 (
128 "convention",
129 &[
130 "convention",
131 "rule",
132 "style",
133 "guideline",
134 "standard",
135 "naming",
136 "policy",
137 "instruction",
138 "must",
139 "quality",
140 "format",
141 ],
142 ),
143];
144
145fn names(words: &[String], keyword: &str) -> bool {
149 if keyword.contains(' ') {
150 let padded = format!(" {} ", words.join(" "));
151 padded.contains(&format!(" {keyword} "))
152 } else {
153 words.iter().any(|word| word.starts_with(keyword))
154 }
155}
156
157fn heading_category(heading: &str) -> Option<&'static str> {
158 let lower = heading.to_lowercase();
159 let words: Vec<String> = lower
160 .split(|c: char| !(c.is_alphanumeric() || c == '\'' || c == '-'))
161 .filter(|word| !word.is_empty())
162 .map(str::to_owned)
163 .collect();
164 HEADING_CATEGORIES
165 .iter()
166 .find(|(_, keywords)| keywords.iter().any(|keyword| names(&words, keyword)))
167 .map(|(category, _)| *category)
168}
169
170fn list_item(line: &str) -> Option<&str> {
172 let trimmed = line.trim_start();
173 for marker in ["- ", "* ", "+ "] {
174 if let Some(rest) = trimmed.strip_prefix(marker) {
175 return Some(rest);
176 }
177 }
178 let digits = trimmed.chars().take_while(char::is_ascii_digit).count();
179 if digits > 0 {
180 let rest = &trimmed[digits..];
181 if let Some(rest) = rest.strip_prefix(". ").or_else(|| rest.strip_prefix(") ")) {
182 return Some(rest);
183 }
184 }
185 None
186}
187
188fn heading(line: &str) -> Option<(usize, &str)> {
189 let level = line.chars().take_while(|c| *c == '#').count();
190 if (1..=6).contains(&level) {
191 let rest = &line[level..];
192 if rest.starts_with(' ') {
193 return Some((level, rest.trim().trim_end_matches('#').trim()));
194 }
195 }
196 None
197}
198
199fn normalized(text: &str) -> String {
200 text.split_whitespace().collect::<Vec<_>>().join(" ")
201}
202
203#[must_use]
206pub fn parse(text: &str, instructions: bool) -> Vec<Entry> {
207 let mut entries = Vec::new();
208 let mut headings: Vec<(usize, String)> = Vec::new();
209 let mut counter = 0usize;
210 let mut current: Option<(usize, String)> = None;
212 let mut in_fence: Option<&str> = None;
213 let mut in_comment = false;
214
215 let mut offset = 0usize;
216 let mut lines = text
217 .split_inclusive('\n')
218 .map(|raw| {
219 let start = offset;
220 offset += raw.len();
221 (start, raw.trim_end_matches(['\n', '\r']))
222 })
223 .peekable();
224 if lines.peek().is_some_and(|(_, line)| line.trim() == "---") {
226 lines.next();
227 for (_, line) in lines.by_ref() {
228 if line.trim() == "---" {
229 break;
230 }
231 }
232 }
233
234 let flush = |current: &mut Option<(usize, String)>,
235 counter: &mut usize,
236 headings: &[(usize, String)],
237 entries: &mut Vec<Entry>| {
238 let Some((offset, text)) = current.take() else {
239 return;
240 };
241 let text = normalized(&text);
242 if text.chars().count() < MIN_FACT_CHARS {
243 return;
244 }
245 let category = headings
246 .iter()
247 .rev()
248 .find_map(|(_, title)| heading_category(title))
249 .unwrap_or(if instructions {
250 "convention"
251 } else {
252 "documentation"
253 });
254 let path: Vec<String> = headings
255 .iter()
256 .map(|(_, title)| crate::core::knowledge::anchors::heading_slug(title))
257 .collect();
258 let section = if path.is_empty() {
259 "top".to_owned()
260 } else {
261 path.join("/")
262 };
263 *counter += 1;
264 entries.push(Entry {
265 category,
266 anchor: format!("{section}/{counter}"),
267 text,
268 offset,
269 });
270 };
271
272 for (start, line) in lines {
273 let trimmed = line.trim();
274 if let Some(fence) = in_fence {
275 if trimmed.starts_with(fence) {
276 in_fence = None;
277 }
278 continue;
279 }
280 if in_comment {
281 if trimmed.contains("-->") {
282 in_comment = false;
283 }
284 continue;
285 }
286 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
287 flush(&mut current, &mut counter, &headings, &mut entries);
288 in_fence = Some(if trimmed.starts_with("```") {
289 "```"
290 } else {
291 "~~~"
292 });
293 continue;
294 }
295 if trimmed.starts_with("<!--") {
296 flush(&mut current, &mut counter, &headings, &mut entries);
297 in_comment = !trimmed.contains("-->");
298 continue;
299 }
300 if let Some((level, title)) = heading(trimmed) {
301 flush(&mut current, &mut counter, &headings, &mut entries);
302 headings.retain(|(l, _)| *l < level);
303 headings.push((level, title.to_owned()));
304 counter = 0;
305 continue;
306 }
307 if trimmed.is_empty() || trimmed.starts_with('|') {
308 flush(&mut current, &mut counter, &headings, &mut entries);
309 continue;
310 }
311 let trimmed = trimmed.trim_start_matches('>').trim();
312 if trimmed.is_empty() {
313 continue;
314 }
315 if let Some(item) = list_item(line) {
316 flush(&mut current, &mut counter, &headings, &mut entries);
317 current = Some((start, item.to_owned()));
318 continue;
319 }
320 match current.as_mut() {
321 Some((_, text)) => {
322 text.push(' ');
323 text.push_str(trimmed);
324 }
325 None => current = Some((start, trimmed.to_owned())),
326 }
327 }
328 flush(&mut current, &mut counter, &headings, &mut entries);
329 entries
330}
331
332fn is_markdown(path: &Path) -> bool {
333 path.extension()
334 .is_some_and(|extension| extension == "md" || extension == "mdc" || extension == "markdown")
335}
336
337fn tracked_entries(dir: &Path) -> Vec<PathBuf> {
340 let mut found: Vec<PathBuf> = ignore::WalkBuilder::new(dir)
341 .max_depth(Some(1))
342 .hidden(false)
343 .build()
344 .flatten()
345 .filter(|entry| entry.depth() == 1)
346 .map(ignore::DirEntry::into_path)
347 .filter(|path| path.is_file())
348 .collect();
349 found.sort();
350 found
351}
352
353fn default_files(root: &Path) -> (Vec<PathBuf>, usize) {
356 let present = tracked_entries(root);
357 let mut files: Vec<PathBuf> = INSTRUCTION_FILES
358 .iter()
359 .map(|name| root.join(name))
360 .filter(|path| present.contains(path))
361 .collect();
362 let rules = root.join(".cursor").join("rules");
363 if rules.is_dir() {
364 files.extend(
365 tracked_entries(&rules)
366 .into_iter()
367 .filter(|path| is_markdown(path)),
368 );
369 }
370 let docs = root.join(DOCS_DIR);
371 if docs.is_dir() {
372 let mut found: Vec<PathBuf> = ignore::WalkBuilder::new(&docs)
373 .max_depth(Some(MAX_DOCS_DEPTH))
374 .hidden(true)
375 .build()
376 .flatten()
377 .map(ignore::DirEntry::into_path)
378 .filter(|path| path.is_file() && is_markdown(path))
379 .collect();
380 found.sort();
381 files.extend(found);
382 }
383 let beyond = files.len().saturating_sub(MAX_FILES);
384 files.truncate(MAX_FILES);
385 (files, beyond)
386}
387
388fn named_files(root: &Path, paths: &[PathBuf]) -> (Vec<PathBuf>, usize) {
390 let mut files = Vec::new();
391 for path in paths {
392 let path = if path.is_absolute() {
393 path.clone()
394 } else {
395 root.join(path)
396 };
397 if path.is_dir() {
398 let mut found: Vec<PathBuf> = ignore::WalkBuilder::new(&path)
399 .max_depth(Some(MAX_DOCS_DEPTH))
400 .build()
401 .flatten()
402 .map(ignore::DirEntry::into_path)
403 .filter(|path| path.is_file() && is_markdown(path))
404 .collect();
405 found.sort();
406 files.extend(found);
407 } else {
408 files.push(path);
409 }
410 }
411 let beyond = files.len().saturating_sub(MAX_FILES);
412 files.truncate(MAX_FILES);
413 (files, beyond)
414}
415
416fn is_instruction_file(relative: &Path) -> bool {
417 let name = relative.to_string_lossy();
418 INSTRUCTION_FILES.iter().any(|file| name == *file)
419 || relative.starts_with(Path::new(".cursor").join("rules"))
420}
421
422#[must_use]
425pub fn import(project_root: &Path, paths: &[PathBuf]) -> ImportResult {
426 use crate::core::admitted_sources::{self, SourceRead};
427 let mut result = ImportResult::default();
428 let Ok(root) = project_root.canonicalize() else {
429 result.refuse(SourceRefusal::Unreadable);
430 return result;
431 };
432 let (files, beyond) = if paths.is_empty() {
433 default_files(&root)
434 } else {
435 named_files(&root, paths)
436 };
437 result.sources_skipped += beyond;
438 let gateway = crate::core::context_admission::carrier::gateway_snapshot();
439 let rules = Some(gateway.admission_policy());
440 let mut seen = HashSet::new();
441 let root_text = root.to_string_lossy().into_owned();
442 for file in files {
443 let Ok(path) = file.canonicalize() else {
444 result.refuse(SourceRefusal::Unreadable);
445 continue;
446 };
447 let Ok(relative) = path.strip_prefix(&root) else {
448 result.refuse(SourceRefusal::OutsideRoot);
449 continue;
450 };
451 if relative.starts_with(".git") || !(is_markdown(&path) || is_instruction_file(relative)) {
454 result.refuse(SourceRefusal::Withheld);
455 continue;
456 }
457 let text = match admitted_sources::read_with_admission(
458 &root_text,
459 &path.to_string_lossy(),
460 "ctx_knowledge",
461 &gateway,
462 ) {
463 read @ SourceRead::Admitted(_) => {
464 let Some(text) = read.storable() else {
465 result.refuse(SourceRefusal::Withheld);
466 continue;
467 };
468 text.to_owned()
469 }
470 SourceRead::Withheld(_) => {
471 result.refuse(SourceRefusal::Withheld);
472 continue;
473 }
474 SourceRead::Unavailable(_) => {
475 result.refuse(SourceRefusal::Unreadable);
476 continue;
477 }
478 };
479 let relative_text = relative.to_string_lossy().replace('\\', "/");
480 let session = super::session_label(ImportSource::Docs, &relative_text, rules);
481 let instructions = is_instruction_file(relative);
482 result.sessions_found += 1;
483 let sections = crate::core::knowledge::anchors::markdown_sections(&text);
486 let section_of = |offset: usize| {
487 sections
488 .iter()
489 .filter(|(_, range)| range.contains(&offset))
490 .max_by_key(|(_, range)| range.start)
491 .map(|(symbol, _)| symbol.as_str())
492 };
493 let scope = result.repo_scope.get_or_insert_with(Default::default);
494 scope.files.push(relative_text.clone());
495 for entry in parse(&text, instructions) {
496 let anchors = section_of(entry.offset)
497 .and_then(|symbol| {
498 crate::core::knowledge::anchors::capture(&root, &relative_text, Some(symbol))
499 .ok()
500 })
501 .into_iter()
502 .collect();
503 push_repo_fact(
504 &mut result,
505 &mut seen,
506 ImportSource::Docs,
507 &session,
508 rules,
509 RepoFact {
510 category: entry.category,
511 key: format!("{relative_text}#{}", entry.anchor),
512 value: entry.text,
513 confidence: if instructions { 0.8 } else { 0.6 },
514 validity: instructions.then_some(FactValidity::Convention),
515 valid_from: None,
516 anchors,
517 },
518 );
519 }
520 }
521 result.files_touched = result.sessions_found;
522 result.facts_extracted = result.facts.len();
523 if paths.is_empty() {
524 result.repo_scope.get_or_insert_with(Default::default).whole = true;
525 }
526 result
527}
528
529#[cfg(test)]
530#[path = "markdown_tests.rs"]
531mod tests;