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