rto-spec 2.0.1

House-style ADR/blueprint parsing, intent interview, and drift checking for Roteiro. Implementation detail of the roteiro CLI; no API stability guarantee.
Documentation
//! Scanning source files for `@rto:<id>` annotations, which link code back to
//! the ADR that authored or governs it.
//!
//! An annotation is an `@rto:<id>` token on a *comment* line. Recognition is
//! restricted to comment lines (a small set of prefixes covering the languages
//! Roteiro ingests — see [`is_comment_line`]) so that example tokens inside
//! string literals, such as test fixtures, are not mistaken for real
//! annotations. The trade-off is that a trailing `code; // @rto:0001` after
//! code on the same line is not recognised — annotations are expected on their
//! own comment or doc-comment line.

/// A `@rto:<id>` annotation found in a source file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Annotation {
    /// Repository-relative path of the file the annotation is in.
    pub path: String,
    /// The referenced ADR id.
    pub adr_id: String,
    /// 1-based line number.
    pub line: usize,
}

/// The graph node key this annotation targets (`adr:<id>`).
impl Annotation {
    /// The ADR node key this annotation references.
    #[must_use]
    pub fn target_key(&self) -> String {
        format!("adr:{}", self.adr_id)
    }
}

const MARKER: &str = "@rto:";

/// Whether a line is a comment, per a small set of prefixes covering the
/// languages Roteiro ingests. Annotations are only recognised on comment lines
/// so that example `@rto:<id>` tokens inside string literals (e.g. test
/// fixtures) are not mistaken for real annotations. Shared with the `@lat:`
/// backlink scanner in [`crate::lat`].
pub(crate) fn is_comment_line(line: &str) -> bool {
    let t = line.trim_start();
    ["//", "#", "*", "/*", "<!--", ";", "--"]
        .iter()
        .any(|p| t.starts_with(p))
}

/// Find every `@rto:<id>` annotation on a comment line in `text`, tagged with
/// `rel_path`.
#[must_use]
pub fn scan_annotations(rel_path: &str, text: &str) -> Vec<Annotation> {
    let mut out = Vec::new();
    for (i, line) in text.lines().enumerate() {
        if !is_comment_line(line) {
            continue;
        }
        // Strip inline code spans (any backtick run) so a documented example
        // such as `@rto:0001` in a doc comment is not counted as a real
        // annotation.
        let stripped = crate::text::strip_code_spans(line);
        let mut rest: &str = &stripped;
        while let Some(pos) = rest.find(MARKER) {
            let after = &rest[pos + MARKER.len()..];
            let id: String = after
                .chars()
                .take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
                .collect();
            if !id.is_empty() {
                out.push(Annotation {
                    path: rel_path.to_owned(),
                    adr_id: id.clone(),
                    line: i + 1,
                });
            }
            // Advance past this marker (plus the id) to find more on one line.
            rest = &after[id.len()..];
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::scan_annotations;

    #[test]
    fn finds_annotations_with_line_numbers() {
        let src = "//! @rto:0001\nfn a() {}\n// see @rto:0042 and @rto:0007 here\n";
        let anns = scan_annotations("src/lib.rs", src);
        assert_eq!(anns.len(), 3);
        assert_eq!(anns[0].adr_id, "0001");
        assert_eq!(anns[0].line, 1);
        assert_eq!(anns[0].target_key(), "adr:0001");
        assert_eq!(anns[1].adr_id, "0042");
        assert_eq!(anns[1].line, 3);
        assert_eq!(anns[2].adr_id, "0007");
    }

    #[test]
    fn ignores_bare_marker_without_id() {
        assert!(scan_annotations("x.rs", "// @rto: nothing\n").is_empty());
    }

    #[test]
    fn ignores_annotations_outside_comments() {
        // An `@rto:` inside a string literal on a code line is not an annotation.
        let src = "let s = \"@rto:9999\";\n// @rto:0001\n";
        let anns = scan_annotations("src/x.rs", src);
        assert_eq!(anns.len(), 1);
        assert_eq!(anns[0].adr_id, "0001");
    }

    #[test]
    fn ignores_examples_inside_code_spans() {
        // `@rto:9999` written as a documentation example in backticks is not an
        // annotation; a bare one on the same comment line still is.
        let src = "//! see the `@rto:9999` example — real: @rto:0001\n";
        let anns = scan_annotations("src/x.rs", src);
        assert_eq!(anns.len(), 1);
        assert_eq!(anns[0].adr_id, "0001");
    }
}