1use 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#[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}