Skip to main content

rustbrain_core/
crate_docs.rs

1//! Harvest **crates.io → docs.rs** links from `Cargo.toml` (+ optional `Cargo.lock`).
2//!
3//! Used by bootstrap/setup so agents can `rustbrain query serde` and land on a
4//! note with the correct docs.rs URL. Purely algorithmic — no network fetch of
5//! docs HTML (URLs follow the public docs.rs convention).
6//!
7//! ## What is included
8//!
9//! - Direct deps from every `Cargo.toml` under the workspace (skip `target/`, …)
10//! - Sections: `[dependencies]`, `[dev-dependencies]`, `[build-dependencies]`,
11//!   and `[workspace.dependencies]`
12//! - Exact versions from `Cargo.lock` when present (better docs.rs deep links)
13//!
14//! ## What is skipped
15//!
16//! - `path = "…"` dependencies (local crates — not on docs.rs)
17//! - Renamed packages use the **package** name for docs.rs, not the key
18//!
19//! ## Output
20//!
21//! - `docs/references/crate-docs.generated.md` — index of all harvested deps
22//! - `docs/references/crates/{crate}.md` — one note per crates.io package
23//!   (`node_type: reference`, `generated: true`)
24
25use crate::error::Result;
26use std::collections::BTreeMap;
27use std::path::{Path, PathBuf};
28
29/// One direct dependency harvested from a manifest.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct CrateDep {
32    /// crates.io / docs.rs package name.
33    pub name: String,
34    /// Version requirement from Cargo.toml (if any).
35    pub req: Option<String>,
36    /// Exact version from Cargo.lock (if any).
37    pub version_exact: Option<String>,
38    /// `normal` | `dev` | `build` | `workspace`.
39    pub kind: String,
40    /// Relative path of the Cargo.toml that declared it.
41    pub declared_in: String,
42}
43
44/// docs.rs URL for a package (optionally pinned to an exact version).
45pub 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
54/// crates.io URL for a package.
55pub fn crates_io_url(name: &str) -> String {
56    format!("https://crates.io/crates/{}", name.trim())
57}
58
59/// Collect unique crates.io dependencies from a workspace tree.
60pub 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
120/// Write index + per-crate notes under `docs/references/`.
121///
122/// Returns number of crate notes written/planned.
123pub 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    // Cap runaway trees (monorepos with huge trees still capped).
137    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            // Still skip without force — same policy as module-map.
241            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        // Stay under workspace
294        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            // Prefer entry that has an exact version / merge kinds lightly.
327            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                    // Prefer normal over workspace/dev labeling when both exist.
336                    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            // Skip local path crates (not published docs).
366            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            // Optional: still allow git deps — docs.rs may or may not host them.
379            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    // Cargo.lock is TOML with [[package]] arrays.
398    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            // Keep first (usually unique per name+version pairs; multiple versions possible —
409            // prefer the last written / highest by naive string is wrong; keep first seen then
410            // upgrade if we see a longer patch-like version).
411            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    // Simple numeric compare of dotted versions; fall back to string.
425    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}