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}