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