1use 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
19struct IdlFile {
21 rel: String,
23 pkg: String,
25 name: String,
27 kind: &'static str,
29 body: String,
31}
32
33pub 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
66fn 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
81fn 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 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 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 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
209fn 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
219fn 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}