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 fn sparql_query(
1506 &self,
1507 _server: &str,
1508 _token: &str,
1509 _query: &str,
1510 _accept: &str,
1511 _timeout_secs: u64,
1512 ) -> Result<crate::client::sparql::SparqlResponse, Diagnostic> {
1513 Err(Diagnostic::Internal("not used in this test".into()))
1514 }
1515 }
1516
1517 struct RecordingRenderer {
1520 dump_outcome: Option<DumpOutcome>,
1521 dump_meta: Option<MetaContext>,
1522 dump_deleted_outcome: Option<DumpDeleteOutcome>,
1523 dump_deleted_meta: Option<MetaContext>,
1524 projects_view: Option<(Vec<Project>, usize, Option<String>)>,
1526 projects_meta: Option<MetaContext>,
1527 describe_detail: Option<ProjectDetail>,
1529 describe_meta: Option<MetaContext>,
1530 }
1531
1532 impl RecordingRenderer {
1533 fn new() -> Self {
1534 Self {
1535 dump_outcome: None,
1536 dump_meta: None,
1537 dump_deleted_outcome: None,
1538 dump_deleted_meta: None,
1539 projects_view: None,
1540 projects_meta: None,
1541 describe_detail: None,
1542 describe_meta: None,
1543 }
1544 }
1545 }
1546
1547 impl Renderer for RecordingRenderer {
1548 fn diagnostic(
1549 &mut self,
1550 _diag: &Diagnostic,
1551 _meta: &MetaContext,
1552 ) -> Result<(), Diagnostic> {
1553 Ok(())
1554 }
1555
1556 fn auth_login(
1557 &mut self,
1558 _outcome: &AuthLoginOutcome,
1559 _meta: &MetaContext,
1560 ) -> Result<(), Diagnostic> {
1561 Ok(())
1562 }
1563
1564 fn auth_status(
1565 &mut self,
1566 _outcome: &AuthStatusOutcome,
1567 _meta: &MetaContext,
1568 ) -> Result<(), Diagnostic> {
1569 Ok(())
1570 }
1571
1572 fn auth_logout(
1573 &mut self,
1574 _outcome: &AuthLogoutOutcome,
1575 _meta: &MetaContext,
1576 ) -> Result<(), Diagnostic> {
1577 Ok(())
1578 }
1579
1580 fn auth_set_token(
1581 &mut self,
1582 _outcome: &AuthSetTokenOutcome,
1583 _meta: &MetaContext,
1584 ) -> Result<(), Diagnostic> {
1585 Ok(())
1586 }
1587
1588 fn project_dump(
1589 &mut self,
1590 outcome: &DumpOutcome,
1591 meta: &MetaContext,
1592 ) -> Result<(), Diagnostic> {
1593 self.dump_outcome = Some(DumpOutcome {
1594 path: outcome.path.clone(),
1595 bytes: outcome.bytes,
1596 cleaned_up: outcome.cleaned_up,
1597 reused: outcome.reused,
1598 created_at: outcome.created_at,
1599 });
1600 self.dump_meta = Some(meta.clone());
1601 Ok(())
1602 }
1603
1604 fn project_dump_deleted(
1605 &mut self,
1606 outcome: &DumpDeleteOutcome,
1607 meta: &MetaContext,
1608 ) -> Result<(), Diagnostic> {
1609 self.dump_deleted_outcome = Some(DumpDeleteOutcome {
1610 deleted: outcome.deleted,
1611 note: outcome.note.clone(),
1612 });
1613 self.dump_deleted_meta = Some(meta.clone());
1614 Ok(())
1615 }
1616
1617 fn projects(
1618 &mut self,
1619 view: &ProjectListView,
1620 meta: &MetaContext,
1621 ) -> Result<(), Diagnostic> {
1622 self.projects_view = Some((view.items.clone(), view.total, view.filter.clone()));
1623 self.projects_meta = Some(meta.clone());
1624 Ok(())
1625 }
1626
1627 fn project_describe(
1628 &mut self,
1629 project: &ProjectDetail,
1630 meta: &MetaContext,
1631 ) -> Result<(), Diagnostic> {
1632 self.describe_detail = Some(project.clone());
1633 self.describe_meta = Some(meta.clone());
1634 Ok(())
1635 }
1636
1637 fn data_models(
1638 &mut self,
1639 _view: &crate::render::DataModelListView,
1640 _meta: &MetaContext,
1641 ) -> Result<(), Diagnostic> {
1642 Ok(())
1643 }
1644
1645 fn data_model_describe(
1646 &mut self,
1647 _detail: &crate::model::DataModelDetail,
1648 _meta: &MetaContext,
1649 ) -> Result<(), Diagnostic> {
1650 Ok(())
1651 }
1652
1653 fn resource_types(
1654 &mut self,
1655 _view: &crate::render::ResourceTypeListView,
1656 _meta: &MetaContext,
1657 ) -> Result<(), Diagnostic> {
1658 Ok(())
1659 }
1660
1661 fn resource_type_describe(
1662 &mut self,
1663 _detail: &crate::model::ResourceTypeDetail,
1664 _meta: &MetaContext,
1665 ) -> Result<(), Diagnostic> {
1666 unimplemented!("resource_type_describe not used in project action tests")
1667 }
1668
1669 fn data_model_structure(
1670 &mut self,
1671 _structure: &crate::model::DataModelStructure,
1672 _meta: &MetaContext,
1673 ) -> Result<(), Diagnostic> {
1674 unimplemented!("data_model_structure not used in project action tests")
1675 }
1676
1677 fn resources(
1678 &mut self,
1679 _view: &crate::render::ResourceListView,
1680 _meta: &MetaContext,
1681 ) -> Result<(), Diagnostic> {
1682 Ok(())
1683 }
1684
1685 fn resource_describe(
1686 &mut self,
1687 _detail: &crate::model::ResourceDetail,
1688 _meta: &MetaContext,
1689 ) -> Result<(), Diagnostic> {
1690 Ok(())
1691 }
1692
1693 fn vocabularies(
1694 &mut self,
1695 _view: &crate::render::VocabularyListView,
1696 _meta: &MetaContext,
1697 ) -> Result<(), Diagnostic> {
1698 unimplemented!("not exercised by this file's tests")
1699 }
1700
1701 fn vocabulary_describe(
1702 &mut self,
1703 _detail: &crate::model::VocabularyDetail,
1704 _meta: &MetaContext,
1705 ) -> Result<(), Diagnostic> {
1706 unimplemented!("not exercised by this file's tests")
1707 }
1708 }
1709
1710 struct RecordingProgressReporter {
1713 events: Vec<EventRecord>,
1714 }
1715
1716 #[derive(Debug, PartialEq)]
1717 enum EventRecord {
1718 Triggered(String),
1719 Polling(u64),
1720 Downloading,
1721 Done(u64),
1722 Adopting(String),
1723 Deleting(String),
1724 ProbeCreated(String),
1725 DiscardingOtherProjectDump { id: String, project_iri: String },
1726 }
1727
1728 impl RecordingProgressReporter {
1729 fn new() -> Self {
1730 Self { events: Vec::new() }
1731 }
1732 }
1733
1734 impl ProgressReporter for RecordingProgressReporter {
1735 fn report(&mut self, event: &DumpEvent) -> Result<(), Diagnostic> {
1736 match event {
1737 DumpEvent::Triggered { id } => self.events.push(EventRecord::Triggered(id.clone())),
1738 DumpEvent::Polling { elapsed_secs, .. } => {
1739 self.events.push(EventRecord::Polling(*elapsed_secs))
1740 }
1741 DumpEvent::Downloading => self.events.push(EventRecord::Downloading),
1742 DumpEvent::Done { bytes } => self.events.push(EventRecord::Done(*bytes)),
1743 DumpEvent::Adopting { id } => self.events.push(EventRecord::Adopting(id.clone())),
1744 DumpEvent::Deleting { id } => self.events.push(EventRecord::Deleting(id.clone())),
1745 DumpEvent::ProbeCreated { id } => {
1746 self.events.push(EventRecord::ProbeCreated(id.clone()))
1747 }
1748 DumpEvent::DiscardingOtherProjectDump { id, project_iri } => {
1749 self.events.push(EventRecord::DiscardingOtherProjectDump {
1750 id: id.clone(),
1751 project_iri: project_iri.clone(),
1752 })
1753 }
1754 }
1755 Ok(())
1756 }
1757 }
1758
1759 fn fixed_now() -> chrono::DateTime<Utc> {
1762 Utc.with_ymd_and_hms(2026, 5, 29, 12, 0, 0).unwrap()
1763 }
1764
1765 fn make_project_ref() -> ProjectRef {
1766 ProjectRef {
1767 iri: "http://rdfh.ch/projects/0001".to_string(),
1768 shortcode: "0001".to_string(),
1769 shortname: "anything".to_string(),
1770 }
1771 }
1772
1773 fn make_dump_task(status: DumpStatus) -> DumpTask {
1774 DumpTask {
1775 id: "dump-id-42".to_string(),
1776 status,
1777 error_message: None,
1778 created_at: None,
1779 }
1780 }
1781
1782 fn created_task(status: DumpStatus) -> CreateDumpOutcome {
1783 CreateDumpOutcome::Created(make_dump_task(status))
1784 }
1785
1786 fn make_args(dir: &TempDir) -> (ProjectDumpArgs, Config) {
1787 let args = ProjectDumpArgs {
1788 server: Some("https://api.test.dasch.swiss".to_string()),
1789 project: Some("0001".to_string()),
1790 skip_assets: false,
1791 output: Some(dir.path().join("out.zip")),
1792 force: false,
1793 cleanup: false,
1794 timeout: 3600,
1795 replace: false,
1796 delete: false,
1797 discard_other_project: false,
1798 format: FormatArgs {
1799 format: Format::Prose,
1800 json: false,
1801 lines: false,
1802 columns: None,
1803 no_header: false,
1804 header_only: false,
1805 },
1806 };
1807 let cfg = Config {
1808 server: "https://api.test.dasch.swiss".to_string(),
1809 };
1810 (args, cfg)
1811 }
1812
1813 fn no_op_sleeper() -> impl Fn(Duration) {
1814 |_| {}
1815 }
1816
1817 #[test]
1820 fn happy_path_resolve_trigger_poll_download() {
1821 let dir = TempDir::new().unwrap();
1822 let (args, cfg) = make_args(&dir);
1823
1824 let client = MockDspClient::new()
1825 .with_resolve_project(Ok(make_project_ref()))
1826 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
1827 .with_poll_sequence([
1828 Ok(make_dump_task(DumpStatus::InProgress)),
1829 Ok(make_dump_task(DumpStatus::Completed)),
1830 ])
1831 .with_download_bytes(b"PK fake zip content".to_vec());
1832
1833 let mut renderer = RecordingRenderer::new();
1834 let mut reporter = RecordingProgressReporter::new();
1835
1836 run_impl(
1837 &args,
1838 &cfg,
1839 &client,
1840 &mut renderer,
1841 &mut reporter,
1842 Some("env-token-abc".to_string()),
1843 &no_op_sleeper(),
1844 fixed_now(),
1845 None,
1846 dir.path(),
1847 )
1848 .unwrap();
1849
1850 let outcome = renderer.dump_outcome.unwrap();
1851 assert_eq!(outcome.bytes, 19); assert!(!outcome.cleaned_up);
1853
1854 assert!(
1856 outcome.path.exists(),
1857 "output file must exist after happy-path download"
1858 );
1859 assert!(
1860 outcome.path.metadata().unwrap().len() > 0,
1861 "output file must be non-empty after happy-path download"
1862 );
1863
1864 assert_eq!(
1866 reporter.events[0],
1867 EventRecord::Triggered("dump-id-42".to_string())
1868 );
1869 assert_eq!(reporter.events[1], EventRecord::Polling(0));
1870 assert_eq!(reporter.events[2], EventRecord::Downloading);
1871 assert_eq!(reporter.events[3], EventRecord::Done(19));
1872 assert_eq!(reporter.events.len(), 4);
1873 }
1874
1875 #[test]
1876 fn default_filename_uses_shortcode_and_fixed_timestamp() {
1877 let dir = TempDir::new().unwrap();
1878 let cache_path = dir.path().join("auth.toml");
1879 let args = ProjectDumpArgs {
1883 server: Some("https://api.test.dasch.swiss".to_string()),
1884 project: Some("0001".to_string()),
1885 skip_assets: false,
1886 output: None,
1887 force: true, cleanup: false,
1889 timeout: 3600,
1890 replace: false,
1891 delete: false,
1892 discard_other_project: false,
1893 format: FormatArgs {
1894 format: Format::Prose,
1895 json: false,
1896 lines: false,
1897 columns: None,
1898 no_header: false,
1899 header_only: false,
1900 },
1901 };
1902 let cfg = Config {
1903 server: "https://api.test.dasch.swiss".to_string(),
1904 };
1905
1906 let client = MockDspClient::new()
1907 .with_resolve_project(Ok(make_project_ref()))
1908 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
1909 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
1910 .with_download_bytes(b"zip".to_vec());
1911
1912 let mut renderer = RecordingRenderer::new();
1913 let mut reporter = RecordingProgressReporter::new();
1914
1915 run_impl(
1916 &args,
1917 &cfg,
1918 &client,
1919 &mut renderer,
1920 &mut reporter,
1921 Some("tok".to_string()),
1922 &no_op_sleeper(),
1923 fixed_now(),
1924 Some(&cache_path),
1925 dir.path(),
1926 )
1927 .unwrap();
1928
1929 let outcome = renderer.dump_outcome.unwrap();
1930 let expected_path = dir.path().join("0001-20260529T120000Z.zip");
1932 assert_eq!(outcome.path, expected_path);
1933 }
1934
1935 #[test]
1936 fn explicit_output_override_respected() {
1937 let dir = TempDir::new().unwrap();
1938 let out_path = dir.path().join("custom.zip");
1939 let (mut args, cfg) = make_args(&dir);
1940 args.output = Some(out_path.clone());
1941
1942 let client = MockDspClient::new()
1943 .with_resolve_project(Ok(make_project_ref()))
1944 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
1945 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
1946 .with_download_bytes(b"data".to_vec());
1947
1948 let mut renderer = RecordingRenderer::new();
1949 let mut reporter = RecordingProgressReporter::new();
1950
1951 run_impl(
1952 &args,
1953 &cfg,
1954 &client,
1955 &mut renderer,
1956 &mut reporter,
1957 Some("tok".to_string()),
1958 &no_op_sleeper(),
1959 fixed_now(),
1960 None,
1961 dir.path(),
1962 )
1963 .unwrap();
1964
1965 let outcome = renderer.dump_outcome.unwrap();
1966 assert_eq!(outcome.path, out_path);
1967 assert!(out_path.exists());
1968 }
1969
1970 #[test]
1971 fn explicit_output_exists_no_force_returns_usage_before_any_client_call() {
1972 let dir = TempDir::new().unwrap();
1973 let out_path = dir.path().join("existing.zip");
1974 std::fs::write(&out_path, b"existing").unwrap();
1975
1976 let (mut args, cfg) = make_args(&dir);
1977 args.output = Some(out_path.clone());
1978 args.force = false;
1979
1980 let client = MockDspClient::new();
1982
1983 let mut renderer = RecordingRenderer::new();
1984 let mut reporter = RecordingProgressReporter::new();
1985
1986 let err = run_impl(
1987 &args,
1988 &cfg,
1989 &client,
1990 &mut renderer,
1991 &mut reporter,
1992 Some("tok".to_string()),
1993 &no_op_sleeper(),
1994 fixed_now(),
1995 None,
1996 dir.path(),
1997 )
1998 .unwrap_err();
1999
2000 assert!(
2001 matches!(err, Diagnostic::Usage(_)),
2002 "expected Usage, got {err:?}"
2003 );
2004 assert!(
2005 err.to_string().contains("refusing to overwrite"),
2006 "message should mention overwrite refusal: {err}"
2007 );
2008 assert_eq!(
2010 *client.resolve_calls.borrow(),
2011 0,
2012 "resolve_project must not be called"
2013 );
2014 assert_eq!(
2015 *client.create_calls.borrow(),
2016 0,
2017 "create must not be called"
2018 );
2019 }
2020
2021 #[test]
2022 fn default_output_exists_no_force_returns_usage_after_resolve_before_trigger() {
2023 let dir = TempDir::new().unwrap();
2024 let cache_path = dir.path().join("auth.toml");
2025
2026 let args = ProjectDumpArgs {
2027 server: Some("https://api.test.dasch.swiss".to_string()),
2028 project: Some("0001".to_string()),
2029 skip_assets: false,
2030 output: None,
2031 force: false,
2032 cleanup: false,
2033 timeout: 3600,
2034 replace: false,
2035 delete: false,
2036 discard_other_project: false,
2037 format: FormatArgs {
2038 format: Format::Prose,
2039 json: false,
2040 lines: false,
2041 columns: None,
2042 no_header: false,
2043 header_only: false,
2044 },
2045 };
2046 let cfg = Config {
2047 server: "https://api.test.dasch.swiss".to_string(),
2048 };
2049
2050 let client = MockDspClient::new().with_resolve_project(Ok(make_project_ref()));
2051 let mut renderer = RecordingRenderer::new();
2052 let mut reporter = RecordingProgressReporter::new();
2053
2054 let default_path_in_tempdir = dir.path().join("0001-20260529T120000Z.zip");
2057 std::fs::write(&default_path_in_tempdir, b"existing")
2058 .expect("must be able to write conflict file into tempdir");
2059
2060 let err = run_impl(
2061 &args,
2062 &cfg,
2063 &client,
2064 &mut renderer,
2065 &mut reporter,
2066 Some("tok".to_string()),
2067 &no_op_sleeper(),
2068 fixed_now(),
2069 Some(&cache_path),
2070 dir.path(),
2071 )
2072 .unwrap_err();
2073
2074 assert!(
2076 matches!(err, Diagnostic::Usage(_)),
2077 "expected Usage, got {err:?}"
2078 );
2079 assert!(
2080 err.to_string().contains("refusing to overwrite"),
2081 "message should mention overwrite refusal: {err}"
2082 );
2083 assert_eq!(
2084 *client.resolve_calls.borrow(),
2085 1,
2086 "resolve must have been called (default-path guard runs after resolve)"
2087 );
2088 assert_eq!(
2089 *client.create_calls.borrow(),
2090 0,
2091 "trigger must NOT have been called (guard fires before trigger)"
2092 );
2093 }
2094
2095 #[test]
2096 fn missing_token_returns_auth_required_with_no_client_calls() {
2097 let dir = TempDir::new().unwrap();
2098 let cache_path = dir.path().join("auth.toml");
2099 let (args, cfg) = make_args(&dir);
2100
2101 let client = MockDspClient::new();
2102 let mut renderer = RecordingRenderer::new();
2103 let mut reporter = RecordingProgressReporter::new();
2104
2105 let err = run_impl(
2107 &args,
2108 &cfg,
2109 &client,
2110 &mut renderer,
2111 &mut reporter,
2112 None, &no_op_sleeper(),
2114 fixed_now(),
2115 Some(&cache_path), dir.path(),
2117 )
2118 .unwrap_err();
2119
2120 assert!(
2121 matches!(err, Diagnostic::AuthRequired(_)),
2122 "expected AuthRequired, got {err:?}"
2123 );
2124 assert_eq!(
2125 *client.resolve_calls.borrow(),
2126 0,
2127 "resolve must not be called"
2128 );
2129 assert_eq!(
2130 *client.create_calls.borrow(),
2131 0,
2132 "trigger must not be called"
2133 );
2134 }
2135
2136 #[test]
2137 fn trigger_conflict_propagates() {
2138 let dir = TempDir::new().unwrap();
2139 let (args, cfg) = make_args(&dir);
2140
2141 let client = MockDspClient::new()
2142 .with_resolve_project(Ok(make_project_ref()))
2143 .with_create_dump(Err(Diagnostic::Conflict(
2144 "a dump for this project is already in progress or present".to_string(),
2145 )));
2146
2147 let mut renderer = RecordingRenderer::new();
2148 let mut reporter = RecordingProgressReporter::new();
2149
2150 let err = run_impl(
2151 &args,
2152 &cfg,
2153 &client,
2154 &mut renderer,
2155 &mut reporter,
2156 Some("tok".to_string()),
2157 &no_op_sleeper(),
2158 fixed_now(),
2159 None,
2160 dir.path(),
2161 )
2162 .unwrap_err();
2163
2164 assert!(
2165 matches!(err, Diagnostic::Conflict(_)),
2166 "expected Conflict, got {err:?}"
2167 );
2168 }
2169
2170 #[test]
2171 fn poll_failed_returns_server_error() {
2172 let dir = TempDir::new().unwrap();
2173 let (args, cfg) = make_args(&dir);
2174
2175 let failed_task = DumpTask {
2176 id: "dump-id-42".to_string(),
2177 status: DumpStatus::Failed,
2178 error_message: Some("out of disk space".to_string()),
2179 created_at: None,
2180 };
2181 let client = MockDspClient::new()
2182 .with_resolve_project(Ok(make_project_ref()))
2183 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2184 .with_poll_sequence([Ok(failed_task)]);
2185
2186 let mut renderer = RecordingRenderer::new();
2187 let mut reporter = RecordingProgressReporter::new();
2188
2189 let err = run_impl(
2190 &args,
2191 &cfg,
2192 &client,
2193 &mut renderer,
2194 &mut reporter,
2195 Some("tok".to_string()),
2196 &no_op_sleeper(),
2197 fixed_now(),
2198 None,
2199 dir.path(),
2200 )
2201 .unwrap_err();
2202
2203 assert!(
2204 matches!(err, Diagnostic::ServerError(_)),
2205 "expected ServerError, got {err:?}"
2206 );
2207 let msg = err.to_string();
2208 assert!(
2209 msg.contains("server-side dump failed"),
2210 "message should mention dump failure: {msg}"
2211 );
2212 assert!(
2213 msg.contains("out of disk space"),
2214 "message should include error_message: {msg}"
2215 );
2216 }
2217
2218 #[test]
2219 fn timeout_via_logical_clock_returns_server_error() {
2220 let dir = TempDir::new().unwrap();
2221 let (mut args, cfg) = make_args(&dir);
2222 args.timeout = 1;
2225
2226 let in_progress: Vec<Result<DumpTask, Diagnostic>> = (0..100)
2228 .map(|_| Ok(make_dump_task(DumpStatus::InProgress)))
2229 .collect();
2230
2231 let client = MockDspClient::new()
2232 .with_resolve_project(Ok(make_project_ref()))
2233 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2234 .with_poll_sequence(in_progress);
2235
2236 let mut renderer = RecordingRenderer::new();
2237 let mut reporter = RecordingProgressReporter::new();
2238
2239 let err = run_impl(
2240 &args,
2241 &cfg,
2242 &client,
2243 &mut renderer,
2244 &mut reporter,
2245 Some("tok".to_string()),
2246 &no_op_sleeper(),
2247 fixed_now(),
2248 None,
2249 dir.path(),
2250 )
2251 .unwrap_err();
2252
2253 assert!(
2254 matches!(err, Diagnostic::ServerError(_)),
2255 "expected ServerError timeout, got {err:?}"
2256 );
2257 let msg = err.to_string();
2258 assert!(
2259 msg.contains("did not complete"),
2260 "message should mention timeout: {msg}"
2261 );
2262 assert!(
2263 msg.contains("may still be running"),
2264 "message should contain user-visible hint 'may still be running': {msg}"
2265 );
2266 }
2267
2268 #[test]
2269 fn cleanup_success_sets_cleaned_up_true() {
2270 let dir = TempDir::new().unwrap();
2271 let (mut args, cfg) = make_args(&dir);
2272 args.cleanup = true;
2273
2274 let client = MockDspClient::new()
2275 .with_resolve_project(Ok(make_project_ref()))
2276 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2277 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2278 .with_download_bytes(b"zip".to_vec())
2279 .with_delete_result(Ok(()));
2280
2281 let mut renderer = RecordingRenderer::new();
2282 let mut reporter = RecordingProgressReporter::new();
2283
2284 run_impl(
2285 &args,
2286 &cfg,
2287 &client,
2288 &mut renderer,
2289 &mut reporter,
2290 Some("tok".to_string()),
2291 &no_op_sleeper(),
2292 fixed_now(),
2293 None,
2294 dir.path(),
2295 )
2296 .unwrap();
2297
2298 let outcome = renderer.dump_outcome.unwrap();
2299 assert!(
2300 outcome.cleaned_up,
2301 "cleanup success should set cleaned_up=true"
2302 );
2303 }
2304
2305 #[test]
2306 fn cleanup_error_keeps_exit_ok_and_cleaned_up_false() {
2307 let dir = TempDir::new().unwrap();
2308 let (mut args, cfg) = make_args(&dir);
2309 args.cleanup = true;
2310
2311 let client = MockDspClient::new()
2312 .with_resolve_project(Ok(make_project_ref()))
2313 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2314 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2315 .with_download_bytes(b"zip".to_vec())
2316 .with_delete_result(Err(Diagnostic::ServerError("delete failed".to_string())));
2317
2318 let mut renderer = RecordingRenderer::new();
2319 let mut reporter = RecordingProgressReporter::new();
2320
2321 run_impl(
2323 &args,
2324 &cfg,
2325 &client,
2326 &mut renderer,
2327 &mut reporter,
2328 Some("tok".to_string()),
2329 &no_op_sleeper(),
2330 fixed_now(),
2331 None,
2332 dir.path(),
2333 )
2334 .unwrap();
2335
2336 let outcome = renderer.dump_outcome.unwrap();
2337 assert!(
2338 !outcome.cleaned_up,
2339 "cleanup error should set cleaned_up=false"
2340 );
2341 }
2342
2343 #[test]
2344 fn download_error_leaves_no_file_at_target_path() {
2345 let dir = TempDir::new().unwrap();
2346 let out_path = dir.path().join("should-not-exist.zip");
2347 let (mut args, cfg) = make_args(&dir);
2348 args.output = Some(out_path.clone());
2349
2350 let client = MockDspClient::new()
2351 .with_resolve_project(Ok(make_project_ref()))
2352 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2353 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2354 .with_download_error(Diagnostic::Network("connection reset".to_string()));
2355
2356 let mut renderer = RecordingRenderer::new();
2357 let mut reporter = RecordingProgressReporter::new();
2358
2359 let err = run_impl(
2360 &args,
2361 &cfg,
2362 &client,
2363 &mut renderer,
2364 &mut reporter,
2365 Some("tok".to_string()),
2366 &no_op_sleeper(),
2367 fixed_now(),
2368 None,
2369 dir.path(),
2370 )
2371 .unwrap_err();
2372
2373 assert!(
2374 matches!(err, Diagnostic::Network(_)),
2375 "expected Network error, got {err:?}"
2376 );
2377 assert!(
2378 !out_path.exists(),
2379 "target file must not exist after download error"
2380 );
2381 }
2382
2383 #[test]
2384 fn default_output_path_pure_fn() {
2385 let now = Utc.with_ymd_and_hms(2026, 5, 29, 12, 0, 0).unwrap();
2386 let base = std::path::Path::new("/tmp/test-base");
2387 let path = default_output_path(base, "0001", now);
2388 assert_eq!(
2389 path,
2390 PathBuf::from("/tmp/test-base/0001-20260529T120000Z.zip")
2391 );
2392 }
2393
2394 #[test]
2395 fn auth_state_env_token_reports_authenticated_via_dsp_token() {
2396 let dir = TempDir::new().unwrap();
2397 let (args, cfg) = make_args(&dir);
2398
2399 let client = MockDspClient::new()
2400 .with_resolve_project(Ok(make_project_ref()))
2401 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2402 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2403 .with_download_bytes(b"zip".to_vec());
2404
2405 let mut renderer = RecordingRenderer::new();
2406 let mut reporter = RecordingProgressReporter::new();
2407
2408 run_impl(
2409 &args,
2410 &cfg,
2411 &client,
2412 &mut renderer,
2413 &mut reporter,
2414 Some("env-token".to_string()),
2415 &no_op_sleeper(),
2416 fixed_now(),
2417 None,
2418 dir.path(),
2419 )
2420 .unwrap();
2421
2422 let meta = renderer.dump_meta.unwrap();
2423 assert_eq!(meta.auth_state, "authenticated via DSP_TOKEN");
2424 }
2425
2426 #[test]
2427 fn auth_state_cache_token_reports_authenticated() {
2428 use crate::config::AuthCache;
2429 use crate::config::auth_cache::ServerEntry;
2430
2431 let dir = TempDir::new().unwrap();
2432 let cache_path = dir.path().join("auth.toml");
2433 let out_path = dir.path().join("out.zip");
2434
2435 let mut cache = AuthCache::default();
2437 cache.set_entry(
2438 "https://api.test.dasch.swiss",
2439 ServerEntry {
2440 token: "cache-tok".to_string(),
2441 user: None,
2442 acquired_at: None,
2443 expires_at: None,
2444 },
2445 );
2446 cache.save_to(&cache_path).unwrap();
2447
2448 let args = ProjectDumpArgs {
2449 server: Some("https://api.test.dasch.swiss".to_string()),
2450 project: Some("0001".to_string()),
2451 skip_assets: false,
2452 output: Some(out_path),
2453 force: false,
2454 cleanup: false,
2455 timeout: 3600,
2456 replace: false,
2457 delete: false,
2458 discard_other_project: false,
2459 format: FormatArgs {
2460 format: Format::Prose,
2461 json: false,
2462 lines: false,
2463 columns: None,
2464 no_header: false,
2465 header_only: false,
2466 },
2467 };
2468 let cfg = Config {
2469 server: "https://api.test.dasch.swiss".to_string(),
2470 };
2471
2472 let client = MockDspClient::new()
2473 .with_resolve_project(Ok(make_project_ref()))
2474 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2475 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2476 .with_download_bytes(b"zip".to_vec());
2477
2478 let mut renderer = RecordingRenderer::new();
2479 let mut reporter = RecordingProgressReporter::new();
2480
2481 run_impl(
2482 &args,
2483 &cfg,
2484 &client,
2485 &mut renderer,
2486 &mut reporter,
2487 None, &no_op_sleeper(),
2489 fixed_now(),
2490 Some(&cache_path),
2491 dir.path(),
2492 )
2493 .unwrap();
2494
2495 let meta = renderer.dump_meta.unwrap();
2497 assert_eq!(meta.auth_state, "authenticated");
2498 }
2499
2500 #[test]
2501 fn rename_failure_returns_io_and_temp_cleaned_up() {
2502 let dir = TempDir::new().unwrap();
2506 let final_path = dir.path().join("dump_dir");
2508 std::fs::create_dir(&final_path).unwrap();
2509
2510 let (mut args, cfg) = make_args(&dir);
2511 args.output = Some(final_path.clone());
2512 args.force = true; let client = MockDspClient::new()
2515 .with_resolve_project(Ok(make_project_ref()))
2516 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2517 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2518 .with_download_bytes(b"zip".to_vec());
2519
2520 let mut renderer = RecordingRenderer::new();
2521 let mut reporter = RecordingProgressReporter::new();
2522
2523 let err = run_impl(
2524 &args,
2525 &cfg,
2526 &client,
2527 &mut renderer,
2528 &mut reporter,
2529 Some("tok".to_string()),
2530 &no_op_sleeper(),
2531 fixed_now(),
2532 None,
2533 dir.path(),
2534 )
2535 .unwrap_err();
2536
2537 assert!(
2539 matches!(err, Diagnostic::Io(_)),
2540 "expected Io error for rename failure, got {err:?}"
2541 );
2542
2543 let pid = std::process::id();
2545 let temp = dir.path().join(format!("dump_dir.{pid}.partial"));
2546 assert!(
2547 !temp.exists(),
2548 "temp file should be cleaned up after rename failure"
2549 );
2550 }
2551
2552 fn assert_download_error_leaves_no_file(download_error: Diagnostic) {
2558 let dir = TempDir::new().unwrap();
2559 let out_path = dir.path().join("should-not-exist.zip");
2560 let (mut args, cfg) = make_args(&dir);
2561 args.output = Some(out_path.clone());
2562
2563 let client = MockDspClient::new()
2564 .with_resolve_project(Ok(make_project_ref()))
2565 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2566 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2567 .with_download_error(download_error);
2568
2569 let mut renderer = RecordingRenderer::new();
2570 let mut reporter = RecordingProgressReporter::new();
2571
2572 let err = run_impl(
2573 &args,
2574 &cfg,
2575 &client,
2576 &mut renderer,
2577 &mut reporter,
2578 Some("tok".to_string()),
2579 &no_op_sleeper(),
2580 fixed_now(),
2581 None,
2582 dir.path(),
2583 )
2584 .unwrap_err();
2585
2586 assert!(
2588 !err.to_string().is_empty(),
2589 "error message must be non-empty"
2590 );
2591 assert!(
2592 !out_path.exists(),
2593 "target file must not exist after download error ({err:?})"
2594 );
2595 }
2596
2597 #[test]
2598 fn download_io_error_leaves_no_file_at_target_path() {
2599 assert_download_error_leaves_no_file(Diagnostic::Io(
2600 "failed to write /tmp/test.zip: no space left on device".to_string(),
2601 ));
2602 }
2603
2604 #[test]
2607 fn skip_assets_true_is_passed_through_to_create_project_dump() {
2608 let dir = TempDir::new().unwrap();
2609 let (mut args, cfg) = make_args(&dir);
2610 args.skip_assets = true;
2611
2612 let client = MockDspClient::new()
2613 .with_resolve_project(Ok(make_project_ref()))
2614 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2615 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2616 .with_download_bytes(b"zip".to_vec());
2617
2618 let mut renderer = RecordingRenderer::new();
2619 let mut reporter = RecordingProgressReporter::new();
2620
2621 run_impl(
2622 &args,
2623 &cfg,
2624 &client,
2625 &mut renderer,
2626 &mut reporter,
2627 Some("tok".to_string()),
2628 &no_op_sleeper(),
2629 fixed_now(),
2630 None,
2631 dir.path(),
2632 )
2633 .unwrap();
2634
2635 assert_eq!(
2636 client.create_skip_assets.get(),
2637 Some(true),
2638 "skip_assets=true must be forwarded to create_project_dump"
2639 );
2640 }
2641
2642 #[test]
2643 fn skip_assets_false_is_passed_through_to_create_project_dump() {
2644 let dir = TempDir::new().unwrap();
2645 let (mut args, cfg) = make_args(&dir);
2646 args.skip_assets = false;
2647
2648 let client = MockDspClient::new()
2649 .with_resolve_project(Ok(make_project_ref()))
2650 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2651 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2652 .with_download_bytes(b"zip".to_vec());
2653
2654 let mut renderer = RecordingRenderer::new();
2655 let mut reporter = RecordingProgressReporter::new();
2656
2657 run_impl(
2658 &args,
2659 &cfg,
2660 &client,
2661 &mut renderer,
2662 &mut reporter,
2663 Some("tok".to_string()),
2664 &no_op_sleeper(),
2665 fixed_now(),
2666 None,
2667 dir.path(),
2668 )
2669 .unwrap();
2670
2671 assert_eq!(
2672 client.create_skip_assets.get(),
2673 Some(false),
2674 "skip_assets=false must be forwarded to create_project_dump"
2675 );
2676 }
2677
2678 #[test]
2683 fn default_fresh_created_no_existing_dump() {
2684 use chrono::TimeZone;
2686 let dir = TempDir::new().unwrap();
2687 let (args, cfg) = make_args(&dir);
2688 let ts = Utc.with_ymd_and_hms(2026, 5, 20, 14, 3, 0).unwrap();
2689
2690 let client = MockDspClient::new()
2691 .with_resolve_project(Ok(make_project_ref()))
2692 .with_create_dump(Ok(CreateDumpOutcome::Created(DumpTask {
2693 id: "dump-id-42".into(),
2694 status: DumpStatus::InProgress,
2695 error_message: None,
2696 created_at: Some(ts),
2697 })))
2698 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2699 .with_download_bytes(b"zipdata".to_vec());
2700
2701 let mut renderer = RecordingRenderer::new();
2702 let mut reporter = RecordingProgressReporter::new();
2703
2704 run_impl(
2705 &args,
2706 &cfg,
2707 &client,
2708 &mut renderer,
2709 &mut reporter,
2710 Some("tok".to_string()),
2711 &no_op_sleeper(),
2712 fixed_now(),
2713 None,
2714 dir.path(),
2715 )
2716 .unwrap();
2717
2718 let outcome = renderer.dump_outcome.unwrap();
2719 assert!(!outcome.reused, "fresh dump must have reused:false");
2720 assert_eq!(
2721 outcome.created_at,
2722 Some(ts),
2723 "created_at must be populated from task"
2724 );
2725 assert_eq!(outcome.bytes, 7); let log = client.call_log();
2728 assert!(log.contains(&CallRecord::Resolve));
2729 assert!(log.contains(&CallRecord::Create {
2730 project_iri: "http://rdfh.ch/projects/0001".to_string(),
2731 }));
2732 assert!(log.contains(&CallRecord::Poll));
2733 assert!(log.contains(&CallRecord::Download));
2734 }
2735
2736 #[test]
2737 fn default_adopt_completed_existing_dump() {
2738 use chrono::TimeZone;
2740 let dir = TempDir::new().unwrap();
2741 let (args, cfg) = make_args(&dir);
2742 let ts = Utc.with_ymd_and_hms(2026, 5, 15, 10, 0, 0).unwrap();
2743
2744 let existing_task = DumpTask {
2745 id: "existing-id".into(),
2746 status: DumpStatus::Completed,
2747 error_message: None,
2748 created_at: Some(ts),
2749 };
2750
2751 let client = MockDspClient::new()
2752 .with_resolve_project(Ok(make_project_ref()))
2753 .with_create_exists("existing-id")
2754 .with_status_sequence([Ok(existing_task)])
2755 .with_download_bytes(b"existing".to_vec());
2756
2757 let mut renderer = RecordingRenderer::new();
2758 let mut reporter = RecordingProgressReporter::new();
2759
2760 run_impl(
2761 &args,
2762 &cfg,
2763 &client,
2764 &mut renderer,
2765 &mut reporter,
2766 Some("tok".to_string()),
2767 &no_op_sleeper(),
2768 fixed_now(),
2769 None,
2770 dir.path(),
2771 )
2772 .unwrap();
2773
2774 let outcome = renderer.dump_outcome.unwrap();
2775 assert!(outcome.reused, "adopted dump must have reused:true");
2776 assert_eq!(
2777 outcome.created_at,
2778 Some(ts),
2779 "created_at must come from status"
2780 );
2781 assert!(
2783 reporter
2784 .events
2785 .contains(&EventRecord::Adopting("existing-id".into())),
2786 "must report Adopting"
2787 );
2788 assert!(
2789 !reporter
2790 .events
2791 .iter()
2792 .any(|e| matches!(e, EventRecord::Triggered(_))),
2793 "must NOT report Triggered when adopting"
2794 );
2795 let log = client.call_log();
2797 assert_eq!(log[0], CallRecord::Resolve);
2798 assert_eq!(
2799 log[1],
2800 CallRecord::Create {
2801 project_iri: "http://rdfh.ch/projects/0001".to_string(),
2802 }
2803 );
2804 assert_eq!(
2805 log[2],
2806 CallRecord::Status("http://rdfh.ch/projects/0001".to_string())
2807 );
2808 assert_eq!(log[3], CallRecord::Download);
2809 }
2810
2811 #[test]
2812 fn default_adopt_in_progress_polls_then_downloads() {
2813 use chrono::TimeZone;
2815 let dir = TempDir::new().unwrap();
2816 let (args, cfg) = make_args(&dir);
2817 let ts = Utc.with_ymd_and_hms(2026, 5, 10, 8, 0, 0).unwrap();
2818
2819 let in_progress_task = DumpTask {
2820 id: "adopt-ip-id".into(),
2821 status: DumpStatus::InProgress,
2822 error_message: None,
2823 created_at: Some(ts),
2824 };
2825
2826 let client = MockDspClient::new()
2827 .with_resolve_project(Ok(make_project_ref()))
2828 .with_create_exists("adopt-ip-id")
2829 .with_status_sequence([Ok(in_progress_task)])
2832 .with_poll_sequence([
2833 Ok(make_dump_task(DumpStatus::InProgress)),
2834 Ok(make_dump_task(DumpStatus::Completed)),
2835 ])
2836 .with_download_bytes(b"data".to_vec());
2837
2838 let mut renderer = RecordingRenderer::new();
2839 let mut reporter = RecordingProgressReporter::new();
2840
2841 run_impl(
2842 &args,
2843 &cfg,
2844 &client,
2845 &mut renderer,
2846 &mut reporter,
2847 Some("tok".to_string()),
2848 &no_op_sleeper(),
2849 fixed_now(),
2850 None,
2851 dir.path(),
2852 )
2853 .unwrap();
2854
2855 let outcome = renderer.dump_outcome.unwrap();
2856 assert!(outcome.reused);
2857 assert_eq!(outcome.created_at, Some(ts));
2858 let log = client.call_log();
2860 assert_eq!(log[0], CallRecord::Resolve, "first call must be Resolve");
2861 assert_eq!(
2862 log[1],
2863 CallRecord::Create {
2864 project_iri: "http://rdfh.ch/projects/0001".to_string(),
2865 },
2866 "second call must be Create"
2867 );
2868 assert_eq!(
2869 log[2],
2870 CallRecord::Status("http://rdfh.ch/projects/0001".to_string()),
2871 "third call must be Status"
2872 );
2873 assert_eq!(
2874 log[3],
2875 CallRecord::Poll,
2876 "fourth call must be Poll (in_progress)"
2877 );
2878 assert_eq!(
2879 log[4],
2880 CallRecord::Poll,
2881 "fifth call must be Poll (completed)"
2882 );
2883 assert_eq!(log[5], CallRecord::Download, "sixth call must be Download");
2884 assert_eq!(log.len(), 6, "must be exactly 6 calls");
2885 let adopting_idx = reporter
2887 .events
2888 .iter()
2889 .position(|e| matches!(e, EventRecord::Adopting(_)))
2890 .expect("Adopting event must be present");
2891 let first_polling_idx = reporter
2892 .events
2893 .iter()
2894 .position(|e| matches!(e, EventRecord::Polling(_)))
2895 .expect("Polling event must be present");
2896 assert!(
2897 adopting_idx < first_polling_idx,
2898 "Adopting must be reported before the first Polling event"
2899 );
2900 }
2901
2902 #[test]
2903 fn default_existing_failed_returns_conflict_with_hint() {
2904 let dir = TempDir::new().unwrap();
2905 let (args, cfg) = make_args(&dir);
2906
2907 let failed_task = DumpTask {
2908 id: "fail-id".into(),
2909 status: DumpStatus::Failed,
2910 error_message: Some("disk full".into()),
2911 created_at: None,
2912 };
2913
2914 let client = MockDspClient::new()
2915 .with_resolve_project(Ok(make_project_ref()))
2916 .with_create_exists("fail-id")
2917 .with_status_sequence([Ok(failed_task)]);
2918
2919 let mut renderer = RecordingRenderer::new();
2920 let mut reporter = RecordingProgressReporter::new();
2921
2922 let err = run_impl(
2923 &args,
2924 &cfg,
2925 &client,
2926 &mut renderer,
2927 &mut reporter,
2928 Some("tok".to_string()),
2929 &no_op_sleeper(),
2930 fixed_now(),
2931 None,
2932 dir.path(),
2933 )
2934 .unwrap_err();
2935
2936 assert!(
2937 matches!(err, Diagnostic::Conflict(_)),
2938 "failed existing dump must yield Conflict"
2939 );
2940 let msg = err.to_string();
2941 assert!(
2942 msg.contains("existing dump failed"),
2943 "message must mention failure: {msg}"
2944 );
2945 assert!(
2946 msg.contains("disk full"),
2947 "message must include server error: {msg}"
2948 );
2949 assert!(
2950 msg.contains("--replace"),
2951 "message must hint at --replace: {msg}"
2952 );
2953 assert!(
2954 msg.contains("--delete"),
2955 "message must hint at --delete: {msg}"
2956 );
2957 }
2958
2959 #[test]
2962 fn replace_none_existing_creates_fresh() {
2963 let dir = TempDir::new().unwrap();
2965 let (mut args, cfg) = make_args(&dir);
2966 args.replace = true;
2967
2968 let client = MockDspClient::new()
2969 .with_resolve_project(Ok(make_project_ref()))
2970 .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2971 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2972 .with_download_bytes(b"fresh".to_vec());
2973
2974 let mut renderer = RecordingRenderer::new();
2975 let mut reporter = RecordingProgressReporter::new();
2976
2977 run_impl(
2978 &args,
2979 &cfg,
2980 &client,
2981 &mut renderer,
2982 &mut reporter,
2983 Some("tok".to_string()),
2984 &no_op_sleeper(),
2985 fixed_now(),
2986 None,
2987 dir.path(),
2988 )
2989 .unwrap();
2990
2991 let outcome = renderer.dump_outcome.unwrap();
2992 assert!(!outcome.reused, "replace with no existing → reused:false");
2993 assert_eq!(*client.create_calls.borrow(), 1);
2995 }
2996
2997 #[test]
2998 fn replace_completed_deletes_then_recreates() {
2999 let dir = TempDir::new().unwrap();
3001 let (mut args, cfg) = make_args(&dir);
3002 args.replace = true;
3003
3004 let existing_task = DumpTask {
3005 id: "old-id".into(),
3006 status: DumpStatus::Completed,
3007 error_message: None,
3008 created_at: None,
3009 };
3010 let task2 = DumpTask {
3011 id: "new-id".into(),
3012 status: DumpStatus::InProgress,
3013 error_message: None,
3014 created_at: None,
3015 };
3016
3017 let client = MockDspClient::new()
3018 .with_resolve_project(Ok(make_project_ref()))
3019 .with_create_exists("old-id")
3020 .with_create_sequence([Ok(CreateDumpOutcome::Created(task2))])
3021 .with_status_sequence([Ok(existing_task)])
3022 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
3023 .with_delete_result(Ok(()))
3024 .with_download_bytes(b"new".to_vec());
3025
3026 let mut renderer = RecordingRenderer::new();
3027 let mut reporter = RecordingProgressReporter::new();
3028
3029 run_impl(
3030 &args,
3031 &cfg,
3032 &client,
3033 &mut renderer,
3034 &mut reporter,
3035 Some("tok".to_string()),
3036 &no_op_sleeper(),
3037 fixed_now(),
3038 None,
3039 dir.path(),
3040 )
3041 .unwrap();
3042
3043 let outcome = renderer.dump_outcome.unwrap();
3044 assert!(!outcome.reused, "replace always produces reused:false");
3045 let log = client.call_log();
3047 assert_eq!(log[0], CallRecord::Resolve, "first call must be Resolve");
3048 assert_eq!(
3049 log[1],
3050 CallRecord::Create {
3051 project_iri: "http://rdfh.ch/projects/0001".to_string(),
3052 },
3053 "second call must be Create"
3054 );
3055 assert_eq!(
3056 log[2],
3057 CallRecord::Status("http://rdfh.ch/projects/0001".to_string()),
3058 "third call must be Status"
3059 );
3060 assert_eq!(
3061 log[3],
3062 CallRecord::Delete("http://rdfh.ch/projects/0001".to_string()),
3063 "fourth call must be Delete"
3064 );
3065 assert_eq!(
3066 log[4],
3067 CallRecord::Create {
3068 project_iri: "http://rdfh.ch/projects/0001".to_string(),
3069 },
3070 "fifth call must be Create (2nd)"
3071 );
3072 assert_eq!(log[5], CallRecord::Poll, "sixth call must be Poll");
3073 assert_eq!(
3074 log[6],
3075 CallRecord::Download,
3076 "seventh call must be Download"
3077 );
3078 assert_eq!(log.len(), 7, "must be exactly 7 calls");
3079 assert!(
3081 reporter
3082 .events
3083 .contains(&EventRecord::Deleting("old-id".into())),
3084 "must report Deleting for the old dump"
3085 );
3086 assert!(
3088 reporter
3089 .events
3090 .contains(&EventRecord::Triggered("new-id".into())),
3091 "must report Triggered for the new dump; events: {:?}",
3092 reporter.events
3093 );
3094 }
3095
3096 #[test]
3097 fn replace_failed_existing_deletes_then_recreates() {
3098 let dir = TempDir::new().unwrap();
3100 let (mut args, cfg) = make_args(&dir);
3101 args.replace = true;
3102
3103 let failed_task = DumpTask {
3104 id: "failed-old-id".into(),
3105 status: DumpStatus::Failed,
3106 error_message: Some("ran out of space".into()),
3107 created_at: None,
3108 };
3109 let task2 = DumpTask {
3110 id: "new-id-2".into(),
3111 status: DumpStatus::InProgress,
3112 error_message: None,
3113 created_at: None,
3114 };
3115
3116 let client = MockDspClient::new()
3117 .with_resolve_project(Ok(make_project_ref()))
3118 .with_create_exists("failed-old-id")
3119 .with_create_sequence([Ok(CreateDumpOutcome::Created(task2))])
3120 .with_status_sequence([Ok(failed_task)])
3121 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
3122 .with_delete_result(Ok(()))
3123 .with_download_bytes(b"new".to_vec());
3124
3125 let mut renderer = RecordingRenderer::new();
3126 let mut reporter = RecordingProgressReporter::new();
3127
3128 run_impl(
3129 &args,
3130 &cfg,
3131 &client,
3132 &mut renderer,
3133 &mut reporter,
3134 Some("tok".to_string()),
3135 &no_op_sleeper(),
3136 fixed_now(),
3137 None,
3138 dir.path(),
3139 )
3140 .unwrap();
3141
3142 let outcome = renderer.dump_outcome.unwrap();
3143 assert!(!outcome.reused);
3144 }
3145
3146 #[test]
3147 fn replace_recreate_race_returns_conflict() {
3148 let dir = TempDir::new().unwrap();
3150 let (mut args, cfg) = make_args(&dir);
3151 args.replace = true;
3152
3153 let existing_task = DumpTask {
3154 id: "race-id".into(),
3155 status: DumpStatus::Completed,
3156 error_message: None,
3157 created_at: None,
3158 };
3159
3160 let client = MockDspClient::new()
3161 .with_resolve_project(Ok(make_project_ref()))
3162 .with_create_exists("race-id")
3163 .with_create_sequence([Ok(CreateDumpOutcome::Exists {
3164 id: "race-id-2".into(),
3165 })])
3166 .with_status_sequence([Ok(existing_task)])
3167 .with_delete_result(Ok(()));
3168
3169 let mut renderer = RecordingRenderer::new();
3170 let mut reporter = RecordingProgressReporter::new();
3171
3172 let err = run_impl(
3173 &args,
3174 &cfg,
3175 &client,
3176 &mut renderer,
3177 &mut reporter,
3178 Some("tok".to_string()),
3179 &no_op_sleeper(),
3180 fixed_now(),
3181 None,
3182 dir.path(),
3183 )
3184 .unwrap_err();
3185
3186 assert!(
3187 matches!(err, Diagnostic::Conflict(_)),
3188 "recreate race must yield Conflict"
3189 );
3190 let msg = err.to_string();
3191 assert!(
3192 msg.contains("recreated"),
3193 "message must mention recreation: {msg}"
3194 );
3195 }
3196
3197 #[test]
3198 fn replace_in_progress_returns_conflict() {
3199 let dir = TempDir::new().unwrap();
3201 let (mut args, cfg) = make_args(&dir);
3202 args.replace = true;
3203
3204 let in_progress_task = DumpTask {
3205 id: "ip-id".into(),
3206 status: DumpStatus::InProgress,
3207 error_message: None,
3208 created_at: None,
3209 };
3210
3211 let client = MockDspClient::new()
3212 .with_resolve_project(Ok(make_project_ref()))
3213 .with_create_exists("ip-id")
3214 .with_status_sequence([Ok(in_progress_task)]);
3215
3216 let mut renderer = RecordingRenderer::new();
3217 let mut reporter = RecordingProgressReporter::new();
3218
3219 let err = run_impl(
3220 &args,
3221 &cfg,
3222 &client,
3223 &mut renderer,
3224 &mut reporter,
3225 Some("tok".to_string()),
3226 &no_op_sleeper(),
3227 fixed_now(),
3228 None,
3229 dir.path(),
3230 )
3231 .unwrap_err();
3232
3233 assert!(
3234 matches!(err, Diagnostic::Conflict(_)),
3235 "in-progress existing dump must block replace"
3236 );
3237 let msg = err.to_string();
3238 assert!(
3239 msg.contains("in progress"),
3240 "message must mention in-progress state: {msg}"
3241 );
3242 assert_eq!(
3244 *client.delete_calls.borrow(),
3245 0,
3246 "delete must not be called when in-progress"
3247 );
3248 }
3249
3250 #[test]
3253 fn delete_completed_deletes_without_downloading() {
3254 let dir = TempDir::new().unwrap();
3256 let (mut args, cfg) = make_args(&dir);
3257 args.delete = true;
3258
3259 let completed_task = DumpTask {
3260 id: "del-id".into(),
3261 status: DumpStatus::Completed,
3262 error_message: None,
3263 created_at: None,
3264 };
3265
3266 let client = MockDspClient::new()
3267 .with_resolve_project(Ok(make_project_ref()))
3268 .with_create_exists("del-id")
3269 .with_status_sequence([Ok(completed_task)])
3270 .with_delete_result(Ok(()));
3271
3272 let mut renderer = RecordingRenderer::new();
3273 let mut reporter = RecordingProgressReporter::new();
3274
3275 run_impl(
3276 &args,
3277 &cfg,
3278 &client,
3279 &mut renderer,
3280 &mut reporter,
3281 Some("tok".to_string()),
3282 &no_op_sleeper(),
3283 fixed_now(),
3284 None,
3285 dir.path(),
3286 )
3287 .unwrap();
3288
3289 assert_eq!(
3291 *client.download_calls.borrow(),
3292 0,
3293 "delete must not download"
3294 );
3295 assert_eq!(*client.delete_calls.borrow(), 1);
3297 let del_outcome = renderer
3299 .dump_deleted_outcome
3300 .expect("project_dump_deleted must have been called");
3301 assert!(del_outcome.deleted);
3302 assert!(del_outcome.note.is_none());
3303 assert!(
3305 renderer.dump_outcome.is_none(),
3306 "project_dump must not be called in delete mode"
3307 );
3308 assert!(
3310 reporter
3311 .events
3312 .contains(&EventRecord::Deleting("del-id".into()))
3313 );
3314 let log = client.call_log();
3316 assert_eq!(log[0], CallRecord::Resolve);
3317 assert_eq!(
3318 log[1],
3319 CallRecord::Create {
3320 project_iri: "http://rdfh.ch/projects/0001".to_string(),
3321 }
3322 );
3323 assert_eq!(
3324 log[2],
3325 CallRecord::Status("http://rdfh.ch/projects/0001".to_string())
3326 );
3327 assert_eq!(
3328 log[3],
3329 CallRecord::Delete("http://rdfh.ch/projects/0001".to_string())
3330 );
3331 assert_eq!(log.len(), 4, "must be exactly 4 calls");
3332 }
3333
3334 #[test]
3335 fn delete_failed_deletes_without_downloading() {
3336 let dir = TempDir::new().unwrap();
3339 let (mut args, cfg) = make_args(&dir);
3340 args.delete = true;
3341
3342 let failed_task = DumpTask {
3343 id: "del-failed-id".into(),
3344 status: DumpStatus::Failed,
3345 error_message: Some("disk full".into()),
3346 created_at: None,
3347 };
3348
3349 let client = MockDspClient::new()
3350 .with_resolve_project(Ok(make_project_ref()))
3351 .with_create_exists("del-failed-id")
3352 .with_status_sequence([Ok(failed_task)])
3353 .with_delete_result(Ok(()));
3354
3355 let mut renderer = RecordingRenderer::new();
3356 let mut reporter = RecordingProgressReporter::new();
3357
3358 run_impl(
3359 &args,
3360 &cfg,
3361 &client,
3362 &mut renderer,
3363 &mut reporter,
3364 Some("tok".to_string()),
3365 &no_op_sleeper(),
3366 fixed_now(),
3367 None,
3368 dir.path(),
3369 )
3370 .unwrap();
3371
3372 assert_eq!(
3374 *client.download_calls.borrow(),
3375 0,
3376 "delete must not download even for a failed dump"
3377 );
3378 assert_eq!(
3380 *client.delete_calls.borrow(),
3381 1,
3382 "delete must be called for a failed dump"
3383 );
3384 let del_outcome = renderer
3386 .dump_deleted_outcome
3387 .expect("project_dump_deleted must have been called");
3388 assert!(del_outcome.deleted, "deleted must be true for failed dump");
3389 assert!(del_outcome.note.is_none());
3390 assert!(
3392 renderer.dump_outcome.is_none(),
3393 "project_dump must not be called in delete mode"
3394 );
3395 let log = client.call_log();
3397 assert_eq!(log[0], CallRecord::Resolve);
3398 assert_eq!(
3399 log[1],
3400 CallRecord::Create {
3401 project_iri: "http://rdfh.ch/projects/0001".to_string(),
3402 }
3403 );
3404 assert_eq!(
3405 log[2],
3406 CallRecord::Status("http://rdfh.ch/projects/0001".to_string())
3407 );
3408 assert_eq!(
3409 log[3],
3410 CallRecord::Delete("http://rdfh.ch/projects/0001".to_string())
3411 );
3412 assert_eq!(log.len(), 4, "must be exactly 4 calls");
3413 }
3414
3415 #[test]
3416 fn delete_in_progress_returns_conflict() {
3417 let dir = TempDir::new().unwrap();
3419 let (mut args, cfg) = make_args(&dir);
3420 args.delete = true;
3421
3422 let in_progress_task = DumpTask {
3423 id: "del-ip-id".into(),
3424 status: DumpStatus::InProgress,
3425 error_message: None,
3426 created_at: None,
3427 };
3428
3429 let client = MockDspClient::new()
3430 .with_resolve_project(Ok(make_project_ref()))
3431 .with_create_exists("del-ip-id")
3432 .with_status_sequence([Ok(in_progress_task)]);
3433
3434 let mut renderer = RecordingRenderer::new();
3435 let mut reporter = RecordingProgressReporter::new();
3436
3437 let err = run_impl(
3438 &args,
3439 &cfg,
3440 &client,
3441 &mut renderer,
3442 &mut reporter,
3443 Some("tok".to_string()),
3444 &no_op_sleeper(),
3445 fixed_now(),
3446 None,
3447 dir.path(),
3448 )
3449 .unwrap_err();
3450
3451 assert!(
3452 matches!(err, Diagnostic::Conflict(_)),
3453 "in-progress dump must block delete"
3454 );
3455 let msg = err.to_string();
3456 assert!(
3457 msg.contains("in progress"),
3458 "message must mention in-progress: {msg}"
3459 );
3460 assert_eq!(
3461 *client.delete_calls.borrow(),
3462 0,
3463 "delete must not be called"
3464 );
3465 }
3466
3467 #[test]
3468 fn delete_none_probe_created_reports_probe_and_exits_ok() {
3469 let dir = TempDir::new().unwrap();
3472 let (mut args, cfg) = make_args(&dir);
3473 args.delete = true;
3474
3475 let client = MockDspClient::new()
3476 .with_resolve_project(Ok(make_project_ref()))
3477 .with_create_dump(Ok(created_task(DumpStatus::InProgress)));
3478
3479 let mut renderer = RecordingRenderer::new();
3480 let mut reporter = RecordingProgressReporter::new();
3481
3482 run_impl(
3483 &args,
3484 &cfg,
3485 &client,
3486 &mut renderer,
3487 &mut reporter,
3488 Some("tok".to_string()),
3489 &no_op_sleeper(),
3490 fixed_now(),
3491 None,
3492 dir.path(),
3493 )
3494 .unwrap(); assert!(renderer.dump_outcome.is_none());
3498 let del_outcome = renderer
3500 .dump_deleted_outcome
3501 .expect("project_dump_deleted must be called");
3502 assert!(
3503 !del_outcome.deleted,
3504 "deleted must be false (probe, not real delete)"
3505 );
3506 let note = del_outcome.note.expect("note must be set for probe case");
3507 assert!(
3508 note.contains("dump-id-42"),
3509 "note must mention the probe id: {note}"
3510 );
3511 assert!(
3513 reporter
3514 .events
3515 .contains(&EventRecord::ProbeCreated("dump-id-42".into())),
3516 "must report ProbeCreated; events: {:?}",
3517 reporter.events
3518 );
3519 assert_eq!(*client.download_calls.borrow(), 0);
3521 assert_eq!(*client.delete_calls.borrow(), 0);
3522 }
3523
3524 fn foreign_iri() -> &'static str {
3528 "http://rdfh.ch/projects/0002"
3529 }
3530
3531 #[test]
3532 fn default_exists_for_other_project_returns_conflict_no_server_calls() {
3533 let dir = TempDir::new().unwrap();
3535 let (args, cfg) = make_args(&dir);
3536
3537 let client = MockDspClient::new()
3538 .with_resolve_project(Ok(make_project_ref()))
3539 .with_create_exists_other_project("foreign-dump-id", foreign_iri());
3540
3541 let mut renderer = RecordingRenderer::new();
3542 let mut reporter = RecordingProgressReporter::new();
3543
3544 let err = run_impl(
3545 &args,
3546 &cfg,
3547 &client,
3548 &mut renderer,
3549 &mut reporter,
3550 Some("tok".to_string()),
3551 &no_op_sleeper(),
3552 fixed_now(),
3553 None,
3554 dir.path(),
3555 )
3556 .unwrap_err();
3557
3558 assert!(
3559 matches!(err, Diagnostic::Conflict(_)),
3560 "Default + ExistsForOtherProject must yield Conflict, got {err:?}"
3561 );
3562 let msg = err.to_string();
3563 assert!(
3564 msg.contains(foreign_iri()),
3565 "Conflict message must name the foreign IRI: {msg}"
3566 );
3567 assert!(
3568 msg.contains("--replace --discard-other-project"),
3569 "Conflict message must hint at --replace --discard-other-project: {msg}"
3570 );
3571 let log = client.call_log();
3573 assert_eq!(log[0], CallRecord::Resolve);
3574 assert_eq!(
3575 log[1],
3576 CallRecord::Create {
3577 project_iri: "http://rdfh.ch/projects/0001".to_string(),
3578 }
3579 );
3580 assert_eq!(
3581 log.len(),
3582 2,
3583 "must be exactly 2 calls (no status/delete/download)"
3584 );
3585 }
3586
3587 #[test]
3588 fn replace_exists_for_other_project_without_flag_returns_conflict_no_status_delete() {
3589 let dir = TempDir::new().unwrap();
3591 let (mut args, cfg) = make_args(&dir);
3592 args.replace = true;
3593 let client = MockDspClient::new()
3596 .with_resolve_project(Ok(make_project_ref()))
3597 .with_create_exists_other_project("foreign-dump-id", foreign_iri());
3598
3599 let mut renderer = RecordingRenderer::new();
3600 let mut reporter = RecordingProgressReporter::new();
3601
3602 let err = run_impl(
3603 &args,
3604 &cfg,
3605 &client,
3606 &mut renderer,
3607 &mut reporter,
3608 Some("tok".to_string()),
3609 &no_op_sleeper(),
3610 fixed_now(),
3611 None,
3612 dir.path(),
3613 )
3614 .unwrap_err();
3615
3616 assert!(
3617 matches!(err, Diagnostic::Conflict(_)),
3618 "Replace + ExistsForOtherProject without flag must yield Conflict, got {err:?}"
3619 );
3620 let msg = err.to_string();
3621 assert!(
3622 msg.contains(foreign_iri()),
3623 "Conflict message must name the foreign IRI: {msg}"
3624 );
3625 assert!(
3626 msg.contains("--replace --discard-other-project"),
3627 "Conflict message must hint at the flag: {msg}"
3628 );
3629 let log = client.call_log();
3631 assert_eq!(log[0], CallRecord::Resolve);
3632 assert_eq!(
3633 log[1],
3634 CallRecord::Create {
3635 project_iri: "http://rdfh.ch/projects/0001".to_string(),
3636 }
3637 );
3638 assert_eq!(log.len(), 2, "must be exactly 2 calls (no status/delete)");
3639 }
3640
3641 #[test]
3642 fn replace_exists_for_other_project_with_flag_foreign_completed_discards_and_recreates() {
3643 let dir = TempDir::new().unwrap();
3646 let (mut args, cfg) = make_args(&dir);
3647 args.replace = true;
3648 args.discard_other_project = true;
3649
3650 let foreign_task = DumpTask {
3651 id: "foreign-dump-id".into(),
3652 status: DumpStatus::Completed,
3653 error_message: None,
3654 created_at: None,
3655 };
3656 let new_task = DumpTask {
3657 id: "new-dump-id".into(),
3658 status: DumpStatus::InProgress,
3659 error_message: None,
3660 created_at: None,
3661 };
3662
3663 let client = MockDspClient::new()
3664 .with_resolve_project(Ok(make_project_ref()))
3665 .with_create_exists_other_project("foreign-dump-id", foreign_iri())
3666 .with_status_sequence([Ok(foreign_task)])
3668 .with_create_sequence([Ok(CreateDumpOutcome::Created(new_task))])
3670 .with_delete_result(Ok(()))
3671 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
3672 .with_download_bytes(b"dump-data".to_vec());
3673
3674 let mut renderer = RecordingRenderer::new();
3675 let mut reporter = RecordingProgressReporter::new();
3676
3677 run_impl(
3678 &args,
3679 &cfg,
3680 &client,
3681 &mut renderer,
3682 &mut reporter,
3683 Some("tok".to_string()),
3684 &no_op_sleeper(),
3685 fixed_now(),
3686 None,
3687 dir.path(),
3688 )
3689 .unwrap();
3690
3691 assert!(
3693 reporter
3694 .events
3695 .contains(&EventRecord::DiscardingOtherProjectDump {
3696 id: "foreign-dump-id".into(),
3697 project_iri: foreign_iri().to_string(),
3698 }),
3699 "must report DiscardingOtherProjectDump; events: {:?}",
3700 reporter.events
3701 );
3702
3703 let log = client.call_log();
3706 assert_eq!(log[0], CallRecord::Resolve, "first must be Resolve");
3707 assert_eq!(
3708 log[1],
3709 CallRecord::Create {
3710 project_iri: make_project_ref().iri,
3711 },
3712 "second must be Create with REQUESTED project IRI (initial probe)"
3713 );
3714 assert_eq!(
3715 log[2],
3716 CallRecord::Status(foreign_iri().to_string()),
3717 "third must be Status with FOREIGN iri"
3718 );
3719 assert_eq!(
3720 log[3],
3721 CallRecord::Delete(foreign_iri().to_string()),
3722 "fourth must be Delete with FOREIGN iri"
3723 );
3724 assert_eq!(
3725 log[4],
3726 CallRecord::Create {
3727 project_iri: make_project_ref().iri,
3728 },
3729 "fifth must be Create with REQUESTED project IRI (recreate after discard)"
3730 );
3731 assert_eq!(log[5], CallRecord::Poll, "sixth must be Poll");
3732 assert_eq!(log[6], CallRecord::Download, "seventh must be Download");
3733 assert_eq!(log.len(), 7, "must be exactly 7 calls");
3734
3735 assert!(
3737 renderer.dump_outcome.is_some(),
3738 "project_dump must be called after successful discard+recreate"
3739 );
3740 }
3741
3742 #[test]
3743 fn replace_exists_for_other_project_with_flag_foreign_failed_discards_and_recreates() {
3744 let dir = TempDir::new().unwrap();
3748 let (mut args, cfg) = make_args(&dir);
3749 args.replace = true;
3750 args.discard_other_project = true;
3751
3752 let foreign_task = DumpTask {
3753 id: "foreign-dump-id".into(),
3754 status: DumpStatus::Failed,
3755 error_message: Some("out of disk space".into()),
3756 created_at: None,
3757 };
3758 let new_task = DumpTask {
3759 id: "new-dump-id".into(),
3760 status: DumpStatus::InProgress,
3761 error_message: None,
3762 created_at: None,
3763 };
3764
3765 let client = MockDspClient::new()
3766 .with_resolve_project(Ok(make_project_ref()))
3767 .with_create_exists_other_project("foreign-dump-id", foreign_iri())
3768 .with_status_sequence([Ok(foreign_task)])
3770 .with_create_sequence([Ok(CreateDumpOutcome::Created(new_task))])
3772 .with_delete_result(Ok(()))
3773 .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
3774 .with_download_bytes(b"dump-data".to_vec());
3775
3776 let mut renderer = RecordingRenderer::new();
3777 let mut reporter = RecordingProgressReporter::new();
3778
3779 run_impl(
3780 &args,
3781 &cfg,
3782 &client,
3783 &mut renderer,
3784 &mut reporter,
3785 Some("tok".to_string()),
3786 &no_op_sleeper(),
3787 fixed_now(),
3788 None,
3789 dir.path(),
3790 )
3791 .unwrap();
3792
3793 assert!(
3795 reporter
3796 .events
3797 .contains(&EventRecord::DiscardingOtherProjectDump {
3798 id: "foreign-dump-id".into(),
3799 project_iri: foreign_iri().to_string(),
3800 }),
3801 "must report DiscardingOtherProjectDump; events: {:?}",
3802 reporter.events
3803 );
3804
3805 let log = client.call_log();
3808 assert_eq!(log[0], CallRecord::Resolve, "first must be Resolve");
3809 assert_eq!(
3810 log[1],
3811 CallRecord::Create {
3812 project_iri: make_project_ref().iri,
3813 },
3814 "second must be Create with REQUESTED project IRI (initial probe)"
3815 );
3816 assert_eq!(
3817 log[2],
3818 CallRecord::Status(foreign_iri().to_string()),
3819 "third must be Status with FOREIGN iri"
3820 );
3821 assert_eq!(
3822 log[3],
3823 CallRecord::Delete(foreign_iri().to_string()),
3824 "fourth must be Delete with FOREIGN iri"
3825 );
3826 assert_eq!(
3827 log[4],
3828 CallRecord::Create {
3829 project_iri: make_project_ref().iri,
3830 },
3831 "fifth must be Create with REQUESTED project IRI (recreate after discard)"
3832 );
3833 assert_eq!(log[5], CallRecord::Poll, "sixth must be Poll");
3834 assert_eq!(log[6], CallRecord::Download, "seventh must be Download");
3835 assert_eq!(log.len(), 7, "must be exactly 7 calls");
3836
3837 assert!(
3839 renderer.dump_outcome.is_some(),
3840 "project_dump must be called after successful discard+recreate"
3841 );
3842 }
3843
3844 #[test]
3845 fn replace_exists_for_other_project_with_flag_foreign_in_progress_returns_conflict() {
3846 let dir = TempDir::new().unwrap();
3849 let (mut args, cfg) = make_args(&dir);
3850 args.replace = true;
3851 args.discard_other_project = true;
3852
3853 let foreign_task = DumpTask {
3854 id: "foreign-dump-id".into(),
3855 status: DumpStatus::InProgress,
3856 error_message: None,
3857 created_at: None,
3858 };
3859
3860 let client = MockDspClient::new()
3861 .with_resolve_project(Ok(make_project_ref()))
3862 .with_create_exists_other_project("foreign-dump-id", foreign_iri())
3863 .with_status_sequence([Ok(foreign_task)]);
3864
3865 let mut renderer = RecordingRenderer::new();
3866 let mut reporter = RecordingProgressReporter::new();
3867
3868 let err = run_impl(
3869 &args,
3870 &cfg,
3871 &client,
3872 &mut renderer,
3873 &mut reporter,
3874 Some("tok".to_string()),
3875 &no_op_sleeper(),
3876 fixed_now(),
3877 None,
3878 dir.path(),
3879 )
3880 .unwrap_err();
3881
3882 assert!(
3883 matches!(err, Diagnostic::Conflict(_)),
3884 "foreign InProgress must yield Conflict, got {err:?}"
3885 );
3886 let msg = err.to_string();
3887 assert!(
3888 msg.contains("in progress"),
3889 "message must mention in progress: {msg}"
3890 );
3891 assert!(
3892 msg.contains(foreign_iri()),
3893 "message must name the foreign IRI: {msg}"
3894 );
3895 let log = client.call_log();
3897 assert_eq!(
3898 log[2],
3899 CallRecord::Status(foreign_iri().to_string()),
3900 "status must use FOREIGN iri"
3901 );
3902 assert_eq!(
3903 *client.delete_calls.borrow(),
3904 0,
3905 "delete must not be called for in-progress foreign dump"
3906 );
3907 }
3908
3909 #[test]
3910 fn delete_exists_for_other_project_is_noop_no_status_delete_calls() {
3911 let dir = TempDir::new().unwrap();
3914 let (mut args, cfg) = make_args(&dir);
3915 args.delete = true;
3916
3917 let client = MockDspClient::new()
3918 .with_resolve_project(Ok(make_project_ref()))
3919 .with_create_exists_other_project("foreign-dump-id", foreign_iri());
3920
3921 let mut renderer = RecordingRenderer::new();
3922 let mut reporter = RecordingProgressReporter::new();
3923
3924 run_impl(
3925 &args,
3926 &cfg,
3927 &client,
3928 &mut renderer,
3929 &mut reporter,
3930 Some("tok".to_string()),
3931 &no_op_sleeper(),
3932 fixed_now(),
3933 None,
3934 dir.path(),
3935 )
3936 .unwrap(); assert!(renderer.dump_outcome.is_none());
3940 let del_outcome = renderer
3942 .dump_deleted_outcome
3943 .expect("project_dump_deleted must have been called");
3944 assert!(
3945 !del_outcome.deleted,
3946 "deleted must be false for foreign-slot no-op"
3947 );
3948 let note = del_outcome
3949 .note
3950 .expect("note must be set for foreign-slot case");
3951 assert!(
3952 note.contains(foreign_iri()),
3953 "note must name the foreign project IRI: {note}"
3954 );
3955 let log = client.call_log();
3957 assert_eq!(log[0], CallRecord::Resolve);
3958 assert_eq!(
3959 log[1],
3960 CallRecord::Create {
3961 project_iri: "http://rdfh.ch/projects/0001".to_string(),
3962 }
3963 );
3964 assert_eq!(log.len(), 2, "must be exactly 2 calls (no status/delete)");
3965 assert_eq!(
3966 *client.delete_calls.borrow(),
3967 0,
3968 "delete must not be called for foreign-slot no-op"
3969 );
3970 }
3971
3972 const LIST_SERVER: &str = "https://api.test.dasch.swiss";
3977
3978 fn make_list_args(filter: Option<&str>) -> ProjectListArgs {
3979 ProjectListArgs {
3980 server: Some(LIST_SERVER.to_string()),
3981 filter: filter.map(str::to_string),
3982 format: FormatArgs {
3983 format: Format::Prose,
3984 json: false,
3985 lines: false,
3986 columns: None,
3987 no_header: false,
3988 header_only: false,
3989 },
3990 }
3991 }
3992
3993 fn make_list_cfg() -> Config {
3994 Config {
3995 server: LIST_SERVER.to_string(),
3996 }
3997 }
3998
3999 fn make_project(shortcode: &str, shortname: &str, longname: Option<&str>) -> Project {
4000 Project {
4001 iri: format!("http://rdfh.ch/projects/{shortcode}"),
4002 shortcode: shortcode.to_string(),
4003 shortname: shortname.to_string(),
4004 longname: longname.map(str::to_string),
4005 status: ProjectStatus::Active,
4006 data_models: 2,
4007 }
4008 }
4009
4010 fn two_project_list() -> Vec<Project> {
4011 vec![
4012 make_project("0002", "images", None),
4013 make_project("0001", "anything", Some("Anything Project")),
4014 ]
4015 }
4016
4017 #[test]
4020 fn list_anonymous_no_token_no_cache() {
4021 let dir = TempDir::new().unwrap();
4022 let cache_path = dir.path().join("auth.toml");
4023 let args = make_list_args(None);
4024 let cfg = make_list_cfg();
4025
4026 let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
4027 let mut renderer = RecordingRenderer::new();
4028
4029 run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4030 .expect("must succeed anonymously");
4031
4032 let meta = renderer.projects_meta.unwrap();
4033 assert_eq!(meta.auth_state, "anonymous");
4034
4035 let recorded_token = client.list_projects_token();
4036 assert_eq!(
4037 recorded_token, None,
4038 "must call list_projects with token=None when no credentials"
4039 );
4040
4041 let (items, total, filter) = renderer.projects_view.unwrap();
4042 assert_eq!(total, 2);
4043 assert_eq!(items.len(), 2);
4044 assert!(filter.is_none());
4045 }
4046
4047 #[test]
4050 fn list_corrupt_cache_falls_back_to_anonymous() {
4051 let dir = TempDir::new().unwrap();
4052 let cache_path = dir.path().join("auth.toml");
4054 std::fs::write(&cache_path, b"NOT VALID TOML }{").unwrap();
4055
4056 let args = make_list_args(None);
4057 let cfg = make_list_cfg();
4058
4059 let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
4060 let mut renderer = RecordingRenderer::new();
4061
4062 run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4064 .expect("corrupt cache must not cause an error for list (auth-optional)");
4065
4066 let meta = renderer.projects_meta.unwrap();
4067 assert_eq!(meta.auth_state, "anonymous");
4068 assert_eq!(client.list_projects_token(), None);
4069 }
4070
4071 #[test]
4074 fn list_env_token_authenticated_via_dsp_token() {
4075 let dir = TempDir::new().unwrap();
4076 let cache_path = dir.path().join("auth.toml");
4077 let args = make_list_args(None);
4078 let cfg = make_list_cfg();
4079
4080 let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
4081 let mut renderer = RecordingRenderer::new();
4082
4083 run_list_impl(
4084 &args,
4085 &cfg,
4086 &client,
4087 &mut renderer,
4088 Some("my-env-token".to_string()),
4089 Some(&cache_path),
4090 )
4091 .expect("must succeed with env token");
4092
4093 let meta = renderer.projects_meta.unwrap();
4094 assert_eq!(meta.auth_state, "authenticated via DSP_TOKEN");
4095
4096 let recorded_token = client.list_projects_token();
4098 assert_eq!(
4099 recorded_token,
4100 Some("my-env-token".to_string()),
4101 "token must be forwarded to list_projects"
4102 );
4103 }
4104
4105 #[test]
4107 fn list_cache_token_with_user() {
4108 let dir = TempDir::new().unwrap();
4109 let cache_path = dir.path().join("auth.toml");
4110
4111 let mut cache = AuthCache::default();
4112 cache.set_entry(
4113 LIST_SERVER,
4114 ServerEntry {
4115 token: "cache-token-xyz".to_string(),
4116 user: Some("alice@example.com".to_string()),
4117 acquired_at: None,
4118 expires_at: None,
4119 },
4120 );
4121 cache.save_to(&cache_path).unwrap();
4122
4123 let args = make_list_args(None);
4124 let cfg = make_list_cfg();
4125
4126 let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
4127 let mut renderer = RecordingRenderer::new();
4128
4129 run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4130 .expect("must succeed with cache token");
4131
4132 let meta = renderer.projects_meta.unwrap();
4133 assert_eq!(meta.auth_state, "authenticated as alice@example.com");
4134
4135 let recorded_token = client.list_projects_token();
4137 assert_eq!(
4138 recorded_token,
4139 Some("cache-token-xyz".to_string()),
4140 "cache token must be forwarded to list_projects"
4141 );
4142 }
4143
4144 #[test]
4147 fn list_filter_matches_subset_case_insensitively() {
4148 let dir = TempDir::new().unwrap();
4149 let cache_path = dir.path().join("auth.toml");
4150 let args = make_list_args(Some("ANYTH"));
4152 let cfg = make_list_cfg();
4153
4154 let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
4155 let mut renderer = RecordingRenderer::new();
4156
4157 run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4158 .expect("filter must not cause an error");
4159
4160 let (items, total, filter) = renderer.projects_view.unwrap();
4161 assert_eq!(total, 2, "total must be pre-filter count");
4162 assert_eq!(items.len(), 1, "only one project matches 'ANYTH'");
4163 assert_eq!(items[0].shortname, "anything");
4164 assert_eq!(filter.as_deref(), Some("ANYTH"));
4165 }
4166
4167 #[test]
4169 fn list_filter_no_match_returns_empty_items() {
4170 let dir = TempDir::new().unwrap();
4171 let cache_path = dir.path().join("auth.toml");
4172 let args = make_list_args(Some("zzz-no-match-zzz"));
4173 let cfg = make_list_cfg();
4174
4175 let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
4176 let mut renderer = RecordingRenderer::new();
4177
4178 run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4179 .expect("no-match filter must not be an error");
4180
4181 let (items, total, _) = renderer.projects_view.unwrap();
4182 assert_eq!(total, 2, "total must still show pre-filter count");
4183 assert!(
4184 items.is_empty(),
4185 "items must be empty when filter matches nothing"
4186 );
4187 }
4188
4189 #[test]
4191 fn list_results_sorted_by_shortcode_ascending() {
4192 let dir = TempDir::new().unwrap();
4193 let cache_path = dir.path().join("auth.toml");
4194 let args = make_list_args(None);
4195 let cfg = make_list_cfg();
4196
4197 let unsorted = vec![
4199 make_project("0003", "proj-c", None),
4200 make_project("0001", "proj-a", None),
4201 make_project("0002", "proj-b", None),
4202 ];
4203
4204 let client = MockDspClient::new().with_list_projects_result(Ok(unsorted));
4205 let mut renderer = RecordingRenderer::new();
4206
4207 run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4208 .expect("sort must not error");
4209
4210 let (items, _, _) = renderer.projects_view.unwrap();
4211 let shortcodes: Vec<&str> = items.iter().map(|p| p.shortcode.as_str()).collect();
4212 assert_eq!(shortcodes, vec!["0001", "0002", "0003"]);
4213 }
4214
4215 #[test]
4217 fn list_filter_matches_longname_case_insensitively() {
4218 let dir = TempDir::new().unwrap();
4219 let cache_path = dir.path().join("auth.toml");
4220 let args = make_list_args(Some("ANYTHING PROJECT"));
4222 let cfg = make_list_cfg();
4223
4224 let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
4226 let mut renderer = RecordingRenderer::new();
4227
4228 run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4229 .expect("longname filter must not error");
4230
4231 let (items, total, _) = renderer.projects_view.unwrap();
4232 assert_eq!(total, 2);
4233 assert_eq!(items.len(), 1);
4234 assert_eq!(items[0].shortname, "anything");
4235 }
4236
4237 #[test]
4239 fn list_filter_matches_shortcode() {
4240 let dir = TempDir::new().unwrap();
4241 let cache_path = dir.path().join("auth.toml");
4242 let args = make_list_args(Some("0002"));
4243 let cfg = make_list_cfg();
4244
4245 let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
4246 let mut renderer = RecordingRenderer::new();
4247
4248 run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4249 .expect("shortcode filter must not error");
4250
4251 let (items, total, _) = renderer.projects_view.unwrap();
4252 assert_eq!(total, 2);
4253 assert_eq!(items.len(), 1);
4254 assert_eq!(items[0].shortcode, "0002");
4255 }
4256
4257 #[test]
4260 fn config_resolve_none_returns_usage_error() {
4261 let err = crate::config::Config::resolve(None).unwrap_err();
4262 assert!(
4263 matches!(err, Diagnostic::Usage(_)),
4264 "expected Usage diagnostic for missing server, got {err:?}"
4265 );
4266 let msg = err.to_string();
4267 assert!(
4268 msg.contains("--server") || msg.contains("DSP_SERVER"),
4269 "{msg}"
4270 );
4271 }
4272
4273 #[test]
4276 fn list_token_assertion_is_real() {
4277 let dir = TempDir::new().unwrap();
4278 let cache_path = dir.path().join("auth.toml");
4279 let args = make_list_args(None);
4280 let cfg = make_list_cfg();
4281
4282 let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
4283 let mut renderer = RecordingRenderer::new();
4284
4285 run_list_impl(
4286 &args,
4287 &cfg,
4288 &client,
4289 &mut renderer,
4290 Some("my-env-token".to_string()),
4291 Some(&cache_path),
4292 )
4293 .unwrap();
4294
4295 let recorded = client.list_projects_token();
4296 assert_eq!(recorded, Some("my-env-token".to_string()));
4298 assert_ne!(
4299 recorded,
4300 Some("wrong-token".to_string()),
4301 "token assertion must be real: wrong token should not match"
4302 );
4303 }
4304
4305 const DESCRIBE_SERVER: &str = "https://api.test.dasch.swiss";
4309
4310 fn make_describe_args(project: Option<&str>) -> ProjectDescribeArgs {
4311 ProjectDescribeArgs {
4312 server: Some(DESCRIBE_SERVER.to_string()),
4313 project: project.map(str::to_string),
4314 format: FormatArgs {
4315 format: Format::Prose,
4316 json: false,
4317 lines: false,
4318 columns: None,
4319 no_header: false,
4320 header_only: false,
4321 },
4322 }
4323 }
4324
4325 fn make_describe_cfg() -> Config {
4326 Config {
4327 server: DESCRIBE_SERVER.to_string(),
4328 }
4329 }
4330
4331 fn make_project_detail() -> ProjectDetail {
4333 ProjectDetail {
4334 iri: "http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF".to_string(),
4335 shortcode: "0801".to_string(),
4336 shortname: "beol".to_string(),
4337 longname: Some("Bernoulli-Euler Online".to_string()),
4338 status: ProjectStatus::Active,
4339 description: vec![ProjectDescription {
4340 value: "A project about Bernoulli and Euler.".to_string(),
4341 language: Some("en".to_string()),
4342 }],
4343 keywords: vec!["Bernoulli".to_string(), "Euler".to_string()],
4344 data_models: vec![
4345 DataModelSummary {
4346 name: "beol".to_string(),
4347 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".to_string(),
4348 },
4349 DataModelSummary {
4350 name: "biblio".to_string(),
4351 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".to_string(),
4352 },
4353 ],
4354 }
4355 }
4356
4357 #[test]
4359 fn describe_success_records_detail_and_meta() {
4360 let dir = TempDir::new().unwrap();
4361 let cache_path = dir.path().join("auth.toml");
4362 let args = make_describe_args(Some("0801"));
4363 let cfg = make_describe_cfg();
4364 let detail = make_project_detail();
4365
4366 let client = MockDspClient::new().with_describe_project_result(Ok(detail.clone()));
4367 let mut renderer = RecordingRenderer::new();
4368
4369 run_describe_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4370 .expect("describe must succeed");
4371
4372 let recorded_detail = renderer.describe_detail.unwrap();
4373 assert_eq!(
4374 recorded_detail, detail,
4375 "renderer must receive the exact ProjectDetail"
4376 );
4377
4378 let meta = renderer.describe_meta.unwrap();
4379 assert_eq!(meta.server_label, DESCRIBE_SERVER);
4380 assert_eq!(meta.auth_state, "anonymous");
4381 assert!(meta.filter_warning.is_none());
4382 }
4383
4384 #[test]
4386 fn describe_forwards_project_and_token_to_client() {
4387 let dir = TempDir::new().unwrap();
4388 let cache_path = dir.path().join("auth.toml");
4389 let args = make_describe_args(Some("0801"));
4390 let cfg = make_describe_cfg();
4391
4392 let client = MockDspClient::new().with_describe_project_result(Ok(make_project_detail()));
4393 let mut renderer = RecordingRenderer::new();
4394
4395 run_describe_impl(
4396 &args,
4397 &cfg,
4398 &client,
4399 &mut renderer,
4400 Some("my-env-token".to_string()),
4401 Some(&cache_path),
4402 )
4403 .expect("describe must succeed");
4404
4405 let (project_arg, token_arg) = client.describe_project_call();
4406 assert_eq!(project_arg, "0801", "project argument must be forwarded");
4407 assert_eq!(
4408 token_arg,
4409 Some("my-env-token".to_string()),
4410 "env token must be forwarded to describe_project"
4411 );
4412 }
4413
4414 #[test]
4416 fn describe_not_found_propagates_error() {
4417 let dir = TempDir::new().unwrap();
4418 let cache_path = dir.path().join("auth.toml");
4419 let args = make_describe_args(Some("9999"));
4420 let cfg = make_describe_cfg();
4421
4422 let client = MockDspClient::new().with_describe_project_result(Err(Diagnostic::NotFound(
4423 "project '9999' not found".to_string(),
4424 )));
4425 let mut renderer = RecordingRenderer::new();
4426
4427 let err = run_describe_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4428 .unwrap_err();
4429
4430 assert!(
4431 matches!(err, Diagnostic::NotFound(_)),
4432 "expected NotFound, got {err:?}"
4433 );
4434 }
4435
4436 #[test]
4439 fn describe_missing_project_returns_usage_error() {
4440 let dir = TempDir::new().unwrap();
4441 let cache_path = dir.path().join("auth.toml");
4442 let args = make_describe_args(None); let cfg = make_describe_cfg();
4444
4445 let client = MockDspClient::new();
4447 let mut renderer = RecordingRenderer::new();
4448
4449 let err = run_describe_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4450 .unwrap_err();
4451
4452 assert!(
4453 matches!(err, Diagnostic::Usage(_)),
4454 "expected Usage diagnostic for missing --project, got {err:?}"
4455 );
4456 let msg = err.to_string();
4457 assert!(
4458 msg.contains("--project"),
4459 "--project must appear in the usage message: {msg}"
4460 );
4461
4462 assert!(
4464 renderer.describe_detail.is_none(),
4465 "renderer must not be called when --project is missing"
4466 );
4467 assert!(
4469 !client.describe_project_was_called(),
4470 "client.describe_project must not be called when --project is missing"
4471 );
4472 }
4473
4474 #[test]
4476 fn describe_anonymous_no_token_no_cache() {
4477 let dir = TempDir::new().unwrap();
4478 let cache_path = dir.path().join("auth.toml");
4479 let args = make_describe_args(Some("0801"));
4480 let cfg = make_describe_cfg();
4481
4482 let client = MockDspClient::new().with_describe_project_result(Ok(make_project_detail()));
4483 let mut renderer = RecordingRenderer::new();
4484
4485 run_describe_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4486 .expect("must succeed anonymously");
4487
4488 let meta = renderer.describe_meta.unwrap();
4489 assert_eq!(meta.auth_state, "anonymous");
4490
4491 let (_, token_arg) = client.describe_project_call();
4492 assert_eq!(token_arg, None, "no token must be forwarded when anonymous");
4493 }
4494
4495 #[test]
4497 fn describe_env_token_authenticated_via_dsp_token() {
4498 let dir = TempDir::new().unwrap();
4499 let cache_path = dir.path().join("auth.toml");
4500 let args = make_describe_args(Some("0801"));
4501 let cfg = make_describe_cfg();
4502
4503 let client = MockDspClient::new().with_describe_project_result(Ok(make_project_detail()));
4504 let mut renderer = RecordingRenderer::new();
4505
4506 run_describe_impl(
4507 &args,
4508 &cfg,
4509 &client,
4510 &mut renderer,
4511 Some("env-token-xyz".to_string()),
4512 Some(&cache_path),
4513 )
4514 .expect("must succeed with env token");
4515
4516 let meta = renderer.describe_meta.unwrap();
4517 assert_eq!(meta.auth_state, "authenticated via DSP_TOKEN");
4518
4519 let (_, token_arg) = client.describe_project_call();
4520 assert_eq!(
4521 token_arg,
4522 Some("env-token-xyz".to_string()),
4523 "env token must be forwarded to describe_project"
4524 );
4525 }
4526
4527 #[test]
4529 fn describe_cache_token_with_user() {
4530 let dir = TempDir::new().unwrap();
4531 let cache_path = dir.path().join("auth.toml");
4532
4533 let mut cache = AuthCache::default();
4534 cache.set_entry(
4535 DESCRIBE_SERVER,
4536 ServerEntry {
4537 token: "cache-token-abc".to_string(),
4538 user: Some("bob@example.com".to_string()),
4539 acquired_at: None,
4540 expires_at: None,
4541 },
4542 );
4543 cache.save_to(&cache_path).unwrap();
4544
4545 let args = make_describe_args(Some("0801"));
4546 let cfg = make_describe_cfg();
4547
4548 let client = MockDspClient::new().with_describe_project_result(Ok(make_project_detail()));
4549 let mut renderer = RecordingRenderer::new();
4550
4551 run_describe_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4552 .expect("must succeed with cache token");
4553
4554 let meta = renderer.describe_meta.unwrap();
4555 assert_eq!(meta.auth_state, "authenticated as bob@example.com");
4556
4557 let (_, token_arg) = client.describe_project_call();
4558 assert_eq!(
4559 token_arg,
4560 Some("cache-token-abc".to_string()),
4561 "cache token must be forwarded to describe_project"
4562 );
4563 }
4564
4565 #[test]
4568 fn describe_corrupt_cache_falls_back_to_anonymous() {
4569 let dir = TempDir::new().unwrap();
4570 let cache_path = dir.path().join("auth.toml");
4571 std::fs::write(&cache_path, b"NOT VALID TOML }{").unwrap();
4572
4573 let args = make_describe_args(Some("0801"));
4574 let cfg = make_describe_cfg();
4575
4576 let client = MockDspClient::new().with_describe_project_result(Ok(make_project_detail()));
4577 let mut renderer = RecordingRenderer::new();
4578
4579 run_describe_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4581 .expect("corrupt cache must not cause an error for describe (auth-optional)");
4582
4583 let meta = renderer.describe_meta.unwrap();
4584 assert_eq!(meta.auth_state, "anonymous");
4585
4586 let (_, token_arg) = client.describe_project_call();
4587 assert_eq!(token_arg, None, "no token must be forwarded when anonymous");
4588 }
4589}