Skip to main content

rto_spec/
lat.rs

1//! lat.md importer: an "Agent Lattice" markdown knowledge graph → `authored`
2//! facts.
3//!
4//! lat.md (<https://github.com/1st1/lat.md>) stores knowledge as markdown files
5//! in a `lat.md/` directory: headings are sections, `[[file#Section]]` links join
6//! sections, and `[[src/x.rs#Symbol]]` links reach into code. That model mirrors
7//! Roteiro's own ADR `[[path#Symbol]]` + `@rto:` layer, so lat content imports as
8//! **`authored`** facts — a `doc` node per file, a `lat_section` node per
9//! heading, `contains` edges for structure, and `references` edges for links.
10//! Links into code are validated by the durable-import layer, which prunes any
11//! that dangle.
12//!
13//! lat also supports `// @lat: [[section]]` backlinks *from* source code (like
14//! `@rto:`). [`scan_lat_annotations`] finds those on comment lines and
15//! [`import_lat_backlinks`] resolves them (via [`resolve_lat_ref`]) into
16//! `authored` `references` edges from the annotated file to the lat section,
17//! stamped [`LAT_REF`] so they live in — and are re-derived with — the lat layer.
18
19use std::collections::BTreeMap;
20
21use rto_graph::{Edge, EdgeKind, FactSet, Node, NodeKind, Provenance};
22
23use crate::annotate::is_comment_line;
24use crate::text::{scan_wiki_links, slugify};
25
26/// `src_ref` stamped on every edge imported from lat.md, so it can be told apart
27/// from other `authored` edges (ADRs) and re-derived authoritatively on re-import.
28pub const LAT_REF: &str = "import:lat";
29
30/// The result of importing a lat.md directory: the authored facts and a report.
31#[derive(Debug, Clone)]
32pub struct LatImport {
33    /// Authored nodes and edges to apply to the store.
34    pub facts: FactSet,
35    /// An auditable summary of what was imported.
36    pub report: LatReport,
37}
38
39/// An auditable summary of a lat.md import.
40#[derive(Debug, Clone, Default, serde::Serialize)]
41pub struct LatReport {
42    /// lat.md markdown files processed.
43    pub files: usize,
44    /// Section (heading) nodes emitted.
45    pub sections: usize,
46    /// Total `[[…]]` links found.
47    pub links_total: usize,
48    /// Links resolved to another lat section/doc.
49    pub links_to_sections: usize,
50    /// Links resolved to a code symbol or file.
51    pub links_to_code: usize,
52    /// `@lat:` backlinks found in source comments that resolved to a lat section.
53    pub backlinks_resolved: usize,
54    /// `@lat:` backlinks whose reference named no known lat file (dropped).
55    pub backlinks_unresolved: usize,
56}
57
58/// Import a lat.md directory. `files` are `(repo-relative path, content)` pairs
59/// for the markdown under `lat.md/`. Cross-file section links are resolved
60/// against the set of files provided.
61#[must_use]
62pub fn import_lat(files: &[(String, String)]) -> LatImport {
63    let index = LatIndex::build(files);
64    let mut facts = FactSet::new();
65    let mut report = LatReport::default();
66    for (path, content) in files {
67        report.files += 1;
68        import_file(path, content, &index, &mut facts, &mut report);
69    }
70    LatImport { facts, report }
71}
72
73/// The natural key of a lat.md file's `doc` node.
74fn doc_key(path: &str) -> String {
75    format!("lat:{path}")
76}
77
78/// The natural key of a section within a lat.md file.
79fn section_key(path: &str, slug: &str) -> String {
80    format!("lat:{path}#{slug}")
81}
82
83/// A lat reference (in a `[[…]]` link or a `@lat:` annotation) resolved to the
84/// graph node key it targets, if it names a known lat file.
85///
86/// Resolves `stem` / `stem.md` / `stem#Section#Subsection` against the file set:
87/// the doc node when no section is given, else the deepest section's node. Code
88/// links (a path with a `/` or a non-`.md` extension) are **not** resolved here —
89/// [`resolve_link`] handles those. Returns `None` when the file is unknown.
90#[must_use]
91pub fn resolve_lat_ref(files: &[(String, String)], raw: &str) -> Option<String> {
92    LatIndex::build(files).resolve_section(raw)
93}
94
95/// The marker introducing a lat backlink in a source comment (`// @lat: [[…]]`).
96const LAT_MARKER: &str = "@lat:";
97
98/// A `@lat:` backlink found in a source file: a code→lat reference carried in a
99/// `[[…]]` wiki-link on a comment line.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct LatAnnotation {
102    /// Repository-relative path of the file the annotation is in.
103    pub path: String,
104    /// The raw lat reference from the `[[…]]` link (e.g. `auth#OAuth Flow`).
105    pub reference: String,
106    /// 1-based line number.
107    pub line: usize,
108}
109
110/// Find every `@lat:` backlink in `text`, tagged with `rel_path`. Recognition is
111/// restricted to comment lines (as with `@rto:`) so example tokens in string
112/// literals are not mistaken for real backlinks, and the reference must be a
113/// `[[…]]` wiki-link so a lat name containing spaces is delimited unambiguously.
114/// A single comment may carry several (`// @lat: [[a#x]] [[b#y]]`).
115#[must_use]
116pub fn scan_lat_annotations(rel_path: &str, text: &str) -> Vec<LatAnnotation> {
117    let mut out = Vec::new();
118    for (i, line) in text.lines().enumerate() {
119        if !is_comment_line(line) {
120            continue;
121        }
122        // Strip inline code spans so a documented `` `@lat: [[x]]` `` example is
123        // not counted, then take the wiki-links after the marker.
124        let stripped = crate::text::strip_code_spans(line);
125        let Some(pos) = stripped.find(LAT_MARKER) else {
126            continue;
127        };
128        let after = &stripped[pos + LAT_MARKER.len()..];
129        for reference in scan_wiki_links(after) {
130            out.push(LatAnnotation {
131                path: rel_path.to_owned(),
132                reference,
133                line: i + 1,
134            });
135        }
136    }
137    out
138}
139
140/// Resolve `@lat:` backlinks against the lat file set, returning `authored`
141/// `references` edges (`file:<path>` → lat section, stamped [`LAT_REF`]) and the
142/// number that named no known lat file. Only lat-section references resolve here;
143/// a backlink to a non-lat target is dropped (and counted unresolved).
144#[must_use]
145pub fn import_lat_backlinks(
146    files: &[(String, String)],
147    annotations: &[LatAnnotation],
148) -> (Vec<Edge>, usize) {
149    let index = LatIndex::build(files);
150    let mut edges = Vec::new();
151    let mut unresolved = 0;
152    // The same reference can appear several times in one file (repeated markers,
153    // or several on a line); collapse to one edge per (file, section) pair so we
154    // never persist duplicates.
155    let mut seen = std::collections::BTreeSet::new();
156    for ann in annotations {
157        if let Some(target) = index.resolve_section(&ann.reference) {
158            let src = format!("file:{}", ann.path);
159            if seen.insert((src.clone(), target.clone())) {
160                edges.push(lat_edge(src, target, EdgeKind::References));
161            }
162        } else {
163            unresolved += 1;
164        }
165    }
166    (edges, unresolved)
167}
168
169/// Index of lat file stems → repo-relative paths, for resolving `[[stem#…]]`
170/// section references.
171struct LatIndex {
172    by_stem: BTreeMap<String, String>,
173}
174
175impl LatIndex {
176    fn build(files: &[(String, String)]) -> Self {
177        let mut by_stem = BTreeMap::new();
178        for (path, _) in files {
179            by_stem.entry(stem_of(path)).or_insert_with(|| path.clone());
180        }
181        Self { by_stem }
182    }
183
184    /// Whether `head` (the part before the first `#`) names a lat file rather
185    /// than a code path: no path separator, and either no extension or `.md`.
186    fn is_lat_file(&self, head: &str) -> bool {
187        let bare = !head.contains('/')
188            && head
189                .rsplit_once('.')
190                .is_none_or(|(_, ext)| ext.eq_ignore_ascii_case("md"));
191        bare && self.by_stem.contains_key(&stem_of(head))
192    }
193
194    /// Resolve a lat section reference (`stem`, `stem#Section`, …) to a node key,
195    /// or `None` if `head` is not a known lat file.
196    fn resolve_section(&self, raw: &str) -> Option<String> {
197        let (head, rest) = split_head(raw);
198        if !self.is_lat_file(head) {
199            return None;
200        }
201        let path = self.by_stem.get(&stem_of(head))?;
202        match rest {
203            // The deepest `#`-separated segment names the target heading.
204            Some(section) => {
205                let leaf = section.rsplit('#').next().unwrap_or(section).trim();
206                Some(section_key(path, &slugify(leaf)))
207            }
208            None => Some(doc_key(path)),
209        }
210    }
211}
212
213/// Build an `authored` lat.md edge stamped with [`LAT_REF`] in `src_ref`, so a
214/// re-import can clear and replace the whole lat layer authoritatively (the
215/// store deletes prior edges by `src_ref` in `apply_import_layer`).
216fn lat_edge(src: String, dst: String, kind: EdgeKind) -> Edge {
217    let mut edge = Edge::authored(src, dst, kind);
218    edge.src_ref = Some(LAT_REF.to_owned());
219    edge
220}
221
222/// Split a reference into `(head, rest)` at the first `#`.
223fn split_head(raw: &str) -> (&str, Option<&str>) {
224    match raw.split_once('#') {
225        Some((h, r)) => (h.trim(), Some(r.trim())),
226        None => (raw.trim(), None),
227    }
228}
229
230/// The file stem (basename without extension) of a path or bare name.
231fn stem_of(path: &str) -> String {
232    let name = path.rsplit('/').next().unwrap_or(path);
233    name.rsplit_once('.')
234        .map_or(name, |(stem, _)| stem)
235        .to_ascii_lowercase()
236}
237
238/// Import one lat.md file into `facts`, emitting the doc node, section nodes with
239/// `contains` structure, and `references` edges for its links.
240fn import_file(
241    path: &str,
242    content: &str,
243    index: &LatIndex,
244    facts: &mut FactSet,
245    report: &mut LatReport,
246) {
247    let doc = doc_key(path);
248    // Section stack of (heading level, node key) for `contains` nesting.
249    let mut stack: Vec<(usize, String)> = Vec::new();
250    let mut title: Option<String> = None;
251    let mut in_fence = false;
252
253    for line in content.lines() {
254        if line.trim_start().starts_with("```") {
255            in_fence = !in_fence;
256            continue;
257        }
258        if in_fence {
259            continue;
260        }
261        if let Some((level, heading)) = heading(line) {
262            title.get_or_insert_with(|| heading.to_owned());
263            let key = section_key(path, &slugify(heading));
264            let mut node = Node::new(key.clone(), NodeKind::Other("lat_section".into()), heading)
265                .with_provenance(Provenance::Authored);
266            node.path = Some(path.to_owned());
267            facts.nodes.push(node);
268            report.sections += 1;
269
270            // Parent is the nearest shallower heading, else the doc.
271            while stack.last().is_some_and(|(l, _)| *l >= level) {
272                stack.pop();
273            }
274            let parent = stack.last().map_or(doc.clone(), |(_, k)| k.clone());
275            facts
276                .edges
277                .push(lat_edge(parent, key.clone(), EdgeKind::Contains));
278            stack.push((level, key));
279            continue;
280        }
281        // A link is attributed to the enclosing section, or the doc if none yet.
282        let from = stack.last().map_or(doc.clone(), |(_, k)| k.clone());
283        for raw in scan_wiki_links(line) {
284            report.links_total += 1;
285            if let Some((target, to_code)) = resolve_link(index, &raw) {
286                if to_code {
287                    report.links_to_code += 1;
288                } else {
289                    report.links_to_sections += 1;
290                }
291                facts
292                    .edges
293                    .push(lat_edge(from.clone(), target, EdgeKind::References));
294            }
295        }
296    }
297
298    let name = title.unwrap_or_else(|| stem_of(path));
299    let mut node =
300        Node::new(doc.clone(), NodeKind::Doc, name).with_provenance(Provenance::Authored);
301    node.path = Some(path.to_owned());
302    // Emit the doc node last so it is present; order does not affect the store.
303    facts.nodes.push(node);
304}
305
306/// Resolve a `[[…]]` link to `(target key, is_code)`. Lat section links resolve
307/// via the file index; anything else is treated as a code/file reference like an
308/// ADR wiki-link.
309fn resolve_link(index: &LatIndex, raw: &str) -> Option<(String, bool)> {
310    if let Some(section) = index.resolve_section(raw) {
311        return Some((section, false));
312    }
313    let (head, rest) = split_head(raw);
314    if head.is_empty() {
315        return None;
316    }
317    let key = match rest.filter(|s| !s.is_empty()) {
318        Some(symbol) => format!("sym:{}:{head}#{symbol}", crate::text::lang_for(head)),
319        None => format!("file:{head}"),
320    };
321    Some((key, true))
322}
323
324/// A `#{1,6} ` heading's `(level, text)`, if `line` is an ATX heading.
325fn heading(line: &str) -> Option<(usize, &str)> {
326    let hashes = line.len() - line.trim_start_matches('#').len();
327    if (1..=6).contains(&hashes) && line.as_bytes().get(hashes) == Some(&b' ') {
328        Some((hashes, line[hashes + 1..].trim()))
329    } else {
330        None
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::{LAT_REF, import_lat, resolve_lat_ref};
337    use rto_graph::{EdgeKind, NodeKind};
338
339    fn files() -> Vec<(String, String)> {
340        vec![
341            (
342                "lat.md/architecture.md".to_owned(),
343                "# Architecture\n\nThe system. See [[auth#OAuth Flow]].\n\n\
344                 ## Request Pipeline\n\nHandled in [[src/server.rs#run]].\n"
345                    .to_owned(),
346            ),
347            (
348                "lat.md/auth.md".to_owned(),
349                "# Auth\n\n## OAuth Flow\n\nTokens via [[src/auth.rs#validate]].\n".to_owned(),
350            ),
351        ]
352    }
353
354    #[test]
355    fn imports_docs_sections_and_contains() {
356        let imp = import_lat(&files());
357        let keys: Vec<_> = imp.facts.nodes.iter().map(|n| n.key.as_str()).collect();
358        assert!(keys.contains(&"lat:lat.md/architecture.md"));
359        assert!(keys.contains(&"lat:lat.md/architecture.md#architecture"));
360        assert!(keys.contains(&"lat:lat.md/architecture.md#request-pipeline"));
361        assert!(keys.contains(&"lat:lat.md/auth.md#oauth-flow"));
362        // Section nodes are authored `lat_section`s; the file is a `doc`.
363        let sec = imp
364            .facts
365            .nodes
366            .iter()
367            .find(|n| n.key == "lat:lat.md/auth.md#oauth-flow")
368            .unwrap();
369        assert_eq!(sec.kind, NodeKind::Other("lat_section".into()));
370        // `contains` nests the subsection under the file (doc → section).
371        assert!(imp.facts.edges.iter().any(|e| e.kind == EdgeKind::Contains
372            && e.src == "lat:lat.md/auth.md"
373            && e.dst == "lat:lat.md/auth.md#auth"));
374        assert_eq!(imp.report.files, 2);
375    }
376
377    #[test]
378    fn resolves_lat_and_code_links() {
379        let imp = import_lat(&files());
380        // A cross-file section link → the target section node (authored).
381        assert!(
382            imp.facts
383                .edges
384                .iter()
385                .any(|e| e.kind == EdgeKind::References
386                    && e.src == "lat:lat.md/architecture.md#architecture"
387                    && e.dst == "lat:lat.md/auth.md#oauth-flow")
388        );
389        // A code link → a `sym:` key, attributed to its enclosing section.
390        assert!(
391            imp.facts
392                .edges
393                .iter()
394                .any(|e| e.kind == EdgeKind::References
395                    && e.src == "lat:lat.md/architecture.md#request-pipeline"
396                    && e.dst == "sym:rust:src/server.rs#run")
397        );
398        assert_eq!(imp.report.links_to_sections, 1);
399        assert_eq!(imp.report.links_to_code, 2);
400        // Every imported edge is authored and stamped with LAT_REF, so a
401        // re-import can clear the whole layer authoritatively by src_ref.
402        assert!(imp.facts.edges.iter().all(|e| {
403            e.provenance.as_str() == "authored" && e.src_ref.as_deref() == Some(LAT_REF)
404        }));
405    }
406
407    #[test]
408    fn resolve_ref_distinguishes_lat_from_code() {
409        let f = files();
410        // A bare stem resolves to a lat section.
411        assert_eq!(
412            resolve_lat_ref(&f, "auth#OAuth Flow").as_deref(),
413            Some("lat:lat.md/auth.md#oauth-flow")
414        );
415        // A path with a slash is code, not a lat file → unresolved here.
416        assert_eq!(resolve_lat_ref(&f, "src/auth.rs#validate"), None);
417        // A bare file name resolves to the doc node.
418        assert_eq!(
419            resolve_lat_ref(&f, "architecture").as_deref(),
420            Some("lat:lat.md/architecture.md")
421        );
422    }
423
424    #[test]
425    fn ref_marker_is_stable() {
426        assert_eq!(LAT_REF, "import:lat");
427    }
428
429    #[test]
430    fn scans_lat_backlinks_only_on_comment_lines() {
431        use super::scan_lat_annotations;
432        let src = "// @lat: [[auth#OAuth Flow]]\n\
433                   fn f() {}\n\
434                   let s = \"@lat: [[architecture]]\";\n\
435                   /* see @lat: [[architecture#Request Pipeline]] and [[auth]] */\n";
436        let anns = scan_lat_annotations("src/auth.rs", src);
437        // The string-literal one is skipped; the comment ones (3 refs) are kept.
438        assert_eq!(anns.len(), 3);
439        assert_eq!(anns[0].reference, "auth#OAuth Flow");
440        assert_eq!(anns[0].line, 1);
441        assert_eq!(anns[1].reference, "architecture#Request Pipeline");
442        assert_eq!(anns[1].line, 4);
443        assert_eq!(anns[2].reference, "auth");
444    }
445
446    #[test]
447    fn imports_backlinks_as_authored_file_to_section_edges() {
448        use super::{import_lat_backlinks, scan_lat_annotations};
449        let f = files();
450        let anns = scan_lat_annotations("src/auth.rs", "// @lat: [[auth#OAuth Flow]]\n");
451        let (edges, unresolved) = import_lat_backlinks(&f, &anns);
452        assert_eq!(unresolved, 0);
453        assert_eq!(edges.len(), 1);
454        let e = &edges[0];
455        assert_eq!(e.src, "file:src/auth.rs");
456        assert_eq!(e.dst, "lat:lat.md/auth.md#oauth-flow");
457        assert_eq!(e.kind, EdgeKind::References);
458        assert_eq!(e.provenance.as_str(), "authored");
459        assert_eq!(e.src_ref.as_deref(), Some(LAT_REF));
460    }
461
462    #[test]
463    fn repeated_backlinks_in_a_file_collapse_to_one_edge() {
464        use super::{import_lat_backlinks, scan_lat_annotations};
465        let f = files();
466        // The same reference twice (two comment lines) → a single edge.
467        let anns = scan_lat_annotations(
468            "src/auth.rs",
469            "// @lat: [[auth#OAuth Flow]]\n// @lat: [[auth#OAuth Flow]]\n",
470        );
471        assert_eq!(anns.len(), 2, "both annotations are scanned");
472        let (edges, unresolved) = import_lat_backlinks(&f, &anns);
473        assert_eq!(unresolved, 0);
474        assert_eq!(edges.len(), 1, "duplicate (file, section) edge collapsed");
475
476        // The same reference twice on ONE comment line also collapses.
477        let same_line = scan_lat_annotations(
478            "src/auth.rs",
479            "// @lat: [[auth#OAuth Flow]] [[auth#OAuth Flow]]\n",
480        );
481        assert_eq!(same_line.len(), 2, "both refs on the line are scanned");
482        let (edges, _) = import_lat_backlinks(&f, &same_line);
483        assert_eq!(edges.len(), 1, "same-line duplicate collapsed");
484    }
485
486    #[test]
487    fn backlink_to_unknown_lat_file_is_unresolved() {
488        use super::{import_lat_backlinks, scan_lat_annotations};
489        let f = files();
490        // `nope` is not a known lat file, and a code path never resolves as a
491        // backlink target.
492        let anns = scan_lat_annotations(
493            "src/x.rs",
494            "// @lat: [[nope#Section]]\n// @lat: [[src/auth.rs#validate]]\n",
495        );
496        let (edges, unresolved) = import_lat_backlinks(&f, &anns);
497        assert!(edges.is_empty());
498        assert_eq!(unresolved, 2);
499    }
500}