1use std::collections::HashSet;
2use std::path::Path;
3use std::sync::{Arc, Mutex};
4use std::time::Duration;
5
6use bytes::Bytes;
7use cargo::GlobalContext;
8use cargo::core::Workspace;
9use cargo::core::compiler::UserIntent;
10use cargo::core::resolver::CliFeatures;
11use cargo::ops::{self, CompileOptions, DocOptions, OutputFormat};
12use cargo::util::Filesystem;
13use flate2::read::GzDecoder;
14use kellnr_common::background_tasks::{BackgroundTasks, FirstRound};
15use kellnr_common::canonical_name::CanonicalName;
16use kellnr_common::original_name::OriginalName;
17use kellnr_common::version::Version;
18use kellnr_db::{DbProvider, DocQueueEntry};
19use kellnr_docs::docs_error::DocsError;
20use kellnr_docs::unpack::{ExtractLimits, unpack_crate};
21use kellnr_docs::upload::upload_dir_and_prune;
22use kellnr_docs::{compute_doc_url, crate_name_to_docs_name};
23use kellnr_settings::{Settings, UrlPathPrefix};
24use kellnr_storage::docs_storage::{DocsName, DocsStorage};
25use kellnr_storage::kellnr_crate_storage::KellnrCrateStorage;
26use tokio::fs::remove_dir_all;
27use tokio_util::sync::CancellationToken;
28use tracing::{debug, error, info, warn};
29
30use crate::build_process::DocBuilder;
31
32pub fn doc_extraction_queue(
33 db: Arc<dyn DbProvider>,
34 cs: Arc<KellnrCrateStorage>,
35 docs_storage: Arc<DocsStorage>,
36 settings: &Settings,
37 tasks: &BackgroundTasks,
38) {
39 let program = match std::env::current_exe() {
40 Ok(program) => program,
41 Err(e) => {
42 error!("Rustdoc generation disabled, cannot locate the kellnr binary: {e}");
43 return;
44 }
45 };
46 let builder = DocBuilder::new(
47 program,
48 settings.docs.build_wrapper.clone(),
49 settings
50 .proxy
51 .cratesio_index_override()
52 .map(ToString::to_string),
53 )
54 .with_timeout(Duration::from_secs(settings.docs.build_timeout_seconds))
55 .with_shutdown(tasks.shutdown_token());
56 let worker = Arc::new(DocQueueWorker {
57 db,
58 cs,
59 docs_storage,
60 path_prefix: settings.origin.path_prefix(),
61 builder,
62 limits: ExtractLimits::from_max_docs_size(settings.docs.max_size),
63 processed: Mutex::default(),
64 shutdown: tasks.shutdown_token(),
65 });
66
67 tasks.spawn_periodic(
68 "docs build queue",
69 Duration::from_secs(10),
70 FirstRound::AfterInterval,
71 move || {
72 let worker = worker.clone();
73 async move {
74 if let Err(e) = worker.process_queue().await {
75 error!("Rustdoc generation loop failed: {e}");
76 }
77 }
78 },
79 );
80}
81
82struct DocQueueWorker {
84 db: Arc<dyn DbProvider>,
85 cs: Arc<KellnrCrateStorage>,
86 docs_storage: Arc<DocsStorage>,
87 path_prefix: UrlPathPrefix,
88 builder: DocBuilder,
89 limits: ExtractLimits,
90 processed: Mutex<HashSet<i64>>,
93 shutdown: CancellationToken,
95}
96
97#[derive(Debug, PartialEq, Eq)]
99enum Outcome {
100 Done,
101 Retry,
102}
103
104impl DocQueueWorker {
105 async fn process_queue(&self) -> Result<(), DocsError> {
106 let entries = self.db.get_doc_queue().await?;
107 let mut built = HashSet::new();
108
109 for entry in entries {
110 if self.shutdown.is_cancelled() {
111 break;
112 }
113 let is_first = built.insert((entry.name.clone(), entry.version.clone()));
115 let outcome = if is_first && !self.is_processed(entry.id) {
116 self.process_entry(&entry).await
117 } else {
118 Outcome::Done
119 };
120
121 if let Err(e) = clean_up(&entry.path).await {
123 error!("Failed to delete temporary rustdoc queue folder: {e}");
124 }
125
126 if outcome == Outcome::Done {
127 self.remove_from_queue(entry.id).await;
128 }
129 }
130
131 Ok(())
132 }
133
134 async fn process_entry(&self, entry: &DocQueueEntry) -> Outcome {
138 let version = &entry.version;
139 let Ok(name) = entry.canonical_name() else {
140 warn!(
141 "Skipping the docs build of {} {version}, the crate name is invalid",
142 entry.name
143 );
144 return Outcome::Done;
145 };
146 let name = &name;
147 let (original_name, contents) = match self.read_crate(name, version).await {
148 Ok(Some(krate)) => krate,
149 Ok(None) => {
150 warn!("Skipping the docs build of {name} {version}, the crate does not exist");
151 return Outcome::Done;
152 }
153 Err(e) => {
154 error!("Failed to read {name} {version} for its docs build, retrying later: {e}");
155 return Outcome::Retry;
156 }
157 };
158
159 let result = self
160 .build_docs(entry, name, &original_name, version, contents)
161 .await;
162 if matches!(result, Err(DocsError::BuildStopped)) {
163 info!(
164 "Stopped the docs build of {name} {version} on shutdown, it runs again after the restart"
165 );
166 return Outcome::Retry;
167 }
168 self.processed.lock().unwrap().insert(entry.id);
170 if let Err(e) = result {
171 error!("Failed to build the docs of {name} {version}: {e}");
172 return Outcome::Done;
173 }
174
175 let docs_link = compute_doc_url(&original_name, version, &self.path_prefix);
176 if let Err(e) = self.db.update_docs_link(name, version, &docs_link).await {
177 error!("Failed to update the docs link of {name} {version}: {e}");
178 }
179 Outcome::Done
180 }
181
182 async fn read_crate(
185 &self,
186 name: &CanonicalName,
187 version: &Version,
188 ) -> Result<Option<(OriginalName, Bytes)>, DocsError> {
189 let Some(original_name) = self.db.get_original_name(name, version).await? else {
191 return Ok(None);
192 };
193 let contents = self.cs.get(&original_name, version).await?;
194 Ok(contents.map(|contents| (original_name, contents)))
195 }
196
197 async fn build_docs(
198 &self,
199 doc: &DocQueueEntry,
200 name: &CanonicalName,
201 original_name: &OriginalName,
202 version: &Version,
203 contents: Bytes,
204 ) -> Result<(), DocsError> {
205 let crate_root = format!("{original_name}-{version}");
206 let tar = GzDecoder::new(std::io::Cursor::new(contents));
207 let dest = doc.path.clone();
208 let root = crate_root.clone();
209 let limits = self.limits;
210 tokio::task::spawn_blocking(move || unpack_crate(tar, &dest, &root, limits))
211 .await
212 .map_err(|_| DocsError::ExtractFailed)??;
213
214 let generated_docs_path = &doc.path.join(crate_root);
216 strip_rust_toolchain_files(generated_docs_path).await?;
217
218 self.builder.build(generated_docs_path).await?;
221
222 if self.db.get_original_name(name, version).await?.is_none() {
225 return Err(DocsError::CrateDoesNotExist(
226 doc.name.clone(),
227 version.to_string(),
228 ));
229 }
230
231 let from = generated_docs_path.join("target").join("doc");
236 let docs_dir = from.clone();
237 let docs_name = crate_name_to_docs_name(original_name);
238 tokio::task::spawn_blocking(move || remove_non_rustdoc_files(&docs_dir, &docs_name))
239 .await
240 .map_err(|_| DocsError::ExtractFailed)??;
241 upload_dir_and_prune(
242 &from,
243 "doc",
244 &DocsName::from(original_name),
245 version,
246 &self.docs_storage,
247 )
248 .await?;
249
250 Ok(())
251 }
252
253 fn is_processed(&self, id: i64) -> bool {
254 self.processed.lock().unwrap().contains(&id)
255 }
256
257 async fn remove_from_queue(&self, id: i64) {
258 match self.db.delete_doc_queue(id).await {
259 Ok(()) => {
260 self.processed.lock().unwrap().remove(&id);
261 }
262 Err(e) => error!("Failed to remove entry {id} from the docs queue: {e}"),
263 }
264 }
265}
266
267const RUSTDOC_OUTPUT: &[&str] = &[
270 "static.files",
271 "search.index",
272 "search.desc",
273 "trait.impl",
274 "type.impl",
275 "implementors",
276 "crates.js",
277 "help.html",
278 "settings.html",
279 "src-files.js",
280 "source-files.js",
281];
282
283fn is_rustdoc_output(name: &str, docs_name: &str) -> bool {
284 name == docs_name
285 || RUSTDOC_OUTPUT.contains(&name)
286 || (name.starts_with("search-index") && name.to_ascii_lowercase().ends_with(".js"))
287}
288
289fn remove_non_rustdoc_files(doc_dir: &Path, docs_name: &str) -> std::io::Result<()> {
297 retain_entries(doc_dir, |name| {
298 name == "src" || is_rustdoc_output(name, docs_name)
299 })?;
300 let src = doc_dir.join("src");
301 if std::fs::symlink_metadata(&src).is_ok_and(|m| m.is_dir()) {
302 retain_entries(&src, |name| name == docs_name)?;
303 }
304 Ok(())
305}
306
307fn retain_entries(dir: &Path, keep: impl Fn(&str) -> bool) -> std::io::Result<()> {
310 for entry in std::fs::read_dir(dir)? {
311 let entry = entry?;
312 if keep(&entry.file_name().to_string_lossy()) {
313 continue;
314 }
315 debug!(
316 "Not publishing {}, it is no rustdoc output",
317 entry.path().display()
318 );
319 if entry.file_type()?.is_dir() {
320 std::fs::remove_dir_all(entry.path())?;
321 } else {
322 std::fs::remove_file(entry.path())?;
323 }
324 }
325 Ok(())
326}
327
328async fn clean_up(path: &Path) -> Result<(), DocsError> {
329 match remove_dir_all(path).await {
330 Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
331 _ => Ok(()),
332 }
333}
334
335async fn strip_rust_toolchain_files(crate_path: &Path) -> Result<(), DocsError> {
358 for name in ["rust-toolchain.toml", "rust-toolchain"] {
359 let path = crate_path.join(name);
360 match tokio::fs::remove_file(&path).await {
361 Ok(()) => {}
362 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
363 Err(e) => return Err(e.into()),
364 }
365 }
366 Ok(())
367}
368
369fn build_doc_context(cratesio_index: Option<&str>) -> Result<GlobalContext, DocsError> {
376 let mut ctx = GlobalContext::default().map_err(|e| DocsError::CargoError(e.to_string()))?;
377
378 if let Some(index) = cratesio_index {
379 let cli_config = vec![
380 "source.crates-io.replace-with=\"kellnr-proxy\"".to_string(),
381 format!("source.kellnr-proxy.registry=\"sparse+{index}\""),
382 ];
383 ctx.configure(0, false, None, false, false, false, &None, &[], &cli_config)
384 .map_err(|e| DocsError::CargoError(e.to_string()))?;
385 }
386
387 Ok(ctx)
388}
389
390fn doc_features(workspace: &Workspace) -> Result<CliFeatures, DocsError> {
399 let package = workspace
400 .current()
401 .map_err(|e| DocsError::CargoError(e.to_string()))?;
402 let docs_rs = package
404 .manifest()
405 .custom_metadata()
406 .and_then(|m| m.get("docs"))
407 .and_then(|m| m.get("rs"));
408
409 let flag = |key: &str| docs_rs.and_then(|m| m.get(key)?.as_bool());
410 let all_features = flag("all-features").unwrap_or(true);
411 let no_default_features = flag("no-default-features").unwrap_or(false);
412 let features: Vec<String> = docs_rs
413 .and_then(|m| m.get("features"))
414 .and_then(|v| v.as_array())
415 .map(|a| {
416 a.iter()
417 .filter_map(|f| f.as_str().map(str::to_string))
418 .collect()
419 })
420 .unwrap_or_default();
421
422 CliFeatures::from_command_line(&features, all_features, !no_default_features)
423 .map_err(|e| DocsError::CargoError(e.to_string()))
424}
425
426pub fn generate_docs(
432 crate_path: impl AsRef<Path>,
433 cratesio_index: Option<&str>,
434) -> Result<(), DocsError> {
435 let manifest_path = crate_path.as_ref().join("Cargo.toml").canonicalize()?;
436 let ctx = build_doc_context(cratesio_index)?;
437 let mut workspace =
438 Workspace::new(&manifest_path, &ctx).map_err(|e| DocsError::CargoError(e.to_string()))?;
439 workspace.set_target_dir(Filesystem::new(crate_path.as_ref().join("target")));
442 let compile_opts = CompileOptions {
443 cli_features: doc_features(&workspace)?,
444 ..CompileOptions::new(
445 &ctx,
446 UserIntent::Doc {
447 deps: false,
448 json: false,
449 },
450 )
451 .map_err(|e| DocsError::CargoError(e.to_string()))?
452 };
453 let options = DocOptions {
454 open_result: false,
455 compile_opts,
456 output_format: OutputFormat::Html,
457 };
458 ops::doc(&workspace, &options).map_err(|e| DocsError::CargoError(e.to_string()))?;
459 Ok(())
460}
461
462#[cfg(test)]
463mod tests {
464 use std::path::PathBuf;
465 use std::sync::atomic::{AtomicBool, Ordering};
466
467 use async_trait::async_trait;
468 use cargo::core::SourceId;
469 use cargo::sources::SourceConfigMap;
470 use kellnr_db::test_utils::{TestCrateBuilder, test_drop_table};
471 use kellnr_storage::object_storage::MemoryStorage;
472 use kellnr_storage::storage::{ObjectWithMeta, Storage};
473 use kellnr_storage::storage_error::StorageError;
474 use kellnr_storage::storage_key::StorageKey;
475 use kellnr_test_db::TestDb;
476
477 use super::*;
478
479 fn crate_tarball(name: &str, version: &str) -> Vec<u8> {
480 let mut builder = tar::Builder::new(flate2::write::GzEncoder::new(
481 Vec::new(),
482 flate2::Compression::default(),
483 ));
484 let manifest = format!("[package]\nname = \"{name}\"\nversion = \"{version}\"\n");
485 let mut header = tar::Header::new_gnu();
486 header.set_size(manifest.len() as u64);
487 header.set_mode(0o644);
488 header.set_cksum();
489 builder
490 .append_data(
491 &mut header,
492 format!("{name}-{version}/Cargo.toml"),
493 manifest.as_bytes(),
494 )
495 .unwrap();
496 builder.into_inner().unwrap().finish().unwrap()
497 }
498
499 fn version() -> Version {
500 Version::try_from("1.0.0").unwrap()
501 }
502
503 fn shell_builder(script: &str, arg: &Path) -> DocBuilder {
506 DocBuilder::new(
507 PathBuf::from("unused"),
508 vec![
509 "/bin/sh".to_string(),
510 "-c".to_string(),
511 script.to_string(),
512 arg.display().to_string(),
513 ],
514 None,
515 )
516 }
517
518 async fn worker(
520 db: &TestDb,
521 data_dir: &Path,
522 names: &[&str],
523 builder: DocBuilder,
524 ) -> DocQueueWorker {
525 let mut settings = kellnr_settings::test_settings();
526 settings.registry.data_dir = data_dir.display().to_string();
527 let cs = KellnrCrateStorage::new(&settings, Box::new(MemoryStorage::new()));
528 for name in names {
529 cs.put(
530 &OriginalName::from_unchecked((*name).to_string()),
531 &version(),
532 crate_tarball(name, "1.0.0").into(),
533 )
534 .await
535 .unwrap();
536 }
537 DocQueueWorker {
538 db: db.provider(),
539 cs: Arc::new(cs),
540 docs_storage: Arc::new(DocsStorage::new(Box::new(MemoryStorage::new()))),
541 path_prefix: UrlPathPrefix::default(),
542 builder,
543 limits: ExtractLimits::from_max_docs_size(1),
544 processed: Mutex::default(),
545 shutdown: CancellationToken::new(),
546 }
547 }
548
549 async fn failing_worker(db: &TestDb, data_dir: &Path, build_log: &Path) -> DocQueueWorker {
551 let builder = shell_builder("echo \"$3\" >> \"$0\"; exit 1", build_log);
552 worker(db, data_dir, &["a", "b"], builder).await
553 }
554
555 async fn add_crates(db: &TestDb, names: &[&str]) {
557 for name in names {
558 TestCrateBuilder::new(db).name(name).build().await.unwrap();
559 }
560 }
561
562 async fn queue(db: &TestDb, data_dir: &Path, names: &[&str]) -> Vec<PathBuf> {
565 let mut paths = Vec::new();
566 for (i, name) in names.iter().enumerate() {
567 let path = data_dir.join(format!("queue{i}"));
568 std::fs::create_dir(&path).unwrap();
569 db.add_doc_queue(
570 &OriginalName::from_unchecked((*name).to_string()),
571 &version(),
572 &path,
573 )
574 .await
575 .unwrap();
576 paths.push(path);
577 }
578 paths
579 }
580
581 async fn queued(db: &TestDb) -> Vec<String> {
582 db.get_doc_queue()
583 .await
584 .unwrap()
585 .into_iter()
586 .map(|entry| entry.name)
587 .collect()
588 }
589
590 async fn docs_link(db: &TestDb, name: &str) -> Option<String> {
591 let name = CanonicalName::try_from(name).unwrap();
592 let data = db.get_crate_data(&name).await.unwrap();
593 data.versions[0].documentation.clone()
594 }
595
596 #[cfg(unix)]
597 #[tokio::test]
598 async fn process_queue_builds_duplicates_once_and_always_cleans_up() {
599 let data_dir = tempfile::tempdir().unwrap();
600 let build_log = data_dir.path().join("builds.log");
601 let db = TestDb::sqlite().await;
602 add_crates(&db, &["a", "b"]).await;
603 let paths = queue(&db, data_dir.path(), &["a", "a", "b"]).await;
604 let worker = failing_worker(&db, data_dir.path(), &build_log).await;
605
606 worker.process_queue().await.unwrap();
607
608 let builds = std::fs::read_to_string(&build_log).unwrap();
609 assert_eq!(builds.lines().count(), 2, "builds: {builds}");
610 assert!(paths.iter().all(|p| !p.exists()));
611 assert!(queued(&db).await.is_empty());
612 assert_eq!(docs_link(&db, "a").await, None);
613 }
614
615 #[cfg(unix)]
616 #[tokio::test]
617 async fn shutdown_stops_the_queue_and_keeps_the_stopped_build_queued() {
618 let data_dir = tempfile::tempdir().unwrap();
619 let build_log = data_dir.path().join("builds.log");
620 let db = TestDb::sqlite().await;
621 add_crates(&db, &["a", "b"]).await;
622 let paths = queue(&db, data_dir.path(), &["a", "b"]).await;
623 let shutdown = CancellationToken::new();
624 let builder = shell_builder("echo \"$3\" >> \"$0\"; sleep 30", &build_log)
625 .with_shutdown(shutdown.clone());
626 let mut worker = worker(&db, data_dir.path(), &["a", "b"], builder).await;
627 worker.shutdown = shutdown.clone();
628
629 let run = tokio::spawn(async move { worker.process_queue().await });
630 while !build_log.exists() {
631 tokio::time::sleep(Duration::from_millis(50)).await;
632 }
633 shutdown.cancel();
634 run.await.unwrap().unwrap();
635
636 let builds = std::fs::read_to_string(&build_log).unwrap();
637 assert_eq!(builds.lines().count(), 1, "builds: {builds}");
638 assert!(!paths[0].exists());
639 assert_eq!(queued(&db).await, vec!["a", "b"]);
640 }
641
642 #[cfg(unix)]
643 #[tokio::test]
644 async fn process_queue_builds_crate_with_uppercase_letters() {
645 let data_dir = tempfile::tempdir().unwrap();
646 let db = TestDb::sqlite().await;
647 add_crates(&db, &["My-Crate"]).await;
648 queue(&db, data_dir.path(), &["My-Crate"]).await;
649 let builder = shell_builder(
650 "test -f \"$3/Cargo.toml\" && mkdir -p \"$3/target/doc/My_Crate\" && echo docs > \"$3/target/doc/My_Crate/index.html\"",
651 data_dir.path(),
652 );
653 let worker = worker(&db, data_dir.path(), &["My-Crate"], builder).await;
654
655 worker.process_queue().await.unwrap();
656
657 let key = DocsStorage::file_key(
658 &DocsName::from(&OriginalName::try_from("my-crate").unwrap()),
659 &version(),
660 "doc/My_Crate/index.html",
661 );
662 assert!(worker.docs_storage.exists(&key).await.unwrap());
663 assert_eq!(
664 docs_link(&db, "my_crate").await.as_deref(),
665 Some("/docs/my-crate/1.0.0/doc/My_Crate/index.html")
666 );
667 assert!(queued(&db).await.is_empty());
668 }
669
670 const SUCCESSFUL_BUILD: &str = "echo \"$3\" >> \"$0\"; mkdir -p \"$3/target/doc/a\" && echo docs > \"$3/target/doc/a/index.html\"";
671
672 const WAITING_BUILD: &str = "echo \"$3\" >> \"$0\"; while [ ! -e \"$0.go\" ]; do sleep 0.01; done; mkdir -p \"$3/target/doc/a\" && echo docs > \"$3/target/doc/a/index.html\"";
675
676 async fn process_queue_with(
679 worker: &DocQueueWorker,
680 build_log: &Path,
681 during_build: impl Future<Output = ()>,
682 ) {
683 let (result, ()) = tokio::join!(worker.process_queue(), async {
684 while !build_log.exists() {
685 tokio::time::sleep(Duration::from_millis(10)).await;
686 }
687 during_build.await;
688 std::fs::write(build_log.with_extension("log.go"), "").unwrap();
689 });
690 result.unwrap();
691 }
692
693 fn build_count(build_log: &Path) -> usize {
694 std::fs::read_to_string(build_log).map_or(0, |log| log.lines().count())
695 }
696
697 #[cfg(unix)]
698 #[tokio::test]
699 async fn process_queue_continues_after_database_errors() {
700 let data_dir = tempfile::tempdir().unwrap();
701 let build_log = data_dir.path().join("builds.log");
702 let db = TestDb::sqlite().await;
703 add_crates(&db, &["a", "b"]).await;
704 queue(&db, data_dir.path(), &["a", "b"]).await;
705 let builder = shell_builder(WAITING_BUILD, &build_log);
706 let worker = worker(&db, data_dir.path(), &["a", "b"], builder).await;
707
708 process_queue_with(&worker, &build_log, async {
710 test_drop_table(&db, "doc_queue").await.unwrap();
711 })
712 .await;
713
714 assert_eq!(build_count(&build_log), 2);
715 }
716
717 #[cfg(unix)]
718 #[tokio::test]
719 async fn process_queue_does_not_build_again_if_entry_cannot_be_removed() {
720 let data_dir = tempfile::tempdir().unwrap();
721 let build_log = data_dir.path().join("builds.log");
722 let db = TestDb::sqlite().await;
723 add_crates(&db, &["a"]).await;
724 queue(&db, data_dir.path(), &["a"]).await;
725 let id = db.get_doc_queue().await.unwrap()[0].id;
726 let builder = shell_builder(WAITING_BUILD, &build_log);
727 let mut worker = worker(&db, data_dir.path(), &["a"], builder).await;
728 process_queue_with(&worker, &build_log, async {
729 test_drop_table(&db, "doc_queue").await.unwrap();
730 })
731 .await;
732
733 let restored = TestDb::sqlite().await;
735 add_crates(&restored, &["a"]).await;
736 queue(&restored, data_dir.path(), &["a"]).await;
737 assert_eq!(restored.get_doc_queue().await.unwrap()[0].id, id);
738 worker.db = restored.provider();
739 worker.process_queue().await.unwrap();
740
741 assert_eq!(build_count(&build_log), 1);
742 assert!(queued(&restored).await.is_empty());
743 }
744
745 #[cfg(unix)]
746 #[tokio::test]
747 async fn process_queue_retries_entry_if_crate_cannot_be_read() {
748 let data_dir = tempfile::tempdir().unwrap();
749 let build_log = data_dir.path().join("builds.log");
750 let db = TestDb::sqlite().await;
751 add_crates(&db, &["a"]).await;
752 queue(&db, data_dir.path(), &["a"]).await;
753 test_drop_table(&db, "crate_meta").await.unwrap();
754 let builder = shell_builder(SUCCESSFUL_BUILD, &build_log);
755 let worker = worker(&db, data_dir.path(), &["a"], builder).await;
756
757 worker.process_queue().await.unwrap();
758
759 assert_eq!(build_count(&build_log), 0);
760 assert_eq!(queued(&db).await, vec!["a"]);
761 }
762
763 #[cfg(unix)]
764 #[tokio::test]
765 async fn process_queue_does_not_upload_docs_of_version_deleted_during_build() {
766 let data_dir = tempfile::tempdir().unwrap();
767 let build_log = data_dir.path().join("builds.log");
768 let db = TestDb::sqlite().await;
769 add_crates(&db, &["a"]).await;
770 queue(&db, data_dir.path(), &["a"]).await;
771 let builder = shell_builder(WAITING_BUILD, &build_log);
772 let worker = worker(&db, data_dir.path(), &["a"], builder).await;
773
774 process_queue_with(&worker, &build_log, async {
775 let name = CanonicalName::try_from("a").unwrap();
776 db.delete_crate(&name, &version()).await.unwrap();
777 })
778 .await;
779
780 assert_eq!(build_count(&build_log), 1);
781 let prefix = DocsStorage::version_prefix(
782 &DocsName::from(&OriginalName::try_from("a").unwrap()),
783 &version(),
784 );
785 assert!(worker.docs_storage.list(&prefix).await.unwrap().is_empty());
786 assert!(queued(&db).await.is_empty());
787 }
788
789 struct DropTableOnUpload {
792 inner: MemoryStorage,
793 db: Arc<TestDb>,
794 table: &'static str,
795 dropped: AtomicBool,
796 }
797
798 #[async_trait]
799 impl Storage for DropTableOnUpload {
800 async fn get(&self, key: &StorageKey) -> Result<Bytes, StorageError> {
801 self.inner.get(key).await
802 }
803
804 async fn get_with_meta(&self, key: &StorageKey) -> Result<ObjectWithMeta, StorageError> {
805 self.inner.get_with_meta(key).await
806 }
807
808 async fn put(&self, key: &StorageKey, object: Bytes) -> Result<(), StorageError> {
809 self.inner.put(key, object).await
810 }
811
812 async fn put_overwrite(&self, key: &StorageKey, object: Bytes) -> Result<(), StorageError> {
813 if !self.dropped.swap(true, Ordering::SeqCst) {
814 test_drop_table(&self.db, self.table).await.unwrap();
815 }
816 self.inner.put_overwrite(key, object).await
817 }
818
819 async fn delete(&self, key: &StorageKey) -> Result<(), StorageError> {
820 self.inner.delete(key).await
821 }
822
823 async fn exists(&self, key: &StorageKey) -> Result<bool, StorageError> {
824 self.inner.exists(key).await
825 }
826
827 async fn size(&self, key: &StorageKey) -> Result<u64, StorageError> {
828 self.inner.size(key).await
829 }
830
831 async fn list(&self, prefix: &str) -> Result<Vec<StorageKey>, StorageError> {
832 self.inner.list(prefix).await
833 }
834 }
835
836 #[cfg(unix)]
837 #[tokio::test]
838 async fn process_queue_publishes_docs_once_if_docs_link_cannot_be_updated() {
839 let data_dir = tempfile::tempdir().unwrap();
840 let build_log = data_dir.path().join("builds.log");
841 let db = Arc::new(TestDb::sqlite().await);
842 add_crates(&db, &["a"]).await;
843 queue(&db, data_dir.path(), &["a"]).await;
844 let builder = shell_builder(SUCCESSFUL_BUILD, &build_log);
845 let mut worker = worker(&db, data_dir.path(), &["a"], builder).await;
846 worker.docs_storage = Arc::new(DocsStorage::new(Box::new(DropTableOnUpload {
848 inner: MemoryStorage::new(),
849 db: db.clone(),
850 table: "crate_meta",
851 dropped: AtomicBool::new(false),
852 })));
853
854 worker.process_queue().await.unwrap();
855 worker.process_queue().await.unwrap();
856
857 assert_eq!(build_count(&build_log), 1);
858 let key = DocsStorage::file_key(
859 &DocsName::from(&OriginalName::try_from("a").unwrap()),
860 &version(),
861 "doc/a/index.html",
862 );
863 assert!(worker.docs_storage.exists(&key).await.unwrap());
864 assert!(queued(&db).await.is_empty());
865 }
866
867 #[cfg(unix)]
868 #[tokio::test]
869 async fn process_queue_publishes_only_rustdoc_output() {
870 let data_dir = tempfile::tempdir().unwrap();
871 let db = TestDb::sqlite().await;
872 add_crates(&db, &["a"]).await;
873 queue(&db, data_dir.path(), &["a"]).await;
874 let builder = shell_builder(
875 "mkdir -p \"$3/target/doc\" && cd \"$3/target/doc\" && mkdir -p a src/a src/other && touch a/index.html src/a/lib.rs.html src/other/x.html crates.js leak.txt",
876 data_dir.path(),
877 );
878 let worker = worker(&db, data_dir.path(), &["a"], builder).await;
879
880 worker.process_queue().await.unwrap();
881
882 let prefix = DocsStorage::version_prefix(
883 &DocsName::from(&OriginalName::try_from("a").unwrap()),
884 &version(),
885 );
886 let mut keys: Vec<String> = worker
887 .docs_storage
888 .list(&prefix)
889 .await
890 .unwrap()
891 .into_iter()
892 .map(StorageKey::into_inner)
893 .collect();
894 keys.sort_unstable();
895 assert_eq!(
896 keys,
897 vec![
898 "a/1.0.0/doc/a/index.html",
899 "a/1.0.0/doc/crates.js",
900 "a/1.0.0/doc/src/a/lib.rs.html",
901 ]
902 );
903 }
904
905 #[test]
906 fn remove_non_rustdoc_files_keeps_rustdoc_output_of_the_crate() {
907 let dir = tempfile::tempdir().unwrap();
908 let kept = [
909 "My_Crate/index.html",
910 "static.files/rustdoc.css",
911 "search.index/root.js",
912 "trait.impl/core/clone/trait.Clone.js",
913 "type.impl/x.js",
914 "implementors/x.js",
915 "src/My_Crate/lib.rs.html",
916 "crates.js",
917 "help.html",
918 "settings.html",
919 "src-files.js",
920 "search-index1.70.0.js",
921 ];
922 let removed = [
923 "src/other/secret.html",
924 "debug/secret",
925 ".lock",
926 "secret.txt",
927 ];
928 for file in kept.iter().chain(&removed) {
929 let path = dir.path().join(file);
930 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
931 std::fs::write(path, "").unwrap();
932 }
933
934 remove_non_rustdoc_files(dir.path(), "My_Crate").unwrap();
935
936 for file in kept {
937 assert!(dir.path().join(file).exists(), "{file}");
938 }
939 for file in removed {
940 assert!(!dir.path().join(file).exists(), "{file}");
941 }
942 }
943
944 #[tokio::test]
945 async fn clean_up_ignores_missing_directory() {
946 let dir = tempfile::tempdir().unwrap();
947
948 clean_up(&dir.path().join("missing")).await.unwrap();
949 }
950
951 #[test]
952 fn no_index_override_does_not_inject_proxy_source() {
953 let ctx = build_doc_context(None).unwrap();
954 assert!(
960 ctx.get_string("source.kellnr-proxy.registry")
961 .unwrap()
962 .is_none()
963 );
964 }
965
966 #[test]
967 fn index_override_replaces_crates_io_source_with_proxy() {
968 let ctx = build_doc_context(Some("https://rsproxy.cn/index/")).unwrap();
969
970 assert_eq!(
972 ctx.get_string("source.crates-io.replace-with")
973 .unwrap()
974 .map(|v| v.val),
975 Some("kellnr-proxy".to_string())
976 );
977
978 let map = SourceConfigMap::new(&ctx).unwrap();
981 let crates_io = SourceId::crates_io(&ctx).unwrap();
982 let source = map.load(crates_io, &HashSet::new()).unwrap();
983 let replaced = source.replaced_source_id();
984 assert!(!replaced.is_crates_io());
985 assert!(
986 replaced.url().as_str().contains("rsproxy.cn/index/"),
987 "expected replacement source to point at the proxy index, got {}",
988 replaced.url()
989 );
990 }
991
992 fn features_for(manifest: &str) -> CliFeatures {
993 let dir = tempfile::tempdir().unwrap();
994 std::fs::write(dir.path().join("Cargo.toml"), manifest).unwrap();
995 std::fs::create_dir(dir.path().join("src")).unwrap();
996 std::fs::write(dir.path().join("src").join("lib.rs"), "").unwrap();
997 let ctx = build_doc_context(None).unwrap();
998 let manifest_path = dir.path().join("Cargo.toml").canonicalize().unwrap();
999 let workspace = Workspace::new(&manifest_path, &ctx).unwrap();
1000 doc_features(&workspace).unwrap()
1001 }
1002
1003 const PACKAGE: &str = "[package]\nname = \"x\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[features]\na = []\nb = []\n";
1004
1005 fn feature_names(features: &CliFeatures) -> Vec<String> {
1006 let mut names: Vec<String> = features.features.iter().map(ToString::to_string).collect();
1007 names.sort();
1008 names
1009 }
1010
1011 #[test]
1012 fn doc_features_default_to_all_features_without_metadata() {
1013 let features = features_for(PACKAGE);
1014
1015 assert!(features.all_features);
1016 assert!(features.uses_default_features);
1017 assert!(features.features.is_empty());
1018 }
1019
1020 #[test]
1021 fn doc_features_honor_docs_rs_metadata() {
1022 let features = features_for(&format!(
1023 "{PACKAGE}\n[package.metadata.docs.rs]\nall-features = false\nno-default-features = true\nfeatures = [\"a\", \"b\"]\n"
1024 ));
1025
1026 assert!(!features.all_features);
1027 assert!(!features.uses_default_features);
1028 assert_eq!(feature_names(&features), vec!["a", "b"]);
1029 }
1030
1031 #[test]
1032 fn doc_features_keep_all_features_when_only_features_are_listed() {
1033 let features = features_for(&format!(
1034 "{PACKAGE}\n[package.metadata.docs.rs]\nfeatures = [\"a\"]\n"
1035 ));
1036
1037 assert!(features.all_features);
1038 assert!(features.uses_default_features);
1039 assert_eq!(feature_names(&features), vec!["a"]);
1040 }
1041
1042 #[test]
1043 fn doc_features_ignore_unrelated_metadata() {
1044 let features = features_for(&format!(
1045 "{PACKAGE}\n[package.metadata.other]\nall-features = false\n"
1046 ));
1047
1048 assert!(features.all_features);
1049 }
1050
1051 #[test]
1052 fn generate_docs_builds_crate_with_mutually_exclusive_features() {
1053 let dir = tempfile::tempdir().unwrap();
1054 std::fs::write(
1055 dir.path().join("Cargo.toml"),
1056 format!("{PACKAGE}\n[package.metadata.docs.rs]\nall-features = false\n"),
1057 )
1058 .unwrap();
1059 std::fs::create_dir(dir.path().join("src")).unwrap();
1060 std::fs::write(
1061 dir.path().join("src").join("lib.rs"),
1062 "#[cfg(all(feature = \"a\", feature = \"b\"))]\ncompile_error!(\"a and b are exclusive\");\n",
1063 )
1064 .unwrap();
1065
1066 generate_docs(dir.path(), None).unwrap();
1067
1068 assert!(dir.path().join("target/doc/x/index.html").exists());
1069 }
1070
1071 #[tokio::test]
1072 async fn strip_rust_toolchain_files_removes_both_variants() {
1073 let dir = tempfile::tempdir().unwrap();
1074 let toml_path = dir.path().join("rust-toolchain.toml");
1075 let legacy_path = dir.path().join("rust-toolchain");
1076 tokio::fs::write(&toml_path, "[toolchain]\nchannel = \"1.65.0\"\n")
1077 .await
1078 .unwrap();
1079 tokio::fs::write(&legacy_path, "1.65.0\n").await.unwrap();
1080
1081 strip_rust_toolchain_files(dir.path()).await.unwrap();
1082
1083 assert!(!toml_path.exists());
1084 assert!(!legacy_path.exists());
1085 }
1086
1087 #[tokio::test]
1088 async fn strip_rust_toolchain_files_noop_when_absent() {
1089 let dir = tempfile::tempdir().unwrap();
1090 let unrelated = dir.path().join("Cargo.toml");
1091 tokio::fs::write(&unrelated, "[package]\nname = \"x\"\n")
1092 .await
1093 .unwrap();
1094
1095 strip_rust_toolchain_files(dir.path()).await.unwrap();
1096
1097 assert!(unrelated.exists());
1098 }
1099
1100 #[tokio::test]
1101 async fn strip_rust_toolchain_files_only_removes_named_files() {
1102 let dir = tempfile::tempdir().unwrap();
1103 let toml_path = dir.path().join("rust-toolchain.toml");
1104 let cargo_toml = dir.path().join("Cargo.toml");
1105 let src = dir.path().join("src");
1106 tokio::fs::write(&toml_path, "").await.unwrap();
1107 tokio::fs::write(&cargo_toml, "").await.unwrap();
1108 tokio::fs::create_dir(&src).await.unwrap();
1109
1110 strip_rust_toolchain_files(dir.path()).await.unwrap();
1111
1112 assert!(!toml_path.exists());
1113 assert!(cargo_toml.exists());
1114 assert!(src.exists());
1115 }
1116}