rustdoc_merger/
main.rs

1//! This is a binary helper to merge rustdoc html outputs
2//!
3//! Normally rustdoc is run sequentially outputting to a single output directory. This goes against how
4//! bazel works and causes issues since every time we change one crate we would need to regenerate the docs for all crates.
5//!
6//! This tool fixes this problem by essentially performing the same steps that rustdoc would do when merging except does it
7//! in a bazel-like way.
8
9use std::path::Path;
10use std::process::Stdio;
11
12use camino::{Utf8Path, Utf8PathBuf};
13use clap::Parser;
14use lol_html::html_content::ContentType;
15use lol_html::{RewriteStrSettings, element, rewrite_str};
16
17/// Simple program to greet a person
18#[derive(Parser, Debug)]
19#[command(version, about, long_about = None)]
20struct Args {
21    #[arg(long)]
22    rustdoc: Utf8PathBuf,
23    #[arg(long)]
24    manifest: Utf8PathBuf,
25    #[arg(long)]
26    output: Utf8PathBuf,
27}
28
29#[derive(Debug, serde_derive::Deserialize)]
30struct Manifest {
31    entries: Vec<ManifestEntry>,
32}
33
34#[derive(Debug, serde_derive::Deserialize)]
35struct ManifestEntry {
36    html_out: Option<Utf8PathBuf>,
37    parts_out: Option<Utf8PathBuf>,
38    json_out: Option<Utf8PathBuf>,
39    crate_name: String,
40    crate_version: String,
41}
42
43fn copy(input: &Utf8Path, output: &Utf8Path, make_path: impl Fn(&Utf8Path) -> Utf8PathBuf) {
44    let input = make_path(input);
45    let output = make_path(output);
46    copy_dir::copy_dir(input, output).expect("failed to copy");
47}
48
49fn path_depth(mut path: &Path) -> usize {
50    let mut count = 0;
51    while let Some(parent) = path.parent() {
52        path = parent;
53        count += 1;
54    }
55    count
56}
57
58fn process_file(target: &Path, file: &Path, replacements: &[String]) -> std::io::Result<()> {
59    if file
60        .extension()
61        .and_then(|ext| ext.to_str())
62        .is_none_or(|ext| !matches!(ext, "css" | "js" | "html"))
63    {
64        return Ok(());
65    }
66
67    let content = std::fs::read(file)?;
68    let Ok(content_str) = String::from_utf8(content) else {
69        return Ok(());
70    };
71
72    let depth = path_depth(file.parent().unwrap().strip_prefix(target).unwrap());
73    let mut prefix = String::new();
74    for _ in 0..depth {
75        prefix.push_str("../");
76    }
77
78    let mut prefix = prefix.trim_matches('/');
79    if prefix.is_empty() {
80        prefix = "."
81    }
82
83    let mut changed = content_str.clone();
84    for replace in replacements {
85        changed = changed.replace(replace, prefix);
86    }
87
88    if changed != content_str {
89        std::fs::remove_file(file)?;
90        std::fs::write(file, changed)?;
91    }
92
93    Ok(())
94}
95
96fn walk_and_replace(target: &Path, replacements: &[String]) -> std::io::Result<()> {
97    let mut entries: Vec<_> = std::fs::read_dir(target)?.collect();
98    while let Some(entry) = entries.pop() {
99        let entry = entry?;
100        let file_type = entry.file_type()?;
101        if file_type.is_dir() {
102            entries.extend(std::fs::read_dir(entry.path())?);
103        } else if file_type.is_file() {
104            process_file(target, &entry.path(), replacements)?;
105        }
106    }
107
108    Ok(())
109}
110
111fn main() {
112    let args = Args::parse();
113
114    let rustdoc_version = std::process::Command::new(&args.rustdoc)
115        .arg("--version")
116        .output()
117        .expect("rustdoc version failed");
118
119    if !rustdoc_version.status.success() {
120        panic!(
121            "failed to get rustdoc version {}",
122            String::from_utf8_lossy(&rustdoc_version.stderr)
123        )
124    }
125
126    let Ok(output) = String::from_utf8(rustdoc_version.stdout) else {
127        panic!("invalid utf8 from rustdoc --version");
128    };
129
130    let splits: Vec<_> = output.splitn(3, ' ').collect();
131    if splits.len() != 3 {
132        panic!("invalid rustdoc output: {output}")
133    }
134
135    let tmp_dir = args.output.join(".tmp");
136
137    let status = std::process::Command::new(&args.rustdoc)
138        .env("RUSTC_BOOTSTRAP", "1")
139        .arg("-Zunstable-options")
140        .arg("--enable-index-page")
141        .arg("--out-dir")
142        .arg(&tmp_dir)
143        .arg("-")
144        .stdin(Stdio::null())
145        .status()
146        .expect("failed to run rustdoc");
147
148    if !status.success() {
149        panic!("failed to generate rustdoc")
150    }
151
152    let vars_edit = || {
153        element!(r#"meta[name="rustdoc-vars"]"#, |el| {
154            el.set_attribute("data-current-crate", "")?;
155            Ok(())
156        })
157    };
158
159    let manifest = std::fs::read_to_string(&args.manifest).expect("manifest read");
160    let manifest: Manifest = serde_json::from_str(&manifest).expect("manifest deserializse");
161
162    let mut sorted_names: Vec<_> = manifest
163        .entries
164        .iter()
165        .filter(|e| e.parts_out.is_some())
166        .map(|e| &e.crate_name)
167        .collect();
168    sorted_names.sort();
169
170    let index_page = rewrite_str(
171        &std::fs::read_to_string(tmp_dir.join("index.html")).expect("index.html missing"),
172        RewriteStrSettings {
173            element_content_handlers: vec![
174                element!("ul.all-items", |el| {
175                    el.set_inner_content("", ContentType::Html);
176                    for name in &sorted_names {
177                        el.append(
178                            &format!(r#"<li><a href="{name}/index.html">{name}</a></li>"#),
179                            ContentType::Html,
180                        );
181                    }
182                    Ok(())
183                }),
184                vars_edit(),
185            ],
186            ..RewriteStrSettings::new()
187        },
188    )
189    .unwrap();
190    let help_page = rewrite_str(
191        &std::fs::read_to_string(tmp_dir.join("help.html")).expect("help.html missing"),
192        RewriteStrSettings {
193            element_content_handlers: vec![vars_edit()],
194            ..RewriteStrSettings::new()
195        },
196    )
197    .unwrap();
198    let settings_page = rewrite_str(
199        &std::fs::read_to_string(tmp_dir.join("settings.html")).expect("settings.html missing"),
200        RewriteStrSettings {
201            element_content_handlers: vec![vars_edit()],
202            ..RewriteStrSettings::new()
203        },
204    )
205    .unwrap();
206
207    std::fs::remove_dir_all(&tmp_dir).expect("remove tmp dir");
208
209    std::fs::create_dir_all(&args.output).expect("create output");
210    std::fs::write(args.output.join("index.html"), index_page).expect("index.html write");
211    std::fs::write(args.output.join("help.html"), help_page).expect("help.html write");
212    std::fs::write(args.output.join("settings.html"), settings_page).expect("settings.html write");
213
214    std::fs::create_dir_all(args.output.join("search.desc")).expect("make dir");
215    std::fs::create_dir_all(args.output.join("src")).expect("make dir");
216
217    let mut finalize_cmd = std::process::Command::new(&args.rustdoc);
218    finalize_cmd
219        .env("RUSTC_BOOTSTRAP", "1")
220        .arg("-Zunstable-options")
221        .arg("--merge=finalize")
222        .arg("-o")
223        .arg(&args.output);
224
225    let mut replacements = Vec::new();
226
227    for entry in &manifest.entries {
228        let crate_name = entry.crate_name.replace("-", "_");
229        if let (Some(parts_out), Some(html_out)) = (&entry.parts_out, &entry.html_out) {
230            finalize_cmd.arg("--include-parts-dir").arg(parts_out);
231            copy(html_out, &args.output, |path| path.join(&crate_name));
232            copy(html_out, &args.output, |path| path.join("src").join(&crate_name));
233            copy(html_out, &args.output, |path| path.join("search.desc").join(&crate_name));
234            replacements.push(format!("https://docs.rs/{crate_name}/{}", entry.crate_version))
235        } else if let Some(json_out) = &entry.json_out {
236            std::fs::copy(json_out, args.output.join(format!("{crate_name}.json"))).expect("failed to copy json output");
237        }
238    }
239
240    let status = finalize_cmd.status().expect("failed to run finalize");
241    if !status.success() {
242        panic!("failed to run rustdoc finalize");
243    }
244
245    walk_and_replace(args.output.as_std_path(), &replacements).expect("failed to replace paths")
246}