Skip to main content

robonix_codegen/codegen/
docs_gen.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// Generate the browsable contract + ROS IDL reference for the mdBook
3// (`docs/src/reference/{contracts,idl}.md`). Reuses the same contract
4// loader as proto generation (`contract_gen::load_contract_summaries`),
5// so the reference can't drift from what codegen / atlas actually parse.
6//
7// Two pages, cross-linked:
8//   contracts.md — every contract; its payload cell links into idl.md.
9//   idl.md       — every .msg/.srv under the lib root(s), raw, anchored.
10
11use anyhow::{Context, Result};
12use std::collections::{BTreeMap, BTreeSet};
13use std::fmt::Write as _;
14use std::fs;
15use std::path::{Path, PathBuf};
16
17use super::contract_gen::load_contract_summaries;
18
19/// One ROS IDL file discovered under a lib root.
20struct IdlFile {
21    /// lib-relative path with extension, e.g. `chassis/srv/ExecuteMoveCommand.srv`.
22    rel: String,
23    /// ROS package (the dir above `msg/`/`srv/`), e.g. `chassis`, `sensor_msgs`.
24    pkg: String,
25    /// Type name without extension, e.g. `ExecuteMoveCommand`.
26    name: String,
27    /// "msg" | "srv".
28    kind: &'static str,
29    /// Raw file content.
30    body: String,
31}
32
33/// Emit `contracts.md` + `idl.md` into `out`. `stamp` is the version line
34/// (e.g. `v0.1 @ abc1234 (2026-06-06)`) written verbatim into each header.
35pub fn generate(
36    contracts_dirs: &[PathBuf],
37    lib_roots: &[PathBuf],
38    out: &Path,
39    stamp: Option<&str>,
40    verbose: bool,
41) -> Result<()> {
42    let contracts = load_contract_summaries(contracts_dirs)?;
43    let idl = collect_idl(lib_roots)?;
44    let idl_rels: BTreeSet<&str> = idl.iter().map(|f| f.rel.as_str()).collect();
45    let banner = stamp.unwrap_or("(version unknown)");
46
47    let contracts_md = render_contracts(&contracts, contracts_dirs, &idl_rels, banner);
48    let idl_md = render_idl(&idl, banner);
49
50    fs::create_dir_all(out)?;
51    let cpath = out.join("contracts.md");
52    let ipath = out.join("idl.md");
53    fs::write(&cpath, contracts_md).with_context(|| format!("write {}", cpath.display()))?;
54    fs::write(&ipath, idl_md).with_context(|| format!("write {}", ipath.display()))?;
55
56    eprintln!(
57        "[robonix-codegen] docs: {} contracts, {} IDL files -> {}",
58        contracts.len(),
59        idl.len(),
60        out.display()
61    );
62    let _ = verbose;
63    Ok(())
64}
65
66/// Stable in-page anchor for a lib-relative IDL path. Both pages compute it
67/// from the same string (contract `idl` field == idl file `rel`), so links
68/// always resolve. `chassis/srv/ExecuteMoveCommand.srv` → `chassis-srv-executemovecommand-srv`.
69fn idl_anchor(rel: &str) -> String {
70    rel.chars()
71        .map(|c| {
72            if c.is_ascii_alphanumeric() {
73                c.to_ascii_lowercase()
74            } else {
75                '-'
76            }
77        })
78        .collect()
79}
80
81/// First namespace segment after `robonix/` — primitive / service / system / skill.
82fn category_of(id: &str) -> &str {
83    id.split('/').nth(1).unwrap_or("other")
84}
85
86fn render_contracts(
87    contracts: &[super::contract_gen::ContractSummary],
88    contracts_dirs: &[PathBuf],
89    idl_rels: &BTreeSet<&str>,
90    banner: &str,
91) -> String {
92    let mut out = String::new();
93    let _ = writeln!(out, "# 能力约定参考(自动生成)");
94    let _ = writeln!(out);
95    let _ = writeln!(
96        out,
97        "> 由 robonix {banner} 自动生成,请勿手改。重新生成:`rbnx docs`。"
98    );
99    let _ = writeln!(out);
100    let _ = writeln!(
101        out,
102        "本页罗列 `capabilities/` 下的所有标准能力约定(共 {} 条)。",
103        contracts.len()
104    );
105    let _ = writeln!(
106        out,
107        "载荷列链到对应的 [ROS IDL](idl.md)。概念与字段含义见 [接口目录](../interface-catalog/index.md)。"
108    );
109    let _ = writeln!(out);
110    let _ = writeln!(out, "[toc]");
111
112    // Stable category order; anything unexpected lands in "other".
113    for cat in ["primitive", "service", "system", "skill", "other"] {
114        let rows: Vec<&super::contract_gen::ContractSummary> = contracts
115            .iter()
116            .filter(|c| {
117                let cc = category_of(&c.id);
118                cc == cat
119                    || (cat == "other"
120                        && !matches!(cc, "primitive" | "service" | "system" | "skill"))
121            })
122            .collect();
123        if rows.is_empty() {
124            continue;
125        }
126        let _ = writeln!(out);
127        let _ = writeln!(out, "## {cat}");
128        let _ = writeln!(out);
129        let _ = writeln!(
130            out,
131            "| 能力约定 ID | 接口含义 | Pilot 可见 | kind | mode | 载荷(IDL) | 能力约定 TOML |"
132        );
133        let _ = writeln!(out, "|---|---|---|---|---|---|---|");
134        for c in rows {
135            let payload = if idl_rels.contains(c.idl.as_str()) {
136                format!("[`{}`](idl.md#{})", c.idl, idl_anchor(&c.idl))
137            } else {
138                format!("`{}`", c.idl)
139            };
140            let toml_rel = rel_under(&c.toml_path, contracts_dirs);
141            let meaning = md_table_cell(&c.description);
142            let _ = writeln!(
143                out,
144                "| `{}` | {} | {} | {} | `{}` | {} | `{}` |",
145                c.id,
146                meaning,
147                if c.llm_callable { "是" } else { "否" },
148                c.kind,
149                c.mode,
150                payload,
151                toml_rel
152            );
153        }
154    }
155    out
156}
157
158fn md_table_cell(value: &str) -> String {
159    // Escapes `|`/newlines for markdown tables and `{`/`}`/`<` so the page
160    // also compiles as MDX (Docusaurus): a bare `{...}` in a contract
161    // description would otherwise be parsed as a JSX expression.
162    let trimmed = value.trim();
163    if trimmed.is_empty() {
164        "-".to_string()
165    } else {
166        trimmed
167            .replace('\\', "\\\\")
168            .replace('|', "\\|")
169            .replace('{', "\\{")
170            .replace('}', "\\}")
171            .replace('<', "\\<")
172            .replace('\n', "<br/>")
173    }
174}
175fn render_idl(idl: &[IdlFile], banner: &str) -> String {
176    // Group by ROS package, then by file name (both sorted).
177    let mut by_pkg: BTreeMap<&str, Vec<&IdlFile>> = BTreeMap::new();
178    for f in idl {
179        by_pkg.entry(f.pkg.as_str()).or_default().push(f);
180    }
181    for v in by_pkg.values_mut() {
182        v.sort_by(|a, b| a.name.cmp(&b.name));
183    }
184
185    let mut out = String::new();
186    let _ = writeln!(out, "# ROS IDL 参考(自动生成)");
187    let _ = writeln!(out);
188    let _ = writeln!(
189        out,
190        "> 由 robonix {banner} 自动生成,请勿手改。重新生成:`rbnx docs`。"
191    );
192    let _ = writeln!(out);
193    let _ = writeln!(
194        out,
195        "本页收录从 IDL 包含根(`rbnx docs --include`,默认 `capabilities/lib/`)收集的全部 ROS IDL(`.msg` / `.srv`)原文,按 ROS 包分组(共 {} 个文件)。[能力约定参考](contracts.md) 的载荷列链到这里对应的锚点。",
196        idl.len()
197    );
198    let _ = writeln!(out);
199    let _ = writeln!(out, "[toc]");
200
201    for (pkg, files) in &by_pkg {
202        let _ = writeln!(out);
203        let _ = writeln!(out, "## {pkg}");
204        for f in files {
205            let _ = writeln!(out);
206            // Explicit HTML anchor so the contract page's computed link
207            // resolves regardless of mdBook's heading-slug rules.
208            let _ = writeln!(out, "<a id=\"{}\"></a>", idl_anchor(&f.rel));
209            let _ = writeln!(out, "### {} `{}`", f.name, f.kind);
210            let _ = writeln!(out);
211            let _ = writeln!(out, "`{}`", f.rel);
212            let _ = writeln!(out);
213            let _ = writeln!(out, "```rosidl");
214            let _ = write!(out, "{}", f.body);
215            if !f.body.ends_with('\n') {
216                let _ = writeln!(out);
217            }
218            let _ = writeln!(out, "```");
219        }
220    }
221    out
222}
223
224/// Path relative to whichever `dirs` prefix it lives under, `/`-normalised.
225fn rel_under(p: &Path, dirs: &[PathBuf]) -> String {
226    for d in dirs {
227        if let Ok(r) = p.strip_prefix(d) {
228            return r.to_string_lossy().replace('\\', "/");
229        }
230    }
231    p.to_string_lossy().replace('\\', "/")
232}
233
234/// ROS package for a lib-relative path: the segment just above `msg/`/`srv/`,
235/// else the first segment. `common_interfaces/sensor_msgs/msg/Image.msg`
236/// → `sensor_msgs`; `chassis/srv/X.srv` → `chassis`.
237fn ros_pkg_of(rel: &str) -> String {
238    let parts: Vec<&str> = rel.split('/').collect();
239    for (i, seg) in parts.iter().enumerate() {
240        if (*seg == "msg" || *seg == "srv") && i > 0 {
241            return parts[i - 1].to_string();
242        }
243    }
244    parts.first().map(|s| s.to_string()).unwrap_or_default()
245}
246
247fn collect_idl(lib_roots: &[PathBuf]) -> Result<Vec<IdlFile>> {
248    let mut by_rel: BTreeMap<String, IdlFile> = BTreeMap::new();
249    for root in lib_roots {
250        let mut files = Vec::new();
251        walk_idl(root, &mut files)?;
252        for p in files {
253            let kind: &'static str = match p.extension().and_then(|e| e.to_str()) {
254                Some("srv") => "srv",
255                Some("msg") => "msg",
256                _ => continue,
257            };
258            let rel = p
259                .strip_prefix(root)
260                .unwrap_or(&p)
261                .to_string_lossy()
262                .replace('\\', "/");
263            let name = p
264                .file_stem()
265                .map(|s| s.to_string_lossy().to_string())
266                .unwrap_or_default();
267            let pkg = ros_pkg_of(&rel);
268            let body = fs::read_to_string(&p).with_context(|| format!("read {}", p.display()))?;
269            by_rel.entry(rel.clone()).or_insert(IdlFile {
270                rel,
271                pkg,
272                name,
273                kind,
274                body,
275            });
276        }
277    }
278    Ok(by_rel.into_values().collect())
279}
280
281fn walk_idl(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
282    if !dir.is_dir() {
283        return Ok(());
284    }
285    for entry in fs::read_dir(dir).with_context(|| format!("read_dir {}", dir.display()))? {
286        let p = entry?.path();
287        if p.is_dir() {
288            walk_idl(&p, out)?;
289        } else if matches!(
290            p.extension().and_then(|e| e.to_str()),
291            Some("msg") | Some("srv")
292        ) {
293            out.push(p);
294        }
295    }
296    Ok(())
297}