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 | 接口含义 | 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 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 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 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
224fn 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
234fn 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}