Skip to main content

cargo_docs/
lib.rs

1use bytes::Bytes;
2use cargo::core::compiler::{CompileMode, Executor, UserIntent};
3use cargo::core::{PackageId, Shell, Target, Verbosity, Workspace};
4use cargo::ops::{compile_with_exec, CompileOptions};
5use cargo::util::errors::CargoResult;
6use cargo::util::{homedir, GlobalContext};
7use cargo_util::ProcessBuilder;
8use http::response::Builder as ResponseBuilder;
9use http::{header, StatusCode};
10use http_body_util::combinators::BoxBody;
11use http_body_util::{BodyExt, Full};
12use hyper::server::conn::http1;
13use hyper::service::service_fn;
14use hyper::{Request, Response};
15use hyper_staticfile::Body;
16use hyper_staticfile::Static;
17use hyper_util::rt::tokio::TokioIo;
18use std::path::PathBuf;
19use std::sync::atomic::{AtomicU64, Ordering};
20use std::sync::Arc;
21use tokio::net::TcpListener;
22
23/// Body type used by the watch-mode and `--all` handlers.
24///
25/// Using a boxed body allows mixing cheaply-constructed in-memory responses
26/// (e.g. `/_buildid`, injected HTML) with the normal staticfile body in one
27/// unified return type.
28type DynBody = BoxBody<Bytes, std::io::Error>;
29
30/// Wrap owned bytes in a [`DynBody`] with no I/O error path.
31fn full_body(bytes: impl Into<Bytes>) -> DynBody {
32    Full::new(bytes.into())
33        .map_err(|e| -> std::io::Error { match e {} })
34        .boxed()
35}
36
37/// JavaScript snippet injected into HTML pages when watch mode is active.
38/// It polls `/_buildid` every second and reloads if the build ID has changed.
39const RELOAD_SCRIPT: &str = r#"<script>
40(function() {
41  var lastId = null;
42  function poll() {
43    fetch('/_buildid')
44      .then(function(r) { return r.text(); })
45      .then(function(id) {
46        if (lastId !== null && id !== lastId) { location.reload(); }
47        lastId = id;
48      })
49      .catch(function() {})
50      .finally(function() { setTimeout(poll, 1000); });
51  }
52  poll();
53})();
54</script>
55"#;
56
57/// run `cargo doc` with extra args
58#[allow(dead_code)]
59pub async fn run_cargo_doc(args: &Vec<String>) -> std::process::ExitStatus {
60    // std::io::Result<> {
61    // async fn main() ->  {
62    let mut cmd = tokio::process::Command::new("cargo");
63    cmd.arg("doc").args(args);
64    let stdcmd = cmd.as_std();
65    log::info!(
66        "Running {} {}",
67        stdcmd.get_program().to_string_lossy(),
68        stdcmd
69            .get_args()
70            .map(|s| s.to_string_lossy().to_string())
71            .collect::<Vec<String>>()
72            .join(" ")
73    );
74    let mut child = tokio::process::Command::new("cargo")
75        .arg("doc")
76        .args(args)
77        .spawn()
78        .expect("failed to run `cargo doc`");
79    child.wait().await.expect("failed to wait")
80}
81
82/// handle crate doc request with redirect on `/`
83///
84/// <https://github.com/stephank/hyper-staticfile/blob/HEAD/examples/doc_server.rs>
85#[allow(dead_code)]
86pub async fn handle_crate_request<B>(
87    req: Request<B>,
88    static_: Static,
89    crate_name: String,
90) -> Result<Response<Body>, std::io::Error> {
91    let target = if let Some(query) = req.uri().query() {
92        format!("/{crate_name}/?{query}")
93    } else {
94        format!("/{crate_name}/")
95    };
96    match req.uri().path() {
97        "/" => Ok(ResponseBuilder::new()
98            .status(StatusCode::FOUND)
99            .header(header::LOCATION, target)
100            .body(Body::Empty)
101            .expect("unable to build response")),
102        _ => static_.clone().serve(req).await,
103    }
104}
105
106/// serve rust book / std doc on `addr`
107#[allow(dead_code)]
108pub async fn serve_rust_doc(addr: &std::net::SocketAddr) -> Result<(), anyhow::Error> {
109    Ok(serve_rustbook(addr).await?)
110}
111
112/// get crate info
113#[allow(dead_code)]
114pub fn get_crate_info(manifest_path: &PathBuf) -> Result<(String, PathBuf), anyhow::Error> {
115    let mut shell = Shell::default();
116    shell.set_verbosity(Verbosity::Quiet);
117    let cwd = std::env::current_dir()?;
118    let cargo_home_dir = homedir(&cwd).expect("Errror locating homedir");
119    let config = GlobalContext::new(shell, cwd, cargo_home_dir);
120    let workspace = Workspace::new(manifest_path, &config).expect("Error making workspace");
121
122    let mut compile_opts = CompileOptions::new(
123        &config,
124        UserIntent::Doc {
125            deps: true,
126            json: false,
127        },
128    )
129    .expect("Making CompileOptions");
130
131    // set to Default, otherwise cargo will complain about virtual manifest:
132    //
133    // https://docs.rs/cargo/latest/src/cargo/core/workspace.rs.html#265-275
134    // https://docs.rs/cargo/latest/src/cargo/ops/cargo_compile.rs.html#125-184
135    compile_opts.spec = cargo::ops::Packages::Default;
136
137    // reference:
138    // https://docs.rs/cargo/latest/src/cargo/ops/cargo_doc.rs.html#18-34
139    /// A `DefaultExecutor` calls rustc without doing anything else. It is Cargo's
140    /// default behaviour.
141    #[derive(Copy, Clone)]
142    struct DefaultExecutor;
143
144    impl Executor for DefaultExecutor {
145        fn exec(
146            &self,
147            _cmd: &ProcessBuilder,
148            _id: PackageId,
149            _target: &Target,
150            _mode: CompileMode,
151            _on_stdout_line: &mut dyn FnMut(&str) -> CargoResult<()>,
152            _on_stderr_line: &mut dyn FnMut(&str) -> CargoResult<()>,
153        ) -> CargoResult<()> {
154            // cmd.exec_with_streaming(on_stdout_line, on_stderr_line, false).map(drop)
155            Ok(())
156        }
157    }
158
159    let exec: Arc<dyn Executor> = Arc::new(DefaultExecutor);
160    let compilation = compile_with_exec(&workspace, &compile_opts, &exec)?;
161    let root_crate_names = &compilation.root_crate_names;
162    let crate_doc_dir = workspace.target_dir().join("doc").into_path_unlocked();
163    let crate_name = root_crate_names
164        .get(0)
165        .ok_or_else(|| anyhow::anyhow!("no crates with documentation"))?;
166    Ok((crate_name.to_string(), crate_doc_dir))
167}
168
169/// serve crate doc on `addr`
170#[allow(dead_code)]
171pub async fn serve_crate_doc(
172    manifest_path: &PathBuf,
173    addr: &std::net::SocketAddr,
174) -> Result<(), anyhow::Error> {
175    let (crate_name, crate_doc_dir) = get_crate_info(manifest_path)?;
176    let crate_doc_dir = Static::new(crate_doc_dir.clone());
177    let crate_name = crate_name.clone();
178    let handler =
179        service_fn(move |req| handle_crate_request(req, crate_doc_dir.clone(), crate_name.clone()));
180
181    let listener = TcpListener::bind(addr)
182        .await
183        .expect("Failed to create TCP listener");
184
185    loop {
186        let (tcp, _) = listener.accept().await?;
187        let io = TokioIo::new(tcp);
188        let service = handler.clone();
189        tokio::task::spawn(async move {
190            if let Err(err) = http1::Builder::new().serve_connection(io, service).await {
191                println!("Failed to serve connection: {:?}", err);
192            }
193        });
194    }
195}
196
197/// find rust book location
198///
199/// Some("/home/aaron/.rustup/toolchains/nightly-2021-12-13-x86_64-unknown-linux-gnu/share/doc/rust/html")
200pub fn find_rustdoc() -> Option<PathBuf> {
201    let output = std::process::Command::new("rustup")
202        .arg("which")
203        .arg("rustdoc")
204        .output()
205        .ok()?;
206    if output.status.success() {
207        Some(PathBuf::from(String::from_utf8(output.stdout).ok()?))
208    } else {
209        None
210    }
211    .and_then(|rustdoc| {
212        Some(
213            rustdoc
214                .parent()?
215                .parent()?
216                .join("share")
217                .join("doc")
218                .join("rust")
219                .join("html"),
220        )
221    })
222}
223
224/// static request handler
225///
226/// <https://github.com/stephank/hyper-staticfile/blob/HEAD/examples/doc_server.rs>
227#[allow(dead_code)]
228pub async fn handle_request<B>(
229    req: Request<B>,
230    static_: Static,
231) -> Result<Response<Body>, std::io::Error> {
232    static_.clone().serve(req).await
233}
234
235/// serve rust book on `addr`
236#[allow(dead_code)]
237pub async fn serve_rustbook(addr: &std::net::SocketAddr) -> Result<(), anyhow::Error> {
238    let rustdoc_dir = find_rustdoc().expect("Error locating rustdoc");
239    Ok(serve_dir(&rustdoc_dir, addr).await?)
240}
241
242/// serve `dir` on `addr`
243#[allow(dead_code)]
244pub async fn serve_dir(dir: &PathBuf, addr: &std::net::SocketAddr) -> Result<(), anyhow::Error> {
245    let dir = Static::new(dir.clone());
246    let handler = service_fn(move |req| handle_request(req, dir.clone()));
247
248    let listener = TcpListener::bind(addr)
249        .await
250        .expect("Failed to create TCP listener");
251
252    loop {
253        let (tcp, _) = listener.accept().await?;
254        let io = TokioIo::new(tcp);
255        let service = handler.clone();
256        tokio::task::spawn(async move {
257            if let Err(err) = http1::Builder::new().serve_connection(io, service).await {
258                println!("Failed to serve connection: {:?}", err);
259            }
260        });
261    }
262}
263
264/// handle crate doc request in watch mode.
265///
266/// Responds to `/_buildid` with the current build counter so the injected
267/// JavaScript can detect when a rebuild has completed and reload the page.
268/// HTML responses have `RELOAD_SCRIPT` injected before `</body>`.
269#[allow(dead_code)]
270pub async fn handle_crate_request_watch<B>(
271    req: Request<B>,
272    static_: Static,
273    crate_name: String,
274    build_id: Arc<AtomicU64>,
275) -> Result<Response<DynBody>, std::io::Error> {
276    // Serve the /_buildid endpoint used by the injected live-reload script.
277    if req.uri().path() == "/_buildid" {
278        let id = build_id.load(Ordering::Relaxed).to_string();
279        return Ok(ResponseBuilder::new()
280            .status(StatusCode::OK)
281            .header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
282            .header(header::CACHE_CONTROL, "no-cache, no-store")
283            .body(full_body(id))
284            .expect("unable to build response"));
285    }
286
287    let target = if let Some(query) = req.uri().query() {
288        format!("/{crate_name}/?{query}")
289    } else {
290        format!("/{crate_name}/")
291    };
292    if req.uri().path() == "/" {
293        return Ok(ResponseBuilder::new()
294            .status(StatusCode::FOUND)
295            .header(header::LOCATION, target)
296            .body(full_body(Bytes::new()))
297            .expect("unable to build response"));
298    }
299
300    let response = static_.clone().serve(req).await?;
301
302    let status = response.status();
303
304    // If the file is missing (e.g. during a rebuild), serve a minimal HTML page
305    // with the reload script so the browser keeps polling /_buildid and
306    // eventually reloads when the build completes. Without this the browser
307    // would land on a script-less 404 and never recover.
308    if status == StatusCode::NOT_FOUND {
309        let fallback = format!(
310            r#"<!DOCTYPE html><html><head><meta charset="utf-8"><title>Rebuilding…</title></head><body><p>Regenerating documentation…</p>{}</body></html>"#,
311            RELOAD_SCRIPT
312        );
313        let fallback_bytes = Bytes::from(fallback);
314        return Ok(ResponseBuilder::new()
315            .status(StatusCode::OK)
316            .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
317            .header(header::CACHE_CONTROL, "no-cache, no-store")
318            .body(full_body(fallback_bytes))
319            .expect("unable to build response"));
320    }
321
322    // Inject the live-reload script into HTML responses.
323    let is_html = response
324        .headers()
325        .get(header::CONTENT_TYPE)
326        .and_then(|v| v.to_str().ok())
327        .map(|s| s.contains("text/html"))
328        .unwrap_or(false);
329
330    if is_html {
331        let (mut parts, body) = response.into_parts();
332        let bytes = body
333            .collect()
334            .await
335            .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?
336            .to_bytes();
337        let html = String::from_utf8_lossy(&bytes);
338        let modified: String = if html.contains("</body>") {
339            html.replacen("</body>", RELOAD_SCRIPT, 1) + "</body>"
340        } else {
341            html.into_owned() + RELOAD_SCRIPT
342        };
343        let modified_bytes = Bytes::from(modified);
344        parts.headers.insert(
345            header::CONTENT_LENGTH,
346            modified_bytes.len().into(),
347        );
348        return Ok(Response::from_parts(parts, full_body(modified_bytes)));
349    }
350
351    Ok(response.map(|body| body.boxed()))
352}
353
354/// serve crate doc in watch mode on `addr`.
355///
356/// Uses [`handle_crate_request_watch`] which injects a live-reload script
357/// into HTML pages and serves the `/_buildid` endpoint.
358#[allow(dead_code)]
359pub async fn serve_crate_doc_watch(
360    manifest_path: &PathBuf,
361    addr: &std::net::SocketAddr,
362    build_id: Arc<AtomicU64>,
363) -> Result<(), anyhow::Error> {
364    let (crate_name, crate_doc_dir) = get_crate_info(manifest_path)?;
365    let crate_doc_dir = Static::new(crate_doc_dir.clone());
366    let crate_name = crate_name.clone();
367    let handler = service_fn(move |req| {
368        handle_crate_request_watch(
369            req,
370            crate_doc_dir.clone(),
371            crate_name.clone(),
372            Arc::clone(&build_id),
373        )
374    });
375
376    let listener = TcpListener::bind(addr)
377        .await
378        .expect("Failed to create TCP listener");
379
380    loop {
381        let (tcp, _) = listener.accept().await?;
382        let io = TokioIo::new(tcp);
383        let service = handler.clone();
384        tokio::task::spawn(async move {
385            if let Err(err) = http1::Builder::new().serve_connection(io, service).await {
386                log::error!("Failed to serve connection: {:?}", err);
387            }
388        });
389    }
390}
391
392/// handle request for the `--all` index page.
393///
394/// Serves the provided `index_html` for the root path `/`, and delegates
395/// everything else to the underlying static file server.
396#[allow(dead_code)]
397pub async fn handle_all_books_request<B>(
398    req: Request<B>,
399    static_: Static,
400    index_html: Arc<String>,
401) -> Result<Response<DynBody>, std::io::Error> {
402    match req.uri().path() {
403        "/" | "/index.html" => Ok(ResponseBuilder::new()
404            .status(StatusCode::OK)
405            .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
406            .body(full_body(index_html.as_str().to_owned()))
407            .expect("unable to build response")),
408        _ => Ok(static_.clone().serve(req).await?.map(|body| body.boxed())),
409    }
410}
411
412/// serve rust book on `addr` with a custom HTML index page at `/`.
413///
414/// Used by the `cargo book --all` subcommand to present a browsable list of
415/// all available books before delegating to the normal static file server.
416#[allow(dead_code)]
417pub async fn serve_rustbook_with_index(
418    addr: &std::net::SocketAddr,
419    index_html: String,
420) -> Result<(), anyhow::Error> {
421    let rustdoc_dir = find_rustdoc().expect("Error locating rustdoc");
422    let dir = Static::new(rustdoc_dir);
423    let index_html = Arc::new(index_html);
424    let handler = service_fn(move |req| {
425        handle_all_books_request(req, dir.clone(), Arc::clone(&index_html))
426    });
427
428    let listener = TcpListener::bind(addr)
429        .await
430        .expect("Failed to create TCP listener");
431
432    loop {
433        let (tcp, _) = listener.accept().await?;
434        let io = TokioIo::new(tcp);
435        let service = handler.clone();
436        tokio::task::spawn(async move {
437            if let Err(err) = http1::Builder::new().serve_connection(io, service).await {
438                log::error!("Failed to serve connection: {:?}", err);
439            }
440        });
441    }
442}