1use crate::error::Result;
26use std::collections::BTreeMap;
27use std::path::{Path, PathBuf};
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct CrateDep {
32 pub name: String,
34 pub req: Option<String>,
36 pub version_exact: Option<String>,
38 pub kind: String,
40 pub declared_in: String,
42}
43
44pub fn docs_rs_url(name: &str, version_exact: Option<&str>) -> String {
46 let name = name.trim();
47 if let Some(v) = version_exact.map(str::trim).filter(|s| !s.is_empty()) {
48 format!("https://docs.rs/{name}/{v}")
49 } else {
50 format!("https://docs.rs/{name}")
51 }
52}
53
54pub fn crates_io_url(name: &str) -> String {
56 format!("https://crates.io/crates/{}", name.trim())
57}
58
59pub fn collect_crate_deps(workspace: &Path) -> Result<Vec<CrateDep>> {
61 let mut by_name: BTreeMap<String, CrateDep> = BTreeMap::new();
62 let lock_versions = parse_lock_versions(&workspace.join("Cargo.lock"));
63
64 let mut manifests = Vec::new();
65 find_cargo_tomls(workspace, workspace, &mut manifests)?;
66
67 for manifest in &manifests {
68 let rel = manifest
69 .strip_prefix(workspace)
70 .unwrap_or(manifest)
71 .to_string_lossy()
72 .replace('\\', "/");
73 let text = match std::fs::read_to_string(manifest) {
74 Ok(t) => t,
75 Err(_) => continue,
76 };
77 let Ok(value) = text.parse::<toml::Value>() else {
78 continue;
79 };
80 harvest_table(
81 &value,
82 "dependencies",
83 "normal",
84 &rel,
85 &lock_versions,
86 &mut by_name,
87 );
88 harvest_table(
89 &value,
90 "dev-dependencies",
91 "dev",
92 &rel,
93 &lock_versions,
94 &mut by_name,
95 );
96 harvest_table(
97 &value,
98 "build-dependencies",
99 "build",
100 &rel,
101 &lock_versions,
102 &mut by_name,
103 );
104 if let Some(ws) = value.get("workspace").and_then(|v| v.as_table()) {
105 if let Some(deps) = ws.get("dependencies") {
106 harvest_value_table(
107 deps,
108 "workspace",
109 &rel,
110 &lock_versions,
111 &mut by_name,
112 );
113 }
114 }
115 }
116
117 Ok(by_name.into_values().collect())
118}
119
120pub fn write_crate_docs_notes(
124 workspace: &Path,
125 deps: &[CrateDep],
126 write: bool,
127 force: bool,
128) -> Result<(usize, Vec<String>)> {
129 let mut actions = Vec::new();
130 let refs_dir = workspace.join("docs/references/crates");
131 if write {
132 std::fs::create_dir_all(&refs_dir)?;
133 std::fs::create_dir_all(workspace.join("docs/references"))?;
134 }
135
136 let deps: Vec<&CrateDep> = deps.iter().take(300).collect();
138
139 let mut index = String::from(
140 "---\n\
141 tags: [reference, generated, crates, docs-rs]\n\
142 node_type: reference\n\
143 aliases: [crate-docs, docs-rs, dependencies, crates-io]\n\
144 generated: true\n\
145 ---\n\
146 # Crate docs (docs.rs) — generated\n\n\
147 > Harvested from `Cargo.toml` (+ `Cargo.lock` versions when present) by\n\
148 > `rustbrain bootstrap` / `setup`. **Not** a download of docs HTML — URLs only.\n\
149 > Re-run with `--force` to refresh. Agents: `rustbrain query <crate> --scores`.\n\n",
150 );
151
152 if deps.is_empty() {
153 index.push_str("_No crates.io dependencies found in this workspace._\n");
154 } else {
155 index.push_str(&format!("_{} unique crates.io package(s)._ \n\n", deps.len()));
156 index.push_str("| Crate | docs.rs | Version | Kind |\n");
157 index.push_str("|-------|---------|---------|------|\n");
158 for d in &deps {
159 let url = docs_rs_url(&d.name, d.version_exact.as_deref());
160 let ver = d
161 .version_exact
162 .as_deref()
163 .or(d.req.as_deref())
164 .unwrap_or("—");
165 index.push_str(&format!(
166 "| [`{}`](./crates/{}.md) | [{url}]({url}) | `{ver}` | {} |\n",
167 d.name,
168 sanitize_filename(&d.name),
169 d.kind
170 ));
171 }
172 index.push('\n');
173 }
174
175 let index_rel = "docs/references/crate-docs.generated.md";
176 let index_path = workspace.join(index_rel);
177 write_generated_file(&index_path, index_rel, &index, write, force, &mut actions)?;
178
179 let mut n = 0usize;
180 for d in &deps {
181 let body = render_crate_note(d);
182 let rel = format!("docs/references/crates/{}.md", sanitize_filename(&d.name));
183 let path = workspace.join(&rel);
184 write_generated_file(&path, &rel, &body, write, force, &mut actions)?;
185 n += 1;
186 }
187
188 Ok((n, actions))
189}
190
191fn render_crate_note(d: &CrateDep) -> String {
192 let docs = docs_rs_url(&d.name, d.version_exact.as_deref());
193 let crates = crates_io_url(&d.name);
194 let ver_line = match (&d.version_exact, &d.req) {
195 (Some(v), Some(r)) => format!("- **Resolved (lock):** `{v}`\n- **Requirement:** `{r}`\n"),
196 (Some(v), None) => format!("- **Resolved (lock):** `{v}`\n"),
197 (None, Some(r)) => format!("- **Requirement:** `{r}`\n"),
198 (None, None) => String::new(),
199 };
200 format!(
201 "---\n\
202 tags: [reference, generated, crate, docs-rs, {name}]\n\
203 node_type: reference\n\
204 aliases: [{name}, crate:{name}, docs.rs/{name}]\n\
205 generated: true\n\
206 source: cargo-deps\n\
207 ---\n\
208 # Crate: `{name}`\n\n\
209 > Generated by rustbrain from Cargo manifests. Re-run bootstrap `--force` to refresh.\n\n\
210 ## Documentation\n\n\
211 - **docs.rs:** [{docs}]({docs})\n\
212 - **crates.io:** [{crates}]({crates})\n\
213 {ver_line}\
214 - **Declared as:** `{kind}` dependency\n\
215 - **Manifest:** `{manifest}`\n\n\
216 ## Agent tips\n\n\
217 - Open the docs.rs URL for API reference (types, features, examples).\n\
218 - Prefer this note over inventing crate APIs from memory.\n\
219 - After upgrading the crate in Cargo.toml/lock, re-run `rustbrain bootstrap --yes --write --force` (or your setup path) and `sync`.\n",
220 name = d.name,
221 docs = docs,
222 crates = crates,
223 ver_line = ver_line,
224 kind = d.kind,
225 manifest = d.declared_in,
226 )
227}
228
229fn write_generated_file(
230 path: &Path,
231 rel: &str,
232 content: &str,
233 write: bool,
234 force: bool,
235 actions: &mut Vec<String>,
236) -> Result<()> {
237 if path.exists() && !force {
238 let existing = std::fs::read_to_string(path).unwrap_or_default();
239 if existing.contains("generated: true") {
240 actions.push(format!("skip {rel} (exists; use --force)"));
242 return Ok(());
243 }
244 actions.push(format!("skip {rel} (exists, not marked generated)"));
245 return Ok(());
246 }
247 if write {
248 if let Some(parent) = path.parent() {
249 std::fs::create_dir_all(parent)?;
250 }
251 std::fs::write(path, content)?;
252 actions.push(format!("write {rel}"));
253 } else {
254 actions.push(format!("would_write {rel}"));
255 }
256 Ok(())
257}
258
259fn sanitize_filename(name: &str) -> String {
260 name.chars()
261 .map(|c| {
262 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
263 c
264 } else {
265 '_'
266 }
267 })
268 .collect()
269}
270
271fn find_cargo_tomls(workspace: &Path, dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
272 if !dir.is_dir() {
273 return Ok(());
274 }
275 let cargo = dir.join("Cargo.toml");
276 if cargo.is_file() {
277 out.push(cargo);
278 }
279 for entry in std::fs::read_dir(dir)? {
280 let entry = entry?;
281 let path = entry.path();
282 if !path.is_dir() {
283 continue;
284 }
285 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
286 if matches!(
287 name,
288 "target" | ".git" | ".brain" | "node_modules" | "vendor" | "dist" | "build"
289 ) || name.starts_with('.')
290 {
291 continue;
292 }
293 if path.starts_with(workspace) {
295 find_cargo_tomls(workspace, &path, out)?;
296 }
297 }
298 Ok(())
299}
300
301fn harvest_table(
302 root: &toml::Value,
303 key: &str,
304 kind: &str,
305 rel: &str,
306 lock: &BTreeMap<String, String>,
307 out: &mut BTreeMap<String, CrateDep>,
308) {
309 if let Some(table) = root.get(key) {
310 harvest_value_table(table, kind, rel, lock, out);
311 }
312}
313
314fn harvest_value_table(
315 table: &toml::Value,
316 kind: &str,
317 rel: &str,
318 lock: &BTreeMap<String, String>,
319 out: &mut BTreeMap<String, CrateDep>,
320) {
321 let Some(map) = table.as_table() else {
322 return;
323 };
324 for (key, val) in map {
325 if let Some(dep) = parse_dep_entry(key, val, kind, rel, lock) {
326 out.entry(dep.name.clone())
328 .and_modify(|existing| {
329 if existing.version_exact.is_none() && dep.version_exact.is_some() {
330 existing.version_exact = dep.version_exact.clone();
331 }
332 if existing.req.is_none() && dep.req.is_some() {
333 existing.req = dep.req.clone();
334 }
335 if existing.kind != "normal" && dep.kind == "normal" {
337 existing.kind = "normal".into();
338 }
339 })
340 .or_insert(dep);
341 }
342 }
343}
344
345fn parse_dep_entry(
346 key: &str,
347 val: &toml::Value,
348 kind: &str,
349 rel: &str,
350 lock: &BTreeMap<String, String>,
351) -> Option<CrateDep> {
352 match val {
353 toml::Value::String(req) => {
354 let name = key.to_string();
355 let version_exact = lock.get(&name).cloned();
356 Some(CrateDep {
357 name,
358 req: Some(req.clone()),
359 version_exact,
360 kind: kind.into(),
361 declared_in: rel.into(),
362 })
363 }
364 toml::Value::Table(t) => {
365 if t.contains_key("path") {
367 return None;
368 }
369 let package = t
370 .get("package")
371 .and_then(|v| v.as_str())
372 .unwrap_or(key)
373 .to_string();
374 let req = t
375 .get("version")
376 .and_then(|v| v.as_str())
377 .map(|s| s.to_string());
378 let version_exact = lock.get(&package).cloned();
380 Some(CrateDep {
381 name: package,
382 req,
383 version_exact,
384 kind: kind.into(),
385 declared_in: rel.into(),
386 })
387 }
388 _ => None,
389 }
390}
391
392fn parse_lock_versions(lock_path: &Path) -> BTreeMap<String, String> {
393 let mut map = BTreeMap::new();
394 let Ok(text) = std::fs::read_to_string(lock_path) else {
395 return map;
396 };
397 let Ok(value) = text.parse::<toml::Value>() else {
399 return map;
400 };
401 let Some(packages) = value.get("package").and_then(|v| v.as_array()) else {
402 return map;
403 };
404 for pkg in packages {
405 let name = pkg.get("name").and_then(|v| v.as_str());
406 let ver = pkg.get("version").and_then(|v| v.as_str());
407 if let (Some(n), Some(v)) = (name, ver) {
408 map.entry(n.to_string())
412 .and_modify(|old| {
413 if version_is_newer(v, old) {
414 *old = v.to_string();
415 }
416 })
417 .or_insert_with(|| v.to_string());
418 }
419 }
420 map
421}
422
423fn version_is_newer(a: &str, b: &str) -> bool {
424 let pa: Vec<u64> = a
426 .split(|c: char| !c.is_ascii_digit())
427 .filter_map(|s| s.parse().ok())
428 .collect();
429 let pb: Vec<u64> = b
430 .split(|c: char| !c.is_ascii_digit())
431 .filter_map(|s| s.parse().ok())
432 .collect();
433 if pa.is_empty() || pb.is_empty() {
434 return a > b;
435 }
436 for i in 0..pa.len().max(pb.len()) {
437 let x = pa.get(i).copied().unwrap_or(0);
438 let y = pb.get(i).copied().unwrap_or(0);
439 if x != y {
440 return x > y;
441 }
442 }
443 false
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449 use std::collections::BTreeSet;
450 use std::fs;
451 use tempfile::tempdir;
452
453 #[test]
454 fn docs_rs_url_shapes() {
455 assert_eq!(docs_rs_url("serde", None), "https://docs.rs/serde");
456 assert_eq!(
457 docs_rs_url("serde", Some("1.0.200")),
458 "https://docs.rs/serde/1.0.200"
459 );
460 }
461
462 #[test]
463 fn harvests_simple_and_table_deps() {
464 let dir = tempdir().unwrap();
465 fs::write(
466 dir.path().join("Cargo.toml"),
467 r#"[package]
468name = "demo"
469version = "0.1.0"
470edition = "2021"
471
472[dependencies]
473serde = "1.0"
474tokio = { version = "1", features = ["full"] }
475mine = { path = "../mine" }
476renamed = { package = "once_cell", version = "1.19" }
477
478[dev-dependencies]
479tempfile = "3"
480"#,
481 )
482 .unwrap();
483 fs::write(
484 dir.path().join("Cargo.lock"),
485 r#"# This file is automatically @generated by Cargo.
486version = 3
487
488[[package]]
489name = "once_cell"
490version = "1.19.0"
491
492[[package]]
493name = "serde"
494version = "1.0.210"
495
496[[package]]
497name = "tempfile"
498version = "3.10.1"
499
500[[package]]
501name = "tokio"
502version = "1.40.0"
503"#,
504 )
505 .unwrap();
506
507 let deps = collect_crate_deps(dir.path()).unwrap();
508 let names: BTreeSet<_> = deps.iter().map(|d| d.name.as_str()).collect();
509 assert!(names.contains("serde"));
510 assert!(names.contains("tokio"));
511 assert!(names.contains("once_cell"));
512 assert!(names.contains("tempfile"));
513 assert!(!names.contains("mine"), "path deps skipped");
514 let serde = deps.iter().find(|d| d.name == "serde").unwrap();
515 assert_eq!(serde.version_exact.as_deref(), Some("1.0.210"));
516 assert_eq!(
517 docs_rs_url("serde", serde.version_exact.as_deref()),
518 "https://docs.rs/serde/1.0.210"
519 );
520 }
521
522 #[test]
523 fn writes_notes() {
524 let dir = tempdir().unwrap();
525 fs::write(
526 dir.path().join("Cargo.toml"),
527 r#"[package]
528name = "demo"
529version = "0.1.0"
530[dependencies]
531serde = "1"
532"#,
533 )
534 .unwrap();
535 let deps = collect_crate_deps(dir.path()).unwrap();
536 let (n, _) = write_crate_docs_notes(dir.path(), &deps, true, true).unwrap();
537 assert!(n >= 1);
538 assert!(dir
539 .path()
540 .join("docs/references/crate-docs.generated.md")
541 .is_file());
542 assert!(dir
543 .path()
544 .join("docs/references/crates/serde.md")
545 .is_file());
546 let note = fs::read_to_string(dir.path().join("docs/references/crates/serde.md")).unwrap();
547 assert!(note.contains("https://docs.rs/serde"));
548 assert!(note.contains("generated: true"));
549 assert!(note.contains("node_type: reference"));
550 }
551}