1use std::io::Write as _;
8use std::path::{Path, PathBuf};
9use std::time::Duration;
10
11use chrono::{DateTime, Utc};
12
13use crate::cli::{ProjectDescribeArgs, ProjectDumpArgs, ProjectListArgs};
14use crate::client::DspClient;
15use crate::config::{AuthCache, Config, resolve_token};
16use crate::diagnostic::Diagnostic;
17use crate::model::{CreateDumpOutcome, DumpStatus};
18use crate::render::progress::ProgressReporter;
19use crate::render::{
20 DumpDeleteOutcome, DumpEvent, DumpOutcome, MetaContext, ProjectListView, Renderer,
21};
22
23use crate::actions::auth_state::read_auth_state;
24
25pub fn list(
31 args: &ProjectListArgs,
32 cfg: &Config,
33 client: &dyn DspClient,
34 renderer: &mut dyn Renderer,
35) -> Result<(), Diagnostic> {
36 let env_token = std::env::var("DSP_TOKEN").ok();
37 run_list_impl(args, cfg, client, renderer, env_token, None)
38}
39
40fn run_list_impl(
52 args: &ProjectListArgs,
53 cfg: &Config,
54 client: &dyn DspClient,
55 renderer: &mut dyn Renderer,
56 env_token: Option<String>,
57 cache_path: Option<&Path>,
58) -> Result<(), Diagnostic> {
59 let cache_result = match cache_path {
61 Some(p) => AuthCache::load_from(p),
62 None => AuthCache::load(),
63 };
64 let cache = match cache_result {
65 Ok(c) => c,
66 Err(e) => {
67 tracing::warn!(
68 error = %e,
69 "auth cache load failed; falling back to anonymous for project list"
70 );
71 AuthCache::default()
72 }
73 };
74
75 let resolved = resolve_token(env_token, &cache, &cfg.server);
77 let token = resolved.as_ref().map(|r| r.token.as_str());
78
79 let auth_state = read_auth_state(resolved.as_ref(), &cache, &cfg.server);
81
82 let mut projects = client.list_projects(&cfg.server, token)?;
84
85 let total = projects.len();
87
88 if let Some(ref f) = args.filter {
90 let lower = f.to_lowercase();
91 projects.retain(|p| {
92 p.shortcode.to_lowercase().contains(&lower)
93 || p.shortname.to_lowercase().contains(&lower)
94 || p.longname
95 .as_deref()
96 .unwrap_or("")
97 .to_lowercase()
98 .contains(&lower)
99 });
100 }
101
102 projects.sort_by(|a, b| a.shortcode.cmp(&b.shortcode));
104
105 let view = ProjectListView {
107 items: projects,
108 total,
109 filter: args.filter.clone(),
110 };
111 let meta = MetaContext {
112 server_label: cfg.server.clone(),
113 auth_state,
114 filter_warning: None,
115 };
116 renderer.projects(&view, &meta)
117}
118
119pub fn describe(
125 args: &ProjectDescribeArgs,
126 cfg: &Config,
127 client: &dyn DspClient,
128 renderer: &mut dyn Renderer,
129) -> Result<(), Diagnostic> {
130 let env_token = std::env::var("DSP_TOKEN").ok();
131 run_describe_impl(args, cfg, client, renderer, env_token, None)
132}
133
134fn run_describe_impl(
145 args: &ProjectDescribeArgs,
146 cfg: &Config,
147 client: &dyn DspClient,
148 renderer: &mut dyn Renderer,
149 env_token: Option<String>,
150 cache_path: Option<&Path>,
151) -> Result<(), Diagnostic> {
152 let project = args.project.as_deref().ok_or_else(|| {
154 Diagnostic::Usage("--project <shortcode|shortname|IRI> is required".to_string())
155 })?;
156
157 let cache_result = match cache_path {
159 Some(p) => AuthCache::load_from(p),
160 None => AuthCache::load(),
161 };
162 let cache = match cache_result {
163 Ok(c) => c,
164 Err(e) => {
165 tracing::warn!(
166 error = %e,
167 "auth cache load failed; falling back to anonymous for project describe"
168 );
169 AuthCache::default()
170 }
171 };
172
173 let resolved = resolve_token(env_token, &cache, &cfg.server);
175 let token = resolved.as_ref().map(|r| r.token.as_str());
176
177 let auth_state = read_auth_state(resolved.as_ref(), &cache, &cfg.server);
179
180 let detail = client.describe_project(&cfg.server, project, token)?;
182
183 let meta = MetaContext {
185 server_label: cfg.server.clone(),
186 auth_state,
187 filter_warning: None,
188 };
189 renderer.project_describe(&detail, &meta)
190}
191
192pub fn dump(
201 args: &ProjectDumpArgs,
202 cfg: &Config,
203 client: &dyn DspClient,
204 renderer: &mut dyn Renderer,
205 reporter: &mut dyn ProgressReporter,
206) -> Result<(), Diagnostic> {
207 let env_token = std::env::var("DSP_TOKEN").ok();
208 let cwd = std::env::current_dir()
209 .map_err(|e| Diagnostic::Io(format!("could not determine current directory: {e}")))?;
210 run_impl(
211 args,
212 cfg,
213 client,
214 renderer,
215 reporter,
216 env_token,
217 &|d| std::thread::sleep(d),
218 Utc::now(),
219 None,
220 &cwd,
221 )
222}
223
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232enum DumpMode {
233 Default,
235 Replace,
237 Delete,
239}
240
241#[allow(clippy::too_many_arguments)]
256fn run_impl(
257 args: &ProjectDumpArgs,
258 cfg: &Config,
259 client: &dyn DspClient,
260 renderer: &mut dyn Renderer,
261 reporter: &mut dyn ProgressReporter,
262 env_token: Option<String>,
263 sleeper: &dyn Fn(Duration),
264 now: DateTime<Utc>,
265 cache_path: Option<&Path>,
266 cwd: &Path,
267) -> Result<(), Diagnostic> {
268 let env_token_would_win = env_token
273 .as_deref()
274 .map(str::trim)
275 .map(|s| !s.is_empty())
276 .unwrap_or(false);
277
278 let cache_result = match cache_path {
279 Some(p) => AuthCache::load_from(p),
280 None => AuthCache::load(),
281 };
282 let cache = match cache_result {
283 Ok(c) => c,
284 Err(e) if env_token_would_win => {
285 tracing::warn!(
286 error = %e,
287 "auth cache load failed; DSP_TOKEN is set, falling through to env token"
288 );
289 AuthCache::default()
290 }
291 Err(e) => return Err(e),
292 };
293
294 let resolved = resolve_token(env_token, &cache, &cfg.server).ok_or_else(|| {
295 Diagnostic::AuthRequired(
296 "dsp vre project dump requires a system-administrator token; \
297run `dsp auth login --server <s>` or set DSP_TOKEN"
298 .to_string(),
299 )
300 })?;
301 let token = resolved.token.clone();
302
303 let project = args.project.as_deref().ok_or_else(|| {
305 Diagnostic::Usage("--project <shortcode|shortname|IRI> is required".to_string())
306 })?;
307
308 let mode = if args.delete {
310 DumpMode::Delete
311 } else if args.replace {
312 DumpMode::Replace
313 } else {
314 DumpMode::Default
315 };
316
317 let explicit_output = if mode != DumpMode::Delete {
321 args.output.clone()
322 } else {
323 None
324 };
325
326 if let Some(ref path) = explicit_output
327 && path.exists()
328 && !args.force
329 {
330 return Err(Diagnostic::Usage(format!(
331 "refusing to overwrite {path}; pass --force",
332 path = path.display()
333 )));
334 }
335
336 let proj = client.resolve_project(&cfg.server, project)?;
338
339 let output_path: Option<PathBuf> = if mode != DumpMode::Delete {
341 let p = match explicit_output {
342 Some(p) => p,
343 None => {
344 let default = default_output_path(cwd, &proj.shortcode, now);
345 if default.exists() && !args.force {
346 return Err(Diagnostic::Usage(format!(
347 "refusing to overwrite {path}; pass --force",
348 path = default.display()
349 )));
350 }
351 default
352 }
353 };
354 Some(p)
355 } else {
356 None
357 };
358
359 let create_outcome =
361 client.create_project_dump(&cfg.server, &proj.iri, args.skip_assets, &token)?;
362
363 let meta = MetaContext {
367 server_label: cfg.server.clone(),
368 auth_state: read_auth_state(Some(&resolved), &cache, &cfg.server),
369 filter_warning: None,
370 };
371
372 match mode {
374 DumpMode::Default => handle_default(
375 create_outcome,
376 args,
377 cfg,
378 client,
379 renderer,
380 reporter,
381 &token,
382 &proj.iri,
383 output_path.ok_or_else(|| {
384 Diagnostic::Internal("output_path unexpectedly None in Default mode".into())
385 })?,
386 &meta,
387 sleeper,
388 ),
389 DumpMode::Replace => handle_replace(
390 create_outcome,
391 args,
392 cfg,
393 client,
394 renderer,
395 reporter,
396 &token,
397 &proj.iri,
398 args.skip_assets,
399 output_path.ok_or_else(|| {
400 Diagnostic::Internal("output_path unexpectedly None in Replace mode".into())
401 })?,
402 &meta,
403 sleeper,
404 ),
405 DumpMode::Delete => handle_delete(
406 create_outcome,
407 cfg,
408 client,
409 renderer,
410 reporter,
411 &token,
412 &proj.iri,
413 &meta,
414 ),
415 }
416}
417
418fn recreated_dump_ids(
432 outcome: CreateDumpOutcome,
433) -> Result<(String, Option<chrono::DateTime<chrono::Utc>>), Diagnostic> {
434 match outcome {
435 CreateDumpOutcome::Created(task2) => Ok((task2.id, task2.created_at)),
436 CreateDumpOutcome::Exists { .. } => Err(Diagnostic::Conflict(
437 "the dump was recreated before it could be replaced; try again".into(),
438 )),
439 CreateDumpOutcome::ExistsForOtherProject {
440 project_iri: racer, ..
441 } => Err(Diagnostic::Conflict(format!(
442 "the dump slot was claimed by another project ({racer}) \
443before this one could be created; try again"
444 ))),
445 }
446}
447
448#[allow(clippy::too_many_arguments)]
460fn handle_default(
461 create_outcome: CreateDumpOutcome,
462 args: &ProjectDumpArgs,
463 cfg: &Config,
464 client: &dyn DspClient,
465 renderer: &mut dyn Renderer,
466 reporter: &mut dyn ProgressReporter,
467 token: &str,
468 project_iri: &str,
469 output_path: PathBuf,
470 meta: &MetaContext,
471 sleeper: &dyn Fn(Duration),
472) -> Result<(), Diagnostic> {
473 match create_outcome {
474 CreateDumpOutcome::Created(task) => {
475 reporter.report(&DumpEvent::Triggered {
476 id: task.id.clone(),
477 })?;
478 let created_at = task.created_at;
479 let id = task.id;
480 poll_until_done(
481 client,
482 cfg,
483 reporter,
484 token,
485 project_iri,
486 &id,
487 args.timeout,
488 sleeper,
489 )?;
490 reporter.report(&DumpEvent::Downloading)?;
491 let bytes =
492 stream_dump_to_path(client, &cfg.server, project_iri, &id, token, &output_path)?;
493 let cleaned_up =
494 run_cleanup(args.cleanup, client, &cfg.server, project_iri, &id, token);
495 reporter.report(&DumpEvent::Done { bytes })?;
496 renderer.project_dump(
497 &DumpOutcome {
498 path: output_path,
499 bytes,
500 cleaned_up,
501 reused: false,
502 created_at,
503 },
504 meta,
505 )
506 }
507 CreateDumpOutcome::ExistsForOtherProject {
508 project_iri: foreign_iri,
509 ..
510 } => Err(Diagnostic::Conflict(format!(
511 "no dump exists for the requested project; the server holds a single \
512dump and it currently belongs to a different project ({foreign_iri}). Re-run \
513with --replace --discard-other-project to discard that dump and create this \
514project's, or wait for it to be removed."
515 ))),
516 CreateDumpOutcome::Exists { id } => {
517 let status_task =
519 client.get_project_dump_status(&cfg.server, project_iri, &id, token)?;
520 match status_task.status {
521 DumpStatus::Failed => Err(Diagnostic::Conflict(format!(
522 "the existing dump failed: {}; re-run with --replace to discard \
523and create a fresh one, or --delete to remove it",
524 status_task.error_message.unwrap_or_default()
525 ))),
526 DumpStatus::Completed => {
527 reporter.report(&DumpEvent::Adopting { id: id.clone() })?;
528 reporter.report(&DumpEvent::Downloading)?;
529 let bytes = stream_dump_to_path(
530 client,
531 &cfg.server,
532 project_iri,
533 &id,
534 token,
535 &output_path,
536 )?;
537 let cleaned_up =
538 run_cleanup(args.cleanup, client, &cfg.server, project_iri, &id, token);
539 reporter.report(&DumpEvent::Done { bytes })?;
540 renderer.project_dump(
541 &DumpOutcome {
542 path: output_path,
543 bytes,
544 cleaned_up,
545 reused: true,
546 created_at: status_task.created_at,
547 },
548 meta,
549 )
550 }
551 DumpStatus::InProgress => {
552 reporter.report(&DumpEvent::Adopting { id: id.clone() })?;
553 let created_at = status_task.created_at;
554 poll_until_done(
555 client,
556 cfg,
557 reporter,
558 token,
559 project_iri,
560 &id,
561 args.timeout,
562 sleeper,
563 )?;
564 reporter.report(&DumpEvent::Downloading)?;
565 let bytes = stream_dump_to_path(
566 client,
567 &cfg.server,
568 project_iri,
569 &id,
570 token,
571 &output_path,
572 )?;
573 let cleaned_up =
574 run_cleanup(args.cleanup, client, &cfg.server, project_iri, &id, token);
575 reporter.report(&DumpEvent::Done { bytes })?;
576 renderer.project_dump(
577 &DumpOutcome {
578 path: output_path,
579 bytes,
580 cleaned_up,
581 reused: true,
582 created_at,
583 },
584 meta,
585 )
586 }
587 }
588 }
589 }
590}
591
592#[allow(clippy::too_many_arguments)]
602fn handle_replace(
603 create_outcome: CreateDumpOutcome,
604 args: &ProjectDumpArgs,
605 cfg: &Config,
606 client: &dyn DspClient,
607 renderer: &mut dyn Renderer,
608 reporter: &mut dyn ProgressReporter,
609 token: &str,
610 project_iri: &str,
611 skip_assets: bool,
612 output_path: PathBuf,
613 meta: &MetaContext,
614 sleeper: &dyn Fn(Duration),
615) -> Result<(), Diagnostic> {
616 let (id, created_at) = match create_outcome {
617 CreateDumpOutcome::Created(task) => {
618 reporter.report(&DumpEvent::Triggered {
619 id: task.id.clone(),
620 })?;
621 let created_at = task.created_at;
622 let id = task.id;
623 poll_until_done(
624 client,
625 cfg,
626 reporter,
627 token,
628 project_iri,
629 &id,
630 args.timeout,
631 sleeper,
632 )?;
633 reporter.report(&DumpEvent::Downloading)?;
634 let bytes =
635 stream_dump_to_path(client, &cfg.server, project_iri, &id, token, &output_path)?;
636 let cleaned_up =
637 run_cleanup(args.cleanup, client, &cfg.server, project_iri, &id, token);
638 reporter.report(&DumpEvent::Done { bytes })?;
639 return renderer.project_dump(
640 &DumpOutcome {
641 path: output_path,
642 bytes,
643 cleaned_up,
644 reused: false,
645 created_at,
646 },
647 meta,
648 );
649 }
650 CreateDumpOutcome::ExistsForOtherProject {
651 id: foreign_id,
652 project_iri: foreign_iri,
653 } => {
654 if !args.discard_other_project {
655 return Err(Diagnostic::Conflict(format!(
656 "the server's single dump slot is held by a different project \
657({foreign_iri}); re-run with --replace --discard-other-project to discard that \
658project's dump and create this one's"
659 )));
660 }
661 let foreign =
664 client.get_project_dump_status(&cfg.server, &foreign_iri, &foreign_id, token)?;
665 match foreign.status {
666 DumpStatus::InProgress => {
667 return Err(Diagnostic::Conflict(format!(
668 "a dump for a different project ({foreign_iri}) is currently in \
669progress; it cannot be discarded until it finishes — wait and retry"
670 )));
671 }
672 DumpStatus::Completed | DumpStatus::Failed => {
673 reporter.report(&DumpEvent::DiscardingOtherProjectDump {
674 id: foreign_id.clone(),
675 project_iri: foreign_iri.clone(),
676 })?;
677 client.delete_project_dump(&cfg.server, &foreign_iri, &foreign_id, token)?;
678 let create2 =
680 client.create_project_dump(&cfg.server, project_iri, skip_assets, token)?;
681 recreated_dump_ids(create2)?
682 }
683 }
684 }
685 CreateDumpOutcome::Exists { id } => {
686 let status_task =
687 client.get_project_dump_status(&cfg.server, project_iri, &id, token)?;
688 match status_task.status {
689 DumpStatus::InProgress => {
690 return Err(Diagnostic::Conflict(
691 "a dump is already in progress; it cannot be replaced until it finishes"
692 .into(),
693 ));
694 }
695 DumpStatus::Completed | DumpStatus::Failed => {
696 reporter.report(&DumpEvent::Deleting { id: id.clone() })?;
698 client.delete_project_dump(&cfg.server, project_iri, &id, token)?;
699 let create2 =
701 client.create_project_dump(&cfg.server, project_iri, skip_assets, token)?;
702 recreated_dump_ids(create2)?
703 }
704 }
705 }
706 };
707
708 reporter.report(&DumpEvent::Triggered { id: id.clone() })?;
709 poll_until_done(
710 client,
711 cfg,
712 reporter,
713 token,
714 project_iri,
715 &id,
716 args.timeout,
717 sleeper,
718 )?;
719 reporter.report(&DumpEvent::Downloading)?;
720 let bytes = stream_dump_to_path(client, &cfg.server, project_iri, &id, token, &output_path)?;
721 let cleaned_up = run_cleanup(args.cleanup, client, &cfg.server, project_iri, &id, token);
722 reporter.report(&DumpEvent::Done { bytes })?;
723 renderer.project_dump(
724 &DumpOutcome {
725 path: output_path,
726 bytes,
727 cleaned_up,
728 reused: false,
729 created_at,
730 },
731 meta,
732 )
733}
734
735#[allow(clippy::too_many_arguments)]
744fn handle_delete(
745 create_outcome: CreateDumpOutcome,
746 cfg: &Config,
747 client: &dyn DspClient,
748 renderer: &mut dyn Renderer,
749 reporter: &mut dyn ProgressReporter,
750 token: &str,
751 project_iri: &str,
752 meta: &MetaContext,
753) -> Result<(), Diagnostic> {
754 match create_outcome {
755 CreateDumpOutcome::ExistsForOtherProject {
756 project_iri: foreign_iri,
757 ..
758 } => renderer.project_dump_deleted(
759 &DumpDeleteOutcome {
760 deleted: false,
761 note: Some(format!(
762 "no dump for the requested project to delete; the server's \
763single dump slot is held by a different project ({foreign_iri})"
764 )),
765 },
766 meta,
767 ),
768 CreateDumpOutcome::Exists { id } => {
769 let status_task =
770 client.get_project_dump_status(&cfg.server, project_iri, &id, token)?;
771 match status_task.status {
772 DumpStatus::InProgress => Err(Diagnostic::Conflict(
773 "the dump is in progress and cannot be deleted until it finishes".into(),
774 )),
775 DumpStatus::Completed | DumpStatus::Failed => {
776 reporter.report(&DumpEvent::Deleting { id: id.clone() })?;
777 client.delete_project_dump(&cfg.server, project_iri, &id, token)?;
778 renderer.project_dump_deleted(
779 &DumpDeleteOutcome {
780 deleted: true,
781 note: None,
782 },
783 meta,
784 )
785 }
786 }
787 }
788 CreateDumpOutcome::Created(task) => {
789 let note = format!(
793 "no dump existed to delete; a probe created an in-progress dump {} \
794that will complete server-side",
795 task.id
796 );
797 reporter.report(&DumpEvent::ProbeCreated {
798 id: task.id.clone(),
799 })?;
800 tracing::warn!(
801 id = %task.id,
802 "delete mode: no existing dump found; probe created in-progress dump \
803 that will complete server-side"
804 );
805 renderer.project_dump_deleted(
806 &DumpDeleteOutcome {
807 deleted: false,
808 note: Some(note),
809 },
810 meta,
811 )
812 }
813 }
814}
815
816#[allow(clippy::too_many_arguments)]
827fn poll_until_done(
828 client: &dyn DspClient,
829 cfg: &Config,
830 reporter: &mut dyn ProgressReporter,
831 token: &str,
832 project_iri: &str,
833 dump_id: &str,
834 timeout_secs: u64,
835 sleeper: &dyn Fn(Duration),
836) -> Result<(), Diagnostic> {
837 const BASE: Duration = Duration::from_secs(1);
838 const CAP: Duration = Duration::from_secs(30);
839 let timeout = Duration::from_secs(timeout_secs);
840 let mut elapsed = Duration::ZERO;
841 let mut delay = BASE;
842
843 loop {
844 let t = client.get_project_dump_status(&cfg.server, project_iri, dump_id, token)?;
845 match t.status {
846 DumpStatus::Completed => return Ok(()),
847 DumpStatus::Failed => {
848 return Err(Diagnostic::ServerError(format!(
849 "server-side dump failed: {}",
850 t.error_message.unwrap_or_default()
851 )));
852 }
853 DumpStatus::InProgress => {
854 reporter.report(&DumpEvent::Polling {
856 elapsed_secs: elapsed.as_secs(),
857 status: DumpStatus::InProgress,
858 })?;
859 if elapsed + delay >= timeout {
860 return Err(Diagnostic::ServerError(format!(
861 "dump did not complete within {timeout_secs}s; \
862the server-side dump may still be running"
863 )));
864 }
865 sleeper(delay);
866 elapsed += delay;
867 delay = (delay * 2).min(CAP);
868 }
869 }
870 }
871}
872
873fn run_cleanup(
878 cleanup: bool,
879 client: &dyn DspClient,
880 server: &str,
881 project_iri: &str,
882 dump_id: &str,
883 token: &str,
884) -> bool {
885 if !cleanup {
886 return false;
887 }
888 match client.delete_project_dump(server, project_iri, dump_id, token) {
889 Ok(()) => true,
890 Err(e) => {
891 tracing::warn!(error = %e, "cleanup failed; dump not deleted from server");
892 false
893 }
894 }
895}
896
897fn stream_dump_to_path(
917 client: &dyn DspClient,
918 server: &str,
919 project_iri: &str,
920 dump_id: &str,
921 token: &str,
922 final_path: &Path,
923) -> Result<u64, Diagnostic> {
924 let file_name = final_path.file_name().ok_or_else(|| {
928 Diagnostic::Usage(format!("invalid --output path: {}", final_path.display()))
929 })?;
930
931 let temp_path = {
932 let pid = std::process::id();
933 let name = format!("{}.{pid}.partial", file_name.to_string_lossy());
934 final_path
935 .parent()
936 .ok_or_else(|| {
937 Diagnostic::Io(format!(
938 "cannot determine parent directory of output path {}",
939 final_path.display()
940 ))
941 })?
942 .join(&name)
943 };
944
945 let mut open_opts = std::fs::OpenOptions::new();
949 open_opts.write(true).create_new(true);
950
951 #[cfg(unix)]
952 {
953 use std::os::unix::fs::OpenOptionsExt;
954 open_opts.mode(0o600);
955 }
956
957 let mut file = open_opts.open(&temp_path).map_err(|e| {
958 Diagnostic::Io(format!(
959 "failed to create temp file {}: {e}",
960 temp_path.display()
961 ))
962 })?;
963
964 let result = client.download_project_dump(server, project_iri, dump_id, token, &mut file);
966
967 let bytes = match result {
968 Err(e) => {
969 let _ = std::fs::remove_file(&temp_path);
971 return Err(e);
972 }
973 Ok(n) => n,
974 };
975
976 file.flush().map_err(|e| {
979 let _ = std::fs::remove_file(&temp_path);
980 Diagnostic::Io(format!(
981 "failed to flush temp file {}: {e}",
982 temp_path.display()
983 ))
984 })?;
985
986 file.sync_all().map_err(|e| {
987 let _ = std::fs::remove_file(&temp_path);
988 Diagnostic::Io(format!(
989 "failed to sync temp file {}: {e}",
990 temp_path.display()
991 ))
992 })?;
993
994 std::fs::rename(&temp_path, final_path).map_err(|e| {
996 let _ = std::fs::remove_file(&temp_path);
997 Diagnostic::Io(format!(
998 "failed to rename {} to {}: {e}",
999 temp_path.display(),
1000 final_path.display()
1001 ))
1002 })?;
1003
1004 Ok(bytes)
1005}
1006
1007fn default_output_path(base: &Path, shortcode: &str, now: DateTime<Utc>) -> PathBuf {
1014 base.join(format!("{shortcode}-{}.zip", now.format("%Y%m%dT%H%M%SZ")))
1015}
1016
1017#[cfg(test)]
1022mod tests {
1023 use std::cell::{Cell, RefCell};
1024 use std::collections::VecDeque;
1025 use std::path::PathBuf;
1026 use std::time::Duration;
1027
1028 use chrono::{TimeZone, Utc};
1029 use tempfile::TempDir;
1030
1031 use super::{default_output_path, run_describe_impl, run_impl, run_list_impl};
1032 use crate::cli::{FormatArgs, ProjectDescribeArgs, ProjectDumpArgs, ProjectListArgs};
1033 use crate::client::DspClient;
1034 use crate::config::auth_cache::ServerEntry;
1035 use crate::config::{AuthCache, Config};
1036 use crate::diagnostic::Diagnostic;
1037 use crate::model::{
1038 CreateDumpOutcome, DataModelSummary, DumpStatus, DumpTask, Project, ProjectDescription,
1039 ProjectDetail, ProjectRef, ProjectStatus,
1040 };
1041 use crate::render::auth::{
1042 AuthLoginOutcome, AuthLogoutOutcome, AuthSetTokenOutcome, AuthStatusOutcome,
1043 };
1044 use crate::render::progress::ProgressReporter;
1045 use crate::render::{
1046 DumpDeleteOutcome, DumpEvent, DumpOutcome, Format, MetaContext, ProjectListView, Renderer,
1047 };
1048
1049 #[derive(Debug, Clone, PartialEq)]
1059 enum CallRecord {
1060 Resolve,
1061 Create {
1065 project_iri: String,
1066 },
1067 Poll,
1069 Download,
1070 Delete(String),
1072 Status(String),
1074 ListProjects {
1076 token: Option<String>,
1077 },
1078 }
1079
1080 struct MockDspClient {
1088 resolve_result: Option<Result<ProjectRef, Diagnostic>>,
1089 resolve_calls: RefCell<u32>,
1090
1091 create_result: Option<Result<CreateDumpOutcome, Diagnostic>>,
1093 create_sequence: RefCell<VecDeque<Result<CreateDumpOutcome, Diagnostic>>>,
1096 create_calls: RefCell<u32>,
1097 create_skip_assets: Cell<Option<bool>>,
1100
1101 poll_sequence: RefCell<VecDeque<Result<DumpTask, Diagnostic>>>,
1104 poll_calls: RefCell<u32>,
1105
1106 status_sequence: RefCell<VecDeque<Result<DumpTask, Diagnostic>>>,
1109
1110 download_bytes: Option<Vec<u8>>,
1111 download_error: Option<Diagnostic>,
1112 download_calls: RefCell<u32>,
1113
1114 delete_result: Option<Result<(), Diagnostic>>,
1115 delete_calls: RefCell<u32>,
1116
1117 list_projects_result: Option<Result<Vec<Project>, Diagnostic>>,
1119 list_projects_calls: RefCell<u32>,
1120 list_projects_token: RefCell<Option<Option<String>>>,
1124
1125 describe_project_result: Option<Result<ProjectDetail, Diagnostic>>,
1127 describe_project_call: RefCell<Option<(String, Option<String>)>>,
1130
1131 call_log: RefCell<Vec<CallRecord>>,
1133 }
1134
1135 impl MockDspClient {
1136 fn new() -> Self {
1137 Self {
1138 resolve_result: None,
1139 resolve_calls: RefCell::new(0),
1140 create_result: None,
1141 create_sequence: RefCell::new(VecDeque::new()),
1142 create_calls: RefCell::new(0),
1143 create_skip_assets: Cell::new(None),
1144 poll_sequence: RefCell::new(VecDeque::new()),
1145 poll_calls: RefCell::new(0),
1146 status_sequence: RefCell::new(VecDeque::new()),
1147 download_bytes: None,
1148 download_error: None,
1149 download_calls: RefCell::new(0),
1150 delete_result: None,
1151 delete_calls: RefCell::new(0),
1152 list_projects_result: None,
1153 list_projects_calls: RefCell::new(0),
1154 list_projects_token: RefCell::new(None),
1155 describe_project_result: None,
1156 describe_project_call: RefCell::new(None),
1157 call_log: RefCell::new(Vec::new()),
1158 }
1159 }
1160
1161 fn with_resolve_project(mut self, result: Result<ProjectRef, Diagnostic>) -> Self {
1162 self.resolve_result = Some(result);
1163 self
1164 }
1165
1166 fn with_create_dump(mut self, result: Result<CreateDumpOutcome, Diagnostic>) -> Self {
1167 self.create_result = Some(result);
1168 self
1169 }
1170
1171 fn with_create_exists(mut self, id: impl Into<String>) -> Self {
1172 self.create_result = Some(Ok(CreateDumpOutcome::Exists { id: id.into() }));
1173 self
1174 }
1175
1176 fn with_create_exists_other_project(
1177 mut self,
1178 id: impl Into<String>,
1179 project_iri: impl Into<String>,
1180 ) -> Self {
1181 self.create_result = Some(Ok(CreateDumpOutcome::ExistsForOtherProject {
1182 id: id.into(),
1183 project_iri: project_iri.into(),
1184 }));
1185 self
1186 }
1187
1188 fn with_create_sequence(
1191 mut self,
1192 seq: impl IntoIterator<Item = Result<CreateDumpOutcome, Diagnostic>>,
1193 ) -> Self {
1194 self.create_sequence = RefCell::new(seq.into_iter().collect());
1195 self
1196 }
1197
1198 fn with_poll_sequence(
1199 mut self,
1200 seq: impl IntoIterator<Item = Result<DumpTask, Diagnostic>>,
1201 ) -> Self {
1202 self.poll_sequence = RefCell::new(seq.into_iter().collect());
1203 self
1204 }
1205
1206 fn with_status_sequence(
1209 mut self,
1210 seq: impl IntoIterator<Item = Result<DumpTask, Diagnostic>>,
1211 ) -> Self {
1212 self.status_sequence = RefCell::new(seq.into_iter().collect());
1213 self
1214 }
1215
1216 fn with_download_bytes(mut self, bytes: Vec<u8>) -> Self {
1217 self.download_bytes = Some(bytes);
1218 self
1219 }
1220
1221 fn with_download_error(mut self, err: Diagnostic) -> Self {
1222 self.download_error = Some(err);
1223 self
1224 }
1225
1226 fn with_delete_result(mut self, result: Result<(), Diagnostic>) -> Self {
1227 self.delete_result = Some(result);
1228 self
1229 }
1230
1231 fn with_list_projects_result(mut self, result: Result<Vec<Project>, Diagnostic>) -> Self {
1232 self.list_projects_result = Some(result);
1233 self
1234 }
1235
1236 fn with_describe_project_result(
1237 mut self,
1238 result: Result<ProjectDetail, Diagnostic>,
1239 ) -> Self {
1240 self.describe_project_result = Some(result);
1241 self
1242 }
1243
1244 fn call_log(&self) -> Vec<CallRecord> {
1245 self.call_log.borrow().clone()
1246 }
1247
1248 fn list_projects_token(&self) -> Option<String> {
1251 self.list_projects_token
1252 .borrow()
1253 .as_ref()
1254 .expect("list_projects was not called")
1255 .clone()
1256 }
1257
1258 fn describe_project_call(&self) -> (String, Option<String>) {
1261 self.describe_project_call
1262 .borrow()
1263 .clone()
1264 .expect("describe_project was not called")
1265 }
1266
1267 fn describe_project_was_called(&self) -> bool {
1269 self.describe_project_call.borrow().is_some()
1270 }
1271 }
1272
1273 impl DspClient for MockDspClient {
1274 fn login(
1275 &self,
1276 _server: &str,
1277 _user: &str,
1278 _password: &str,
1279 ) -> Result<crate::model::LoginResponse, Diagnostic> {
1280 unimplemented!("login not used in dump tests")
1281 }
1282
1283 fn resolve_project(&self, _server: &str, _project: &str) -> Result<ProjectRef, Diagnostic> {
1284 *self.resolve_calls.borrow_mut() += 1;
1285 self.call_log.borrow_mut().push(CallRecord::Resolve);
1286 self.resolve_result
1287 .clone()
1288 .expect("resolve_result must be set when resolve_project is called")
1289 }
1290
1291 fn create_project_dump(
1292 &self,
1293 _server: &str,
1294 project_iri: &str,
1295 skip_assets: bool,
1296 _token: &str,
1297 ) -> Result<CreateDumpOutcome, Diagnostic> {
1298 *self.create_calls.borrow_mut() += 1;
1299 self.call_log.borrow_mut().push(CallRecord::Create {
1300 project_iri: project_iri.to_string(),
1301 });
1302 self.create_skip_assets.set(Some(skip_assets));
1303 if *self.create_calls.borrow() > 1
1305 && let Some(result) = self.create_sequence.borrow_mut().pop_front()
1306 {
1307 return result;
1308 }
1309 self.create_result
1310 .clone()
1311 .expect("create_result must be set when create_project_dump is called")
1312 }
1313
1314 fn get_project_dump_status(
1315 &self,
1316 _server: &str,
1317 project_iri: &str,
1318 _dump_id: &str,
1319 _token: &str,
1320 ) -> Result<DumpTask, Diagnostic> {
1321 let from_status = self.status_sequence.borrow_mut().pop_front();
1326 if let Some(result) = from_status {
1327 self.call_log
1328 .borrow_mut()
1329 .push(CallRecord::Status(project_iri.to_string()));
1330 return result;
1331 }
1332 *self.poll_calls.borrow_mut() += 1;
1333 self.call_log.borrow_mut().push(CallRecord::Poll);
1334 self.poll_sequence
1335 .borrow_mut()
1336 .pop_front()
1337 .expect("poll_sequence exhausted — test bug: provide enough entries or let the logical clock fire first")
1338 }
1339
1340 fn download_project_dump(
1341 &self,
1342 _server: &str,
1343 _project_iri: &str,
1344 _dump_id: &str,
1345 _token: &str,
1346 dest: &mut dyn std::io::Write,
1347 ) -> Result<u64, Diagnostic> {
1348 *self.download_calls.borrow_mut() += 1;
1349 self.call_log.borrow_mut().push(CallRecord::Download);
1350 if let Some(ref e) = self.download_error {
1351 return Err(e.clone());
1352 }
1353 let bytes = self.download_bytes.as_deref().unwrap_or(&[]);
1354 dest.write_all(bytes)
1355 .map_err(|e| Diagnostic::Internal(format!("mock write error: {e}")))?;
1356 Ok(bytes.len() as u64)
1357 }
1358
1359 fn delete_project_dump(
1360 &self,
1361 _server: &str,
1362 project_iri: &str,
1363 _dump_id: &str,
1364 _token: &str,
1365 ) -> Result<(), Diagnostic> {
1366 *self.delete_calls.borrow_mut() += 1;
1367 self.call_log
1368 .borrow_mut()
1369 .push(CallRecord::Delete(project_iri.to_string()));
1370 self.delete_result
1371 .clone()
1372 .expect("delete_result must be set when delete_project_dump is called")
1373 }
1374
1375 fn list_projects(
1376 &self,
1377 _server: &str,
1378 token: Option<&str>,
1379 ) -> Result<Vec<crate::model::Project>, Diagnostic> {
1380 *self.list_projects_calls.borrow_mut() += 1;
1381 *self.list_projects_token.borrow_mut() = Some(token.map(str::to_owned));
1382 self.call_log.borrow_mut().push(CallRecord::ListProjects {
1383 token: token.map(str::to_owned),
1384 });
1385 match &self.list_projects_result {
1386 Some(r) => r.clone(),
1387 None => Err(Diagnostic::NotImplemented(
1388 "list_projects not configured in MockDspClient".into(),
1389 )),
1390 }
1391 }
1392
1393 fn describe_project(
1394 &self,
1395 _server: &str,
1396 project: &str,
1397 token: Option<&str>,
1398 ) -> Result<crate::model::ProjectDetail, Diagnostic> {
1399 *self.describe_project_call.borrow_mut() =
1400 Some((project.to_owned(), token.map(str::to_owned)));
1401 match &self.describe_project_result {
1402 Some(r) => r.clone(),
1403 None => Err(Diagnostic::NotImplemented(
1404 "describe_project not configured in MockDspClient".into(),
1405 )),
1406 }
1407 }
1408
1409 fn list_data_models(
1410 &self,
1411 _server: &str,
1412 _project_iri: &str,
1413 _token: Option<&str>,
1414 ) -> Result<Vec<crate::model::DataModel>, Diagnostic> {
1415 unimplemented!("list_data_models not used by project tests")
1416 }
1417
1418 fn describe_data_model(
1419 &self,
1420 _server: &str,
1421 _data_model_iri: &str,
1422 _token: Option<&str>,
1423 ) -> Result<crate::model::DataModelDetail, Diagnostic> {
1424 unimplemented!("describe_data_model not used in project tests")
1425 }
1426
1427 fn describe_resource_type(
1428 &self,
1429 _server: &str,
1430 _data_model_iri: &str,
1431 _resource_type: &str,
1432 _token: Option<&str>,
1433 ) -> Result<crate::model::ResourceTypeDetail, Diagnostic> {
1434 unimplemented!("describe_resource_type not used in project tests")
1435 }
1436
1437 fn data_model_structure(
1438 &self,
1439 _server: &str,
1440 _data_model_iri: &str,
1441 _token: Option<&str>,
1442 ) -> Result<crate::model::DataModelStructure, Diagnostic> {
1443 unimplemented!("data_model_structure not used in project tests")
1444 }
1445
1446 fn list_resources(
1447 &self,
1448 _server: &str,
1449 _project_iri: &str,
1450 _resource_type_iri: &str,
1451 _order_by: Option<&str>,
1452 _page: u32,
1453 _token: Option<&str>,
1454 ) -> Result<crate::model::ResourcePage, Diagnostic> {
1455 unimplemented!("list_resources not used in project tests")
1456 }
1457
1458 fn describe_resource(
1459 &self,
1460 _server: &str,
1461 _resource_iri: &str,
1462 _token: Option<&str>,
1463 _with_values: bool,
1464 ) -> Result<crate::model::ResourceDetail, Diagnostic> {
1465 unimplemented!("describe_resource not used in project tests")
1466 }
1467
1468 fn verify_token(&self, _server: &str, _token: &str) -> Result<(), Diagnostic> {
1469 unimplemented!("verify_token not used by dump tests")
1470 }
1471 }
1472
1473 struct RecordingRenderer {
1476 dump_outcome: Option<DumpOutcome>,
1477 dump_meta: Option<MetaContext>,
1478 dump_deleted_outcome: Option<DumpDeleteOutcome>,
1479 dump_deleted_meta: Option<MetaContext>,
1480 projects_view: Option<(Vec<Project>, usize, Option<String>)>,
1482 projects_meta: Option<MetaContext>,
1483 describe_detail: Option<ProjectDetail>,
1485 describe_meta: Option<MetaContext>,
1486 }
1487
1488 impl RecordingRenderer {
1489 fn new() -> Self {
1490 Self {
1491 dump_outcome: None,
1492 dump_meta: None,
1493 dump_deleted_outcome: None,
1494 dump_deleted_meta: None,
1495 projects_view: None,
1496 projects_meta: None,
1497 describe_detail: None,
1498 describe_meta: None,
1499 }
1500 }
1501 }
1502
1503 impl Renderer for RecordingRenderer {
1504 fn diagnostic(
1505 &mut self,
1506 _diag: &Diagnostic,
1507 _meta: &MetaContext,
1508 ) -> Result<(), Diagnostic> {
1509 Ok(())
1510 }
1511
1512 fn auth_login(
1513 &mut self,
1514 _outcome: &AuthLoginOutcome,
1515 _meta: &MetaContext,
1516 ) -> Result<(), Diagnostic> {
1517 Ok(())
1518 }
1519
1520 fn auth_status(
1521 &mut self,
1522 _outcome: &AuthStatusOutcome,
1523 _meta: &MetaContext,
1524 ) -> Result<(), Diagnostic> {
1525 Ok(())
1526 }
1527
1528 fn auth_logout(
1529 &mut self,
1530 _outcome: &AuthLogoutOutcome,
1531 _meta: &MetaContext,
1532 ) -> Result<(), Diagnostic> {
1533 Ok(())
1534 }
1535
1536 fn auth_set_token(
1537 &mut self,
1538 _outcome: &AuthSetTokenOutcome,
1539 _meta: &MetaContext,
1540 ) -> Result<(), Diagnostic> {
1541 Ok(())
1542 }
1543
1544 fn project_dump(
1545 &mut self,
1546 outcome: &DumpOutcome,
1547 meta: &MetaContext,
1548 ) -> Result<(), Diagnostic> {
1549 self.dump_outcome = Some(DumpOutcome {
1550 path: outcome.path.clone(),
1551 bytes: outcome.bytes,
1552 cleaned_up: outcome.cleaned_up,
1553 reused: outcome.reused,
1554 created_at: outcome.created_at,
1555 });
1556 self.dump_meta = Some(meta.clone());
1557 Ok(())
1558 }
1559
1560 fn project_dump_deleted(
1561 &mut self,
1562 outcome: &DumpDeleteOutcome,
1563 meta: &MetaContext,
1564 ) -> Result<(), Diagnostic> {
1565 self.dump_deleted_outcome = Some(DumpDeleteOutcome {
1566 deleted: outcome.deleted,
1567 note: outcome.note.clone(),
1568 });
1569 self.dump_deleted_meta = Some(meta.clone());
1570 Ok(())
1571 }
1572
1573 fn projects(
1574 &mut self,
1575 view: &ProjectListView,
1576 meta: &MetaContext,
1577 ) -> Result<(), Diagnostic> {
1578 self.projects_view = Some((view.items.clone(), view.total, view.filter.clone()));
1579 self.projects_meta = Some(meta.clone());
1580 Ok(())
1581 }
1582
1583 fn project_describe(
1584 &mut self,
1585 project: &ProjectDetail,
1586 meta: &MetaContext,
1587 ) -> Result<(), Diagnostic> {
1588 self.describe_detail = Some(project.clone());
1589 self.describe_meta = Some(meta.clone());
1590 Ok(())
1591 }
1592
1593 fn data_models(
1594 &mut self,
1595 _view: &crate::render::DataModelListView,
1596 _meta: &MetaContext,
1597 ) -> Result<(), Diagnostic> {
1598 Ok(())
1599 }
1600
1601 fn data_model_describe(
1602 &mut self,
1603 _detail: &crate::model::DataModelDetail,
1604 _meta: &MetaContext,
1605 ) -> Result<(), Diagnostic> {
1606 Ok(())
1607 }
1608
1609 fn resource_types(
1610 &mut self,
1611 _view: &crate::render::ResourceTypeListView,
1612 _meta: &MetaContext,
1613 ) -> Result<(), Diagnostic> {
1614 Ok(())
1615 }
1616
1617 fn resource_type_describe(
1618 &mut self,
1619 _detail: &crate::model::ResourceTypeDetail,
1620 _meta: &MetaContext,
1621 ) -> Result<(), Diagnostic> {
1622 unimplemented!("resource_type_describe not used in project action tests")
1623 }
1624
1625 fn data_model_structure(
1626 &mut self,
1627 _structure: &crate::model::DataModelStructure,
1628 _meta: &MetaContext,
1629 ) -> Result<(), Diagnostic> {
1630 unimplemented!("data_model_structure not used in project action tests")
1631 }
1632
1633 fn resources(
1634 &mut self,
1635 _view: &crate::render::ResourceListView,
1636 _meta: &MetaContext,
1637 ) -> Result<(), Diagnostic> {
1638 Ok(())
1639 }
1640
1641 fn resource_describe(
1642 &mut self,
1643 _detail: &crate::model::ResourceDetail,
1644 _meta: &MetaContext,
1645 ) -> Result<(), Diagnostic> {
1646 Ok(())
1647 }
1648 }
1649
1650 struct RecordingProgressReporter {
1653 events: Vec<EventRecord>,
1654 }
1655
1656 #[derive(Debug, PartialEq)]
1657 enum EventRecord {
1658 Triggered(String),
1659 Polling(u64),
1660 Downloading,
1661 Done(u64),
1662 Adopting(String),
1663 Deleting(String),
1664 ProbeCreated(String),
1665 DiscardingOtherProjectDump { id: String, project_iri: String },
1666 }
1667
1668 impl RecordingProgressReporter {
1669 fn new() -> Self {
1670 Self { events: Vec::new() }
1671 }
1672 }
1673
1674 impl ProgressReporter for RecordingProgressReporter {
1675 fn report(&mut self, event: &DumpEvent) -> Result<(), Diagnostic> {
1676 match event {
1677 DumpEvent::Triggered { id } => self.events.push(EventRecord::Triggered(id.clone())),
1678 DumpEvent::Polling { elapsed_secs, .. } => {
1679 self.events.push(EventRecord::Polling(*elapsed_secs))
1680 }
1681 DumpEvent::Downloading => self.events.push(EventRecord::Downloading),
1682 DumpEvent::Done { bytes } => self.events.push(EventRecord::Done(*bytes)),
1683 DumpEvent::Adopting { id } => self.events.push(EventRecord::Adopting(id.clone())),
1684 DumpEvent::Deleting { id } => self.events.push(EventRecord::Deleting(id.clone())),
1685 DumpEvent::ProbeCreated { id } => {
1686 self.events.push(EventRecord::ProbeCreated(id.clone()))
1687 }
1688 DumpEvent::DiscardingOtherProjectDump { id, project_iri } => {
1689 self.events.push(EventRecord::DiscardingOtherProjectDump {
1690 id: id.clone(),
1691 project_iri: project_iri.clone(),
1692 })
1693 }
1694 }
1695 Ok(())
1696 }
1697 }
1698
1699 fn fixed_now() -> chrono::DateTime<Utc> {
1702 Utc.with_ymd_and_hms(2026, 5, 29, 12, 0, 0).unwrap()
1703 }
1704
1705 fn make_project_ref() -> ProjectRef {
1706 ProjectRef {
1707 iri: "http://rdfh.ch/projects/0001".to_string(),
1708 shortcode: "0001".to_string(),
1709 shortname: "anything".to_string(),
1710 }
1711 }
1712
1713 fn make_dump_task(status: DumpStatus) -> DumpTask {
1714 DumpTask {
1715 id: "dump-id-42".to_string(),
1716 status,
1717 error_message: None,
1718 created_at: None,
1719 }
1720 }
1721
1722 fn created_task(status: DumpStatus) -> CreateDumpOutcome {
1723 CreateDumpOutcome::Created(make_dump_task(status))
1724 }
1725
1726 fn make_args(dir: &TempDir) -> (ProjectDumpArgs, Config) {
1727 let args = ProjectDumpArgs {
1728 server: Some("https://api.test.dasch.swiss".to_string()),
1729 project: Some("0001".to_string()),
1730 skip_assets: false,
1731 output: Some(dir.path().join("out.zip")),
1732 force: false,
1733 cleanup: false,
1734 timeout: 3600,
1735 replace: false,
1736 delete: false,
1737 discard_other_project: false,
1738 format: FormatArgs {
1739 format: Format::Prose,
1740 json: false,
1741 lines: false,
1742 columns: None,
1743 no_header: false,
1744 header_only: false,
1745 },
1746 };
1747 let cfg = Config {
1748 server: "https://api.test.dasch.swiss".to_string(),
1749 };
1750 (args, cfg)
1751 }
1752
1753 fn no_op_sleeper() -> impl Fn(Duration) {
1754 |_| {}
1755 }
1756
1757 #[test]
1760 fn happy_path_resolve_trigger_poll_download() {
1761 let dir = TempDir::new().unwrap();
1762 let (args, cfg) = make_args(&dir);
1763
1764 let client = MockDspClient::new()
1765 .with_resolve_project(Ok(make_project_ref()))
1766 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
1767 .with_poll_sequence([
1768 Ok(make_dump_task(DumpStatus::InProgress)),
1769 Ok(make_dump_task(DumpStatus::Completed)),
1770 ])
1771 .with_download_bytes(b"PK fake zip content".to_vec());
1772
1773 let mut renderer = RecordingRenderer::new();
1774 let mut reporter = RecordingProgressReporter::new();
1775
1776 run_impl(
1777 &args,
1778 &cfg,
1779 &client,
1780 &mut renderer,
1781 &mut reporter,
1782 Some("env-token-abc".to_string()),
1783 &no_op_sleeper(),
1784 fixed_now(),
1785 None,
1786 dir.path(),
1787 )
1788 .unwrap();
1789
1790 let outcome = renderer.dump_outcome.unwrap();
1791 assert_eq!(outcome.bytes, 19); assert!(!outcome.cleaned_up);
1793
1794 assert!(
1796 outcome.path.exists(),
1797 "output file must exist after happy-path download"
1798 );
1799 assert!(
1800 outcome.path.metadata().unwrap().len() > 0,
1801 "output file must be non-empty after happy-path download"
1802 );
1803
1804 assert_eq!(
1806 reporter.events[0],
1807 EventRecord::Triggered("dump-id-42".to_string())
1808 );
1809 assert_eq!(reporter.events[1], EventRecord::Polling(0));
1810 assert_eq!(reporter.events[2], EventRecord::Downloading);
1811 assert_eq!(reporter.events[3], EventRecord::Done(19));
1812 assert_eq!(reporter.events.len(), 4);
1813 }
1814
1815 #[test]
1816 fn default_filename_uses_shortcode_and_fixed_timestamp() {
1817 let dir = TempDir::new().unwrap();
1818 let cache_path = dir.path().join("auth.toml");
1819 let args = ProjectDumpArgs {
1823 server: Some("https://api.test.dasch.swiss".to_string()),
1824 project: Some("0001".to_string()),
1825 skip_assets: false,
1826 output: None,
1827 force: true, cleanup: false,
1829 timeout: 3600,
1830 replace: false,
1831 delete: false,
1832 discard_other_project: false,
1833 format: FormatArgs {
1834 format: Format::Prose,
1835 json: false,
1836 lines: false,
1837 columns: None,
1838 no_header: false,
1839 header_only: false,
1840 },
1841 };
1842 let cfg = Config {
1843 server: "https://api.test.dasch.swiss".to_string(),
1844 };
1845
1846 let client = MockDspClient::new()
1847 .with_resolve_project(Ok(make_project_ref()))
1848 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
1849 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
1850 .with_download_bytes(b"zip".to_vec());
1851
1852 let mut renderer = RecordingRenderer::new();
1853 let mut reporter = RecordingProgressReporter::new();
1854
1855 run_impl(
1856 &args,
1857 &cfg,
1858 &client,
1859 &mut renderer,
1860 &mut reporter,
1861 Some("tok".to_string()),
1862 &no_op_sleeper(),
1863 fixed_now(),
1864 Some(&cache_path),
1865 dir.path(),
1866 )
1867 .unwrap();
1868
1869 let outcome = renderer.dump_outcome.unwrap();
1870 let expected_path = dir.path().join("0001-20260529T120000Z.zip");
1872 assert_eq!(outcome.path, expected_path);
1873 }
1874
1875 #[test]
1876 fn explicit_output_override_respected() {
1877 let dir = TempDir::new().unwrap();
1878 let out_path = dir.path().join("custom.zip");
1879 let (mut args, cfg) = make_args(&dir);
1880 args.output = Some(out_path.clone());
1881
1882 let client = MockDspClient::new()
1883 .with_resolve_project(Ok(make_project_ref()))
1884 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
1885 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
1886 .with_download_bytes(b"data".to_vec());
1887
1888 let mut renderer = RecordingRenderer::new();
1889 let mut reporter = RecordingProgressReporter::new();
1890
1891 run_impl(
1892 &args,
1893 &cfg,
1894 &client,
1895 &mut renderer,
1896 &mut reporter,
1897 Some("tok".to_string()),
1898 &no_op_sleeper(),
1899 fixed_now(),
1900 None,
1901 dir.path(),
1902 )
1903 .unwrap();
1904
1905 let outcome = renderer.dump_outcome.unwrap();
1906 assert_eq!(outcome.path, out_path);
1907 assert!(out_path.exists());
1908 }
1909
1910 #[test]
1911 fn explicit_output_exists_no_force_returns_usage_before_any_client_call() {
1912 let dir = TempDir::new().unwrap();
1913 let out_path = dir.path().join("existing.zip");
1914 std::fs::write(&out_path, b"existing").unwrap();
1915
1916 let (mut args, cfg) = make_args(&dir);
1917 args.output = Some(out_path.clone());
1918 args.force = false;
1919
1920 let client = MockDspClient::new();
1922
1923 let mut renderer = RecordingRenderer::new();
1924 let mut reporter = RecordingProgressReporter::new();
1925
1926 let err = run_impl(
1927 &args,
1928 &cfg,
1929 &client,
1930 &mut renderer,
1931 &mut reporter,
1932 Some("tok".to_string()),
1933 &no_op_sleeper(),
1934 fixed_now(),
1935 None,
1936 dir.path(),
1937 )
1938 .unwrap_err();
1939
1940 assert!(
1941 matches!(err, Diagnostic::Usage(_)),
1942 "expected Usage, got {err:?}"
1943 );
1944 assert!(
1945 err.to_string().contains("refusing to overwrite"),
1946 "message should mention overwrite refusal: {err}"
1947 );
1948 assert_eq!(
1950 *client.resolve_calls.borrow(),
1951 0,
1952 "resolve_project must not be called"
1953 );
1954 assert_eq!(
1955 *client.create_calls.borrow(),
1956 0,
1957 "create must not be called"
1958 );
1959 }
1960
1961 #[test]
1962 fn default_output_exists_no_force_returns_usage_after_resolve_before_trigger() {
1963 let dir = TempDir::new().unwrap();
1964 let cache_path = dir.path().join("auth.toml");
1965
1966 let args = ProjectDumpArgs {
1967 server: Some("https://api.test.dasch.swiss".to_string()),
1968 project: Some("0001".to_string()),
1969 skip_assets: false,
1970 output: None,
1971 force: false,
1972 cleanup: false,
1973 timeout: 3600,
1974 replace: false,
1975 delete: false,
1976 discard_other_project: false,
1977 format: FormatArgs {
1978 format: Format::Prose,
1979 json: false,
1980 lines: false,
1981 columns: None,
1982 no_header: false,
1983 header_only: false,
1984 },
1985 };
1986 let cfg = Config {
1987 server: "https://api.test.dasch.swiss".to_string(),
1988 };
1989
1990 let client = MockDspClient::new().with_resolve_project(Ok(make_project_ref()));
1991 let mut renderer = RecordingRenderer::new();
1992 let mut reporter = RecordingProgressReporter::new();
1993
1994 let default_path_in_tempdir = dir.path().join("0001-20260529T120000Z.zip");
1997 std::fs::write(&default_path_in_tempdir, b"existing")
1998 .expect("must be able to write conflict file into tempdir");
1999
2000 let err = run_impl(
2001 &args,
2002 &cfg,
2003 &client,
2004 &mut renderer,
2005 &mut reporter,
2006 Some("tok".to_string()),
2007 &no_op_sleeper(),
2008 fixed_now(),
2009 Some(&cache_path),
2010 dir.path(),
2011 )
2012 .unwrap_err();
2013
2014 assert!(
2016 matches!(err, Diagnostic::Usage(_)),
2017 "expected Usage, got {err:?}"
2018 );
2019 assert!(
2020 err.to_string().contains("refusing to overwrite"),
2021 "message should mention overwrite refusal: {err}"
2022 );
2023 assert_eq!(
2024 *client.resolve_calls.borrow(),
2025 1,
2026 "resolve must have been called (default-path guard runs after resolve)"
2027 );
2028 assert_eq!(
2029 *client.create_calls.borrow(),
2030 0,
2031 "trigger must NOT have been called (guard fires before trigger)"
2032 );
2033 }
2034
2035 #[test]
2036 fn missing_token_returns_auth_required_with_no_client_calls() {
2037 let dir = TempDir::new().unwrap();
2038 let cache_path = dir.path().join("auth.toml");
2039 let (args, cfg) = make_args(&dir);
2040
2041 let client = MockDspClient::new();
2042 let mut renderer = RecordingRenderer::new();
2043 let mut reporter = RecordingProgressReporter::new();
2044
2045 let err = run_impl(
2047 &args,
2048 &cfg,
2049 &client,
2050 &mut renderer,
2051 &mut reporter,
2052 None, &no_op_sleeper(),
2054 fixed_now(),
2055 Some(&cache_path), dir.path(),
2057 )
2058 .unwrap_err();
2059
2060 assert!(
2061 matches!(err, Diagnostic::AuthRequired(_)),
2062 "expected AuthRequired, got {err:?}"
2063 );
2064 assert_eq!(
2065 *client.resolve_calls.borrow(),
2066 0,
2067 "resolve must not be called"
2068 );
2069 assert_eq!(
2070 *client.create_calls.borrow(),
2071 0,
2072 "trigger must not be called"
2073 );
2074 }
2075
2076 #[test]
2077 fn trigger_conflict_propagates() {
2078 let dir = TempDir::new().unwrap();
2079 let (args, cfg) = make_args(&dir);
2080
2081 let client = MockDspClient::new()
2082 .with_resolve_project(Ok(make_project_ref()))
2083 .with_create_dump(Err(Diagnostic::Conflict(
2084 "a dump for this project is already in progress or present".to_string(),
2085 )));
2086
2087 let mut renderer = RecordingRenderer::new();
2088 let mut reporter = RecordingProgressReporter::new();
2089
2090 let err = run_impl(
2091 &args,
2092 &cfg,
2093 &client,
2094 &mut renderer,
2095 &mut reporter,
2096 Some("tok".to_string()),
2097 &no_op_sleeper(),
2098 fixed_now(),
2099 None,
2100 dir.path(),
2101 )
2102 .unwrap_err();
2103
2104 assert!(
2105 matches!(err, Diagnostic::Conflict(_)),
2106 "expected Conflict, got {err:?}"
2107 );
2108 }
2109
2110 #[test]
2111 fn poll_failed_returns_server_error() {
2112 let dir = TempDir::new().unwrap();
2113 let (args, cfg) = make_args(&dir);
2114
2115 let failed_task = DumpTask {
2116 id: "dump-id-42".to_string(),
2117 status: DumpStatus::Failed,
2118 error_message: Some("out of disk space".to_string()),
2119 created_at: None,
2120 };
2121 let client = MockDspClient::new()
2122 .with_resolve_project(Ok(make_project_ref()))
2123 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2124 .with_poll_sequence([Ok(failed_task)]);
2125
2126 let mut renderer = RecordingRenderer::new();
2127 let mut reporter = RecordingProgressReporter::new();
2128
2129 let err = run_impl(
2130 &args,
2131 &cfg,
2132 &client,
2133 &mut renderer,
2134 &mut reporter,
2135 Some("tok".to_string()),
2136 &no_op_sleeper(),
2137 fixed_now(),
2138 None,
2139 dir.path(),
2140 )
2141 .unwrap_err();
2142
2143 assert!(
2144 matches!(err, Diagnostic::ServerError(_)),
2145 "expected ServerError, got {err:?}"
2146 );
2147 let msg = err.to_string();
2148 assert!(
2149 msg.contains("server-side dump failed"),
2150 "message should mention dump failure: {msg}"
2151 );
2152 assert!(
2153 msg.contains("out of disk space"),
2154 "message should include error_message: {msg}"
2155 );
2156 }
2157
2158 #[test]
2159 fn timeout_via_logical_clock_returns_server_error() {
2160 let dir = TempDir::new().unwrap();
2161 let (mut args, cfg) = make_args(&dir);
2162 args.timeout = 1;
2165
2166 let in_progress: Vec<Result<DumpTask, Diagnostic>> = (0..100)
2168 .map(|_| Ok(make_dump_task(DumpStatus::InProgress)))
2169 .collect();
2170
2171 let client = MockDspClient::new()
2172 .with_resolve_project(Ok(make_project_ref()))
2173 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2174 .with_poll_sequence(in_progress);
2175
2176 let mut renderer = RecordingRenderer::new();
2177 let mut reporter = RecordingProgressReporter::new();
2178
2179 let err = run_impl(
2180 &args,
2181 &cfg,
2182 &client,
2183 &mut renderer,
2184 &mut reporter,
2185 Some("tok".to_string()),
2186 &no_op_sleeper(),
2187 fixed_now(),
2188 None,
2189 dir.path(),
2190 )
2191 .unwrap_err();
2192
2193 assert!(
2194 matches!(err, Diagnostic::ServerError(_)),
2195 "expected ServerError timeout, got {err:?}"
2196 );
2197 let msg = err.to_string();
2198 assert!(
2199 msg.contains("did not complete"),
2200 "message should mention timeout: {msg}"
2201 );
2202 assert!(
2203 msg.contains("may still be running"),
2204 "message should contain user-visible hint 'may still be running': {msg}"
2205 );
2206 }
2207
2208 #[test]
2209 fn cleanup_success_sets_cleaned_up_true() {
2210 let dir = TempDir::new().unwrap();
2211 let (mut args, cfg) = make_args(&dir);
2212 args.cleanup = true;
2213
2214 let client = MockDspClient::new()
2215 .with_resolve_project(Ok(make_project_ref()))
2216 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2217 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2218 .with_download_bytes(b"zip".to_vec())
2219 .with_delete_result(Ok(()));
2220
2221 let mut renderer = RecordingRenderer::new();
2222 let mut reporter = RecordingProgressReporter::new();
2223
2224 run_impl(
2225 &args,
2226 &cfg,
2227 &client,
2228 &mut renderer,
2229 &mut reporter,
2230 Some("tok".to_string()),
2231 &no_op_sleeper(),
2232 fixed_now(),
2233 None,
2234 dir.path(),
2235 )
2236 .unwrap();
2237
2238 let outcome = renderer.dump_outcome.unwrap();
2239 assert!(
2240 outcome.cleaned_up,
2241 "cleanup success should set cleaned_up=true"
2242 );
2243 }
2244
2245 #[test]
2246 fn cleanup_error_keeps_exit_ok_and_cleaned_up_false() {
2247 let dir = TempDir::new().unwrap();
2248 let (mut args, cfg) = make_args(&dir);
2249 args.cleanup = true;
2250
2251 let client = MockDspClient::new()
2252 .with_resolve_project(Ok(make_project_ref()))
2253 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2254 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2255 .with_download_bytes(b"zip".to_vec())
2256 .with_delete_result(Err(Diagnostic::ServerError("delete failed".to_string())));
2257
2258 let mut renderer = RecordingRenderer::new();
2259 let mut reporter = RecordingProgressReporter::new();
2260
2261 run_impl(
2263 &args,
2264 &cfg,
2265 &client,
2266 &mut renderer,
2267 &mut reporter,
2268 Some("tok".to_string()),
2269 &no_op_sleeper(),
2270 fixed_now(),
2271 None,
2272 dir.path(),
2273 )
2274 .unwrap();
2275
2276 let outcome = renderer.dump_outcome.unwrap();
2277 assert!(
2278 !outcome.cleaned_up,
2279 "cleanup error should set cleaned_up=false"
2280 );
2281 }
2282
2283 #[test]
2284 fn download_error_leaves_no_file_at_target_path() {
2285 let dir = TempDir::new().unwrap();
2286 let out_path = dir.path().join("should-not-exist.zip");
2287 let (mut args, cfg) = make_args(&dir);
2288 args.output = Some(out_path.clone());
2289
2290 let client = MockDspClient::new()
2291 .with_resolve_project(Ok(make_project_ref()))
2292 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2293 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2294 .with_download_error(Diagnostic::Network("connection reset".to_string()));
2295
2296 let mut renderer = RecordingRenderer::new();
2297 let mut reporter = RecordingProgressReporter::new();
2298
2299 let err = run_impl(
2300 &args,
2301 &cfg,
2302 &client,
2303 &mut renderer,
2304 &mut reporter,
2305 Some("tok".to_string()),
2306 &no_op_sleeper(),
2307 fixed_now(),
2308 None,
2309 dir.path(),
2310 )
2311 .unwrap_err();
2312
2313 assert!(
2314 matches!(err, Diagnostic::Network(_)),
2315 "expected Network error, got {err:?}"
2316 );
2317 assert!(
2318 !out_path.exists(),
2319 "target file must not exist after download error"
2320 );
2321 }
2322
2323 #[test]
2324 fn default_output_path_pure_fn() {
2325 let now = Utc.with_ymd_and_hms(2026, 5, 29, 12, 0, 0).unwrap();
2326 let base = std::path::Path::new("/tmp/test-base");
2327 let path = default_output_path(base, "0001", now);
2328 assert_eq!(
2329 path,
2330 PathBuf::from("/tmp/test-base/0001-20260529T120000Z.zip")
2331 );
2332 }
2333
2334 #[test]
2335 fn auth_state_env_token_reports_authenticated_via_dsp_token() {
2336 let dir = TempDir::new().unwrap();
2337 let (args, cfg) = make_args(&dir);
2338
2339 let client = MockDspClient::new()
2340 .with_resolve_project(Ok(make_project_ref()))
2341 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2342 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2343 .with_download_bytes(b"zip".to_vec());
2344
2345 let mut renderer = RecordingRenderer::new();
2346 let mut reporter = RecordingProgressReporter::new();
2347
2348 run_impl(
2349 &args,
2350 &cfg,
2351 &client,
2352 &mut renderer,
2353 &mut reporter,
2354 Some("env-token".to_string()),
2355 &no_op_sleeper(),
2356 fixed_now(),
2357 None,
2358 dir.path(),
2359 )
2360 .unwrap();
2361
2362 let meta = renderer.dump_meta.unwrap();
2363 assert_eq!(meta.auth_state, "authenticated via DSP_TOKEN");
2364 }
2365
2366 #[test]
2367 fn auth_state_cache_token_reports_authenticated() {
2368 use crate::config::AuthCache;
2369 use crate::config::auth_cache::ServerEntry;
2370
2371 let dir = TempDir::new().unwrap();
2372 let cache_path = dir.path().join("auth.toml");
2373 let out_path = dir.path().join("out.zip");
2374
2375 let mut cache = AuthCache::default();
2377 cache.set_entry(
2378 "https://api.test.dasch.swiss",
2379 ServerEntry {
2380 token: "cache-tok".to_string(),
2381 user: None,
2382 acquired_at: None,
2383 expires_at: None,
2384 },
2385 );
2386 cache.save_to(&cache_path).unwrap();
2387
2388 let args = ProjectDumpArgs {
2389 server: Some("https://api.test.dasch.swiss".to_string()),
2390 project: Some("0001".to_string()),
2391 skip_assets: false,
2392 output: Some(out_path),
2393 force: false,
2394 cleanup: false,
2395 timeout: 3600,
2396 replace: false,
2397 delete: false,
2398 discard_other_project: false,
2399 format: FormatArgs {
2400 format: Format::Prose,
2401 json: false,
2402 lines: false,
2403 columns: None,
2404 no_header: false,
2405 header_only: false,
2406 },
2407 };
2408 let cfg = Config {
2409 server: "https://api.test.dasch.swiss".to_string(),
2410 };
2411
2412 let client = MockDspClient::new()
2413 .with_resolve_project(Ok(make_project_ref()))
2414 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2415 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2416 .with_download_bytes(b"zip".to_vec());
2417
2418 let mut renderer = RecordingRenderer::new();
2419 let mut reporter = RecordingProgressReporter::new();
2420
2421 run_impl(
2422 &args,
2423 &cfg,
2424 &client,
2425 &mut renderer,
2426 &mut reporter,
2427 None, &no_op_sleeper(),
2429 fixed_now(),
2430 Some(&cache_path),
2431 dir.path(),
2432 )
2433 .unwrap();
2434
2435 let meta = renderer.dump_meta.unwrap();
2437 assert_eq!(meta.auth_state, "authenticated");
2438 }
2439
2440 #[test]
2441 fn rename_failure_returns_io_and_temp_cleaned_up() {
2442 let dir = TempDir::new().unwrap();
2446 let final_path = dir.path().join("dump_dir");
2448 std::fs::create_dir(&final_path).unwrap();
2449
2450 let (mut args, cfg) = make_args(&dir);
2451 args.output = Some(final_path.clone());
2452 args.force = true; let client = MockDspClient::new()
2455 .with_resolve_project(Ok(make_project_ref()))
2456 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2457 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2458 .with_download_bytes(b"zip".to_vec());
2459
2460 let mut renderer = RecordingRenderer::new();
2461 let mut reporter = RecordingProgressReporter::new();
2462
2463 let err = run_impl(
2464 &args,
2465 &cfg,
2466 &client,
2467 &mut renderer,
2468 &mut reporter,
2469 Some("tok".to_string()),
2470 &no_op_sleeper(),
2471 fixed_now(),
2472 None,
2473 dir.path(),
2474 )
2475 .unwrap_err();
2476
2477 assert!(
2479 matches!(err, Diagnostic::Io(_)),
2480 "expected Io error for rename failure, got {err:?}"
2481 );
2482
2483 let pid = std::process::id();
2485 let temp = dir.path().join(format!("dump_dir.{pid}.partial"));
2486 assert!(
2487 !temp.exists(),
2488 "temp file should be cleaned up after rename failure"
2489 );
2490 }
2491
2492 fn assert_download_error_leaves_no_file(download_error: Diagnostic) {
2498 let dir = TempDir::new().unwrap();
2499 let out_path = dir.path().join("should-not-exist.zip");
2500 let (mut args, cfg) = make_args(&dir);
2501 args.output = Some(out_path.clone());
2502
2503 let client = MockDspClient::new()
2504 .with_resolve_project(Ok(make_project_ref()))
2505 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2506 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2507 .with_download_error(download_error);
2508
2509 let mut renderer = RecordingRenderer::new();
2510 let mut reporter = RecordingProgressReporter::new();
2511
2512 let err = run_impl(
2513 &args,
2514 &cfg,
2515 &client,
2516 &mut renderer,
2517 &mut reporter,
2518 Some("tok".to_string()),
2519 &no_op_sleeper(),
2520 fixed_now(),
2521 None,
2522 dir.path(),
2523 )
2524 .unwrap_err();
2525
2526 assert!(
2528 !err.to_string().is_empty(),
2529 "error message must be non-empty"
2530 );
2531 assert!(
2532 !out_path.exists(),
2533 "target file must not exist after download error ({err:?})"
2534 );
2535 }
2536
2537 #[test]
2538 fn download_io_error_leaves_no_file_at_target_path() {
2539 assert_download_error_leaves_no_file(Diagnostic::Io(
2540 "failed to write /tmp/test.zip: no space left on device".to_string(),
2541 ));
2542 }
2543
2544 #[test]
2547 fn skip_assets_true_is_passed_through_to_create_project_dump() {
2548 let dir = TempDir::new().unwrap();
2549 let (mut args, cfg) = make_args(&dir);
2550 args.skip_assets = true;
2551
2552 let client = MockDspClient::new()
2553 .with_resolve_project(Ok(make_project_ref()))
2554 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2555 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2556 .with_download_bytes(b"zip".to_vec());
2557
2558 let mut renderer = RecordingRenderer::new();
2559 let mut reporter = RecordingProgressReporter::new();
2560
2561 run_impl(
2562 &args,
2563 &cfg,
2564 &client,
2565 &mut renderer,
2566 &mut reporter,
2567 Some("tok".to_string()),
2568 &no_op_sleeper(),
2569 fixed_now(),
2570 None,
2571 dir.path(),
2572 )
2573 .unwrap();
2574
2575 assert_eq!(
2576 client.create_skip_assets.get(),
2577 Some(true),
2578 "skip_assets=true must be forwarded to create_project_dump"
2579 );
2580 }
2581
2582 #[test]
2583 fn skip_assets_false_is_passed_through_to_create_project_dump() {
2584 let dir = TempDir::new().unwrap();
2585 let (mut args, cfg) = make_args(&dir);
2586 args.skip_assets = false;
2587
2588 let client = MockDspClient::new()
2589 .with_resolve_project(Ok(make_project_ref()))
2590 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2591 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2592 .with_download_bytes(b"zip".to_vec());
2593
2594 let mut renderer = RecordingRenderer::new();
2595 let mut reporter = RecordingProgressReporter::new();
2596
2597 run_impl(
2598 &args,
2599 &cfg,
2600 &client,
2601 &mut renderer,
2602 &mut reporter,
2603 Some("tok".to_string()),
2604 &no_op_sleeper(),
2605 fixed_now(),
2606 None,
2607 dir.path(),
2608 )
2609 .unwrap();
2610
2611 assert_eq!(
2612 client.create_skip_assets.get(),
2613 Some(false),
2614 "skip_assets=false must be forwarded to create_project_dump"
2615 );
2616 }
2617
2618 #[test]
2623 fn default_fresh_created_no_existing_dump() {
2624 use chrono::TimeZone;
2626 let dir = TempDir::new().unwrap();
2627 let (args, cfg) = make_args(&dir);
2628 let ts = Utc.with_ymd_and_hms(2026, 5, 20, 14, 3, 0).unwrap();
2629
2630 let client = MockDspClient::new()
2631 .with_resolve_project(Ok(make_project_ref()))
2632 .with_create_dump(Ok(CreateDumpOutcome::Created(DumpTask {
2633 id: "dump-id-42".into(),
2634 status: DumpStatus::InProgress,
2635 error_message: None,
2636 created_at: Some(ts),
2637 })))
2638 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2639 .with_download_bytes(b"zipdata".to_vec());
2640
2641 let mut renderer = RecordingRenderer::new();
2642 let mut reporter = RecordingProgressReporter::new();
2643
2644 run_impl(
2645 &args,
2646 &cfg,
2647 &client,
2648 &mut renderer,
2649 &mut reporter,
2650 Some("tok".to_string()),
2651 &no_op_sleeper(),
2652 fixed_now(),
2653 None,
2654 dir.path(),
2655 )
2656 .unwrap();
2657
2658 let outcome = renderer.dump_outcome.unwrap();
2659 assert!(!outcome.reused, "fresh dump must have reused:false");
2660 assert_eq!(
2661 outcome.created_at,
2662 Some(ts),
2663 "created_at must be populated from task"
2664 );
2665 assert_eq!(outcome.bytes, 7); let log = client.call_log();
2668 assert!(log.contains(&CallRecord::Resolve));
2669 assert!(log.contains(&CallRecord::Create {
2670 project_iri: "http://rdfh.ch/projects/0001".to_string(),
2671 }));
2672 assert!(log.contains(&CallRecord::Poll));
2673 assert!(log.contains(&CallRecord::Download));
2674 }
2675
2676 #[test]
2677 fn default_adopt_completed_existing_dump() {
2678 use chrono::TimeZone;
2680 let dir = TempDir::new().unwrap();
2681 let (args, cfg) = make_args(&dir);
2682 let ts = Utc.with_ymd_and_hms(2026, 5, 15, 10, 0, 0).unwrap();
2683
2684 let existing_task = DumpTask {
2685 id: "existing-id".into(),
2686 status: DumpStatus::Completed,
2687 error_message: None,
2688 created_at: Some(ts),
2689 };
2690
2691 let client = MockDspClient::new()
2692 .with_resolve_project(Ok(make_project_ref()))
2693 .with_create_exists("existing-id")
2694 .with_status_sequence([Ok(existing_task)])
2695 .with_download_bytes(b"existing".to_vec());
2696
2697 let mut renderer = RecordingRenderer::new();
2698 let mut reporter = RecordingProgressReporter::new();
2699
2700 run_impl(
2701 &args,
2702 &cfg,
2703 &client,
2704 &mut renderer,
2705 &mut reporter,
2706 Some("tok".to_string()),
2707 &no_op_sleeper(),
2708 fixed_now(),
2709 None,
2710 dir.path(),
2711 )
2712 .unwrap();
2713
2714 let outcome = renderer.dump_outcome.unwrap();
2715 assert!(outcome.reused, "adopted dump must have reused:true");
2716 assert_eq!(
2717 outcome.created_at,
2718 Some(ts),
2719 "created_at must come from status"
2720 );
2721 assert!(
2723 reporter
2724 .events
2725 .contains(&EventRecord::Adopting("existing-id".into())),
2726 "must report Adopting"
2727 );
2728 assert!(
2729 !reporter
2730 .events
2731 .iter()
2732 .any(|e| matches!(e, EventRecord::Triggered(_))),
2733 "must NOT report Triggered when adopting"
2734 );
2735 let log = client.call_log();
2737 assert_eq!(log[0], CallRecord::Resolve);
2738 assert_eq!(
2739 log[1],
2740 CallRecord::Create {
2741 project_iri: "http://rdfh.ch/projects/0001".to_string(),
2742 }
2743 );
2744 assert_eq!(
2745 log[2],
2746 CallRecord::Status("http://rdfh.ch/projects/0001".to_string())
2747 );
2748 assert_eq!(log[3], CallRecord::Download);
2749 }
2750
2751 #[test]
2752 fn default_adopt_in_progress_polls_then_downloads() {
2753 use chrono::TimeZone;
2755 let dir = TempDir::new().unwrap();
2756 let (args, cfg) = make_args(&dir);
2757 let ts = Utc.with_ymd_and_hms(2026, 5, 10, 8, 0, 0).unwrap();
2758
2759 let in_progress_task = DumpTask {
2760 id: "adopt-ip-id".into(),
2761 status: DumpStatus::InProgress,
2762 error_message: None,
2763 created_at: Some(ts),
2764 };
2765
2766 let client = MockDspClient::new()
2767 .with_resolve_project(Ok(make_project_ref()))
2768 .with_create_exists("adopt-ip-id")
2769 .with_status_sequence([Ok(in_progress_task)])
2772 .with_poll_sequence([
2773 Ok(make_dump_task(DumpStatus::InProgress)),
2774 Ok(make_dump_task(DumpStatus::Completed)),
2775 ])
2776 .with_download_bytes(b"data".to_vec());
2777
2778 let mut renderer = RecordingRenderer::new();
2779 let mut reporter = RecordingProgressReporter::new();
2780
2781 run_impl(
2782 &args,
2783 &cfg,
2784 &client,
2785 &mut renderer,
2786 &mut reporter,
2787 Some("tok".to_string()),
2788 &no_op_sleeper(),
2789 fixed_now(),
2790 None,
2791 dir.path(),
2792 )
2793 .unwrap();
2794
2795 let outcome = renderer.dump_outcome.unwrap();
2796 assert!(outcome.reused);
2797 assert_eq!(outcome.created_at, Some(ts));
2798 let log = client.call_log();
2800 assert_eq!(log[0], CallRecord::Resolve, "first call must be Resolve");
2801 assert_eq!(
2802 log[1],
2803 CallRecord::Create {
2804 project_iri: "http://rdfh.ch/projects/0001".to_string(),
2805 },
2806 "second call must be Create"
2807 );
2808 assert_eq!(
2809 log[2],
2810 CallRecord::Status("http://rdfh.ch/projects/0001".to_string()),
2811 "third call must be Status"
2812 );
2813 assert_eq!(
2814 log[3],
2815 CallRecord::Poll,
2816 "fourth call must be Poll (in_progress)"
2817 );
2818 assert_eq!(
2819 log[4],
2820 CallRecord::Poll,
2821 "fifth call must be Poll (completed)"
2822 );
2823 assert_eq!(log[5], CallRecord::Download, "sixth call must be Download");
2824 assert_eq!(log.len(), 6, "must be exactly 6 calls");
2825 let adopting_idx = reporter
2827 .events
2828 .iter()
2829 .position(|e| matches!(e, EventRecord::Adopting(_)))
2830 .expect("Adopting event must be present");
2831 let first_polling_idx = reporter
2832 .events
2833 .iter()
2834 .position(|e| matches!(e, EventRecord::Polling(_)))
2835 .expect("Polling event must be present");
2836 assert!(
2837 adopting_idx < first_polling_idx,
2838 "Adopting must be reported before the first Polling event"
2839 );
2840 }
2841
2842 #[test]
2843 fn default_existing_failed_returns_conflict_with_hint() {
2844 let dir = TempDir::new().unwrap();
2845 let (args, cfg) = make_args(&dir);
2846
2847 let failed_task = DumpTask {
2848 id: "fail-id".into(),
2849 status: DumpStatus::Failed,
2850 error_message: Some("disk full".into()),
2851 created_at: None,
2852 };
2853
2854 let client = MockDspClient::new()
2855 .with_resolve_project(Ok(make_project_ref()))
2856 .with_create_exists("fail-id")
2857 .with_status_sequence([Ok(failed_task)]);
2858
2859 let mut renderer = RecordingRenderer::new();
2860 let mut reporter = RecordingProgressReporter::new();
2861
2862 let err = run_impl(
2863 &args,
2864 &cfg,
2865 &client,
2866 &mut renderer,
2867 &mut reporter,
2868 Some("tok".to_string()),
2869 &no_op_sleeper(),
2870 fixed_now(),
2871 None,
2872 dir.path(),
2873 )
2874 .unwrap_err();
2875
2876 assert!(
2877 matches!(err, Diagnostic::Conflict(_)),
2878 "failed existing dump must yield Conflict"
2879 );
2880 let msg = err.to_string();
2881 assert!(
2882 msg.contains("existing dump failed"),
2883 "message must mention failure: {msg}"
2884 );
2885 assert!(
2886 msg.contains("disk full"),
2887 "message must include server error: {msg}"
2888 );
2889 assert!(
2890 msg.contains("--replace"),
2891 "message must hint at --replace: {msg}"
2892 );
2893 assert!(
2894 msg.contains("--delete"),
2895 "message must hint at --delete: {msg}"
2896 );
2897 }
2898
2899 #[test]
2902 fn replace_none_existing_creates_fresh() {
2903 let dir = TempDir::new().unwrap();
2905 let (mut args, cfg) = make_args(&dir);
2906 args.replace = true;
2907
2908 let client = MockDspClient::new()
2909 .with_resolve_project(Ok(make_project_ref()))
2910 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2911 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2912 .with_download_bytes(b"fresh".to_vec());
2913
2914 let mut renderer = RecordingRenderer::new();
2915 let mut reporter = RecordingProgressReporter::new();
2916
2917 run_impl(
2918 &args,
2919 &cfg,
2920 &client,
2921 &mut renderer,
2922 &mut reporter,
2923 Some("tok".to_string()),
2924 &no_op_sleeper(),
2925 fixed_now(),
2926 None,
2927 dir.path(),
2928 )
2929 .unwrap();
2930
2931 let outcome = renderer.dump_outcome.unwrap();
2932 assert!(!outcome.reused, "replace with no existing → reused:false");
2933 assert_eq!(*client.create_calls.borrow(), 1);
2935 }
2936
2937 #[test]
2938 fn replace_completed_deletes_then_recreates() {
2939 let dir = TempDir::new().unwrap();
2941 let (mut args, cfg) = make_args(&dir);
2942 args.replace = true;
2943
2944 let existing_task = DumpTask {
2945 id: "old-id".into(),
2946 status: DumpStatus::Completed,
2947 error_message: None,
2948 created_at: None,
2949 };
2950 let task2 = DumpTask {
2951 id: "new-id".into(),
2952 status: DumpStatus::InProgress,
2953 error_message: None,
2954 created_at: None,
2955 };
2956
2957 let client = MockDspClient::new()
2958 .with_resolve_project(Ok(make_project_ref()))
2959 .with_create_exists("old-id")
2960 .with_create_sequence([Ok(CreateDumpOutcome::Created(task2))])
2961 .with_status_sequence([Ok(existing_task)])
2962 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2963 .with_delete_result(Ok(()))
2964 .with_download_bytes(b"new".to_vec());
2965
2966 let mut renderer = RecordingRenderer::new();
2967 let mut reporter = RecordingProgressReporter::new();
2968
2969 run_impl(
2970 &args,
2971 &cfg,
2972 &client,
2973 &mut renderer,
2974 &mut reporter,
2975 Some("tok".to_string()),
2976 &no_op_sleeper(),
2977 fixed_now(),
2978 None,
2979 dir.path(),
2980 )
2981 .unwrap();
2982
2983 let outcome = renderer.dump_outcome.unwrap();
2984 assert!(!outcome.reused, "replace always produces reused:false");
2985 let log = client.call_log();
2987 assert_eq!(log[0], CallRecord::Resolve, "first call must be Resolve");
2988 assert_eq!(
2989 log[1],
2990 CallRecord::Create {
2991 project_iri: "http://rdfh.ch/projects/0001".to_string(),
2992 },
2993 "second call must be Create"
2994 );
2995 assert_eq!(
2996 log[2],
2997 CallRecord::Status("http://rdfh.ch/projects/0001".to_string()),
2998 "third call must be Status"
2999 );
3000 assert_eq!(
3001 log[3],
3002 CallRecord::Delete("http://rdfh.ch/projects/0001".to_string()),
3003 "fourth call must be Delete"
3004 );
3005 assert_eq!(
3006 log[4],
3007 CallRecord::Create {
3008 project_iri: "http://rdfh.ch/projects/0001".to_string(),
3009 },
3010 "fifth call must be Create (2nd)"
3011 );
3012 assert_eq!(log[5], CallRecord::Poll, "sixth call must be Poll");
3013 assert_eq!(
3014 log[6],
3015 CallRecord::Download,
3016 "seventh call must be Download"
3017 );
3018 assert_eq!(log.len(), 7, "must be exactly 7 calls");
3019 assert!(
3021 reporter
3022 .events
3023 .contains(&EventRecord::Deleting("old-id".into())),
3024 "must report Deleting for the old dump"
3025 );
3026 assert!(
3028 reporter
3029 .events
3030 .contains(&EventRecord::Triggered("new-id".into())),
3031 "must report Triggered for the new dump; events: {:?}",
3032 reporter.events
3033 );
3034 }
3035
3036 #[test]
3037 fn replace_failed_existing_deletes_then_recreates() {
3038 let dir = TempDir::new().unwrap();
3040 let (mut args, cfg) = make_args(&dir);
3041 args.replace = true;
3042
3043 let failed_task = DumpTask {
3044 id: "failed-old-id".into(),
3045 status: DumpStatus::Failed,
3046 error_message: Some("ran out of space".into()),
3047 created_at: None,
3048 };
3049 let task2 = DumpTask {
3050 id: "new-id-2".into(),
3051 status: DumpStatus::InProgress,
3052 error_message: None,
3053 created_at: None,
3054 };
3055
3056 let client = MockDspClient::new()
3057 .with_resolve_project(Ok(make_project_ref()))
3058 .with_create_exists("failed-old-id")
3059 .with_create_sequence([Ok(CreateDumpOutcome::Created(task2))])
3060 .with_status_sequence([Ok(failed_task)])
3061 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
3062 .with_delete_result(Ok(()))
3063 .with_download_bytes(b"new".to_vec());
3064
3065 let mut renderer = RecordingRenderer::new();
3066 let mut reporter = RecordingProgressReporter::new();
3067
3068 run_impl(
3069 &args,
3070 &cfg,
3071 &client,
3072 &mut renderer,
3073 &mut reporter,
3074 Some("tok".to_string()),
3075 &no_op_sleeper(),
3076 fixed_now(),
3077 None,
3078 dir.path(),
3079 )
3080 .unwrap();
3081
3082 let outcome = renderer.dump_outcome.unwrap();
3083 assert!(!outcome.reused);
3084 }
3085
3086 #[test]
3087 fn replace_recreate_race_returns_conflict() {
3088 let dir = TempDir::new().unwrap();
3090 let (mut args, cfg) = make_args(&dir);
3091 args.replace = true;
3092
3093 let existing_task = DumpTask {
3094 id: "race-id".into(),
3095 status: DumpStatus::Completed,
3096 error_message: None,
3097 created_at: None,
3098 };
3099
3100 let client = MockDspClient::new()
3101 .with_resolve_project(Ok(make_project_ref()))
3102 .with_create_exists("race-id")
3103 .with_create_sequence([Ok(CreateDumpOutcome::Exists {
3104 id: "race-id-2".into(),
3105 })])
3106 .with_status_sequence([Ok(existing_task)])
3107 .with_delete_result(Ok(()));
3108
3109 let mut renderer = RecordingRenderer::new();
3110 let mut reporter = RecordingProgressReporter::new();
3111
3112 let err = run_impl(
3113 &args,
3114 &cfg,
3115 &client,
3116 &mut renderer,
3117 &mut reporter,
3118 Some("tok".to_string()),
3119 &no_op_sleeper(),
3120 fixed_now(),
3121 None,
3122 dir.path(),
3123 )
3124 .unwrap_err();
3125
3126 assert!(
3127 matches!(err, Diagnostic::Conflict(_)),
3128 "recreate race must yield Conflict"
3129 );
3130 let msg = err.to_string();
3131 assert!(
3132 msg.contains("recreated"),
3133 "message must mention recreation: {msg}"
3134 );
3135 }
3136
3137 #[test]
3138 fn replace_in_progress_returns_conflict() {
3139 let dir = TempDir::new().unwrap();
3141 let (mut args, cfg) = make_args(&dir);
3142 args.replace = true;
3143
3144 let in_progress_task = DumpTask {
3145 id: "ip-id".into(),
3146 status: DumpStatus::InProgress,
3147 error_message: None,
3148 created_at: None,
3149 };
3150
3151 let client = MockDspClient::new()
3152 .with_resolve_project(Ok(make_project_ref()))
3153 .with_create_exists("ip-id")
3154 .with_status_sequence([Ok(in_progress_task)]);
3155
3156 let mut renderer = RecordingRenderer::new();
3157 let mut reporter = RecordingProgressReporter::new();
3158
3159 let err = run_impl(
3160 &args,
3161 &cfg,
3162 &client,
3163 &mut renderer,
3164 &mut reporter,
3165 Some("tok".to_string()),
3166 &no_op_sleeper(),
3167 fixed_now(),
3168 None,
3169 dir.path(),
3170 )
3171 .unwrap_err();
3172
3173 assert!(
3174 matches!(err, Diagnostic::Conflict(_)),
3175 "in-progress existing dump must block replace"
3176 );
3177 let msg = err.to_string();
3178 assert!(
3179 msg.contains("in progress"),
3180 "message must mention in-progress state: {msg}"
3181 );
3182 assert_eq!(
3184 *client.delete_calls.borrow(),
3185 0,
3186 "delete must not be called when in-progress"
3187 );
3188 }
3189
3190 #[test]
3193 fn delete_completed_deletes_without_downloading() {
3194 let dir = TempDir::new().unwrap();
3196 let (mut args, cfg) = make_args(&dir);
3197 args.delete = true;
3198
3199 let completed_task = DumpTask {
3200 id: "del-id".into(),
3201 status: DumpStatus::Completed,
3202 error_message: None,
3203 created_at: None,
3204 };
3205
3206 let client = MockDspClient::new()
3207 .with_resolve_project(Ok(make_project_ref()))
3208 .with_create_exists("del-id")
3209 .with_status_sequence([Ok(completed_task)])
3210 .with_delete_result(Ok(()));
3211
3212 let mut renderer = RecordingRenderer::new();
3213 let mut reporter = RecordingProgressReporter::new();
3214
3215 run_impl(
3216 &args,
3217 &cfg,
3218 &client,
3219 &mut renderer,
3220 &mut reporter,
3221 Some("tok".to_string()),
3222 &no_op_sleeper(),
3223 fixed_now(),
3224 None,
3225 dir.path(),
3226 )
3227 .unwrap();
3228
3229 assert_eq!(
3231 *client.download_calls.borrow(),
3232 0,
3233 "delete must not download"
3234 );
3235 assert_eq!(*client.delete_calls.borrow(), 1);
3237 let del_outcome = renderer
3239 .dump_deleted_outcome
3240 .expect("project_dump_deleted must have been called");
3241 assert!(del_outcome.deleted);
3242 assert!(del_outcome.note.is_none());
3243 assert!(
3245 renderer.dump_outcome.is_none(),
3246 "project_dump must not be called in delete mode"
3247 );
3248 assert!(
3250 reporter
3251 .events
3252 .contains(&EventRecord::Deleting("del-id".into()))
3253 );
3254 let log = client.call_log();
3256 assert_eq!(log[0], CallRecord::Resolve);
3257 assert_eq!(
3258 log[1],
3259 CallRecord::Create {
3260 project_iri: "http://rdfh.ch/projects/0001".to_string(),
3261 }
3262 );
3263 assert_eq!(
3264 log[2],
3265 CallRecord::Status("http://rdfh.ch/projects/0001".to_string())
3266 );
3267 assert_eq!(
3268 log[3],
3269 CallRecord::Delete("http://rdfh.ch/projects/0001".to_string())
3270 );
3271 assert_eq!(log.len(), 4, "must be exactly 4 calls");
3272 }
3273
3274 #[test]
3275 fn delete_failed_deletes_without_downloading() {
3276 let dir = TempDir::new().unwrap();
3279 let (mut args, cfg) = make_args(&dir);
3280 args.delete = true;
3281
3282 let failed_task = DumpTask {
3283 id: "del-failed-id".into(),
3284 status: DumpStatus::Failed,
3285 error_message: Some("disk full".into()),
3286 created_at: None,
3287 };
3288
3289 let client = MockDspClient::new()
3290 .with_resolve_project(Ok(make_project_ref()))
3291 .with_create_exists("del-failed-id")
3292 .with_status_sequence([Ok(failed_task)])
3293 .with_delete_result(Ok(()));
3294
3295 let mut renderer = RecordingRenderer::new();
3296 let mut reporter = RecordingProgressReporter::new();
3297
3298 run_impl(
3299 &args,
3300 &cfg,
3301 &client,
3302 &mut renderer,
3303 &mut reporter,
3304 Some("tok".to_string()),
3305 &no_op_sleeper(),
3306 fixed_now(),
3307 None,
3308 dir.path(),
3309 )
3310 .unwrap();
3311
3312 assert_eq!(
3314 *client.download_calls.borrow(),
3315 0,
3316 "delete must not download even for a failed dump"
3317 );
3318 assert_eq!(
3320 *client.delete_calls.borrow(),
3321 1,
3322 "delete must be called for a failed dump"
3323 );
3324 let del_outcome = renderer
3326 .dump_deleted_outcome
3327 .expect("project_dump_deleted must have been called");
3328 assert!(del_outcome.deleted, "deleted must be true for failed dump");
3329 assert!(del_outcome.note.is_none());
3330 assert!(
3332 renderer.dump_outcome.is_none(),
3333 "project_dump must not be called in delete mode"
3334 );
3335 let log = client.call_log();
3337 assert_eq!(log[0], CallRecord::Resolve);
3338 assert_eq!(
3339 log[1],
3340 CallRecord::Create {
3341 project_iri: "http://rdfh.ch/projects/0001".to_string(),
3342 }
3343 );
3344 assert_eq!(
3345 log[2],
3346 CallRecord::Status("http://rdfh.ch/projects/0001".to_string())
3347 );
3348 assert_eq!(
3349 log[3],
3350 CallRecord::Delete("http://rdfh.ch/projects/0001".to_string())
3351 );
3352 assert_eq!(log.len(), 4, "must be exactly 4 calls");
3353 }
3354
3355 #[test]
3356 fn delete_in_progress_returns_conflict() {
3357 let dir = TempDir::new().unwrap();
3359 let (mut args, cfg) = make_args(&dir);
3360 args.delete = true;
3361
3362 let in_progress_task = DumpTask {
3363 id: "del-ip-id".into(),
3364 status: DumpStatus::InProgress,
3365 error_message: None,
3366 created_at: None,
3367 };
3368
3369 let client = MockDspClient::new()
3370 .with_resolve_project(Ok(make_project_ref()))
3371 .with_create_exists("del-ip-id")
3372 .with_status_sequence([Ok(in_progress_task)]);
3373
3374 let mut renderer = RecordingRenderer::new();
3375 let mut reporter = RecordingProgressReporter::new();
3376
3377 let err = run_impl(
3378 &args,
3379 &cfg,
3380 &client,
3381 &mut renderer,
3382 &mut reporter,
3383 Some("tok".to_string()),
3384 &no_op_sleeper(),
3385 fixed_now(),
3386 None,
3387 dir.path(),
3388 )
3389 .unwrap_err();
3390
3391 assert!(
3392 matches!(err, Diagnostic::Conflict(_)),
3393 "in-progress dump must block delete"
3394 );
3395 let msg = err.to_string();
3396 assert!(
3397 msg.contains("in progress"),
3398 "message must mention in-progress: {msg}"
3399 );
3400 assert_eq!(
3401 *client.delete_calls.borrow(),
3402 0,
3403 "delete must not be called"
3404 );
3405 }
3406
3407 #[test]
3408 fn delete_none_probe_created_reports_probe_and_exits_ok() {
3409 let dir = TempDir::new().unwrap();
3412 let (mut args, cfg) = make_args(&dir);
3413 args.delete = true;
3414
3415 let client = MockDspClient::new()
3416 .with_resolve_project(Ok(make_project_ref()))
3417 .with_create_dump(Ok(created_task(DumpStatus::InProgress)));
3418
3419 let mut renderer = RecordingRenderer::new();
3420 let mut reporter = RecordingProgressReporter::new();
3421
3422 run_impl(
3423 &args,
3424 &cfg,
3425 &client,
3426 &mut renderer,
3427 &mut reporter,
3428 Some("tok".to_string()),
3429 &no_op_sleeper(),
3430 fixed_now(),
3431 None,
3432 dir.path(),
3433 )
3434 .unwrap(); assert!(renderer.dump_outcome.is_none());
3438 let del_outcome = renderer
3440 .dump_deleted_outcome
3441 .expect("project_dump_deleted must be called");
3442 assert!(
3443 !del_outcome.deleted,
3444 "deleted must be false (probe, not real delete)"
3445 );
3446 let note = del_outcome.note.expect("note must be set for probe case");
3447 assert!(
3448 note.contains("dump-id-42"),
3449 "note must mention the probe id: {note}"
3450 );
3451 assert!(
3453 reporter
3454 .events
3455 .contains(&EventRecord::ProbeCreated("dump-id-42".into())),
3456 "must report ProbeCreated; events: {:?}",
3457 reporter.events
3458 );
3459 assert_eq!(*client.download_calls.borrow(), 0);
3461 assert_eq!(*client.delete_calls.borrow(), 0);
3462 }
3463
3464 fn foreign_iri() -> &'static str {
3468 "http://rdfh.ch/projects/0002"
3469 }
3470
3471 #[test]
3472 fn default_exists_for_other_project_returns_conflict_no_server_calls() {
3473 let dir = TempDir::new().unwrap();
3475 let (args, cfg) = make_args(&dir);
3476
3477 let client = MockDspClient::new()
3478 .with_resolve_project(Ok(make_project_ref()))
3479 .with_create_exists_other_project("foreign-dump-id", foreign_iri());
3480
3481 let mut renderer = RecordingRenderer::new();
3482 let mut reporter = RecordingProgressReporter::new();
3483
3484 let err = run_impl(
3485 &args,
3486 &cfg,
3487 &client,
3488 &mut renderer,
3489 &mut reporter,
3490 Some("tok".to_string()),
3491 &no_op_sleeper(),
3492 fixed_now(),
3493 None,
3494 dir.path(),
3495 )
3496 .unwrap_err();
3497
3498 assert!(
3499 matches!(err, Diagnostic::Conflict(_)),
3500 "Default + ExistsForOtherProject must yield Conflict, got {err:?}"
3501 );
3502 let msg = err.to_string();
3503 assert!(
3504 msg.contains(foreign_iri()),
3505 "Conflict message must name the foreign IRI: {msg}"
3506 );
3507 assert!(
3508 msg.contains("--replace --discard-other-project"),
3509 "Conflict message must hint at --replace --discard-other-project: {msg}"
3510 );
3511 let log = client.call_log();
3513 assert_eq!(log[0], CallRecord::Resolve);
3514 assert_eq!(
3515 log[1],
3516 CallRecord::Create {
3517 project_iri: "http://rdfh.ch/projects/0001".to_string(),
3518 }
3519 );
3520 assert_eq!(
3521 log.len(),
3522 2,
3523 "must be exactly 2 calls (no status/delete/download)"
3524 );
3525 }
3526
3527 #[test]
3528 fn replace_exists_for_other_project_without_flag_returns_conflict_no_status_delete() {
3529 let dir = TempDir::new().unwrap();
3531 let (mut args, cfg) = make_args(&dir);
3532 args.replace = true;
3533 let client = MockDspClient::new()
3536 .with_resolve_project(Ok(make_project_ref()))
3537 .with_create_exists_other_project("foreign-dump-id", foreign_iri());
3538
3539 let mut renderer = RecordingRenderer::new();
3540 let mut reporter = RecordingProgressReporter::new();
3541
3542 let err = run_impl(
3543 &args,
3544 &cfg,
3545 &client,
3546 &mut renderer,
3547 &mut reporter,
3548 Some("tok".to_string()),
3549 &no_op_sleeper(),
3550 fixed_now(),
3551 None,
3552 dir.path(),
3553 )
3554 .unwrap_err();
3555
3556 assert!(
3557 matches!(err, Diagnostic::Conflict(_)),
3558 "Replace + ExistsForOtherProject without flag must yield Conflict, got {err:?}"
3559 );
3560 let msg = err.to_string();
3561 assert!(
3562 msg.contains(foreign_iri()),
3563 "Conflict message must name the foreign IRI: {msg}"
3564 );
3565 assert!(
3566 msg.contains("--replace --discard-other-project"),
3567 "Conflict message must hint at the flag: {msg}"
3568 );
3569 let log = client.call_log();
3571 assert_eq!(log[0], CallRecord::Resolve);
3572 assert_eq!(
3573 log[1],
3574 CallRecord::Create {
3575 project_iri: "http://rdfh.ch/projects/0001".to_string(),
3576 }
3577 );
3578 assert_eq!(log.len(), 2, "must be exactly 2 calls (no status/delete)");
3579 }
3580
3581 #[test]
3582 fn replace_exists_for_other_project_with_flag_foreign_completed_discards_and_recreates() {
3583 let dir = TempDir::new().unwrap();
3586 let (mut args, cfg) = make_args(&dir);
3587 args.replace = true;
3588 args.discard_other_project = true;
3589
3590 let foreign_task = DumpTask {
3591 id: "foreign-dump-id".into(),
3592 status: DumpStatus::Completed,
3593 error_message: None,
3594 created_at: None,
3595 };
3596 let new_task = DumpTask {
3597 id: "new-dump-id".into(),
3598 status: DumpStatus::InProgress,
3599 error_message: None,
3600 created_at: None,
3601 };
3602
3603 let client = MockDspClient::new()
3604 .with_resolve_project(Ok(make_project_ref()))
3605 .with_create_exists_other_project("foreign-dump-id", foreign_iri())
3606 .with_status_sequence([Ok(foreign_task)])
3608 .with_create_sequence([Ok(CreateDumpOutcome::Created(new_task))])
3610 .with_delete_result(Ok(()))
3611 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
3612 .with_download_bytes(b"dump-data".to_vec());
3613
3614 let mut renderer = RecordingRenderer::new();
3615 let mut reporter = RecordingProgressReporter::new();
3616
3617 run_impl(
3618 &args,
3619 &cfg,
3620 &client,
3621 &mut renderer,
3622 &mut reporter,
3623 Some("tok".to_string()),
3624 &no_op_sleeper(),
3625 fixed_now(),
3626 None,
3627 dir.path(),
3628 )
3629 .unwrap();
3630
3631 assert!(
3633 reporter
3634 .events
3635 .contains(&EventRecord::DiscardingOtherProjectDump {
3636 id: "foreign-dump-id".into(),
3637 project_iri: foreign_iri().to_string(),
3638 }),
3639 "must report DiscardingOtherProjectDump; events: {:?}",
3640 reporter.events
3641 );
3642
3643 let log = client.call_log();
3646 assert_eq!(log[0], CallRecord::Resolve, "first must be Resolve");
3647 assert_eq!(
3648 log[1],
3649 CallRecord::Create {
3650 project_iri: make_project_ref().iri,
3651 },
3652 "second must be Create with REQUESTED project IRI (initial probe)"
3653 );
3654 assert_eq!(
3655 log[2],
3656 CallRecord::Status(foreign_iri().to_string()),
3657 "third must be Status with FOREIGN iri"
3658 );
3659 assert_eq!(
3660 log[3],
3661 CallRecord::Delete(foreign_iri().to_string()),
3662 "fourth must be Delete with FOREIGN iri"
3663 );
3664 assert_eq!(
3665 log[4],
3666 CallRecord::Create {
3667 project_iri: make_project_ref().iri,
3668 },
3669 "fifth must be Create with REQUESTED project IRI (recreate after discard)"
3670 );
3671 assert_eq!(log[5], CallRecord::Poll, "sixth must be Poll");
3672 assert_eq!(log[6], CallRecord::Download, "seventh must be Download");
3673 assert_eq!(log.len(), 7, "must be exactly 7 calls");
3674
3675 assert!(
3677 renderer.dump_outcome.is_some(),
3678 "project_dump must be called after successful discard+recreate"
3679 );
3680 }
3681
3682 #[test]
3683 fn replace_exists_for_other_project_with_flag_foreign_failed_discards_and_recreates() {
3684 let dir = TempDir::new().unwrap();
3688 let (mut args, cfg) = make_args(&dir);
3689 args.replace = true;
3690 args.discard_other_project = true;
3691
3692 let foreign_task = DumpTask {
3693 id: "foreign-dump-id".into(),
3694 status: DumpStatus::Failed,
3695 error_message: Some("out of disk space".into()),
3696 created_at: None,
3697 };
3698 let new_task = DumpTask {
3699 id: "new-dump-id".into(),
3700 status: DumpStatus::InProgress,
3701 error_message: None,
3702 created_at: None,
3703 };
3704
3705 let client = MockDspClient::new()
3706 .with_resolve_project(Ok(make_project_ref()))
3707 .with_create_exists_other_project("foreign-dump-id", foreign_iri())
3708 .with_status_sequence([Ok(foreign_task)])
3710 .with_create_sequence([Ok(CreateDumpOutcome::Created(new_task))])
3712 .with_delete_result(Ok(()))
3713 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
3714 .with_download_bytes(b"dump-data".to_vec());
3715
3716 let mut renderer = RecordingRenderer::new();
3717 let mut reporter = RecordingProgressReporter::new();
3718
3719 run_impl(
3720 &args,
3721 &cfg,
3722 &client,
3723 &mut renderer,
3724 &mut reporter,
3725 Some("tok".to_string()),
3726 &no_op_sleeper(),
3727 fixed_now(),
3728 None,
3729 dir.path(),
3730 )
3731 .unwrap();
3732
3733 assert!(
3735 reporter
3736 .events
3737 .contains(&EventRecord::DiscardingOtherProjectDump {
3738 id: "foreign-dump-id".into(),
3739 project_iri: foreign_iri().to_string(),
3740 }),
3741 "must report DiscardingOtherProjectDump; events: {:?}",
3742 reporter.events
3743 );
3744
3745 let log = client.call_log();
3748 assert_eq!(log[0], CallRecord::Resolve, "first must be Resolve");
3749 assert_eq!(
3750 log[1],
3751 CallRecord::Create {
3752 project_iri: make_project_ref().iri,
3753 },
3754 "second must be Create with REQUESTED project IRI (initial probe)"
3755 );
3756 assert_eq!(
3757 log[2],
3758 CallRecord::Status(foreign_iri().to_string()),
3759 "third must be Status with FOREIGN iri"
3760 );
3761 assert_eq!(
3762 log[3],
3763 CallRecord::Delete(foreign_iri().to_string()),
3764 "fourth must be Delete with FOREIGN iri"
3765 );
3766 assert_eq!(
3767 log[4],
3768 CallRecord::Create {
3769 project_iri: make_project_ref().iri,
3770 },
3771 "fifth must be Create with REQUESTED project IRI (recreate after discard)"
3772 );
3773 assert_eq!(log[5], CallRecord::Poll, "sixth must be Poll");
3774 assert_eq!(log[6], CallRecord::Download, "seventh must be Download");
3775 assert_eq!(log.len(), 7, "must be exactly 7 calls");
3776
3777 assert!(
3779 renderer.dump_outcome.is_some(),
3780 "project_dump must be called after successful discard+recreate"
3781 );
3782 }
3783
3784 #[test]
3785 fn replace_exists_for_other_project_with_flag_foreign_in_progress_returns_conflict() {
3786 let dir = TempDir::new().unwrap();
3789 let (mut args, cfg) = make_args(&dir);
3790 args.replace = true;
3791 args.discard_other_project = true;
3792
3793 let foreign_task = DumpTask {
3794 id: "foreign-dump-id".into(),
3795 status: DumpStatus::InProgress,
3796 error_message: None,
3797 created_at: None,
3798 };
3799
3800 let client = MockDspClient::new()
3801 .with_resolve_project(Ok(make_project_ref()))
3802 .with_create_exists_other_project("foreign-dump-id", foreign_iri())
3803 .with_status_sequence([Ok(foreign_task)]);
3804
3805 let mut renderer = RecordingRenderer::new();
3806 let mut reporter = RecordingProgressReporter::new();
3807
3808 let err = run_impl(
3809 &args,
3810 &cfg,
3811 &client,
3812 &mut renderer,
3813 &mut reporter,
3814 Some("tok".to_string()),
3815 &no_op_sleeper(),
3816 fixed_now(),
3817 None,
3818 dir.path(),
3819 )
3820 .unwrap_err();
3821
3822 assert!(
3823 matches!(err, Diagnostic::Conflict(_)),
3824 "foreign InProgress must yield Conflict, got {err:?}"
3825 );
3826 let msg = err.to_string();
3827 assert!(
3828 msg.contains("in progress"),
3829 "message must mention in progress: {msg}"
3830 );
3831 assert!(
3832 msg.contains(foreign_iri()),
3833 "message must name the foreign IRI: {msg}"
3834 );
3835 let log = client.call_log();
3837 assert_eq!(
3838 log[2],
3839 CallRecord::Status(foreign_iri().to_string()),
3840 "status must use FOREIGN iri"
3841 );
3842 assert_eq!(
3843 *client.delete_calls.borrow(),
3844 0,
3845 "delete must not be called for in-progress foreign dump"
3846 );
3847 }
3848
3849 #[test]
3850 fn delete_exists_for_other_project_is_noop_no_status_delete_calls() {
3851 let dir = TempDir::new().unwrap();
3854 let (mut args, cfg) = make_args(&dir);
3855 args.delete = true;
3856
3857 let client = MockDspClient::new()
3858 .with_resolve_project(Ok(make_project_ref()))
3859 .with_create_exists_other_project("foreign-dump-id", foreign_iri());
3860
3861 let mut renderer = RecordingRenderer::new();
3862 let mut reporter = RecordingProgressReporter::new();
3863
3864 run_impl(
3865 &args,
3866 &cfg,
3867 &client,
3868 &mut renderer,
3869 &mut reporter,
3870 Some("tok".to_string()),
3871 &no_op_sleeper(),
3872 fixed_now(),
3873 None,
3874 dir.path(),
3875 )
3876 .unwrap(); assert!(renderer.dump_outcome.is_none());
3880 let del_outcome = renderer
3882 .dump_deleted_outcome
3883 .expect("project_dump_deleted must have been called");
3884 assert!(
3885 !del_outcome.deleted,
3886 "deleted must be false for foreign-slot no-op"
3887 );
3888 let note = del_outcome
3889 .note
3890 .expect("note must be set for foreign-slot case");
3891 assert!(
3892 note.contains(foreign_iri()),
3893 "note must name the foreign project IRI: {note}"
3894 );
3895 let log = client.call_log();
3897 assert_eq!(log[0], CallRecord::Resolve);
3898 assert_eq!(
3899 log[1],
3900 CallRecord::Create {
3901 project_iri: "http://rdfh.ch/projects/0001".to_string(),
3902 }
3903 );
3904 assert_eq!(log.len(), 2, "must be exactly 2 calls (no status/delete)");
3905 assert_eq!(
3906 *client.delete_calls.borrow(),
3907 0,
3908 "delete must not be called for foreign-slot no-op"
3909 );
3910 }
3911
3912 const LIST_SERVER: &str = "https://api.test.dasch.swiss";
3917
3918 fn make_list_args(filter: Option<&str>) -> ProjectListArgs {
3919 ProjectListArgs {
3920 server: Some(LIST_SERVER.to_string()),
3921 filter: filter.map(str::to_string),
3922 format: FormatArgs {
3923 format: Format::Prose,
3924 json: false,
3925 lines: false,
3926 columns: None,
3927 no_header: false,
3928 header_only: false,
3929 },
3930 }
3931 }
3932
3933 fn make_list_cfg() -> Config {
3934 Config {
3935 server: LIST_SERVER.to_string(),
3936 }
3937 }
3938
3939 fn make_project(shortcode: &str, shortname: &str, longname: Option<&str>) -> Project {
3940 Project {
3941 iri: format!("http://rdfh.ch/projects/{shortcode}"),
3942 shortcode: shortcode.to_string(),
3943 shortname: shortname.to_string(),
3944 longname: longname.map(str::to_string),
3945 status: ProjectStatus::Active,
3946 data_models: 2,
3947 }
3948 }
3949
3950 fn two_project_list() -> Vec<Project> {
3951 vec![
3952 make_project("0002", "images", None),
3953 make_project("0001", "anything", Some("Anything Project")),
3954 ]
3955 }
3956
3957 #[test]
3960 fn list_anonymous_no_token_no_cache() {
3961 let dir = TempDir::new().unwrap();
3962 let cache_path = dir.path().join("auth.toml");
3963 let args = make_list_args(None);
3964 let cfg = make_list_cfg();
3965
3966 let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
3967 let mut renderer = RecordingRenderer::new();
3968
3969 run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
3970 .expect("must succeed anonymously");
3971
3972 let meta = renderer.projects_meta.unwrap();
3973 assert_eq!(meta.auth_state, "anonymous");
3974
3975 let recorded_token = client.list_projects_token();
3976 assert_eq!(
3977 recorded_token, None,
3978 "must call list_projects with token=None when no credentials"
3979 );
3980
3981 let (items, total, filter) = renderer.projects_view.unwrap();
3982 assert_eq!(total, 2);
3983 assert_eq!(items.len(), 2);
3984 assert!(filter.is_none());
3985 }
3986
3987 #[test]
3990 fn list_corrupt_cache_falls_back_to_anonymous() {
3991 let dir = TempDir::new().unwrap();
3992 let cache_path = dir.path().join("auth.toml");
3994 std::fs::write(&cache_path, b"NOT VALID TOML }{").unwrap();
3995
3996 let args = make_list_args(None);
3997 let cfg = make_list_cfg();
3998
3999 let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
4000 let mut renderer = RecordingRenderer::new();
4001
4002 run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4004 .expect("corrupt cache must not cause an error for list (auth-optional)");
4005
4006 let meta = renderer.projects_meta.unwrap();
4007 assert_eq!(meta.auth_state, "anonymous");
4008 assert_eq!(client.list_projects_token(), None);
4009 }
4010
4011 #[test]
4014 fn list_env_token_authenticated_via_dsp_token() {
4015 let dir = TempDir::new().unwrap();
4016 let cache_path = dir.path().join("auth.toml");
4017 let args = make_list_args(None);
4018 let cfg = make_list_cfg();
4019
4020 let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
4021 let mut renderer = RecordingRenderer::new();
4022
4023 run_list_impl(
4024 &args,
4025 &cfg,
4026 &client,
4027 &mut renderer,
4028 Some("my-env-token".to_string()),
4029 Some(&cache_path),
4030 )
4031 .expect("must succeed with env token");
4032
4033 let meta = renderer.projects_meta.unwrap();
4034 assert_eq!(meta.auth_state, "authenticated via DSP_TOKEN");
4035
4036 let recorded_token = client.list_projects_token();
4038 assert_eq!(
4039 recorded_token,
4040 Some("my-env-token".to_string()),
4041 "token must be forwarded to list_projects"
4042 );
4043 }
4044
4045 #[test]
4047 fn list_cache_token_with_user() {
4048 let dir = TempDir::new().unwrap();
4049 let cache_path = dir.path().join("auth.toml");
4050
4051 let mut cache = AuthCache::default();
4052 cache.set_entry(
4053 LIST_SERVER,
4054 ServerEntry {
4055 token: "cache-token-xyz".to_string(),
4056 user: Some("alice@example.com".to_string()),
4057 acquired_at: None,
4058 expires_at: None,
4059 },
4060 );
4061 cache.save_to(&cache_path).unwrap();
4062
4063 let args = make_list_args(None);
4064 let cfg = make_list_cfg();
4065
4066 let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
4067 let mut renderer = RecordingRenderer::new();
4068
4069 run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4070 .expect("must succeed with cache token");
4071
4072 let meta = renderer.projects_meta.unwrap();
4073 assert_eq!(meta.auth_state, "authenticated as alice@example.com");
4074
4075 let recorded_token = client.list_projects_token();
4077 assert_eq!(
4078 recorded_token,
4079 Some("cache-token-xyz".to_string()),
4080 "cache token must be forwarded to list_projects"
4081 );
4082 }
4083
4084 #[test]
4087 fn list_filter_matches_subset_case_insensitively() {
4088 let dir = TempDir::new().unwrap();
4089 let cache_path = dir.path().join("auth.toml");
4090 let args = make_list_args(Some("ANYTH"));
4092 let cfg = make_list_cfg();
4093
4094 let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
4095 let mut renderer = RecordingRenderer::new();
4096
4097 run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4098 .expect("filter must not cause an error");
4099
4100 let (items, total, filter) = renderer.projects_view.unwrap();
4101 assert_eq!(total, 2, "total must be pre-filter count");
4102 assert_eq!(items.len(), 1, "only one project matches 'ANYTH'");
4103 assert_eq!(items[0].shortname, "anything");
4104 assert_eq!(filter.as_deref(), Some("ANYTH"));
4105 }
4106
4107 #[test]
4109 fn list_filter_no_match_returns_empty_items() {
4110 let dir = TempDir::new().unwrap();
4111 let cache_path = dir.path().join("auth.toml");
4112 let args = make_list_args(Some("zzz-no-match-zzz"));
4113 let cfg = make_list_cfg();
4114
4115 let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
4116 let mut renderer = RecordingRenderer::new();
4117
4118 run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4119 .expect("no-match filter must not be an error");
4120
4121 let (items, total, _) = renderer.projects_view.unwrap();
4122 assert_eq!(total, 2, "total must still show pre-filter count");
4123 assert!(
4124 items.is_empty(),
4125 "items must be empty when filter matches nothing"
4126 );
4127 }
4128
4129 #[test]
4131 fn list_results_sorted_by_shortcode_ascending() {
4132 let dir = TempDir::new().unwrap();
4133 let cache_path = dir.path().join("auth.toml");
4134 let args = make_list_args(None);
4135 let cfg = make_list_cfg();
4136
4137 let unsorted = vec![
4139 make_project("0003", "proj-c", None),
4140 make_project("0001", "proj-a", None),
4141 make_project("0002", "proj-b", None),
4142 ];
4143
4144 let client = MockDspClient::new().with_list_projects_result(Ok(unsorted));
4145 let mut renderer = RecordingRenderer::new();
4146
4147 run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4148 .expect("sort must not error");
4149
4150 let (items, _, _) = renderer.projects_view.unwrap();
4151 let shortcodes: Vec<&str> = items.iter().map(|p| p.shortcode.as_str()).collect();
4152 assert_eq!(shortcodes, vec!["0001", "0002", "0003"]);
4153 }
4154
4155 #[test]
4157 fn list_filter_matches_longname_case_insensitively() {
4158 let dir = TempDir::new().unwrap();
4159 let cache_path = dir.path().join("auth.toml");
4160 let args = make_list_args(Some("ANYTHING PROJECT"));
4162 let cfg = make_list_cfg();
4163
4164 let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
4166 let mut renderer = RecordingRenderer::new();
4167
4168 run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4169 .expect("longname filter must not error");
4170
4171 let (items, total, _) = renderer.projects_view.unwrap();
4172 assert_eq!(total, 2);
4173 assert_eq!(items.len(), 1);
4174 assert_eq!(items[0].shortname, "anything");
4175 }
4176
4177 #[test]
4179 fn list_filter_matches_shortcode() {
4180 let dir = TempDir::new().unwrap();
4181 let cache_path = dir.path().join("auth.toml");
4182 let args = make_list_args(Some("0002"));
4183 let cfg = make_list_cfg();
4184
4185 let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
4186 let mut renderer = RecordingRenderer::new();
4187
4188 run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4189 .expect("shortcode filter must not error");
4190
4191 let (items, total, _) = renderer.projects_view.unwrap();
4192 assert_eq!(total, 2);
4193 assert_eq!(items.len(), 1);
4194 assert_eq!(items[0].shortcode, "0002");
4195 }
4196
4197 #[test]
4200 fn config_resolve_none_returns_usage_error() {
4201 let err = crate::config::Config::resolve(None).unwrap_err();
4202 assert!(
4203 matches!(err, Diagnostic::Usage(_)),
4204 "expected Usage diagnostic for missing server, got {err:?}"
4205 );
4206 let msg = err.to_string();
4207 assert!(
4208 msg.contains("--server") || msg.contains("DSP_SERVER"),
4209 "{msg}"
4210 );
4211 }
4212
4213 #[test]
4216 fn list_token_assertion_is_real() {
4217 let dir = TempDir::new().unwrap();
4218 let cache_path = dir.path().join("auth.toml");
4219 let args = make_list_args(None);
4220 let cfg = make_list_cfg();
4221
4222 let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
4223 let mut renderer = RecordingRenderer::new();
4224
4225 run_list_impl(
4226 &args,
4227 &cfg,
4228 &client,
4229 &mut renderer,
4230 Some("my-env-token".to_string()),
4231 Some(&cache_path),
4232 )
4233 .unwrap();
4234
4235 let recorded = client.list_projects_token();
4236 assert_eq!(recorded, Some("my-env-token".to_string()));
4238 assert_ne!(
4239 recorded,
4240 Some("wrong-token".to_string()),
4241 "token assertion must be real: wrong token should not match"
4242 );
4243 }
4244
4245 const DESCRIBE_SERVER: &str = "https://api.test.dasch.swiss";
4249
4250 fn make_describe_args(project: Option<&str>) -> ProjectDescribeArgs {
4251 ProjectDescribeArgs {
4252 server: Some(DESCRIBE_SERVER.to_string()),
4253 project: project.map(str::to_string),
4254 format: FormatArgs {
4255 format: Format::Prose,
4256 json: false,
4257 lines: false,
4258 columns: None,
4259 no_header: false,
4260 header_only: false,
4261 },
4262 }
4263 }
4264
4265 fn make_describe_cfg() -> Config {
4266 Config {
4267 server: DESCRIBE_SERVER.to_string(),
4268 }
4269 }
4270
4271 fn make_project_detail() -> ProjectDetail {
4273 ProjectDetail {
4274 iri: "http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF".to_string(),
4275 shortcode: "0801".to_string(),
4276 shortname: "beol".to_string(),
4277 longname: Some("Bernoulli-Euler Online".to_string()),
4278 status: ProjectStatus::Active,
4279 description: vec![ProjectDescription {
4280 value: "A project about Bernoulli and Euler.".to_string(),
4281 language: Some("en".to_string()),
4282 }],
4283 keywords: vec!["Bernoulli".to_string(), "Euler".to_string()],
4284 data_models: vec![
4285 DataModelSummary {
4286 name: "beol".to_string(),
4287 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".to_string(),
4288 },
4289 DataModelSummary {
4290 name: "biblio".to_string(),
4291 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".to_string(),
4292 },
4293 ],
4294 }
4295 }
4296
4297 #[test]
4299 fn describe_success_records_detail_and_meta() {
4300 let dir = TempDir::new().unwrap();
4301 let cache_path = dir.path().join("auth.toml");
4302 let args = make_describe_args(Some("0801"));
4303 let cfg = make_describe_cfg();
4304 let detail = make_project_detail();
4305
4306 let client = MockDspClient::new().with_describe_project_result(Ok(detail.clone()));
4307 let mut renderer = RecordingRenderer::new();
4308
4309 run_describe_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4310 .expect("describe must succeed");
4311
4312 let recorded_detail = renderer.describe_detail.unwrap();
4313 assert_eq!(
4314 recorded_detail, detail,
4315 "renderer must receive the exact ProjectDetail"
4316 );
4317
4318 let meta = renderer.describe_meta.unwrap();
4319 assert_eq!(meta.server_label, DESCRIBE_SERVER);
4320 assert_eq!(meta.auth_state, "anonymous");
4321 assert!(meta.filter_warning.is_none());
4322 }
4323
4324 #[test]
4326 fn describe_forwards_project_and_token_to_client() {
4327 let dir = TempDir::new().unwrap();
4328 let cache_path = dir.path().join("auth.toml");
4329 let args = make_describe_args(Some("0801"));
4330 let cfg = make_describe_cfg();
4331
4332 let client = MockDspClient::new().with_describe_project_result(Ok(make_project_detail()));
4333 let mut renderer = RecordingRenderer::new();
4334
4335 run_describe_impl(
4336 &args,
4337 &cfg,
4338 &client,
4339 &mut renderer,
4340 Some("my-env-token".to_string()),
4341 Some(&cache_path),
4342 )
4343 .expect("describe must succeed");
4344
4345 let (project_arg, token_arg) = client.describe_project_call();
4346 assert_eq!(project_arg, "0801", "project argument must be forwarded");
4347 assert_eq!(
4348 token_arg,
4349 Some("my-env-token".to_string()),
4350 "env token must be forwarded to describe_project"
4351 );
4352 }
4353
4354 #[test]
4356 fn describe_not_found_propagates_error() {
4357 let dir = TempDir::new().unwrap();
4358 let cache_path = dir.path().join("auth.toml");
4359 let args = make_describe_args(Some("9999"));
4360 let cfg = make_describe_cfg();
4361
4362 let client = MockDspClient::new().with_describe_project_result(Err(Diagnostic::NotFound(
4363 "project '9999' not found".to_string(),
4364 )));
4365 let mut renderer = RecordingRenderer::new();
4366
4367 let err = run_describe_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4368 .unwrap_err();
4369
4370 assert!(
4371 matches!(err, Diagnostic::NotFound(_)),
4372 "expected NotFound, got {err:?}"
4373 );
4374 }
4375
4376 #[test]
4379 fn describe_missing_project_returns_usage_error() {
4380 let dir = TempDir::new().unwrap();
4381 let cache_path = dir.path().join("auth.toml");
4382 let args = make_describe_args(None); let cfg = make_describe_cfg();
4384
4385 let client = MockDspClient::new();
4387 let mut renderer = RecordingRenderer::new();
4388
4389 let err = run_describe_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4390 .unwrap_err();
4391
4392 assert!(
4393 matches!(err, Diagnostic::Usage(_)),
4394 "expected Usage diagnostic for missing --project, got {err:?}"
4395 );
4396 let msg = err.to_string();
4397 assert!(
4398 msg.contains("--project"),
4399 "--project must appear in the usage message: {msg}"
4400 );
4401
4402 assert!(
4404 renderer.describe_detail.is_none(),
4405 "renderer must not be called when --project is missing"
4406 );
4407 assert!(
4409 !client.describe_project_was_called(),
4410 "client.describe_project must not be called when --project is missing"
4411 );
4412 }
4413
4414 #[test]
4416 fn describe_anonymous_no_token_no_cache() {
4417 let dir = TempDir::new().unwrap();
4418 let cache_path = dir.path().join("auth.toml");
4419 let args = make_describe_args(Some("0801"));
4420 let cfg = make_describe_cfg();
4421
4422 let client = MockDspClient::new().with_describe_project_result(Ok(make_project_detail()));
4423 let mut renderer = RecordingRenderer::new();
4424
4425 run_describe_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4426 .expect("must succeed anonymously");
4427
4428 let meta = renderer.describe_meta.unwrap();
4429 assert_eq!(meta.auth_state, "anonymous");
4430
4431 let (_, token_arg) = client.describe_project_call();
4432 assert_eq!(token_arg, None, "no token must be forwarded when anonymous");
4433 }
4434
4435 #[test]
4437 fn describe_env_token_authenticated_via_dsp_token() {
4438 let dir = TempDir::new().unwrap();
4439 let cache_path = dir.path().join("auth.toml");
4440 let args = make_describe_args(Some("0801"));
4441 let cfg = make_describe_cfg();
4442
4443 let client = MockDspClient::new().with_describe_project_result(Ok(make_project_detail()));
4444 let mut renderer = RecordingRenderer::new();
4445
4446 run_describe_impl(
4447 &args,
4448 &cfg,
4449 &client,
4450 &mut renderer,
4451 Some("env-token-xyz".to_string()),
4452 Some(&cache_path),
4453 )
4454 .expect("must succeed with env token");
4455
4456 let meta = renderer.describe_meta.unwrap();
4457 assert_eq!(meta.auth_state, "authenticated via DSP_TOKEN");
4458
4459 let (_, token_arg) = client.describe_project_call();
4460 assert_eq!(
4461 token_arg,
4462 Some("env-token-xyz".to_string()),
4463 "env token must be forwarded to describe_project"
4464 );
4465 }
4466
4467 #[test]
4469 fn describe_cache_token_with_user() {
4470 let dir = TempDir::new().unwrap();
4471 let cache_path = dir.path().join("auth.toml");
4472
4473 let mut cache = AuthCache::default();
4474 cache.set_entry(
4475 DESCRIBE_SERVER,
4476 ServerEntry {
4477 token: "cache-token-abc".to_string(),
4478 user: Some("bob@example.com".to_string()),
4479 acquired_at: None,
4480 expires_at: None,
4481 },
4482 );
4483 cache.save_to(&cache_path).unwrap();
4484
4485 let args = make_describe_args(Some("0801"));
4486 let cfg = make_describe_cfg();
4487
4488 let client = MockDspClient::new().with_describe_project_result(Ok(make_project_detail()));
4489 let mut renderer = RecordingRenderer::new();
4490
4491 run_describe_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4492 .expect("must succeed with cache token");
4493
4494 let meta = renderer.describe_meta.unwrap();
4495 assert_eq!(meta.auth_state, "authenticated as bob@example.com");
4496
4497 let (_, token_arg) = client.describe_project_call();
4498 assert_eq!(
4499 token_arg,
4500 Some("cache-token-abc".to_string()),
4501 "cache token must be forwarded to describe_project"
4502 );
4503 }
4504
4505 #[test]
4508 fn describe_corrupt_cache_falls_back_to_anonymous() {
4509 let dir = TempDir::new().unwrap();
4510 let cache_path = dir.path().join("auth.toml");
4511 std::fs::write(&cache_path, b"NOT VALID TOML }{").unwrap();
4512
4513 let args = make_describe_args(Some("0801"));
4514 let cfg = make_describe_cfg();
4515
4516 let client = MockDspClient::new().with_describe_project_result(Ok(make_project_detail()));
4517 let mut renderer = RecordingRenderer::new();
4518
4519 run_describe_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4521 .expect("corrupt cache must not cause an error for describe (auth-optional)");
4522
4523 let meta = renderer.describe_meta.unwrap();
4524 assert_eq!(meta.auth_state, "anonymous");
4525
4526 let (_, token_arg) = client.describe_project_call();
4527 assert_eq!(token_arg, None, "no token must be forwarded when anonymous");
4528 }
4529}