1use std::io::{BufRead, Write};
34use std::path::{Path, PathBuf};
35use std::process::ExitCode;
36
37use djogi::config::DjogiConfig;
38use djogi::migrate::{
39 DescriptorProvider, ResetError, ResetReport, ResetRequest, SeedError, SeedOutcome, SeedReport,
40 generate_docs_with_provider, reset_app_database, run_seeds,
41};
42
43fn resolve_workspace(workspace: Option<PathBuf>) -> PathBuf {
47 workspace.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
48}
49
50fn build_runtime(label: &str) -> Result<tokio::runtime::Runtime, ExitCode> {
54 tokio::runtime::Builder::new_current_thread()
55 .enable_all()
56 .build()
57 .map_err(|e| {
58 eprintln!("djogi {label}: tokio runtime: {e}");
59 ExitCode::from(1)
60 })
61}
62
63pub fn reset_cmd(
76 yes: bool,
77 allow_checksum_drift_reset: bool,
78 maintenance_database: String,
79 workspace: Option<PathBuf>,
80 node_id: Option<u32>,
81 single_node_dev: bool,
82) -> ExitCode {
83 let workspace = resolve_workspace(workspace);
84 let config = match DjogiConfig::load_from_workspace(&workspace) {
85 Ok(c) => c,
86 Err(e) => {
87 eprintln!("djogi db reset: config load: {e}");
88 return ExitCode::from(1);
89 }
90 };
91
92 let runner_identity = match crate::identity::resolve_identity(
97 node_id,
98 single_node_dev,
99 &config.profile,
100 "db reset",
101 ) {
102 Ok(resolved) => {
103 match resolved {
105 crate::identity::CliResolvedIdentity::SingleNodeDev => {
106 Some(djogi::migrate::RunnerIdentity::SingleNodeDev)
107 }
108 crate::identity::CliResolvedIdentity::Selected(id) => {
109 eprintln!(
110 "djogi db reset: refused — selected node {id} is not \
111 permitted for destructive reset; use --single-node-dev"
112 );
113 return ExitCode::from(2);
114 }
115 }
116 }
117 Err(e) => {
118 eprintln!("djogi db reset: refused — {e}");
119 return ExitCode::from(2);
120 }
121 };
122
123 let confirmed = if yes {
127 true
128 } else {
129 match interactive_confirm(&config.database.url) {
130 Ok(c) => c,
131 Err(_) => {
132 eprintln!(
134 "djogi db reset: failed to read confirmation; \
135 refusing without an explicit `--yes`"
136 );
137 return ExitCode::from(1);
138 }
139 }
140 };
141
142 let runtime = match build_runtime("db reset") {
143 Ok(r) => r,
144 Err(code) => return code,
145 };
146
147 let exit = runtime.block_on(async {
148 run_reset(
149 &workspace,
150 &config,
151 &maintenance_database,
152 confirmed,
153 allow_checksum_drift_reset,
154 runner_identity,
155 )
156 .await
157 });
158 ExitCode::from(exit as u8)
159}
160
161async fn run_reset(
178 workspace: &Path,
179 config: &DjogiConfig,
180 maintenance_database: &str,
181 confirmed: bool,
182 allow_checksum_drift_reset: bool,
183 runner_identity: Option<djogi::migrate::RunnerIdentity>,
184) -> i32 {
185 let maintenance_url =
192 djogi::migrate::replace_db_in_url(&config.database.url, maintenance_database);
193 let preflight_url = maintenance_url.as_deref().unwrap_or(&config.database.url);
194 let preflight_pool = match djogi::pg::pool::DjogiPool::connect(preflight_url).await {
195 Ok(p) => p,
196 Err(e) => {
197 eprintln!("djogi db reset: support boundary: connect to maintenance DB: {e}");
198 return 1;
199 }
200 };
201 if let Err(e) = djogi::pg::preflight::check_postgres_version(&preflight_pool).await {
202 crate::print_support_boundary_error("db reset", &e);
203 return 2;
204 }
205 drop(preflight_pool);
206
207 let audit_pool = resolve_audit_pool_best_effort(config).await;
208 let req = ResetRequest {
209 workspace_root: workspace,
210 database_url: &config.database.url,
211 profile: &config.profile,
212 confirmed,
213 allow_checksum_drift_reset,
214 maintenance_database,
215 migrate_config: djogi::config::MigrateConfig {
216 concurrent_warn_relpages: config.migrate.concurrent_warn_relpages,
217 strict_concurrent_warnings: config.migrate.strict_concurrent_warnings,
218 pk_flip_long_tx_threshold_secs: config.migrate.pk_flip_long_tx_threshold_secs,
219 pk_flip_join_table_option: config.migrate.pk_flip_join_table_option,
220 },
221 audit_pool,
222 runner_identity,
223 };
224 match reset_app_database(req).await {
225 Ok(report) => {
226 print_reset_report(&report);
227 0
228 }
229 Err(ResetError::Refused(refusal)) => {
230 eprintln!("djogi db reset: refused — {refusal}");
231 2
235 }
236 Err(other) => {
237 eprintln!("djogi db reset: {other}");
238 1
239 }
240 }
241}
242
243async fn resolve_audit_pool_best_effort(config: &DjogiConfig) -> Option<deadpool_postgres::Pool> {
269 let url = match djogi::migrate::resolve_audit_url(config) {
270 Ok(u) => u,
271 Err(e) => {
272 eprintln!(
273 "djogi db reset: warning — audit-pool URL resolution failed; \
274 proceeding without djogi_ddl_audit rows: {e}"
275 );
276 tracing::warn!(
277 target: "djogi::cli::db::reset",
278 error = %e,
279 "audit-pool URL resolution failed; db reset will proceed without writing \
280 djogi_ddl_audit rows"
281 );
282 return None;
283 }
284 };
285 match djogi::migrate::build_audit_pool(&url).await {
286 Ok(pool) => Some(pool),
287 Err(e) => {
288 eprintln!(
289 "djogi db reset: warning — audit-pool construction failed for `{url}`; \
290 proceeding without djogi_ddl_audit rows: {e}"
291 );
292 tracing::warn!(
293 target: "djogi::cli::db::reset",
294 audit_url = %url,
295 error = %e,
296 "audit-pool construction failed; db reset will proceed without writing \
297 djogi_ddl_audit rows"
298 );
299 None
300 }
301 }
302}
303
304fn print_reset_report(report: &ResetReport) {
307 println!(
308 "db reset complete — recreated database `{}`",
309 report.database
310 );
311 if report.replayed_versions.is_empty() {
312 println!(" no committed migrations replayed");
313 return;
314 }
315 for entry in &report.replayed_versions {
316 let app = if entry.bucket.app.is_empty() {
317 "_global_"
318 } else {
319 entry.bucket.app.as_str()
320 };
321 println!(
322 " replayed {database}/{app}: {version}",
323 database = entry.bucket.database,
324 version = entry.version,
325 );
326 }
327 println!(
328 " total: {} migration(s) replayed",
329 report.replayed_versions.len()
330 );
331}
332
333fn interactive_confirm(database_url: &str) -> std::io::Result<bool> {
337 let stderr = std::io::stderr();
338 let mut handle = stderr.lock();
339 writeln!(
340 handle,
341 "WARNING: db reset will DROP and RECREATE the application database \
342 pointed at by DATABASE_URL ({database_url}); every row will be lost. \
343 Migrations under `migrations/<database>/` will be replayed onto the \
344 freshly-created database. This action cannot be undone."
345 )?;
346 write!(handle, "Type `yes` to confirm, anything else to abort: ")?;
347 handle.flush()?;
348 let stdin = std::io::stdin();
349 let mut line = String::new();
350 stdin.lock().read_line(&mut line)?;
351 Ok(matches!(
352 line.trim().to_ascii_lowercase().as_str(),
353 "y" | "yes"
354 ))
355}
356
357pub fn seed_cmd(
361 database: String,
362 allow_non_localhost: bool,
363 workspace: Option<PathBuf>,
364) -> ExitCode {
365 let workspace = resolve_workspace(workspace);
366 let config = match DjogiConfig::load_from_workspace(&workspace) {
367 Ok(c) => c,
368 Err(e) => {
369 eprintln!("djogi db seed: config load: {e}");
370 return ExitCode::from(1);
371 }
372 };
373
374 let runtime = match build_runtime("db seed") {
375 Ok(r) => r,
376 Err(code) => return code,
377 };
378 let exit = runtime
379 .block_on(async { run_seed(&workspace, &config, &database, allow_non_localhost).await });
380 ExitCode::from(exit as u8)
381}
382
383async fn run_seed(
396 workspace: &Path,
397 config: &DjogiConfig,
398 database: &str,
399 allow_non_localhost: bool,
400) -> i32 {
401 let routed_url = match djogi::migrate::derive_per_database_url(&config.database.url, database) {
410 Some(u) => u,
411 None => {
412 let err = SeedError::MalformedApplicationUrl {
413 application_url: config.database.url.clone(),
414 };
415 eprintln!("djogi db seed: {err} (--database `{database}`)");
416 return 1;
417 }
418 };
419
420 let pool = match djogi::pg::pool::DjogiPool::connect(&routed_url).await {
422 Ok(p) => p,
423 Err(e) => {
424 eprintln!("djogi db seed: connect: {e}");
425 return 1;
426 }
427 };
428 if let Err(e) = djogi::pg::preflight::check_postgres_version(&pool).await {
429 crate::print_support_boundary_error("db seed", &e);
430 return 2;
431 }
432 let mut ctx = djogi::context::DjogiContext::from_pool(pool);
433
434 match run_seeds(
435 &mut ctx,
436 workspace,
437 database,
438 &routed_url,
439 allow_non_localhost,
440 )
441 .await
442 {
443 Ok(report) => {
444 print_seed_report(&report);
445 0
446 }
447 Err(SeedError::LocalhostGate { database_url }) => {
448 eprintln!(
449 "djogi db seed: refused — DATABASE_URL `{database_url}` is not \
450 localhost; pass `--allow-non-localhost` to override"
451 );
452 2
453 }
454 Err(other) => {
455 eprintln!("djogi db seed: {other}");
456 1
457 }
458 }
459}
460
461fn print_seed_report(report: &SeedReport) {
462 if report.entries.is_empty() {
463 println!("db seed: no seeds discovered");
464 return;
465 }
466 let mut applied = 0usize;
467 let mut skipped = 0usize;
468 for entry in &report.entries {
469 let label = match entry.outcome {
470 SeedOutcome::Applied => {
471 applied += 1;
472 "applied"
473 }
474 SeedOutcome::SkippedAlreadyApplied => {
475 skipped += 1;
476 "skipped (already applied)"
477 }
478 };
479 println!(" {label:>30} {name}", name = entry.seed_name);
480 }
481 println!("db seed: {applied} applied, {skipped} skipped");
482}
483
484pub fn cleanup_test_dbs_cmd(
511 dry_run: bool,
512 yes: bool,
513 maintenance_database: String,
514 allow_non_localhost: bool,
515 workspace: Option<PathBuf>,
516) -> ExitCode {
517 let workspace = resolve_workspace(workspace);
518 let config = match DjogiConfig::load_from_workspace(&workspace) {
519 Ok(c) => c,
520 Err(e) => {
521 eprintln!("djogi db cleanup-test-dbs: config load: {e}");
522 return ExitCode::from(1);
523 }
524 };
525
526 if !allow_non_localhost && !djogi::migrate::is_localhost_connection(&config.database.url) {
532 eprintln!(
533 "djogi db cleanup-test-dbs: refused — DATABASE_URL `{}` is not \
534 localhost; pass `--allow-non-localhost` to override",
535 config.database.url
536 );
537 return ExitCode::from(2);
538 }
539
540 if config.profile == "production" {
544 eprintln!(
545 "djogi db cleanup-test-dbs: refused — Djogi.toml::profile = `{}`; \
546 refusing to run on a production profile",
547 config.profile
548 );
549 return ExitCode::from(2);
550 }
551
552 if !dry_run && !yes {
555 eprintln!(
556 "djogi db cleanup-test-dbs: refused — pass `--yes` to confirm, \
557 or `--dry-run` to list candidates without dropping"
558 );
559 return ExitCode::from(2);
560 }
561
562 if !is_valid_pg_identifier(&maintenance_database) {
567 eprintln!(
568 "djogi db cleanup-test-dbs: invalid maintenance database name `{maintenance_database}`"
569 );
570 return ExitCode::from(1);
571 }
572
573 let admin_url = match djogi::migrate::derive_per_database_url(
579 &config.database.url,
580 &maintenance_database,
581 ) {
582 Some(u) => u,
583 None => {
584 eprintln!(
585 "djogi db cleanup-test-dbs: malformed application URL `{}` — \
586 cannot derive maintenance connection URL",
587 config.database.url
588 );
589 return ExitCode::from(1);
590 }
591 };
592
593 let runtime = match build_runtime("db cleanup-test-dbs") {
594 Ok(r) => r,
595 Err(code) => return code,
596 };
597 let exit = runtime.block_on(async { run_cleanup_test_dbs(&admin_url, dry_run).await });
598 ExitCode::from(exit as u8)
599}
600
601async fn run_cleanup_test_dbs(admin_url: &str, dry_run: bool) -> i32 {
604 if dry_run {
605 match djogi::testing::list_orphaned_test_databases(admin_url).await {
606 Ok(candidates) => {
607 if candidates.is_empty() {
608 println!("db cleanup-test-dbs (dry run): no orphaned test databases found");
609 } else {
610 println!(
611 "db cleanup-test-dbs (dry run): {} candidate(s):",
612 candidates.len()
613 );
614 for name in &candidates {
615 println!(" {name}");
616 }
617 }
618 0
619 }
620 Err(e) => {
621 eprintln!("djogi db cleanup-test-dbs: {e}");
622 1
623 }
624 }
625 } else {
626 match djogi::testing::cleanup_orphaned_test_databases(admin_url).await {
627 Ok(dropped) => {
628 if dropped.is_empty() {
629 println!("db cleanup-test-dbs: no orphaned test databases dropped");
630 } else {
631 println!(
632 "db cleanup-test-dbs: dropped {} database(s):",
633 dropped.len()
634 );
635 for name in &dropped {
636 println!(" {name}");
637 }
638 }
639 0
640 }
641 Err(e) => {
642 eprintln!("djogi db cleanup-test-dbs: {e}");
643 1
644 }
645 }
646 }
647}
648
649fn is_valid_pg_identifier(name: &str) -> bool {
657 let bytes = name.as_bytes();
658 if bytes.is_empty() || bytes.len() > 63 {
659 return false;
660 }
661 let first = bytes[0];
662 if !(first.is_ascii_alphabetic() || first == b'_') {
663 return false;
664 }
665 for &b in &bytes[1..] {
666 if !(b.is_ascii_alphanumeric() || b == b'_') {
667 return false;
668 }
669 }
670 true
671}
672
673pub fn docs_cmd(
680 provider: &dyn DescriptorProvider,
681 output: Option<PathBuf>,
682 workspace: Option<PathBuf>,
683) -> ExitCode {
684 if provider.models().is_empty() {
687 crate::print_zero_descriptor_diagnostic("docs");
688 return ExitCode::from(2);
689 }
690 let workspace = resolve_workspace(workspace);
691 let output = output.unwrap_or_else(|| workspace.join("target").join("djogi-docs"));
692 let intent = match djogi::intent::load(&workspace) {
699 Ok(maybe) => maybe,
700 Err(e) => {
701 eprintln!("djogi docs: {e}");
702 return ExitCode::from(1);
703 }
704 };
705 match generate_docs_with_provider(provider, &output, intent.as_ref()) {
706 Ok(report) => {
707 println!(
708 "docs: rendered {n} model page(s) into {path}",
709 n = report.models_rendered,
710 path = report.output_root.display(),
711 );
712 ExitCode::from(0)
713 }
714 Err(e) => {
715 eprintln!("djogi docs: {e}");
716 ExitCode::from(1)
717 }
718 }
719}
720
721#[cfg(test)]
722mod tests {
723 use super::*;
724 use std::fs;
725 use std::sync::atomic::{AtomicUsize, Ordering};
726
727 struct DatabaseUrlEnvGuard {
728 _lock: std::sync::MutexGuard<'static, ()>,
729 prior: Option<String>,
730 }
731
732 impl DatabaseUrlEnvGuard {
733 fn new() -> Self {
734 Self {
735 _lock: crate::test_env_lock(),
736 prior: std::env::var("DATABASE_URL").ok(),
737 }
738 }
739
740 fn set(&self, value: &str) {
741 unsafe { std::env::set_var("DATABASE_URL", value) };
742 }
743
744 fn remove(&self) {
745 unsafe { std::env::remove_var("DATABASE_URL") };
746 }
747 }
748
749 impl Drop for DatabaseUrlEnvGuard {
750 fn drop(&mut self) {
751 match &self.prior {
752 Some(value) => unsafe { std::env::set_var("DATABASE_URL", value) },
753 None => unsafe { std::env::remove_var("DATABASE_URL") },
754 }
755 }
756 }
757
758 fn without_database_url<T>(f: impl FnOnce() -> T) -> T {
759 let env_guard = DatabaseUrlEnvGuard::new();
760 env_guard.remove();
761 f()
762 }
763
764 #[test]
765 fn database_url_env_guard_restores_prior_value() {
766 let env_guard = DatabaseUrlEnvGuard::new();
767 let expected = env_guard.prior.clone();
768 let next = if expected.as_deref() == Some("postgres://temporary/test") {
769 "postgres://temporary/other"
770 } else {
771 "postgres://temporary/test"
772 };
773 env_guard.set(next);
774 drop(env_guard);
775 assert_eq!(std::env::var("DATABASE_URL").ok(), expected);
776 }
777
778 fn temp_workspace(tag: &str) -> PathBuf {
779 static COUNTER: AtomicUsize = AtomicUsize::new(0);
780 let n = COUNTER.fetch_add(1, Ordering::SeqCst);
781 let nanos = std::time::SystemTime::now()
782 .duration_since(std::time::UNIX_EPOCH)
783 .unwrap()
784 .as_nanos();
785 let p = std::env::temp_dir().join(format!("djogi-cli-db-{tag}-{nanos}-{n}"));
786 fs::create_dir_all(&p).unwrap();
787 p
788 }
789
790 #[test]
793 fn reset_cmd_refuses_when_identity_is_missing() {
794 let work = temp_workspace("reset_remote");
795 let toml = "[database]\nurl = \"postgres://prod.example.com/main\"\n\
796 max_connections = 1\ndev_mode = false\n\
797 [server]\nhost = \"127.0.0.1\"\nport = 1234\n";
798 fs::write(work.join("Djogi.toml"), toml).unwrap();
799 let exit = without_database_url(|| {
800 reset_cmd(
801 true, false, "postgres".to_string(), Some(work.clone()), None, false, )
808 });
809 assert_eq!(
810 exit,
811 ExitCode::from(2),
812 "missing identity must refuse before localhost gating"
813 );
814 let _ = fs::remove_dir_all(&work);
815 }
816
817 #[test]
821 fn reset_cmd_refuses_single_node_dev_in_production_profile() {
822 let work = temp_workspace("reset_prod");
823 let toml = "profile = \"production\"\n\
824 [database]\nurl = \"postgres://localhost/main\"\n\
825 max_connections = 1\ndev_mode = false\n\
826 [server]\nhost = \"127.0.0.1\"\nport = 1234\n";
827 fs::write(work.join("Djogi.toml"), toml).unwrap();
828 let exit = without_database_url(|| {
829 reset_cmd(
830 true, false, "postgres".to_string(), Some(work.clone()), None, true, )
837 });
838 assert_eq!(
839 exit,
840 ExitCode::from(2),
841 "production profile must refuse single-node-dev during identity resolution"
842 );
843 let _ = fs::remove_dir_all(&work);
844 }
845
846 #[test]
852 fn cleanup_test_dbs_refuses_non_localhost_without_override() {
853 let work = temp_workspace("cleanup_remote");
854 let toml = "[database]\nurl = \"postgres://prod.example.com/main\"\n\
855 max_connections = 1\ndev_mode = false\n\
856 [server]\nhost = \"127.0.0.1\"\nport = 1234\n";
857 fs::write(work.join("Djogi.toml"), toml).unwrap();
858 let exit = without_database_url(|| {
861 cleanup_test_dbs_cmd(
862 false,
863 true,
864 "postgres".to_string(),
865 false,
866 Some(work.clone()),
867 )
868 });
869 assert_eq!(
870 exit,
871 ExitCode::from(2),
872 "non-localhost without override must refuse"
873 );
874 let _ = fs::remove_dir_all(&work);
875 }
876
877 #[test]
880 fn cleanup_test_dbs_refuses_on_production_profile() {
881 let work = temp_workspace("cleanup_prod");
882 let toml = "profile = \"production\"\n\
883 [database]\nurl = \"postgres://localhost/main\"\n\
884 max_connections = 1\ndev_mode = false\n\
885 [server]\nhost = \"127.0.0.1\"\nport = 1234\n";
886 fs::write(work.join("Djogi.toml"), toml).unwrap();
887 let exit = without_database_url(|| {
888 cleanup_test_dbs_cmd(
889 false,
890 true,
891 "postgres".to_string(),
892 false,
893 Some(work.clone()),
894 )
895 });
896 assert_eq!(exit, ExitCode::from(2), "production must refuse");
897 let _ = fs::remove_dir_all(&work);
898 }
899
900 #[test]
903 fn cleanup_test_dbs_refuses_without_yes_or_dry_run() {
904 let work = temp_workspace("cleanup_no_yes");
905 let toml = "[database]\nurl = \"postgres://localhost/main\"\n\
906 max_connections = 1\ndev_mode = false\n\
907 [server]\nhost = \"127.0.0.1\"\nport = 1234\n";
908 fs::write(work.join("Djogi.toml"), toml).unwrap();
909 let exit = without_database_url(|| {
910 cleanup_test_dbs_cmd(
911 false,
912 false,
913 "postgres".to_string(),
914 false,
915 Some(work.clone()),
916 )
917 });
918 assert_eq!(
919 exit,
920 ExitCode::from(2),
921 "missing --yes without --dry-run must refuse"
922 );
923 let _ = fs::remove_dir_all(&work);
924 }
925
926 #[test]
931 fn cleanup_test_dbs_rejects_invalid_maintenance_database() {
932 let work = temp_workspace("cleanup_bad_maint");
933 let toml = "[database]\nurl = \"postgres://localhost/main\"\n\
934 max_connections = 1\ndev_mode = false\n\
935 [server]\nhost = \"127.0.0.1\"\nport = 1234\n";
936 fs::write(work.join("Djogi.toml"), toml).unwrap();
937 let exit = without_database_url(|| {
938 cleanup_test_dbs_cmd(
939 false,
940 true,
941 "'; DROP DATABASE main; --".to_string(),
942 false,
943 Some(work.clone()),
944 )
945 });
946 assert_eq!(
947 exit,
948 ExitCode::from(1),
949 "invalid maintenance DB name must reject"
950 );
951 let _ = fs::remove_dir_all(&work);
952 }
953
954 #[test]
958 fn is_valid_pg_identifier_byte_grammar() {
959 assert!(is_valid_pg_identifier("postgres"));
960 assert!(is_valid_pg_identifier("rdsadmin"));
961 assert!(is_valid_pg_identifier("_under"));
962 assert!(is_valid_pg_identifier("a"));
963 assert!(is_valid_pg_identifier("a_1_b"));
964
965 assert!(!is_valid_pg_identifier(""));
966 assert!(!is_valid_pg_identifier("1starts_with_digit"));
967 assert!(!is_valid_pg_identifier("has space"));
968 assert!(!is_valid_pg_identifier("'; DROP TABLE foo; --"));
969 assert!(!is_valid_pg_identifier(&"a".repeat(64)));
971 assert!(is_valid_pg_identifier(&"a".repeat(63)));
972 }
973
974 #[test]
978 fn docs_cmd_against_empty_provider_refuses() {
979 struct EmptyProvider;
980 impl djogi::migrate::DescriptorProvider for EmptyProvider {
981 fn models(&self) -> Vec<&'static djogi::descriptor::ModelDescriptor> {
982 Vec::new()
983 }
984 fn enums(&self) -> Vec<&'static djogi::descriptor::EnumDescriptor> {
985 Vec::new()
986 }
987 fn apps(&self) -> &'static [djogi::apps::AppDescriptor] {
988 djogi::apps::AppRegistry::all()
989 }
990 fn deferrability_specs(&self) -> Vec<&'static djogi::descriptor::DeferrabilitySpec> {
991 Vec::new()
992 }
993 }
994 let work = temp_workspace("docs_empty_refusal");
995 let out = work.join("target/djogi-docs");
996 let exit = docs_cmd(&EmptyProvider, Some(out.clone()), Some(work.clone()));
997 assert_eq!(exit, ExitCode::from(2));
998 assert!(
1000 !out.join("README.md").exists(),
1001 "refusal must not render docs"
1002 );
1003 let _ = fs::remove_dir_all(&work);
1004 }
1005}