Skip to main content

lean_ctx/core/import/
markdown.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The project's instruction files and documentation → knowledge facts (K1).
3//!
4//! `AGENTS.md`, `CLAUDE.md`, `.cursorrules`, `.cursor/rules/*.mdc` and
5//! `docs/**/*.md`, or the files a caller names inside the project. Every file
6//! is read through the rooted admitted reader as ONE text — path jail,
7//! secret-path rule, project policy and gateway decide on the original before
8//! a line is parsed (E3) — and every fact built from it is admitted for its
9//! store again (`super::push_repo_fact`). A file that is withheld or only
10//! readable (restricted) yields nothing and is counted.
11//!
12//! A fact is one bullet item or one paragraph. Its category comes from the
13//! nearest heading above it that names one (`## Conventions`, `### How to
14//! release`, `## Dead ends` …); without one, an instruction file yields
15//! `convention` and a document `documentation`. Fenced code, front matter,
16//! HTML comments and tables are not facts. The section headings of an
17//! `AGENTS.md` export ([`crate::core::knowledge::agents_md`]) map back to
18//! exactly the categories they were rendered from, so export and import
19//! round-trip.
20//!
21//! On a machine connected to Hive, `lean-ctx import docs` queues the facts
22//! it added or changed as import drafts (the Hive connector's knowledge
23//! import drafts).
24
25use std::collections::HashSet;
26use std::path::{Path, PathBuf};
27
28use super::{ImportResult, ImportSource, RepoFact, SourceRefusal, push_repo_fact};
29use crate::core::knowledge::FactValidity;
30
31/// Instruction files at the project root, in this order.
32pub const INSTRUCTION_FILES: [&str; 3] = ["AGENTS.md", "CLAUDE.md", ".cursorrules"];
33/// The documentation directory below the project root.
34pub const DOCS_DIR: &str = "docs";
35/// At most this many files per run; the rest is counted as skipped.
36const MAX_FILES: usize = 400;
37/// Directory depth below `docs/` that is walked.
38const MAX_DOCS_DEPTH: usize = 6;
39/// A bullet or paragraph shorter than this is a label, not a fact.
40const MIN_FACT_CHARS: usize = 12;
41
42/// One fact candidate of a parsed file, before admission.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Entry {
45    pub category: &'static str,
46    /// `<section-slug>/<n>`: the heading path and the item's place in it.
47    pub anchor: String,
48    pub text: String,
49    /// Byte offset of the item's first line in the parsed text.
50    pub offset: usize,
51}
52
53/// Categories by the words a heading uses. The first match of the NEAREST
54/// heading wins; parent headings are consulted only when a heading names
55/// none. Order matters: "dead end" before "decision", "gotcha" before
56/// "convention".
57const 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
145/// Whether `heading` names `keyword`: a one-word keyword is a prefix of one
146/// of its words ("conventions", "testing"; not "information" for "format"),
147/// a phrase appears as whole words.
148fn 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
170/// A Markdown list marker: `-`, `*`, `+` or `1.` / `1)`, followed by a space.
171fn 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/// Parse one file's text into fact candidates. `instructions`: the file is
204/// an agent instruction file (its uncategorised items are conventions).
205#[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    // The item being read and the offset of its first line.
211    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    // YAML front matter.
225    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
337/// The files directly in `dir` that git does not ignore (hidden ones
338/// included): a personal, gitignored instruction file is no team knowledge.
339fn 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
353/// The files a default run reads, relative to `root`, sorted, at most
354/// [`MAX_FILES`] (the count beyond is returned as the second value).
355fn 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
388/// Expand named paths: a directory contributes its Markdown files.
389fn 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/// Read the project's instruction files and docs (or the named `paths`)
423/// below `project_root` into facts. Nothing is stored here.
424#[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        // Judged by what is read, not by the name it is reached under: a
452        // committed `notes.md` linking to `.env` or `.git/config` is no doc.
453        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        // Each fact under a heading is anchored to its section (J2): an
484        // edit there makes it *to verify*, a removed section `source_removed`.
485        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;