Skip to main content

majordomus_cli/capability/
rustdoc.rs

1//! Rust doc comments, translated into the CommonMark every projection promises.
2//!
3//! A doc comment is the canonical declaration of what a type and its fields mean, and
4//! [`CanonicalSchema::of`](super::CanonicalSchema::of) carries it into `description` —
5//! from there into `docs/generated/registry.json`, `docs/generated/openapi.json`, MCP's
6//! `inputSchema`, Swagger UI and the published site. Every one of those consumers reads
7//! that field as CommonMark: OpenAPI says so in the specification, and the site hands it
8//! to a Markdown renderer.
9//!
10//! Rustdoc is *not* CommonMark. It adds two link forms that only rustdoc can resolve:
11//!
12//! - the **intra-doc link**, `` [`Type::Variant`] `` — a shortcut reference link with no
13//!   definition anywhere, which CommonMark renders as literal brackets around a code span;
14//! - a **reference definition pointing at a path relative to the source file**, such as
15//!   `[ADR 0040]: ../../../../.ai/repo/adrs/0040-….md`, which resolves from
16//!   `apps/majordomus-cli/src/capability/` and from nowhere a reader will ever stand.
17//!
18//! Unresolved, both reach the reader as source: the site published
19//! ``[`WaiverReason::ExternalDependency`]:`` and an eighty-seven character `../../../../`
20//! path as body text. The prose is not wrong — it is right for rustdoc, which is the doc
21//! comment's first reader — so the declaration stays as it is and the *projection*
22//! translates, once, here. That is the rule a repeated semantic would break: the doc
23//! comment says what a field means exactly once, and no projection restates it.
24//!
25//! What this does **not** touch is as important as what it does. Code blocks are left
26//! byte for byte alone: a doc comment's fenced example is Rust, and `["capability"]` and
27//! `[a-z][a-z0-9_-]` are an array literal and a character class, not links. A link whose
28//! destination carries a URL scheme resolves for every reader and is kept.
29//!
30//! ```
31//! use majordomus_cli::capability::rustdoc::to_commonmark;
32//!
33//! // an intra-doc link degrades to the code span it wrapped
34//! assert_eq!(to_commonmark("see [`Waiver::Reason`] first"), "see `Waiver::Reason` first");
35//!
36//! // a definition only rustdoc can follow goes, and its label stays as text
37//! let doc = "As [ADR 0040] says.\n\n[ADR 0040]: ../../../.ai/repo/adrs/0040-x.md";
38//! assert_eq!(to_commonmark(doc), "As ADR 0040 says.");
39//!
40//! // a fenced example is untouched, brackets and all
41//! let code = "Text.\n\n```\nlet v = [\"capability\"];\n```";
42//! assert_eq!(to_commonmark(code), code);
43//! ```
44
45use std::collections::BTreeSet;
46
47use serde_json::Value;
48
49/// Translate one Rust doc comment into CommonMark.
50///
51/// Returns the text unchanged when it carries nothing rustdoc-only, so a description that
52/// was already portable is not rewritten and the generated artifacts do not churn.
53///
54/// ```
55/// use majordomus_cli::capability::rustdoc::to_commonmark;
56///
57/// let doc = "Distinct from [`WaiverReason::ExternalDependency`]: nothing else.";
58/// assert_eq!(to_commonmark(doc), "Distinct from `WaiverReason::ExternalDependency`: nothing else.");
59///
60/// // portable text comes back as it went in
61/// assert_eq!(to_commonmark("Already *CommonMark*."), "Already *CommonMark*.");
62/// ```
63pub fn to_commonmark(doc: &str) -> String {
64    // Nothing to do for the overwhelming majority of descriptions: a scan is far cheaper
65    // than a rewrite, and this runs for every property of every schema of every capability.
66    if !doc.contains('[') {
67        return doc.to_string();
68    }
69
70    let lines: Vec<&str> = doc.split('\n').collect();
71    let fenced = fenced_lines(&lines);
72
73    // Pass one: find the reference definitions rustdoc alone can follow. Their labels have
74    // to be known before the prose is rewritten, because `[ADR 0040]` is only safe to
75    // flatten when we are also dropping the definition that gave it a destination — a
76    // bracketed phrase with no definition is somebody's prose and stays as it is.
77    let mut orphaned: BTreeSet<String> = BTreeSet::new();
78    for (i, line) in lines.iter().enumerate() {
79        if fenced[i] {
80            continue;
81        }
82        if let Some((label, destination)) = reference_definition(line) {
83            if !has_url_scheme(destination) {
84                orphaned.insert(label.to_ascii_lowercase());
85            }
86        }
87    }
88
89    let mut out: Vec<String> = Vec::with_capacity(lines.len());
90    for (i, line) in lines.iter().enumerate() {
91        if fenced[i] {
92            out.push((*line).to_string());
93            continue;
94        }
95        if let Some((label, destination)) = reference_definition(line) {
96            if orphaned.contains(&label.to_ascii_lowercase()) && !has_url_scheme(destination) {
97                continue; // the definition is dropped, not rendered
98            }
99        }
100        out.push(rewrite_links(line, &orphaned));
101    }
102
103    // Dropping a definition can leave the trailing blank line that separated it from the
104    // prose, which would otherwise show up as a change in every rendered paragraph break.
105    let mut text = out.join("\n");
106    while text.ends_with('\n') || text.ends_with(' ') {
107        text.truncate(text.len() - 1);
108    }
109    text
110}
111
112/// Which lines sit inside a fenced code block, and are therefore not prose.
113///
114/// The fence line itself counts as code so that an info string is never rewritten.
115fn fenced_lines(lines: &[&str]) -> Vec<bool> {
116    let mut inside = false;
117    let mut fence = ' ';
118    let mut run = 0usize;
119    lines
120        .iter()
121        .map(|line| {
122            let trimmed = line.trim_start();
123            let (marker, count) = fence_marker(trimmed);
124            match (inside, marker) {
125                (false, Some(m)) => {
126                    inside = true;
127                    fence = m;
128                    run = count;
129                    true
130                }
131                (true, Some(m)) if m == fence && count >= run => {
132                    inside = false;
133                    true
134                }
135                (state, _) => state,
136            }
137        })
138        .collect()
139}
140
141/// The fence character and its length, for a line that opens or closes a code block.
142fn fence_marker(trimmed: &str) -> (Option<char>, usize) {
143    for marker in ['`', '~'] {
144        let run = trimmed.chars().take_while(|c| *c == marker).count();
145        if run >= 3 {
146            return (Some(marker), run);
147        }
148    }
149    (None, 0)
150}
151
152/// A link reference definition, as `[label]: destination`, if the line is one.
153fn reference_definition(line: &str) -> Option<(&str, &str)> {
154    let trimmed = line.trim();
155    let rest = trimmed.strip_prefix('[')?;
156    let end = rest.find("]:")?;
157    let label = &rest[..end];
158    let destination = rest[end + 2..].trim();
159    if label.is_empty() || destination.is_empty() || destination.contains(char::is_whitespace) {
160        return None;
161    }
162    Some((label, destination))
163}
164
165/// Whether a link destination names a scheme, and so resolves for every reader.
166fn has_url_scheme(destination: &str) -> bool {
167    let destination = destination.trim_start_matches('<');
168    match destination.find("://") {
169        Some(at) => destination[..at]
170            .chars()
171            .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.'),
172        None => destination.starts_with("mailto:"),
173    }
174}
175
176/// Rewrite the rustdoc-only links on one line of prose.
177///
178/// Three forms are flattened to the text they wrap: `` [`X`](destination) `` and
179/// `` [`X`] `` unconditionally, since neither destination exists outside rustdoc, and
180/// `[label]` when the definition that gave it a destination has just been dropped.
181fn rewrite_links(line: &str, orphaned: &BTreeSet<String>) -> String {
182    let bytes = line.as_bytes();
183    let mut out = String::with_capacity(line.len());
184    let mut i = 0usize;
185    while i < bytes.len() {
186        if bytes[i] != b'[' {
187            let ch = line[i..].chars().next().unwrap();
188            out.push(ch);
189            i += ch.len_utf8();
190            continue;
191        }
192        match link_at(line, i, orphaned) {
193            Some((text, next)) => {
194                out.push_str(text);
195                i = next;
196            }
197            None => {
198                out.push('[');
199                i += 1;
200            }
201        }
202    }
203    out
204}
205
206/// The rustdoc link starting at `start`, as the text it should become and where to resume.
207fn link_at<'a>(
208    line: &'a str,
209    start: usize,
210    orphaned: &BTreeSet<String>,
211) -> Option<(&'a str, usize)> {
212    let rest = &line[start + 1..];
213    let close = rest.find(']')?;
214    let inner = &rest[..close];
215    if inner.is_empty() || inner.contains('[') {
216        return None;
217    }
218    let after = start + 1 + close + 1;
219    let code_span = inner.starts_with('`') && inner.ends_with('`') && inner.len() > 1;
220
221    // `[`X`](destination)` — an intra-doc link written the long way.
222    if line[after..].starts_with('(') {
223        let end = line[after..].find(')')? + after;
224        let destination = &line[after + 1..end];
225        if has_url_scheme(destination) {
226            return None; // portable for every reader: left alone
227        }
228        return code_span.then_some((inner, end + 1));
229    }
230
231    // `[X][reference]` is a collapsed link, and its own definition decides it elsewhere.
232    if line[after..].starts_with('[') {
233        return None;
234    }
235
236    if code_span {
237        // `[`X`]` — the shortcut intra-doc link, which is the common case by far.
238        return Some((inner, after));
239    }
240    // `[ADR 0040]`, only once its rustdoc-relative definition has been dropped.
241    orphaned
242        .contains(&inner.to_ascii_lowercase())
243        .then_some((inner, after))
244}
245
246/// Translate every `description` of a JSON Schema document, in place.
247///
248/// schemars writes one for the type and one for each field, at every depth the type
249/// reaches, so the walk is the whole document rather than its root.
250///
251/// ```
252/// use majordomus_cli::capability::rustdoc::translate_descriptions;
253///
254/// let mut schema = serde_json::json!({
255///     "description": "A [`Root::Thing`].",
256///     "properties": { "field": { "description": "See [`Other::Field`]." } },
257///     "title": "Only descriptions are prose [`Kept`]."
258/// });
259/// translate_descriptions(&mut schema);
260/// assert_eq!(schema["description"], "A `Root::Thing`.");
261/// assert_eq!(schema["properties"]["field"]["description"], "See `Other::Field`.");
262/// assert_eq!(schema["title"], "Only descriptions are prose [`Kept`].");
263/// ```
264pub fn translate_descriptions(value: &mut Value) {
265    match value {
266        Value::Object(map) => {
267            for (key, child) in map.iter_mut() {
268                match child {
269                    Value::String(text) if key == "description" => {
270                        let translated = to_commonmark(text);
271                        if &translated != text {
272                            *text = translated;
273                        }
274                    }
275                    other => translate_descriptions(other),
276                }
277            }
278        }
279        Value::Array(items) => items.iter_mut().for_each(translate_descriptions),
280        _ => {}
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn intra_doc_link_becomes_the_code_span_it_wrapped() {
290        assert_eq!(
291            to_commonmark("Distinct from [`WaiverReason::ExternalDependency`]: nothing else."),
292            "Distinct from `WaiverReason::ExternalDependency`: nothing else."
293        );
294    }
295
296    #[test]
297    fn a_long_form_intra_doc_link_loses_its_unreachable_destination() {
298        assert_eq!(
299            to_commonmark("one of [`EDGES`](select::EDGES)"),
300            "one of `EDGES`"
301        );
302    }
303
304    #[test]
305    fn a_definition_relative_to_the_source_file_is_dropped_with_its_label_kept() {
306        let doc = "Classified by [ADR 0040].\n\n[ADR 0040]: ../../../../.ai/repo/adrs/0040-x.md";
307        assert_eq!(to_commonmark(doc), "Classified by ADR 0040.");
308    }
309
310    #[test]
311    fn a_link_every_reader_can_follow_is_kept() {
312        let doc = "See [the spec](https://spec.openapis.org/oas/v3.1.0).";
313        assert_eq!(to_commonmark(doc), doc);
314        let definition = "Text.\n\n[spec]: https://example.org/x";
315        assert_eq!(to_commonmark(definition), definition);
316    }
317
318    #[test]
319    fn a_fenced_example_is_left_byte_for_byte() {
320        // brackets in Rust are array literals and character classes, never links
321        let doc = "Prose [`Type::X`].\n\n```\nlet v = [\"capability\"];\nlet re = \"[a-z][a-z0-9_-]\";\n```";
322        let out = to_commonmark(doc);
323        assert!(out.contains("let v = [\"capability\"];"), "{out}");
324        assert!(out.contains("[a-z][a-z0-9_-]"), "{out}");
325        assert!(out.starts_with("Prose `Type::X`."), "{out}");
326    }
327
328    #[test]
329    fn prose_brackets_with_no_definition_are_somebody_s_writing() {
330        let doc = "The field [omitted] is not a link.";
331        assert_eq!(to_commonmark(doc), doc);
332    }
333
334    #[test]
335    fn text_without_a_bracket_is_returned_unchanged() {
336        let doc = "Nothing to translate here at all.";
337        assert_eq!(to_commonmark(doc), doc);
338    }
339
340    #[test]
341    fn every_description_of_a_document_is_translated() {
342        let mut value = serde_json::json!({
343            "description": "A [`Root::Thing`].",
344            "properties": { "field": { "description": "See [`Other::Field`]." } },
345            "title": "Untouched [`Not::A::Description`]."
346        });
347        translate_descriptions(&mut value);
348        assert_eq!(value["description"], "A `Root::Thing`.");
349        assert_eq!(
350            value["properties"]["field"]["description"],
351            "See `Other::Field`."
352        );
353        assert_eq!(value["title"], "Untouched [`Not::A::Description`].");
354    }
355}