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 | 接口含义 | 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, meaning, c.kind, c.mode, payload, toml_rel
146            );
147        }
148    }
149    out
150}
151
152fn md_table_cell(value: &str) -> String {
153    let trimmed = value.trim();
154    if trimmed.is_empty() {
155        "-".to_string()
156    } else {
157        trimmed.replace('|', "\\|").replace('\n', "<br>")
158    }
159}
160fn render_idl(idl: &[IdlFile], banner: &str) -> String {
161    // Group by ROS package, then by file name (both sorted).
162    let mut by_pkg: BTreeMap<&str, Vec<&IdlFile>> = BTreeMap::new();
163    for f in idl {
164        by_pkg.entry(f.pkg.as_str()).or_default().push(f);
165    }
166    for v in by_pkg.values_mut() {
167        v.sort_by(|a, b| a.name.cmp(&b.name));
168    }
169
170    let mut out = String::new();
171    let _ = writeln!(out, "# ROS IDL 参考(自动生成)");
172    let _ = writeln!(out);
173    let _ = writeln!(
174        out,
175        "> 由 robonix {banner} 自动生成,请勿手改。重新生成:`rbnx docs`。"
176    );
177    let _ = writeln!(out);
178    let _ = writeln!(
179        out,
180        "本页收录从 IDL 包含根(`rbnx docs --include`,默认 `capabilities/lib/`)收集的全部 ROS IDL(`.msg` / `.srv`)原文,按 ROS 包分组(共 {} 个文件)。[能力约定参考](contracts.md) 的载荷列链到这里对应的锚点。",
181        idl.len()
182    );
183    let _ = writeln!(out);
184    let _ = writeln!(out, "[toc]");
185
186    for (pkg, files) in &by_pkg {
187        let _ = writeln!(out);
188        let _ = writeln!(out, "## {pkg}");
189        for f in files {
190            let _ = writeln!(out);
191            // Explicit HTML anchor so the contract page's computed link
192            // resolves regardless of mdBook's heading-slug rules.
193            let _ = writeln!(out, "<a id=\"{}\"></a>", idl_anchor(&f.rel));
194            let _ = writeln!(out, "### {} `{}`", f.name, f.kind);
195            let _ = writeln!(out);
196            let _ = writeln!(out, "`{}`", f.rel);
197            let _ = writeln!(out);
198            let _ = writeln!(out, "```rosidl");
199            let _ = write!(out, "{}", f.body);
200            if !f.body.ends_with('\n') {
201                let _ = writeln!(out);
202            }
203            let _ = writeln!(out, "```");
204        }
205    }
206    out
207}
208
209/// Path relative to whichever `dirs` prefix it lives under, `/`-normalised.
210fn rel_under(p: &Path, dirs: &[PathBuf]) -> String {
211    for d in dirs {
212        if let Ok(r) = p.strip_prefix(d) {
213            return r.to_string_lossy().replace('\\', "/");
214        }
215    }
216    p.to_string_lossy().replace('\\', "/")
217}
218
219/// ROS package for a lib-relative path: the segment just above `msg/`/`srv/`,
220/// else the first segment. `common_interfaces/sensor_msgs/msg/Image.msg`
221/// → `sensor_msgs`; `chassis/srv/X.srv` → `chassis`.
222fn ros_pkg_of(rel: &str) -> String {
223    let parts: Vec<&str> = rel.split('/').collect();
224    for (i, seg) in parts.iter().enumerate() {
225        if (*seg == "msg" || *seg == "srv") && i > 0 {
226            return parts[i - 1].to_string();
227        }
228    }
229    parts.first().map(|s| s.to_string()).unwrap_or_default()
230}
231
232fn collect_idl(lib_roots: &[PathBuf]) -> Result<Vec<IdlFile>> {
233    let mut by_rel: BTreeMap<String, IdlFile> = BTreeMap::new();
234    for root in lib_roots {
235        let mut files = Vec::new();
236        walk_idl(root, &mut files)?;
237        for p in files {
238            let kind: &'static str = match p.extension().and_then(|e| e.to_str()) {
239                Some("srv") => "srv",
240                Some("msg") => "msg",
241                _ => continue,
242            };
243            let rel = p
244                .strip_prefix(root)
245                .unwrap_or(&p)
246                .to_string_lossy()
247                .replace('\\', "/");
248            let name = p
249                .file_stem()
250                .map(|s| s.to_string_lossy().to_string())
251                .unwrap_or_default();
252            let pkg = ros_pkg_of(&rel);
253            let body = fs::read_to_string(&p).with_context(|| format!("read {}", p.display()))?;
254            by_rel.entry(rel.clone()).or_insert(IdlFile {
255                rel,
256                pkg,
257                name,
258                kind,
259                body,
260            });
261        }
262    }
263    Ok(by_rel.into_values().collect())
264}
265
266fn walk_idl(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
267    if !dir.is_dir() {
268        return Ok(());
269    }
270    for entry in fs::read_dir(dir).with_context(|| format!("read_dir {}", dir.display()))? {
271        let p = entry?.path();
272        if p.is_dir() {
273            walk_idl(&p, out)?;
274        } else if matches!(
275            p.extension().and_then(|e| e.to_str()),
276            Some("msg") | Some("srv")
277        ) {
278            out.push(p);
279        }
280    }
281    Ok(())
282}