Skip to main content

dsp_cli/actions/vre/
project.rs

1//! Actions for `dsp vre project { list | describe | dump }`.
2//!
3//! Phase 3 added `dump`; Phase 4 added `list` (the first read command).
4//! Phase 5 added `describe` (the first single-object read command).
5//! See ADR-0008.
6
7use 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
25/// List all projects on the DSP server.
26///
27/// Authentication is optional (public endpoint per PRD AC 2). Reads `DSP_TOKEN`
28/// from the environment (env wins over cache per ADR-0007), and delegates all
29/// work to `run_list_impl` with injectable seams for deterministic testing.
30pub 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
40/// Internal entry point for `list` with injectable seams for testing.
41///
42/// - `env_token`: the `DSP_TOKEN` env value (read by the public `list` entry
43///   point before calling this, so tests never touch process env).
44/// - `cache_path`: `Some(path)` in tests to use a temp auth cache; `None` in
45///   production to use the default `~/.config/dsp-cli/auth.toml`.
46///
47/// **Auth-optional:** a cache-load failure ALWAYS falls back to an empty cache
48/// with a `tracing::warn!` — NEVER returns `Err`. This differs deliberately from
49/// `dump`, which requires auth and propagates errors. For a public endpoint, a
50/// corrupt or missing `auth.toml` must still list anonymously (PRD AC 2).
51fn 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    // ── 1. Load cache (auth-optional: failures fall back to empty cache) ──────
60    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    // ── 2. Resolve token (optional) ───────────────────────────────────────────
76    let resolved = resolve_token(env_token, &cache, &cfg.server);
77    let token = resolved.as_ref().map(|r| r.token.as_str());
78
79    // ── 3. Build auth-state disclosure string ─────────────────────────────────
80    let auth_state = read_auth_state(resolved.as_ref(), &cache, &cfg.server);
81
82    // ── 4. Fetch projects ─────────────────────────────────────────────────────
83    let mut projects = client.list_projects(&cfg.server, token)?;
84
85    // ── 5. Capture total BEFORE filtering ────────────────────────────────────
86    let total = projects.len();
87
88    // ── 6. Apply --filter (case-insensitive substring) ────────────────────────
89    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    // ── 7. Sort surviving projects ascending by shortcode ─────────────────────
103    projects.sort_by(|a, b| a.shortcode.cmp(&b.shortcode));
104
105    // ── 8. Build view + meta and render ──────────────────────────────────────
106    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
121/// Describe a single DSP project.
122///
123/// Authentication is optional (public endpoint per ADR-0007). Reads `DSP_TOKEN`
124/// from the environment (env wins over cache), and delegates all work to
125/// `run_describe_impl` with injectable seams for deterministic testing.
126pub 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
136/// Internal entry point for `describe` with injectable seams for testing.
137///
138/// - `env_token`: the `DSP_TOKEN` env value (read by the public `describe` entry
139///   point before calling this, so tests never touch process env).
140/// - `cache_path`: `Some(path)` in tests to use a temp auth cache; `None` in
141///   production to use the default `~/.config/dsp-cli/auth.toml`.
142///
143/// **Auth-optional:** a cache-load failure ALWAYS falls back to an empty cache
144/// with a `tracing::warn!` — NEVER returns `Err`. Project metadata is public
145/// (ADR-0007), so a corrupt or missing `auth.toml` must still describe anonymously.
146fn 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    // ── 1. --project required (fail-fast, BEFORE any cache/IO) ───────────────
155    let project = args.project.as_deref().ok_or_else(|| {
156        Diagnostic::Usage("--project <shortcode|shortname|IRI> is required".to_string())
157    })?;
158
159    // ── 2. Load cache (auth-optional: failures fall back to empty cache) ──────
160    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    // ── 3. Resolve token (optional) ───────────────────────────────────────────
176    let resolved = resolve_token(env_token, &cache, &cfg.server);
177    let token = resolved.as_ref().map(|r| r.token.as_str());
178
179    // ── 4. Build auth-state disclosure string ─────────────────────────────────
180    let auth_state = read_auth_state(resolved.as_ref(), &cache, &cfg.server);
181
182    // ── 5. Fetch project detail ───────────────────────────────────────────────
183    let detail = client.describe_project(&cfg.server, project, token)?;
184
185    // ── 6. Build meta and render ──────────────────────────────────────────────
186    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
196/// Trigger, poll, download, and optionally clean up a project dump.
197///
198/// Reads `DSP_TOKEN` from the environment (env wins over cache per ADR-0007),
199/// uses the real system clock for the default output filename, and delegates
200/// all work to `run_impl` with injectable seams for deterministic testing.
201///
202/// Requires a system-administrator token. Returns `Diagnostic::AuthRequired`
203/// when no token is available.
204pub 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/// The operational mode for `dsp vre project dump`, derived from clap args.
229///
230/// Derived as:
231/// `if args.delete { Delete } else if args.replace { Replace } else { Default }`.
232///
233/// Clap enforces that `--replace` and `--delete` are mutually exclusive (Step 16),
234/// so the both-true case is impossible at runtime.
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236enum DumpMode {
237    /// Default: adopt an existing dump if present, otherwise create fresh.
238    Default,
239    /// `--replace`: delete any existing dump and create a fresh one.
240    Replace,
241    /// `--delete`: remove the existing dump without downloading.
242    Delete,
243}
244
245/// Internal entry point with injectable seams for testing.
246///
247/// - `env_token`: the `DSP_TOKEN` env value (read by the public `dump` entry
248///   point before calling this, so tests never touch process env).
249/// - `sleeper`: a `Fn(Duration)` called between poll attempts; tests use a
250///   no-op `|_| {}` so the logical clock advances without real wall-clock time.
251/// - `now`: used only to build the default output filename; the poll-loop
252///   timeout is an explicit logical accumulator independent of this.
253/// - `cache_path`: `Some(path)` in tests to use a temp auth cache; `None` in
254///   production to use the default `~/.config/dsp-cli/auth.toml`.
255/// - `cwd`: the base directory for the default output path when `--output` is
256///   not specified. The public `dump` entry point passes the real process CWD
257///   (obtained via `std::env::current_dir()`); tests pass an explicit
258///   `TempDir` path so they never mutate process-global state.
259#[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    // ── 1. Resolve token (fail fast) ──────────────────────────────────────────
273    // ADR-0007: a non-blank DSP_TOKEN wins over the cache. A corrupt/unreadable
274    // auth.toml must not mask the env token — tolerate cache-load failures only
275    // when the env token would win (mirrors auth::status).
276    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    // ── 2. --project required ─────────────────────────────────────────────────
308    let project = args.project.as_deref().ok_or_else(|| {
309        Diagnostic::Usage("--project <shortcode|shortname|IRI> is required".to_string())
310    })?;
311
312    // ── 3. Derive mode from args ──────────────────────────────────────────────
313    let mode = if args.delete {
314        DumpMode::Delete
315    } else if args.replace {
316        DumpMode::Replace
317    } else {
318        DumpMode::Default
319    };
320
321    // ── 4. Staged overwrite guard (only when downloading) ─────────────────────
322    // Delete mode neither downloads nor produces an output file — skip the guard.
323    // For Default/Replace: check for an explicit collision BEFORE any server call.
324    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    // ── 5. Resolve project (always — every mode needs the IRI) ───────────────
341    let proj = client.resolve_project(&cfg.server, project)?;
342
343    // ── 6. Compute output path (Download modes only) ──────────────────────────
344    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    // ── 7. Create (probe) the dump ────────────────────────────────────────────
364    let create_outcome =
365        client.create_project_dump(&cfg.server, &proj.iri, args.skip_assets, &token)?;
366
367    // ── 8. Build MetaContext for the final render ─────────────────────────────
368    // Use the shared ADR-0007 helper so _meta.auth uses the same uniform
369    // vocabulary as all other commands (presence/origin, not validity).
370    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    // ── 9. Dispatch by mode + create outcome ──────────────────────────────────
379    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
424// ---------------------------------------------------------------------------
425// Private helpers
426// ---------------------------------------------------------------------------
427
428/// Map a `CreateDumpOutcome` from the second `create_project_dump` call in
429/// `handle_replace` (after an existing dump was deleted) to `(id, created_at)`.
430///
431/// Both the `ExistsForOtherProject`-then-discard path and the `Exists`-then-delete
432/// path use identical race-handling arms after the re-create call. This helper
433/// centralises that logic so the messages stay byte-identical across both paths.
434///
435/// Returns `Ok((id, created_at))` on `Created`, or a `Conflict` error for the
436/// two race variants (`Exists` again, or `ExistsForOtherProject` with a new racer).
437fn 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// ---------------------------------------------------------------------------
455// Private mode helpers
456// ---------------------------------------------------------------------------
457
458/// Handle `create_project_dump` result in **Default** mode.
459///
460/// - `Created(task)` → poll → download → optional cleanup → `DumpOutcome{reused:false}`.
461/// - `Exists{id}` → fetch status:
462///   - `Completed` → Adopting → download → cleanup → `DumpOutcome{reused:true}`.
463///   - `InProgress` → Adopting → poll → download → cleanup → `DumpOutcome{reused:true}`.
464///   - `Failed` → `Conflict` with hint to use `--replace` or `--delete`.
465#[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            // Fetch current status to decide what to do.
524            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/// Handle `create_project_dump` result in **Replace** mode.
599///
600/// - `Created(task)` → poll → download → `DumpOutcome{reused:false}`.
601/// - `Exists{id}`:
602///   - `InProgress` → `Conflict` (can't replace in-progress).
603///   - `Completed`/`Failed` → Deleting → delete → create again:
604///     - `Created(task2)` → poll → download → `DumpOutcome{reused:false}`.
605///     - `Exists{..}` again (race) → `Conflict`.
606///     - `Err(_)` propagates.
607#[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            // --discard-other-project given: status-check the FOREIGN dump via its OWN iri (intentional —
668            // never the requested project's iri).
669            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                    // Recreate for the REQUESTED project (outer project_iri).
685                    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                    // Delete the existing dump and create a fresh one.
703                    reporter.report(&DumpEvent::Deleting { id: id.clone() })?;
704                    client.delete_project_dump(&cfg.server, project_iri, &id, token)?;
705                    // Re-create — may race with another client.
706                    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/// Handle `create_project_dump` result in **Delete** mode.
742///
743/// - `Exists{id}`:
744///   - `Completed`/`Failed` → Deleting → delete → `project_dump_deleted{deleted:true}`.
745///   - `InProgress` → `Conflict`.
746/// - `Created(task)` → nothing existed; a probe created a new in-progress dump.
747///   Discloses via `ProbeCreated` event, emits `project_dump_deleted{deleted:false}`.
748///   Does NOT attempt to delete the in-progress dump (would 409).
749#[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            // Nothing existed; the POST probe created a new in-progress dump.
796            // Disclose the side effect explicitly — do not attempt to delete it
797            // (it is in_progress and a DELETE would 409).
798            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// ---------------------------------------------------------------------------
823// Shared poll loop
824// ---------------------------------------------------------------------------
825
826/// Poll `get_project_dump_status` until `Completed`, using a deterministic
827/// logical clock and capped exponential backoff.
828///
829/// Returns `Ok(())` on completion. Returns `Err(ServerError)` on:
830/// - `Failed` status from the server.
831/// - Timeout (logical elapsed ≥ `timeout_secs`).
832#[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                // Report BEFORE incrementing elapsed so the first event reads 0s.
861                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
879/// Run optional cleanup after a successful download.
880///
881/// On success returns `true`; on any error logs a warning and returns `false`
882/// (non-fatal, exit stays 0).
883fn 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
903/// Stream a completed server-side dump to a local file atomically.
904///
905/// IMPORTANT: Every `std::io::Error` in this helper must be mapped to
906/// `Diagnostic::Io(format!("…{path}…: {e}"))` — NEVER use bare `?` on an
907/// `io::Error` here. Bare `?` would hit `From<io::Error> → Diagnostic::Internal`
908/// and mis-classify the error as an internal bug instead of a user-visible
909/// filesystem failure. See `Diagnostic::Io` for the design rationale.
910///
911/// Implementation:
912/// 1. Create a sibling temp file `<final>.<pid>.partial` with `O_EXCL` so no
913///    concurrent writer is clobbered.
914/// 2. On Unix, restrict the file to owner-only (`0o600`) — dumps may hold
915///    sensitive research data.
916/// 3. Stream the download into the temp file.
917/// 4. `flush()` + `sync_all()` before rename to avoid truncated archives.
918/// 5. `rename(temp, final)` — sibling temp guarantees same-filesystem, no EXDEV.
919/// 6. On any error: best-effort `remove_file(temp)` and return the `Io` diagnostic.
920///
921/// Returns the number of bytes written.
922fn 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    // Guard: a path like `/` or an empty path has no file_name component and
931    // would silently produce a degenerate temp name. Fail fast instead. Bind
932    // the file name here so the invariant is structural (no later unwrap).
933    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    // Create the temp file with O_EXCL (create_new = true); on Unix also set
952    // mode 0o600 so dumps (which may contain sensitive research data) are
953    // owner-readable only — matching the auth.toml 0600 idiom.
954    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    // Stream download into the temp file.
971    let result = client.download_project_dump(server, project_iri, dump_id, token, &mut file);
972
973    let bytes = match result {
974        Err(e) => {
975            // Best-effort cleanup of the temp file before returning.
976            let _ = std::fs::remove_file(&temp_path);
977            return Err(e);
978        }
979        Ok(n) => n,
980    };
981
982    // flush() then sync_all() before rename — prevents truncated archives from
983    // a buffered or interrupted write.
984    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    // Rename temp → final (sibling temp ⇒ same filesystem ⇒ no EXDEV).
1001    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
1013/// Compute the default output path for a dump.
1014///
1015/// Produces `<base>/<shortcode>-<timestamp>.zip` using the provided base
1016/// directory, the shortcode of the resolved project, and the injected `now`
1017/// value (for deterministic tests). When `base` is absolute (the normal case),
1018/// the result is absolute — no process CWD dependency.
1019fn 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// ─────────────────────────────────────────────────────────────────────────────
1024// Tests
1025// ─────────────────────────────────────────────────────────────────────────────
1026
1027#[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    // ── MockDspClient ─────────────────────────────────────────────────────────
1056
1057    /// Records which client method was called in sequence, for asserting
1058    /// exact call order in mode-aware orchestration tests.
1059    ///
1060    /// `Status(iri)` and `Delete(iri)` carry the `project_iri` argument so tests
1061    /// can assert that the FOREIGN iri (not the requested project's) was used in
1062    /// cross-project scenarios.  `Create { project_iri }` similarly locks in the
1063    /// IRI used for dump creation, closing the mirror gap for recreate calls.
1064    #[derive(Debug, Clone, PartialEq)]
1065    enum CallRecord {
1066        Resolve,
1067        /// Carries the `project_iri` argument passed to `create_project_dump`,
1068        /// so tests can assert the recreate call used the REQUESTED project's IRI
1069        /// and not a foreign IRI.
1070        Create {
1071            project_iri: String,
1072        },
1073        /// Came from `poll_sequence` (not `status_sequence`).
1074        Poll,
1075        Download,
1076        /// Carries the `project_iri` passed to `delete_project_dump`.
1077        Delete(String),
1078        /// Came from `status_sequence`; carries the `project_iri` passed.
1079        Status(String),
1080        /// Came from `list_projects`; carries the token passed (for auth assertions).
1081        ListProjects {
1082            token: Option<String>,
1083        },
1084    }
1085
1086    /// Local mock client for orchestration tests. Tracks call counts and
1087    /// call order so tests can assert "no server call was made" for the
1088    /// fail-fast guard paths, and assert the exact sequence per mode.
1089    ///
1090    /// Also records the `skip_assets` value passed to `create_project_dump`.
1091    /// For `--replace` mode, `create_result` may be used twice — the second
1092    /// call pops from `create_sequence` if set.
1093    struct MockDspClient {
1094        resolve_result: Option<Result<ProjectRef, Diagnostic>>,
1095        resolve_calls: RefCell<u32>,
1096
1097        /// The primary create result (first call).
1098        create_result: Option<Result<CreateDumpOutcome, Diagnostic>>,
1099        /// If set, the second call to `create_project_dump` pops from this
1100        /// instead of using `create_result` again.
1101        create_sequence: RefCell<VecDeque<Result<CreateDumpOutcome, Diagnostic>>>,
1102        create_calls: RefCell<u32>,
1103        /// Records the `skip_assets` argument passed on the most recent call to
1104        /// `create_project_dump`. `None` means the method was never called.
1105        create_skip_assets: Cell<Option<bool>>,
1106
1107        // Poll progression: pop-front per call; panics on exhaustion so an
1108        // over-eager loop fails loudly instead of silently looping forever.
1109        poll_sequence: RefCell<VecDeque<Result<DumpTask, Diagnostic>>>,
1110        poll_calls: RefCell<u32>,
1111
1112        // Status progression: pop-front per call (used for get_project_dump_status
1113        // in the Exists path). If empty, falls back to poll_sequence.
1114        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        /// Canned result for `list_projects`. When `None`, returns `NotImplemented`.
1124        list_projects_result: Option<Result<Vec<Project>, Diagnostic>>,
1125        list_projects_calls: RefCell<u32>,
1126        /// Records the token argument passed to the most recent `list_projects` call.
1127        /// `None` before any call; `Some(None)` means called with token=None;
1128        /// `Some(Some(t))` means called with token=Some(t).
1129        list_projects_token: RefCell<Option<Option<String>>>,
1130
1131        /// Canned result for `describe_project`. When `None`, returns `NotImplemented`.
1132        describe_project_result: Option<Result<ProjectDetail, Diagnostic>>,
1133        /// Records the (project, token) arguments passed to the most recent
1134        /// `describe_project` call. `None` before any call.
1135        describe_project_call: RefCell<Option<(String, Option<String>)>>,
1136
1137        /// Records all client calls in order, for exact-sequence assertions.
1138        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        /// Set a second-and-beyond sequence of results for `create_project_dump`
1195        /// (used in replace tests where create is called twice).
1196        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        /// Set a status sequence used for `get_project_dump_status` in the
1213        /// Exists path (first pop from this, then falls back to poll_sequence).
1214        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        /// Return the token argument passed to the most recent `list_projects` call.
1255        /// Panics if `list_projects` was never called.
1256        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        /// Return the (project, token) arguments passed to the most recent
1265        /// `describe_project` call. Panics if `describe_project` was never called.
1266        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        /// Return `true` if `describe_project` was called at least once.
1274        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            // On the second+ call, pop from create_sequence if available.
1310            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            // First try the status_sequence (for Exists-path one-shot checks);
1328            // if empty, fall back to poll_sequence (for the poll loop).
1329            // The call_log distinguishes: Status(iri) = came from status_sequence,
1330            // Poll = came from poll_sequence.
1331            let from_status = self.status_sequence.borrow_mut().pop_front();
1332            if let Some(result) = from_status {
1333                self.call_log
1334                    .borrow_mut()
1335                    .push(CallRecord::Status(project_iri.to_string()));
1336                return result;
1337            }
1338            *self.poll_calls.borrow_mut() += 1;
1339            self.call_log.borrow_mut().push(CallRecord::Poll);
1340            self.poll_sequence
1341                .borrow_mut()
1342                .pop_front()
1343                .expect("poll_sequence exhausted — test bug: provide enough entries or let the logical clock fire first")
1344        }
1345
1346        fn download_project_dump(
1347            &self,
1348            _server: &str,
1349            _project_iri: &str,
1350            _dump_id: &str,
1351            _token: &str,
1352            dest: &mut dyn std::io::Write,
1353        ) -> Result<u64, Diagnostic> {
1354            *self.download_calls.borrow_mut() += 1;
1355            self.call_log.borrow_mut().push(CallRecord::Download);
1356            if let Some(ref e) = self.download_error {
1357                return Err(e.clone());
1358            }
1359            let bytes = self.download_bytes.as_deref().unwrap_or(&[]);
1360            dest.write_all(bytes)
1361                .map_err(|e| Diagnostic::Internal(format!("mock write error: {e}")))?;
1362            Ok(bytes.len() as u64)
1363        }
1364
1365        fn delete_project_dump(
1366            &self,
1367            _server: &str,
1368            project_iri: &str,
1369            _dump_id: &str,
1370            _token: &str,
1371        ) -> Result<(), Diagnostic> {
1372            *self.delete_calls.borrow_mut() += 1;
1373            self.call_log
1374                .borrow_mut()
1375                .push(CallRecord::Delete(project_iri.to_string()));
1376            self.delete_result
1377                .clone()
1378                .expect("delete_result must be set when delete_project_dump is called")
1379        }
1380
1381        fn list_projects(
1382            &self,
1383            _server: &str,
1384            token: Option<&str>,
1385        ) -> Result<Vec<crate::model::Project>, Diagnostic> {
1386            *self.list_projects_calls.borrow_mut() += 1;
1387            *self.list_projects_token.borrow_mut() = Some(token.map(str::to_owned));
1388            self.call_log.borrow_mut().push(CallRecord::ListProjects {
1389                token: token.map(str::to_owned),
1390            });
1391            match &self.list_projects_result {
1392                Some(r) => r.clone(),
1393                None => Err(Diagnostic::NotImplemented(
1394                    "list_projects not configured in MockDspClient".into(),
1395                )),
1396            }
1397        }
1398
1399        fn describe_project(
1400            &self,
1401            _server: &str,
1402            project: &str,
1403            token: Option<&str>,
1404        ) -> Result<crate::model::ProjectDetail, Diagnostic> {
1405            *self.describe_project_call.borrow_mut() =
1406                Some((project.to_owned(), token.map(str::to_owned)));
1407            match &self.describe_project_result {
1408                Some(r) => r.clone(),
1409                None => Err(Diagnostic::NotImplemented(
1410                    "describe_project not configured in MockDspClient".into(),
1411                )),
1412            }
1413        }
1414
1415        fn list_data_models(
1416            &self,
1417            _server: &str,
1418            _project_iri: &str,
1419            _token: Option<&str>,
1420        ) -> Result<Vec<crate::model::DataModel>, Diagnostic> {
1421            unimplemented!("list_data_models not used by project tests")
1422        }
1423
1424        fn describe_data_model(
1425            &self,
1426            _server: &str,
1427            _data_model_iri: &str,
1428            _token: Option<&str>,
1429        ) -> Result<crate::model::DataModelDetail, Diagnostic> {
1430            unimplemented!("describe_data_model not used in project tests")
1431        }
1432
1433        fn describe_resource_type(
1434            &self,
1435            _server: &str,
1436            _data_model_iri: &str,
1437            _resource_type: &str,
1438            _token: Option<&str>,
1439        ) -> Result<crate::model::ResourceTypeDetail, Diagnostic> {
1440            unimplemented!("describe_resource_type not used in project tests")
1441        }
1442
1443        fn data_model_structure(
1444            &self,
1445            _server: &str,
1446            _data_model_iri: &str,
1447            _token: Option<&str>,
1448        ) -> Result<crate::model::DataModelStructure, Diagnostic> {
1449            unimplemented!("data_model_structure not used in project tests")
1450        }
1451
1452        fn list_resources(
1453            &self,
1454            _server: &str,
1455            _project_iri: &str,
1456            _resource_type_iri: &str,
1457            _order_by: Option<&str>,
1458            _page: u32,
1459            _token: Option<&str>,
1460        ) -> Result<crate::model::ResourcePage, Diagnostic> {
1461            unimplemented!("list_resources not used in project tests")
1462        }
1463
1464        fn describe_resource(
1465            &self,
1466            _server: &str,
1467            _resource_iri: &str,
1468            _token: Option<&str>,
1469            _with_values: bool,
1470        ) -> Result<crate::model::ResourceDetail, Diagnostic> {
1471            unimplemented!("describe_resource not used in project tests")
1472        }
1473
1474        fn verify_token(&self, _server: &str, _token: &str) -> Result<(), Diagnostic> {
1475            unimplemented!("verify_token not used by dump tests")
1476        }
1477
1478        fn resource_counts(
1479            &self,
1480            _server: &str,
1481            _project_iri: &str,
1482            _token: Option<&str>,
1483        ) -> Result<std::collections::HashMap<String, u64>, Diagnostic> {
1484            Ok(std::collections::HashMap::new())
1485        }
1486
1487        fn list_vocabularies(
1488            &self,
1489            _server: &str,
1490            _project_iri: &str,
1491            _token: Option<&str>,
1492        ) -> Result<Vec<crate::model::Vocabulary>, Diagnostic> {
1493            unimplemented!("not exercised by this file's tests")
1494        }
1495
1496        fn describe_vocabulary(
1497            &self,
1498            _server: &str,
1499            _iri: &str,
1500            _token: Option<&str>,
1501        ) -> Result<crate::model::VocabularyTree, Diagnostic> {
1502            unimplemented!("not exercised by this file's tests")
1503        }
1504    }
1505
1506    // ── RecordingRenderer ─────────────────────────────────────────────────────
1507
1508    struct RecordingRenderer {
1509        dump_outcome: Option<DumpOutcome>,
1510        dump_meta: Option<MetaContext>,
1511        dump_deleted_outcome: Option<DumpDeleteOutcome>,
1512        dump_deleted_meta: Option<MetaContext>,
1513        /// Recorded from `projects()` calls.
1514        projects_view: Option<(Vec<Project>, usize, Option<String>)>,
1515        projects_meta: Option<MetaContext>,
1516        /// Recorded from `project_describe()` calls.
1517        describe_detail: Option<ProjectDetail>,
1518        describe_meta: Option<MetaContext>,
1519    }
1520
1521    impl RecordingRenderer {
1522        fn new() -> Self {
1523            Self {
1524                dump_outcome: None,
1525                dump_meta: None,
1526                dump_deleted_outcome: None,
1527                dump_deleted_meta: None,
1528                projects_view: None,
1529                projects_meta: None,
1530                describe_detail: None,
1531                describe_meta: None,
1532            }
1533        }
1534    }
1535
1536    impl Renderer for RecordingRenderer {
1537        fn diagnostic(
1538            &mut self,
1539            _diag: &Diagnostic,
1540            _meta: &MetaContext,
1541        ) -> Result<(), Diagnostic> {
1542            Ok(())
1543        }
1544
1545        fn auth_login(
1546            &mut self,
1547            _outcome: &AuthLoginOutcome,
1548            _meta: &MetaContext,
1549        ) -> Result<(), Diagnostic> {
1550            Ok(())
1551        }
1552
1553        fn auth_status(
1554            &mut self,
1555            _outcome: &AuthStatusOutcome,
1556            _meta: &MetaContext,
1557        ) -> Result<(), Diagnostic> {
1558            Ok(())
1559        }
1560
1561        fn auth_logout(
1562            &mut self,
1563            _outcome: &AuthLogoutOutcome,
1564            _meta: &MetaContext,
1565        ) -> Result<(), Diagnostic> {
1566            Ok(())
1567        }
1568
1569        fn auth_set_token(
1570            &mut self,
1571            _outcome: &AuthSetTokenOutcome,
1572            _meta: &MetaContext,
1573        ) -> Result<(), Diagnostic> {
1574            Ok(())
1575        }
1576
1577        fn project_dump(
1578            &mut self,
1579            outcome: &DumpOutcome,
1580            meta: &MetaContext,
1581        ) -> Result<(), Diagnostic> {
1582            self.dump_outcome = Some(DumpOutcome {
1583                path: outcome.path.clone(),
1584                bytes: outcome.bytes,
1585                cleaned_up: outcome.cleaned_up,
1586                reused: outcome.reused,
1587                created_at: outcome.created_at,
1588            });
1589            self.dump_meta = Some(meta.clone());
1590            Ok(())
1591        }
1592
1593        fn project_dump_deleted(
1594            &mut self,
1595            outcome: &DumpDeleteOutcome,
1596            meta: &MetaContext,
1597        ) -> Result<(), Diagnostic> {
1598            self.dump_deleted_outcome = Some(DumpDeleteOutcome {
1599                deleted: outcome.deleted,
1600                note: outcome.note.clone(),
1601            });
1602            self.dump_deleted_meta = Some(meta.clone());
1603            Ok(())
1604        }
1605
1606        fn projects(
1607            &mut self,
1608            view: &ProjectListView,
1609            meta: &MetaContext,
1610        ) -> Result<(), Diagnostic> {
1611            self.projects_view = Some((view.items.clone(), view.total, view.filter.clone()));
1612            self.projects_meta = Some(meta.clone());
1613            Ok(())
1614        }
1615
1616        fn project_describe(
1617            &mut self,
1618            project: &ProjectDetail,
1619            meta: &MetaContext,
1620        ) -> Result<(), Diagnostic> {
1621            self.describe_detail = Some(project.clone());
1622            self.describe_meta = Some(meta.clone());
1623            Ok(())
1624        }
1625
1626        fn data_models(
1627            &mut self,
1628            _view: &crate::render::DataModelListView,
1629            _meta: &MetaContext,
1630        ) -> Result<(), Diagnostic> {
1631            Ok(())
1632        }
1633
1634        fn data_model_describe(
1635            &mut self,
1636            _detail: &crate::model::DataModelDetail,
1637            _meta: &MetaContext,
1638        ) -> Result<(), Diagnostic> {
1639            Ok(())
1640        }
1641
1642        fn resource_types(
1643            &mut self,
1644            _view: &crate::render::ResourceTypeListView,
1645            _meta: &MetaContext,
1646        ) -> Result<(), Diagnostic> {
1647            Ok(())
1648        }
1649
1650        fn resource_type_describe(
1651            &mut self,
1652            _detail: &crate::model::ResourceTypeDetail,
1653            _meta: &MetaContext,
1654        ) -> Result<(), Diagnostic> {
1655            unimplemented!("resource_type_describe not used in project action tests")
1656        }
1657
1658        fn data_model_structure(
1659            &mut self,
1660            _structure: &crate::model::DataModelStructure,
1661            _meta: &MetaContext,
1662        ) -> Result<(), Diagnostic> {
1663            unimplemented!("data_model_structure not used in project action tests")
1664        }
1665
1666        fn resources(
1667            &mut self,
1668            _view: &crate::render::ResourceListView,
1669            _meta: &MetaContext,
1670        ) -> Result<(), Diagnostic> {
1671            Ok(())
1672        }
1673
1674        fn resource_describe(
1675            &mut self,
1676            _detail: &crate::model::ResourceDetail,
1677            _meta: &MetaContext,
1678        ) -> Result<(), Diagnostic> {
1679            Ok(())
1680        }
1681
1682        fn vocabularies(
1683            &mut self,
1684            _view: &crate::render::VocabularyListView,
1685            _meta: &MetaContext,
1686        ) -> Result<(), Diagnostic> {
1687            unimplemented!("not exercised by this file's tests")
1688        }
1689
1690        fn vocabulary_describe(
1691            &mut self,
1692            _detail: &crate::model::VocabularyDetail,
1693            _meta: &MetaContext,
1694        ) -> Result<(), Diagnostic> {
1695            unimplemented!("not exercised by this file's tests")
1696        }
1697    }
1698
1699    // ── RecordingProgressReporter ─────────────────────────────────────────────
1700
1701    struct RecordingProgressReporter {
1702        events: Vec<EventRecord>,
1703    }
1704
1705    #[derive(Debug, PartialEq)]
1706    enum EventRecord {
1707        Triggered(String),
1708        Polling(u64),
1709        Downloading,
1710        Done(u64),
1711        Adopting(String),
1712        Deleting(String),
1713        ProbeCreated(String),
1714        DiscardingOtherProjectDump { id: String, project_iri: String },
1715    }
1716
1717    impl RecordingProgressReporter {
1718        fn new() -> Self {
1719            Self { events: Vec::new() }
1720        }
1721    }
1722
1723    impl ProgressReporter for RecordingProgressReporter {
1724        fn report(&mut self, event: &DumpEvent) -> Result<(), Diagnostic> {
1725            match event {
1726                DumpEvent::Triggered { id } => self.events.push(EventRecord::Triggered(id.clone())),
1727                DumpEvent::Polling { elapsed_secs, .. } => {
1728                    self.events.push(EventRecord::Polling(*elapsed_secs))
1729                }
1730                DumpEvent::Downloading => self.events.push(EventRecord::Downloading),
1731                DumpEvent::Done { bytes } => self.events.push(EventRecord::Done(*bytes)),
1732                DumpEvent::Adopting { id } => self.events.push(EventRecord::Adopting(id.clone())),
1733                DumpEvent::Deleting { id } => self.events.push(EventRecord::Deleting(id.clone())),
1734                DumpEvent::ProbeCreated { id } => {
1735                    self.events.push(EventRecord::ProbeCreated(id.clone()))
1736                }
1737                DumpEvent::DiscardingOtherProjectDump { id, project_iri } => {
1738                    self.events.push(EventRecord::DiscardingOtherProjectDump {
1739                        id: id.clone(),
1740                        project_iri: project_iri.clone(),
1741                    })
1742                }
1743            }
1744            Ok(())
1745        }
1746    }
1747
1748    // ── helpers ───────────────────────────────────────────────────────────────
1749
1750    fn fixed_now() -> chrono::DateTime<Utc> {
1751        Utc.with_ymd_and_hms(2026, 5, 29, 12, 0, 0).unwrap()
1752    }
1753
1754    fn make_project_ref() -> ProjectRef {
1755        ProjectRef {
1756            iri: "http://rdfh.ch/projects/0001".to_string(),
1757            shortcode: "0001".to_string(),
1758            shortname: "anything".to_string(),
1759        }
1760    }
1761
1762    fn make_dump_task(status: DumpStatus) -> DumpTask {
1763        DumpTask {
1764            id: "dump-id-42".to_string(),
1765            status,
1766            error_message: None,
1767            created_at: None,
1768        }
1769    }
1770
1771    fn created_task(status: DumpStatus) -> CreateDumpOutcome {
1772        CreateDumpOutcome::Created(make_dump_task(status))
1773    }
1774
1775    fn make_args(dir: &TempDir) -> (ProjectDumpArgs, Config) {
1776        let args = ProjectDumpArgs {
1777            server: Some("https://api.test.dasch.swiss".to_string()),
1778            project: Some("0001".to_string()),
1779            skip_assets: false,
1780            output: Some(dir.path().join("out.zip")),
1781            force: false,
1782            cleanup: false,
1783            timeout: 3600,
1784            replace: false,
1785            delete: false,
1786            discard_other_project: false,
1787            format: FormatArgs {
1788                format: Format::Prose,
1789                json: false,
1790                lines: false,
1791                columns: None,
1792                no_header: false,
1793                header_only: false,
1794            },
1795        };
1796        let cfg = Config {
1797            server: "https://api.test.dasch.swiss".to_string(),
1798        };
1799        (args, cfg)
1800    }
1801
1802    fn no_op_sleeper() -> impl Fn(Duration) {
1803        |_| {}
1804    }
1805
1806    // ── tests ─────────────────────────────────────────────────────────────────
1807
1808    #[test]
1809    fn happy_path_resolve_trigger_poll_download() {
1810        let dir = TempDir::new().unwrap();
1811        let (args, cfg) = make_args(&dir);
1812
1813        let client = MockDspClient::new()
1814            .with_resolve_project(Ok(make_project_ref()))
1815            .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
1816            .with_poll_sequence([
1817                Ok(make_dump_task(DumpStatus::InProgress)),
1818                Ok(make_dump_task(DumpStatus::Completed)),
1819            ])
1820            .with_download_bytes(b"PK fake zip content".to_vec());
1821
1822        let mut renderer = RecordingRenderer::new();
1823        let mut reporter = RecordingProgressReporter::new();
1824
1825        run_impl(
1826            &args,
1827            &cfg,
1828            &client,
1829            &mut renderer,
1830            &mut reporter,
1831            Some("env-token-abc".to_string()),
1832            &no_op_sleeper(),
1833            fixed_now(),
1834            None,
1835            dir.path(),
1836        )
1837        .unwrap();
1838
1839        let outcome = renderer.dump_outcome.unwrap();
1840        assert_eq!(outcome.bytes, 19); // b"PK fake zip content".len()
1841        assert!(!outcome.cleaned_up);
1842
1843        // The output file must exist on disk and be non-empty after a happy-path run.
1844        assert!(
1845            outcome.path.exists(),
1846            "output file must exist after happy-path download"
1847        );
1848        assert!(
1849            outcome.path.metadata().unwrap().len() > 0,
1850            "output file must be non-empty after happy-path download"
1851        );
1852
1853        // Reporter saw: Triggered, Polling(0), Downloading, Done
1854        assert_eq!(
1855            reporter.events[0],
1856            EventRecord::Triggered("dump-id-42".to_string())
1857        );
1858        assert_eq!(reporter.events[1], EventRecord::Polling(0));
1859        assert_eq!(reporter.events[2], EventRecord::Downloading);
1860        assert_eq!(reporter.events[3], EventRecord::Done(19));
1861        assert_eq!(reporter.events.len(), 4);
1862    }
1863
1864    #[test]
1865    fn default_filename_uses_shortcode_and_fixed_timestamp() {
1866        let dir = TempDir::new().unwrap();
1867        let cache_path = dir.path().join("auth.toml");
1868        // No explicit --output: default path is <cwd>/0001-20260529T120000Z.zip.
1869        // The injected cwd seam points at the tempdir so the file lands there
1870        // and no process-global CWD mutation is needed.
1871        let args = ProjectDumpArgs {
1872            server: Some("https://api.test.dasch.swiss".to_string()),
1873            project: Some("0001".to_string()),
1874            skip_assets: false,
1875            output: None,
1876            force: true, // skip the overwrite guard; we just want to verify the name
1877            cleanup: false,
1878            timeout: 3600,
1879            replace: false,
1880            delete: false,
1881            discard_other_project: false,
1882            format: FormatArgs {
1883                format: Format::Prose,
1884                json: false,
1885                lines: false,
1886                columns: None,
1887                no_header: false,
1888                header_only: false,
1889            },
1890        };
1891        let cfg = Config {
1892            server: "https://api.test.dasch.swiss".to_string(),
1893        };
1894
1895        let client = MockDspClient::new()
1896            .with_resolve_project(Ok(make_project_ref()))
1897            .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
1898            .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
1899            .with_download_bytes(b"zip".to_vec());
1900
1901        let mut renderer = RecordingRenderer::new();
1902        let mut reporter = RecordingProgressReporter::new();
1903
1904        run_impl(
1905            &args,
1906            &cfg,
1907            &client,
1908            &mut renderer,
1909            &mut reporter,
1910            Some("tok".to_string()),
1911            &no_op_sleeper(),
1912            fixed_now(),
1913            Some(&cache_path),
1914            dir.path(),
1915        )
1916        .unwrap();
1917
1918        let outcome = renderer.dump_outcome.unwrap();
1919        // The path should be <tempdir>/0001-20260529T120000Z.zip.
1920        let expected_path = dir.path().join("0001-20260529T120000Z.zip");
1921        assert_eq!(outcome.path, expected_path);
1922    }
1923
1924    #[test]
1925    fn explicit_output_override_respected() {
1926        let dir = TempDir::new().unwrap();
1927        let out_path = dir.path().join("custom.zip");
1928        let (mut args, cfg) = make_args(&dir);
1929        args.output = Some(out_path.clone());
1930
1931        let client = MockDspClient::new()
1932            .with_resolve_project(Ok(make_project_ref()))
1933            .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
1934            .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
1935            .with_download_bytes(b"data".to_vec());
1936
1937        let mut renderer = RecordingRenderer::new();
1938        let mut reporter = RecordingProgressReporter::new();
1939
1940        run_impl(
1941            &args,
1942            &cfg,
1943            &client,
1944            &mut renderer,
1945            &mut reporter,
1946            Some("tok".to_string()),
1947            &no_op_sleeper(),
1948            fixed_now(),
1949            None,
1950            dir.path(),
1951        )
1952        .unwrap();
1953
1954        let outcome = renderer.dump_outcome.unwrap();
1955        assert_eq!(outcome.path, out_path);
1956        assert!(out_path.exists());
1957    }
1958
1959    #[test]
1960    fn explicit_output_exists_no_force_returns_usage_before_any_client_call() {
1961        let dir = TempDir::new().unwrap();
1962        let out_path = dir.path().join("existing.zip");
1963        std::fs::write(&out_path, b"existing").unwrap();
1964
1965        let (mut args, cfg) = make_args(&dir);
1966        args.output = Some(out_path.clone());
1967        args.force = false;
1968
1969        // Client should NOT be called at all — fail-fast before any server call.
1970        let client = MockDspClient::new();
1971
1972        let mut renderer = RecordingRenderer::new();
1973        let mut reporter = RecordingProgressReporter::new();
1974
1975        let err = run_impl(
1976            &args,
1977            &cfg,
1978            &client,
1979            &mut renderer,
1980            &mut reporter,
1981            Some("tok".to_string()),
1982            &no_op_sleeper(),
1983            fixed_now(),
1984            None,
1985            dir.path(),
1986        )
1987        .unwrap_err();
1988
1989        assert!(
1990            matches!(err, Diagnostic::Usage(_)),
1991            "expected Usage, got {err:?}"
1992        );
1993        assert!(
1994            err.to_string().contains("refusing to overwrite"),
1995            "message should mention overwrite refusal: {err}"
1996        );
1997        // Assert no client method was called.
1998        assert_eq!(
1999            *client.resolve_calls.borrow(),
2000            0,
2001            "resolve_project must not be called"
2002        );
2003        assert_eq!(
2004            *client.create_calls.borrow(),
2005            0,
2006            "create must not be called"
2007        );
2008    }
2009
2010    #[test]
2011    fn default_output_exists_no_force_returns_usage_after_resolve_before_trigger() {
2012        let dir = TempDir::new().unwrap();
2013        let cache_path = dir.path().join("auth.toml");
2014
2015        let args = ProjectDumpArgs {
2016            server: Some("https://api.test.dasch.swiss".to_string()),
2017            project: Some("0001".to_string()),
2018            skip_assets: false,
2019            output: None,
2020            force: false,
2021            cleanup: false,
2022            timeout: 3600,
2023            replace: false,
2024            delete: false,
2025            discard_other_project: false,
2026            format: FormatArgs {
2027                format: Format::Prose,
2028                json: false,
2029                lines: false,
2030                columns: None,
2031                no_header: false,
2032                header_only: false,
2033            },
2034        };
2035        let cfg = Config {
2036            server: "https://api.test.dasch.swiss".to_string(),
2037        };
2038
2039        let client = MockDspClient::new().with_resolve_project(Ok(make_project_ref()));
2040        let mut renderer = RecordingRenderer::new();
2041        let mut reporter = RecordingProgressReporter::new();
2042
2043        // Pre-create the default file inside the tempdir so the overwrite-guard fires.
2044        // No CWD mutation needed: the injected `cwd` seam points at `dir.path()`.
2045        let default_path_in_tempdir = dir.path().join("0001-20260529T120000Z.zip");
2046        std::fs::write(&default_path_in_tempdir, b"existing")
2047            .expect("must be able to write conflict file into tempdir");
2048
2049        let err = run_impl(
2050            &args,
2051            &cfg,
2052            &client,
2053            &mut renderer,
2054            &mut reporter,
2055            Some("tok".to_string()),
2056            &no_op_sleeper(),
2057            fixed_now(),
2058            Some(&cache_path),
2059            dir.path(),
2060        )
2061        .unwrap_err();
2062
2063        // Guard fires after resolve but before trigger: unconditional assertions.
2064        assert!(
2065            matches!(err, Diagnostic::Usage(_)),
2066            "expected Usage, got {err:?}"
2067        );
2068        assert!(
2069            err.to_string().contains("refusing to overwrite"),
2070            "message should mention overwrite refusal: {err}"
2071        );
2072        assert_eq!(
2073            *client.resolve_calls.borrow(),
2074            1,
2075            "resolve must have been called (default-path guard runs after resolve)"
2076        );
2077        assert_eq!(
2078            *client.create_calls.borrow(),
2079            0,
2080            "trigger must NOT have been called (guard fires before trigger)"
2081        );
2082    }
2083
2084    #[test]
2085    fn missing_token_returns_auth_required_with_no_client_calls() {
2086        let dir = TempDir::new().unwrap();
2087        let cache_path = dir.path().join("auth.toml");
2088        let (args, cfg) = make_args(&dir);
2089
2090        let client = MockDspClient::new();
2091        let mut renderer = RecordingRenderer::new();
2092        let mut reporter = RecordingProgressReporter::new();
2093
2094        // No env token, no cache entry → AuthRequired.
2095        let err = run_impl(
2096            &args,
2097            &cfg,
2098            &client,
2099            &mut renderer,
2100            &mut reporter,
2101            None, // no env token
2102            &no_op_sleeper(),
2103            fixed_now(),
2104            Some(&cache_path), // empty cache
2105            dir.path(),
2106        )
2107        .unwrap_err();
2108
2109        assert!(
2110            matches!(err, Diagnostic::AuthRequired(_)),
2111            "expected AuthRequired, got {err:?}"
2112        );
2113        assert_eq!(
2114            *client.resolve_calls.borrow(),
2115            0,
2116            "resolve must not be called"
2117        );
2118        assert_eq!(
2119            *client.create_calls.borrow(),
2120            0,
2121            "trigger must not be called"
2122        );
2123    }
2124
2125    #[test]
2126    fn trigger_conflict_propagates() {
2127        let dir = TempDir::new().unwrap();
2128        let (args, cfg) = make_args(&dir);
2129
2130        let client = MockDspClient::new()
2131            .with_resolve_project(Ok(make_project_ref()))
2132            .with_create_dump(Err(Diagnostic::Conflict(
2133                "a dump for this project is already in progress or present".to_string(),
2134            )));
2135
2136        let mut renderer = RecordingRenderer::new();
2137        let mut reporter = RecordingProgressReporter::new();
2138
2139        let err = run_impl(
2140            &args,
2141            &cfg,
2142            &client,
2143            &mut renderer,
2144            &mut reporter,
2145            Some("tok".to_string()),
2146            &no_op_sleeper(),
2147            fixed_now(),
2148            None,
2149            dir.path(),
2150        )
2151        .unwrap_err();
2152
2153        assert!(
2154            matches!(err, Diagnostic::Conflict(_)),
2155            "expected Conflict, got {err:?}"
2156        );
2157    }
2158
2159    #[test]
2160    fn poll_failed_returns_server_error() {
2161        let dir = TempDir::new().unwrap();
2162        let (args, cfg) = make_args(&dir);
2163
2164        let failed_task = DumpTask {
2165            id: "dump-id-42".to_string(),
2166            status: DumpStatus::Failed,
2167            error_message: Some("out of disk space".to_string()),
2168            created_at: None,
2169        };
2170        let client = MockDspClient::new()
2171            .with_resolve_project(Ok(make_project_ref()))
2172            .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2173            .with_poll_sequence([Ok(failed_task)]);
2174
2175        let mut renderer = RecordingRenderer::new();
2176        let mut reporter = RecordingProgressReporter::new();
2177
2178        let err = run_impl(
2179            &args,
2180            &cfg,
2181            &client,
2182            &mut renderer,
2183            &mut reporter,
2184            Some("tok".to_string()),
2185            &no_op_sleeper(),
2186            fixed_now(),
2187            None,
2188            dir.path(),
2189        )
2190        .unwrap_err();
2191
2192        assert!(
2193            matches!(err, Diagnostic::ServerError(_)),
2194            "expected ServerError, got {err:?}"
2195        );
2196        let msg = err.to_string();
2197        assert!(
2198            msg.contains("server-side dump failed"),
2199            "message should mention dump failure: {msg}"
2200        );
2201        assert!(
2202            msg.contains("out of disk space"),
2203            "message should include error_message: {msg}"
2204        );
2205    }
2206
2207    #[test]
2208    fn timeout_via_logical_clock_returns_server_error() {
2209        let dir = TempDir::new().unwrap();
2210        let (mut args, cfg) = make_args(&dir);
2211        // A tiny timeout of 1s. BASE=1s, so after the first in_progress response
2212        // elapsed=0, delay=1s, elapsed+delay=1s >= timeout=1s → terminate.
2213        args.timeout = 1;
2214
2215        // Provide a long enough poll sequence that exhaustion won't fire first.
2216        let in_progress: Vec<Result<DumpTask, Diagnostic>> = (0..100)
2217            .map(|_| Ok(make_dump_task(DumpStatus::InProgress)))
2218            .collect();
2219
2220        let client = MockDspClient::new()
2221            .with_resolve_project(Ok(make_project_ref()))
2222            .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2223            .with_poll_sequence(in_progress);
2224
2225        let mut renderer = RecordingRenderer::new();
2226        let mut reporter = RecordingProgressReporter::new();
2227
2228        let err = run_impl(
2229            &args,
2230            &cfg,
2231            &client,
2232            &mut renderer,
2233            &mut reporter,
2234            Some("tok".to_string()),
2235            &no_op_sleeper(),
2236            fixed_now(),
2237            None,
2238            dir.path(),
2239        )
2240        .unwrap_err();
2241
2242        assert!(
2243            matches!(err, Diagnostic::ServerError(_)),
2244            "expected ServerError timeout, got {err:?}"
2245        );
2246        let msg = err.to_string();
2247        assert!(
2248            msg.contains("did not complete"),
2249            "message should mention timeout: {msg}"
2250        );
2251        assert!(
2252            msg.contains("may still be running"),
2253            "message should contain user-visible hint 'may still be running': {msg}"
2254        );
2255    }
2256
2257    #[test]
2258    fn cleanup_success_sets_cleaned_up_true() {
2259        let dir = TempDir::new().unwrap();
2260        let (mut args, cfg) = make_args(&dir);
2261        args.cleanup = true;
2262
2263        let client = MockDspClient::new()
2264            .with_resolve_project(Ok(make_project_ref()))
2265            .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2266            .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2267            .with_download_bytes(b"zip".to_vec())
2268            .with_delete_result(Ok(()));
2269
2270        let mut renderer = RecordingRenderer::new();
2271        let mut reporter = RecordingProgressReporter::new();
2272
2273        run_impl(
2274            &args,
2275            &cfg,
2276            &client,
2277            &mut renderer,
2278            &mut reporter,
2279            Some("tok".to_string()),
2280            &no_op_sleeper(),
2281            fixed_now(),
2282            None,
2283            dir.path(),
2284        )
2285        .unwrap();
2286
2287        let outcome = renderer.dump_outcome.unwrap();
2288        assert!(
2289            outcome.cleaned_up,
2290            "cleanup success should set cleaned_up=true"
2291        );
2292    }
2293
2294    #[test]
2295    fn cleanup_error_keeps_exit_ok_and_cleaned_up_false() {
2296        let dir = TempDir::new().unwrap();
2297        let (mut args, cfg) = make_args(&dir);
2298        args.cleanup = true;
2299
2300        let client = MockDspClient::new()
2301            .with_resolve_project(Ok(make_project_ref()))
2302            .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2303            .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2304            .with_download_bytes(b"zip".to_vec())
2305            .with_delete_result(Err(Diagnostic::ServerError("delete failed".to_string())));
2306
2307        let mut renderer = RecordingRenderer::new();
2308        let mut reporter = RecordingProgressReporter::new();
2309
2310        // Should return Ok even when cleanup fails.
2311        run_impl(
2312            &args,
2313            &cfg,
2314            &client,
2315            &mut renderer,
2316            &mut reporter,
2317            Some("tok".to_string()),
2318            &no_op_sleeper(),
2319            fixed_now(),
2320            None,
2321            dir.path(),
2322        )
2323        .unwrap();
2324
2325        let outcome = renderer.dump_outcome.unwrap();
2326        assert!(
2327            !outcome.cleaned_up,
2328            "cleanup error should set cleaned_up=false"
2329        );
2330    }
2331
2332    #[test]
2333    fn download_error_leaves_no_file_at_target_path() {
2334        let dir = TempDir::new().unwrap();
2335        let out_path = dir.path().join("should-not-exist.zip");
2336        let (mut args, cfg) = make_args(&dir);
2337        args.output = Some(out_path.clone());
2338
2339        let client = MockDspClient::new()
2340            .with_resolve_project(Ok(make_project_ref()))
2341            .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2342            .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2343            .with_download_error(Diagnostic::Network("connection reset".to_string()));
2344
2345        let mut renderer = RecordingRenderer::new();
2346        let mut reporter = RecordingProgressReporter::new();
2347
2348        let err = run_impl(
2349            &args,
2350            &cfg,
2351            &client,
2352            &mut renderer,
2353            &mut reporter,
2354            Some("tok".to_string()),
2355            &no_op_sleeper(),
2356            fixed_now(),
2357            None,
2358            dir.path(),
2359        )
2360        .unwrap_err();
2361
2362        assert!(
2363            matches!(err, Diagnostic::Network(_)),
2364            "expected Network error, got {err:?}"
2365        );
2366        assert!(
2367            !out_path.exists(),
2368            "target file must not exist after download error"
2369        );
2370    }
2371
2372    #[test]
2373    fn default_output_path_pure_fn() {
2374        let now = Utc.with_ymd_and_hms(2026, 5, 29, 12, 0, 0).unwrap();
2375        let base = std::path::Path::new("/tmp/test-base");
2376        let path = default_output_path(base, "0001", now);
2377        assert_eq!(
2378            path,
2379            PathBuf::from("/tmp/test-base/0001-20260529T120000Z.zip")
2380        );
2381    }
2382
2383    #[test]
2384    fn auth_state_env_token_reports_authenticated_via_dsp_token() {
2385        let dir = TempDir::new().unwrap();
2386        let (args, cfg) = make_args(&dir);
2387
2388        let client = MockDspClient::new()
2389            .with_resolve_project(Ok(make_project_ref()))
2390            .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2391            .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2392            .with_download_bytes(b"zip".to_vec());
2393
2394        let mut renderer = RecordingRenderer::new();
2395        let mut reporter = RecordingProgressReporter::new();
2396
2397        run_impl(
2398            &args,
2399            &cfg,
2400            &client,
2401            &mut renderer,
2402            &mut reporter,
2403            Some("env-token".to_string()),
2404            &no_op_sleeper(),
2405            fixed_now(),
2406            None,
2407            dir.path(),
2408        )
2409        .unwrap();
2410
2411        let meta = renderer.dump_meta.unwrap();
2412        assert_eq!(meta.auth_state, "authenticated via DSP_TOKEN");
2413    }
2414
2415    #[test]
2416    fn auth_state_cache_token_reports_authenticated() {
2417        use crate::config::AuthCache;
2418        use crate::config::auth_cache::ServerEntry;
2419
2420        let dir = TempDir::new().unwrap();
2421        let cache_path = dir.path().join("auth.toml");
2422        let out_path = dir.path().join("out.zip");
2423
2424        // Put a token in the cache.
2425        let mut cache = AuthCache::default();
2426        cache.set_entry(
2427            "https://api.test.dasch.swiss",
2428            ServerEntry {
2429                token: "cache-tok".to_string(),
2430                user: None,
2431                acquired_at: None,
2432                expires_at: None,
2433            },
2434        );
2435        cache.save_to(&cache_path).unwrap();
2436
2437        let args = ProjectDumpArgs {
2438            server: Some("https://api.test.dasch.swiss".to_string()),
2439            project: Some("0001".to_string()),
2440            skip_assets: false,
2441            output: Some(out_path),
2442            force: false,
2443            cleanup: false,
2444            timeout: 3600,
2445            replace: false,
2446            delete: false,
2447            discard_other_project: false,
2448            format: FormatArgs {
2449                format: Format::Prose,
2450                json: false,
2451                lines: false,
2452                columns: None,
2453                no_header: false,
2454                header_only: false,
2455            },
2456        };
2457        let cfg = Config {
2458            server: "https://api.test.dasch.swiss".to_string(),
2459        };
2460
2461        let client = MockDspClient::new()
2462            .with_resolve_project(Ok(make_project_ref()))
2463            .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2464            .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2465            .with_download_bytes(b"zip".to_vec());
2466
2467        let mut renderer = RecordingRenderer::new();
2468        let mut reporter = RecordingProgressReporter::new();
2469
2470        run_impl(
2471            &args,
2472            &cfg,
2473            &client,
2474            &mut renderer,
2475            &mut reporter,
2476            None, // no env token → falls through to cache
2477            &no_op_sleeper(),
2478            fixed_now(),
2479            Some(&cache_path),
2480            dir.path(),
2481        )
2482        .unwrap();
2483
2484        // cache entry has no user → "authenticated" (not "authenticated as {user}")
2485        let meta = renderer.dump_meta.unwrap();
2486        assert_eq!(meta.auth_state, "authenticated");
2487    }
2488
2489    #[test]
2490    fn rename_failure_returns_io_and_temp_cleaned_up() {
2491        // Point --output at a path whose parent is an existing *directory* so
2492        // rename succeeds the temp create but fails the rename step (can't
2493        // rename a file to a path that is an existing directory on most OSes).
2494        let dir = TempDir::new().unwrap();
2495        // Create a sub-directory at the target path so rename fails.
2496        let final_path = dir.path().join("dump_dir");
2497        std::fs::create_dir(&final_path).unwrap();
2498
2499        let (mut args, cfg) = make_args(&dir);
2500        args.output = Some(final_path.clone());
2501        args.force = true; // skip the exists guard (it's a dir, exists())
2502
2503        let client = MockDspClient::new()
2504            .with_resolve_project(Ok(make_project_ref()))
2505            .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2506            .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2507            .with_download_bytes(b"zip".to_vec());
2508
2509        let mut renderer = RecordingRenderer::new();
2510        let mut reporter = RecordingProgressReporter::new();
2511
2512        let err = run_impl(
2513            &args,
2514            &cfg,
2515            &client,
2516            &mut renderer,
2517            &mut reporter,
2518            Some("tok".to_string()),
2519            &no_op_sleeper(),
2520            fixed_now(),
2521            None,
2522            dir.path(),
2523        )
2524        .unwrap_err();
2525
2526        // Should be Io (not Internal).
2527        assert!(
2528            matches!(err, Diagnostic::Io(_)),
2529            "expected Io error for rename failure, got {err:?}"
2530        );
2531
2532        // The .partial temp should have been cleaned up.
2533        let pid = std::process::id();
2534        let temp = dir.path().join(format!("dump_dir.{pid}.partial"));
2535        assert!(
2536            !temp.exists(),
2537            "temp file should be cleaned up after rename failure"
2538        );
2539    }
2540
2541    // ── Fix 4: download_error Io variant ─────────────────────────────────────
2542
2543    /// Parameterised helper that verifies a download error propagates and
2544    /// leaves no file at the target path. Covers both `Network` and `Io` error
2545    /// variants so both code paths through `stream_dump_to_path` are exercised.
2546    fn assert_download_error_leaves_no_file(download_error: Diagnostic) {
2547        let dir = TempDir::new().unwrap();
2548        let out_path = dir.path().join("should-not-exist.zip");
2549        let (mut args, cfg) = make_args(&dir);
2550        args.output = Some(out_path.clone());
2551
2552        let client = MockDspClient::new()
2553            .with_resolve_project(Ok(make_project_ref()))
2554            .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2555            .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2556            .with_download_error(download_error);
2557
2558        let mut renderer = RecordingRenderer::new();
2559        let mut reporter = RecordingProgressReporter::new();
2560
2561        let err = run_impl(
2562            &args,
2563            &cfg,
2564            &client,
2565            &mut renderer,
2566            &mut reporter,
2567            Some("tok".to_string()),
2568            &no_op_sleeper(),
2569            fixed_now(),
2570            None,
2571            dir.path(),
2572        )
2573        .unwrap_err();
2574
2575        // The error must propagate unchanged and no output file must exist.
2576        assert!(
2577            !err.to_string().is_empty(),
2578            "error message must be non-empty"
2579        );
2580        assert!(
2581            !out_path.exists(),
2582            "target file must not exist after download error ({err:?})"
2583        );
2584    }
2585
2586    #[test]
2587    fn download_io_error_leaves_no_file_at_target_path() {
2588        assert_download_error_leaves_no_file(Diagnostic::Io(
2589            "failed to write /tmp/test.zip: no space left on device".to_string(),
2590        ));
2591    }
2592
2593    // ── Fix 6: skip_assets pass-through ──────────────────────────────────────
2594
2595    #[test]
2596    fn skip_assets_true_is_passed_through_to_create_project_dump() {
2597        let dir = TempDir::new().unwrap();
2598        let (mut args, cfg) = make_args(&dir);
2599        args.skip_assets = true;
2600
2601        let client = MockDspClient::new()
2602            .with_resolve_project(Ok(make_project_ref()))
2603            .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2604            .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2605            .with_download_bytes(b"zip".to_vec());
2606
2607        let mut renderer = RecordingRenderer::new();
2608        let mut reporter = RecordingProgressReporter::new();
2609
2610        run_impl(
2611            &args,
2612            &cfg,
2613            &client,
2614            &mut renderer,
2615            &mut reporter,
2616            Some("tok".to_string()),
2617            &no_op_sleeper(),
2618            fixed_now(),
2619            None,
2620            dir.path(),
2621        )
2622        .unwrap();
2623
2624        assert_eq!(
2625            client.create_skip_assets.get(),
2626            Some(true),
2627            "skip_assets=true must be forwarded to create_project_dump"
2628        );
2629    }
2630
2631    #[test]
2632    fn skip_assets_false_is_passed_through_to_create_project_dump() {
2633        let dir = TempDir::new().unwrap();
2634        let (mut args, cfg) = make_args(&dir);
2635        args.skip_assets = false;
2636
2637        let client = MockDspClient::new()
2638            .with_resolve_project(Ok(make_project_ref()))
2639            .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2640            .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2641            .with_download_bytes(b"zip".to_vec());
2642
2643        let mut renderer = RecordingRenderer::new();
2644        let mut reporter = RecordingProgressReporter::new();
2645
2646        run_impl(
2647            &args,
2648            &cfg,
2649            &client,
2650            &mut renderer,
2651            &mut reporter,
2652            Some("tok".to_string()),
2653            &no_op_sleeper(),
2654            fixed_now(),
2655            None,
2656            dir.path(),
2657        )
2658        .unwrap();
2659
2660        assert_eq!(
2661            client.create_skip_assets.get(),
2662            Some(false),
2663            "skip_assets=false must be forwarded to create_project_dump"
2664        );
2665    }
2666
2667    // ── Amendment 1: mode-aware orchestration matrix ──────────────────────────
2668
2669    // --- Default mode ---
2670
2671    #[test]
2672    fn default_fresh_created_no_existing_dump() {
2673        // Default mode + Created → poll → download → reused:false, created_at populated.
2674        use chrono::TimeZone;
2675        let dir = TempDir::new().unwrap();
2676        let (args, cfg) = make_args(&dir);
2677        let ts = Utc.with_ymd_and_hms(2026, 5, 20, 14, 3, 0).unwrap();
2678
2679        let client = MockDspClient::new()
2680            .with_resolve_project(Ok(make_project_ref()))
2681            .with_create_dump(Ok(CreateDumpOutcome::Created(DumpTask {
2682                id: "dump-id-42".into(),
2683                status: DumpStatus::InProgress,
2684                error_message: None,
2685                created_at: Some(ts),
2686            })))
2687            .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2688            .with_download_bytes(b"zipdata".to_vec());
2689
2690        let mut renderer = RecordingRenderer::new();
2691        let mut reporter = RecordingProgressReporter::new();
2692
2693        run_impl(
2694            &args,
2695            &cfg,
2696            &client,
2697            &mut renderer,
2698            &mut reporter,
2699            Some("tok".to_string()),
2700            &no_op_sleeper(),
2701            fixed_now(),
2702            None,
2703            dir.path(),
2704        )
2705        .unwrap();
2706
2707        let outcome = renderer.dump_outcome.unwrap();
2708        assert!(!outcome.reused, "fresh dump must have reused:false");
2709        assert_eq!(
2710            outcome.created_at,
2711            Some(ts),
2712            "created_at must be populated from task"
2713        );
2714        assert_eq!(outcome.bytes, 7); // b"zipdata".len()
2715        // Call sequence: Resolve, Create, Status/Poll, Download
2716        let log = client.call_log();
2717        assert!(log.contains(&CallRecord::Resolve));
2718        assert!(log.contains(&CallRecord::Create {
2719            project_iri: "http://rdfh.ch/projects/0001".to_string(),
2720        }));
2721        assert!(log.contains(&CallRecord::Poll));
2722        assert!(log.contains(&CallRecord::Download));
2723    }
2724
2725    #[test]
2726    fn default_adopt_completed_existing_dump() {
2727        // Default mode + Exists{id} → status=Completed → download → reused:true.
2728        use chrono::TimeZone;
2729        let dir = TempDir::new().unwrap();
2730        let (args, cfg) = make_args(&dir);
2731        let ts = Utc.with_ymd_and_hms(2026, 5, 15, 10, 0, 0).unwrap();
2732
2733        let existing_task = DumpTask {
2734            id: "existing-id".into(),
2735            status: DumpStatus::Completed,
2736            error_message: None,
2737            created_at: Some(ts),
2738        };
2739
2740        let client = MockDspClient::new()
2741            .with_resolve_project(Ok(make_project_ref()))
2742            .with_create_exists("existing-id")
2743            .with_status_sequence([Ok(existing_task)])
2744            .with_download_bytes(b"existing".to_vec());
2745
2746        let mut renderer = RecordingRenderer::new();
2747        let mut reporter = RecordingProgressReporter::new();
2748
2749        run_impl(
2750            &args,
2751            &cfg,
2752            &client,
2753            &mut renderer,
2754            &mut reporter,
2755            Some("tok".to_string()),
2756            &no_op_sleeper(),
2757            fixed_now(),
2758            None,
2759            dir.path(),
2760        )
2761        .unwrap();
2762
2763        let outcome = renderer.dump_outcome.unwrap();
2764        assert!(outcome.reused, "adopted dump must have reused:true");
2765        assert_eq!(
2766            outcome.created_at,
2767            Some(ts),
2768            "created_at must come from status"
2769        );
2770        // Reporter: Adopting, Downloading, Done (no Triggered, no Polling)
2771        assert!(
2772            reporter
2773                .events
2774                .contains(&EventRecord::Adopting("existing-id".into())),
2775            "must report Adopting"
2776        );
2777        assert!(
2778            !reporter
2779                .events
2780                .iter()
2781                .any(|e| matches!(e, EventRecord::Triggered(_))),
2782            "must NOT report Triggered when adopting"
2783        );
2784        // Call sequence: Resolve, Create, Status, Download
2785        let log = client.call_log();
2786        assert_eq!(log[0], CallRecord::Resolve);
2787        assert_eq!(
2788            log[1],
2789            CallRecord::Create {
2790                project_iri: "http://rdfh.ch/projects/0001".to_string(),
2791            }
2792        );
2793        assert_eq!(
2794            log[2],
2795            CallRecord::Status("http://rdfh.ch/projects/0001".to_string())
2796        );
2797        assert_eq!(log[3], CallRecord::Download);
2798    }
2799
2800    #[test]
2801    fn default_adopt_in_progress_polls_then_downloads() {
2802        // Default mode + Exists{id} → status=InProgress → poll → download → reused:true.
2803        use chrono::TimeZone;
2804        let dir = TempDir::new().unwrap();
2805        let (args, cfg) = make_args(&dir);
2806        let ts = Utc.with_ymd_and_hms(2026, 5, 10, 8, 0, 0).unwrap();
2807
2808        let in_progress_task = DumpTask {
2809            id: "adopt-ip-id".into(),
2810            status: DumpStatus::InProgress,
2811            error_message: None,
2812            created_at: Some(ts),
2813        };
2814
2815        let client = MockDspClient::new()
2816            .with_resolve_project(Ok(make_project_ref()))
2817            .with_create_exists("adopt-ip-id")
2818            // status call returns in_progress; then poll_sequence has one in_progress
2819            // tick followed by completed so a Polling event is emitted.
2820            .with_status_sequence([Ok(in_progress_task)])
2821            .with_poll_sequence([
2822                Ok(make_dump_task(DumpStatus::InProgress)),
2823                Ok(make_dump_task(DumpStatus::Completed)),
2824            ])
2825            .with_download_bytes(b"data".to_vec());
2826
2827        let mut renderer = RecordingRenderer::new();
2828        let mut reporter = RecordingProgressReporter::new();
2829
2830        run_impl(
2831            &args,
2832            &cfg,
2833            &client,
2834            &mut renderer,
2835            &mut reporter,
2836            Some("tok".to_string()),
2837            &no_op_sleeper(),
2838            fixed_now(),
2839            None,
2840            dir.path(),
2841        )
2842        .unwrap();
2843
2844        let outcome = renderer.dump_outcome.unwrap();
2845        assert!(outcome.reused);
2846        assert_eq!(outcome.created_at, Some(ts));
2847        // Full call sequence: Resolve, Create, Status, Poll (in_progress), Poll (completed), Download
2848        let log = client.call_log();
2849        assert_eq!(log[0], CallRecord::Resolve, "first call must be Resolve");
2850        assert_eq!(
2851            log[1],
2852            CallRecord::Create {
2853                project_iri: "http://rdfh.ch/projects/0001".to_string(),
2854            },
2855            "second call must be Create"
2856        );
2857        assert_eq!(
2858            log[2],
2859            CallRecord::Status("http://rdfh.ch/projects/0001".to_string()),
2860            "third call must be Status"
2861        );
2862        assert_eq!(
2863            log[3],
2864            CallRecord::Poll,
2865            "fourth call must be Poll (in_progress)"
2866        );
2867        assert_eq!(
2868            log[4],
2869            CallRecord::Poll,
2870            "fifth call must be Poll (completed)"
2871        );
2872        assert_eq!(log[5], CallRecord::Download, "sixth call must be Download");
2873        assert_eq!(log.len(), 6, "must be exactly 6 calls");
2874        // Adopting must be reported before any Polling event.
2875        let adopting_idx = reporter
2876            .events
2877            .iter()
2878            .position(|e| matches!(e, EventRecord::Adopting(_)))
2879            .expect("Adopting event must be present");
2880        let first_polling_idx = reporter
2881            .events
2882            .iter()
2883            .position(|e| matches!(e, EventRecord::Polling(_)))
2884            .expect("Polling event must be present");
2885        assert!(
2886            adopting_idx < first_polling_idx,
2887            "Adopting must be reported before the first Polling event"
2888        );
2889    }
2890
2891    #[test]
2892    fn default_existing_failed_returns_conflict_with_hint() {
2893        let dir = TempDir::new().unwrap();
2894        let (args, cfg) = make_args(&dir);
2895
2896        let failed_task = DumpTask {
2897            id: "fail-id".into(),
2898            status: DumpStatus::Failed,
2899            error_message: Some("disk full".into()),
2900            created_at: None,
2901        };
2902
2903        let client = MockDspClient::new()
2904            .with_resolve_project(Ok(make_project_ref()))
2905            .with_create_exists("fail-id")
2906            .with_status_sequence([Ok(failed_task)]);
2907
2908        let mut renderer = RecordingRenderer::new();
2909        let mut reporter = RecordingProgressReporter::new();
2910
2911        let err = run_impl(
2912            &args,
2913            &cfg,
2914            &client,
2915            &mut renderer,
2916            &mut reporter,
2917            Some("tok".to_string()),
2918            &no_op_sleeper(),
2919            fixed_now(),
2920            None,
2921            dir.path(),
2922        )
2923        .unwrap_err();
2924
2925        assert!(
2926            matches!(err, Diagnostic::Conflict(_)),
2927            "failed existing dump must yield Conflict"
2928        );
2929        let msg = err.to_string();
2930        assert!(
2931            msg.contains("existing dump failed"),
2932            "message must mention failure: {msg}"
2933        );
2934        assert!(
2935            msg.contains("disk full"),
2936            "message must include server error: {msg}"
2937        );
2938        assert!(
2939            msg.contains("--replace"),
2940            "message must hint at --replace: {msg}"
2941        );
2942        assert!(
2943            msg.contains("--delete"),
2944            "message must hint at --delete: {msg}"
2945        );
2946    }
2947
2948    // --- Replace mode ---
2949
2950    #[test]
2951    fn replace_none_existing_creates_fresh() {
2952        // Replace + Created (no existing) → same as fresh.
2953        let dir = TempDir::new().unwrap();
2954        let (mut args, cfg) = make_args(&dir);
2955        args.replace = true;
2956
2957        let client = MockDspClient::new()
2958            .with_resolve_project(Ok(make_project_ref()))
2959            .with_create_dump(Ok(created_task(DumpStatus::InProgress)))
2960            .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
2961            .with_download_bytes(b"fresh".to_vec());
2962
2963        let mut renderer = RecordingRenderer::new();
2964        let mut reporter = RecordingProgressReporter::new();
2965
2966        run_impl(
2967            &args,
2968            &cfg,
2969            &client,
2970            &mut renderer,
2971            &mut reporter,
2972            Some("tok".to_string()),
2973            &no_op_sleeper(),
2974            fixed_now(),
2975            None,
2976            dir.path(),
2977        )
2978        .unwrap();
2979
2980        let outcome = renderer.dump_outcome.unwrap();
2981        assert!(!outcome.reused, "replace with no existing → reused:false");
2982        // Exactly one create call
2983        assert_eq!(*client.create_calls.borrow(), 1);
2984    }
2985
2986    #[test]
2987    fn replace_completed_deletes_then_recreates() {
2988        // Replace + Exists (completed) → status→delete→create2→poll→download.
2989        let dir = TempDir::new().unwrap();
2990        let (mut args, cfg) = make_args(&dir);
2991        args.replace = true;
2992
2993        let existing_task = DumpTask {
2994            id: "old-id".into(),
2995            status: DumpStatus::Completed,
2996            error_message: None,
2997            created_at: None,
2998        };
2999        let task2 = DumpTask {
3000            id: "new-id".into(),
3001            status: DumpStatus::InProgress,
3002            error_message: None,
3003            created_at: None,
3004        };
3005
3006        let client = MockDspClient::new()
3007            .with_resolve_project(Ok(make_project_ref()))
3008            .with_create_exists("old-id")
3009            .with_create_sequence([Ok(CreateDumpOutcome::Created(task2))])
3010            .with_status_sequence([Ok(existing_task)])
3011            .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
3012            .with_delete_result(Ok(()))
3013            .with_download_bytes(b"new".to_vec());
3014
3015        let mut renderer = RecordingRenderer::new();
3016        let mut reporter = RecordingProgressReporter::new();
3017
3018        run_impl(
3019            &args,
3020            &cfg,
3021            &client,
3022            &mut renderer,
3023            &mut reporter,
3024            Some("tok".to_string()),
3025            &no_op_sleeper(),
3026            fixed_now(),
3027            None,
3028            dir.path(),
3029        )
3030        .unwrap();
3031
3032        let outcome = renderer.dump_outcome.unwrap();
3033        assert!(!outcome.reused, "replace always produces reused:false");
3034        // Assert full call order: Resolve, Create(1st), Status, Delete, Create(2nd), Poll, Download
3035        let log = client.call_log();
3036        assert_eq!(log[0], CallRecord::Resolve, "first call must be Resolve");
3037        assert_eq!(
3038            log[1],
3039            CallRecord::Create {
3040                project_iri: "http://rdfh.ch/projects/0001".to_string(),
3041            },
3042            "second call must be Create"
3043        );
3044        assert_eq!(
3045            log[2],
3046            CallRecord::Status("http://rdfh.ch/projects/0001".to_string()),
3047            "third call must be Status"
3048        );
3049        assert_eq!(
3050            log[3],
3051            CallRecord::Delete("http://rdfh.ch/projects/0001".to_string()),
3052            "fourth call must be Delete"
3053        );
3054        assert_eq!(
3055            log[4],
3056            CallRecord::Create {
3057                project_iri: "http://rdfh.ch/projects/0001".to_string(),
3058            },
3059            "fifth call must be Create (2nd)"
3060        );
3061        assert_eq!(log[5], CallRecord::Poll, "sixth call must be Poll");
3062        assert_eq!(
3063            log[6],
3064            CallRecord::Download,
3065            "seventh call must be Download"
3066        );
3067        assert_eq!(log.len(), 7, "must be exactly 7 calls");
3068        // Deleting event must have been reported
3069        assert!(
3070            reporter
3071                .events
3072                .contains(&EventRecord::Deleting("old-id".into())),
3073            "must report Deleting for the old dump"
3074        );
3075        // Triggered must have been reported for the second (new) dump
3076        assert!(
3077            reporter
3078                .events
3079                .contains(&EventRecord::Triggered("new-id".into())),
3080            "must report Triggered for the new dump; events: {:?}",
3081            reporter.events
3082        );
3083    }
3084
3085    #[test]
3086    fn replace_failed_existing_deletes_then_recreates() {
3087        // Replace + Exists (failed) → same path as completed.
3088        let dir = TempDir::new().unwrap();
3089        let (mut args, cfg) = make_args(&dir);
3090        args.replace = true;
3091
3092        let failed_task = DumpTask {
3093            id: "failed-old-id".into(),
3094            status: DumpStatus::Failed,
3095            error_message: Some("ran out of space".into()),
3096            created_at: None,
3097        };
3098        let task2 = DumpTask {
3099            id: "new-id-2".into(),
3100            status: DumpStatus::InProgress,
3101            error_message: None,
3102            created_at: None,
3103        };
3104
3105        let client = MockDspClient::new()
3106            .with_resolve_project(Ok(make_project_ref()))
3107            .with_create_exists("failed-old-id")
3108            .with_create_sequence([Ok(CreateDumpOutcome::Created(task2))])
3109            .with_status_sequence([Ok(failed_task)])
3110            .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
3111            .with_delete_result(Ok(()))
3112            .with_download_bytes(b"new".to_vec());
3113
3114        let mut renderer = RecordingRenderer::new();
3115        let mut reporter = RecordingProgressReporter::new();
3116
3117        run_impl(
3118            &args,
3119            &cfg,
3120            &client,
3121            &mut renderer,
3122            &mut reporter,
3123            Some("tok".to_string()),
3124            &no_op_sleeper(),
3125            fixed_now(),
3126            None,
3127            dir.path(),
3128        )
3129        .unwrap();
3130
3131        let outcome = renderer.dump_outcome.unwrap();
3132        assert!(!outcome.reused);
3133    }
3134
3135    #[test]
3136    fn replace_recreate_race_returns_conflict() {
3137        // Replace + Exists → status→delete→create2 returns Exists again (race).
3138        let dir = TempDir::new().unwrap();
3139        let (mut args, cfg) = make_args(&dir);
3140        args.replace = true;
3141
3142        let existing_task = DumpTask {
3143            id: "race-id".into(),
3144            status: DumpStatus::Completed,
3145            error_message: None,
3146            created_at: None,
3147        };
3148
3149        let client = MockDspClient::new()
3150            .with_resolve_project(Ok(make_project_ref()))
3151            .with_create_exists("race-id")
3152            .with_create_sequence([Ok(CreateDumpOutcome::Exists {
3153                id: "race-id-2".into(),
3154            })])
3155            .with_status_sequence([Ok(existing_task)])
3156            .with_delete_result(Ok(()));
3157
3158        let mut renderer = RecordingRenderer::new();
3159        let mut reporter = RecordingProgressReporter::new();
3160
3161        let err = run_impl(
3162            &args,
3163            &cfg,
3164            &client,
3165            &mut renderer,
3166            &mut reporter,
3167            Some("tok".to_string()),
3168            &no_op_sleeper(),
3169            fixed_now(),
3170            None,
3171            dir.path(),
3172        )
3173        .unwrap_err();
3174
3175        assert!(
3176            matches!(err, Diagnostic::Conflict(_)),
3177            "recreate race must yield Conflict"
3178        );
3179        let msg = err.to_string();
3180        assert!(
3181            msg.contains("recreated"),
3182            "message must mention recreation: {msg}"
3183        );
3184    }
3185
3186    #[test]
3187    fn replace_in_progress_returns_conflict() {
3188        // Replace + Exists (in_progress) → cannot replace.
3189        let dir = TempDir::new().unwrap();
3190        let (mut args, cfg) = make_args(&dir);
3191        args.replace = true;
3192
3193        let in_progress_task = DumpTask {
3194            id: "ip-id".into(),
3195            status: DumpStatus::InProgress,
3196            error_message: None,
3197            created_at: None,
3198        };
3199
3200        let client = MockDspClient::new()
3201            .with_resolve_project(Ok(make_project_ref()))
3202            .with_create_exists("ip-id")
3203            .with_status_sequence([Ok(in_progress_task)]);
3204
3205        let mut renderer = RecordingRenderer::new();
3206        let mut reporter = RecordingProgressReporter::new();
3207
3208        let err = run_impl(
3209            &args,
3210            &cfg,
3211            &client,
3212            &mut renderer,
3213            &mut reporter,
3214            Some("tok".to_string()),
3215            &no_op_sleeper(),
3216            fixed_now(),
3217            None,
3218            dir.path(),
3219        )
3220        .unwrap_err();
3221
3222        assert!(
3223            matches!(err, Diagnostic::Conflict(_)),
3224            "in-progress existing dump must block replace"
3225        );
3226        let msg = err.to_string();
3227        assert!(
3228            msg.contains("in progress"),
3229            "message must mention in-progress state: {msg}"
3230        );
3231        // Delete must NOT have been called.
3232        assert_eq!(
3233            *client.delete_calls.borrow(),
3234            0,
3235            "delete must not be called when in-progress"
3236        );
3237    }
3238
3239    // --- Delete mode ---
3240
3241    #[test]
3242    fn delete_completed_deletes_without_downloading() {
3243        // Delete + Exists (completed) → status→delete → project_dump_deleted{deleted:true}.
3244        let dir = TempDir::new().unwrap();
3245        let (mut args, cfg) = make_args(&dir);
3246        args.delete = true;
3247
3248        let completed_task = DumpTask {
3249            id: "del-id".into(),
3250            status: DumpStatus::Completed,
3251            error_message: None,
3252            created_at: None,
3253        };
3254
3255        let client = MockDspClient::new()
3256            .with_resolve_project(Ok(make_project_ref()))
3257            .with_create_exists("del-id")
3258            .with_status_sequence([Ok(completed_task)])
3259            .with_delete_result(Ok(()));
3260
3261        let mut renderer = RecordingRenderer::new();
3262        let mut reporter = RecordingProgressReporter::new();
3263
3264        run_impl(
3265            &args,
3266            &cfg,
3267            &client,
3268            &mut renderer,
3269            &mut reporter,
3270            Some("tok".to_string()),
3271            &no_op_sleeper(),
3272            fixed_now(),
3273            None,
3274            dir.path(),
3275        )
3276        .unwrap();
3277
3278        // Must NOT have downloaded.
3279        assert_eq!(
3280            *client.download_calls.borrow(),
3281            0,
3282            "delete must not download"
3283        );
3284        // Must have called delete.
3285        assert_eq!(*client.delete_calls.borrow(), 1);
3286        // project_dump_deleted must have been called with deleted:true.
3287        let del_outcome = renderer
3288            .dump_deleted_outcome
3289            .expect("project_dump_deleted must have been called");
3290        assert!(del_outcome.deleted);
3291        assert!(del_outcome.note.is_none());
3292        // project_dump must NOT have been called.
3293        assert!(
3294            renderer.dump_outcome.is_none(),
3295            "project_dump must not be called in delete mode"
3296        );
3297        // Reporter: Deleting{id}
3298        assert!(
3299            reporter
3300                .events
3301                .contains(&EventRecord::Deleting("del-id".into()))
3302        );
3303        // Call sequence: Resolve, Create, Status, Delete — no Download
3304        let log = client.call_log();
3305        assert_eq!(log[0], CallRecord::Resolve);
3306        assert_eq!(
3307            log[1],
3308            CallRecord::Create {
3309                project_iri: "http://rdfh.ch/projects/0001".to_string(),
3310            }
3311        );
3312        assert_eq!(
3313            log[2],
3314            CallRecord::Status("http://rdfh.ch/projects/0001".to_string())
3315        );
3316        assert_eq!(
3317            log[3],
3318            CallRecord::Delete("http://rdfh.ch/projects/0001".to_string())
3319        );
3320        assert_eq!(log.len(), 4, "must be exactly 4 calls");
3321    }
3322
3323    #[test]
3324    fn delete_failed_deletes_without_downloading() {
3325        // Delete + Exists (failed) → same path as completed: status→delete → project_dump_deleted{deleted:true}.
3326        // Verifies the `Completed | Failed` arm handles Failed identically to Completed.
3327        let dir = TempDir::new().unwrap();
3328        let (mut args, cfg) = make_args(&dir);
3329        args.delete = true;
3330
3331        let failed_task = DumpTask {
3332            id: "del-failed-id".into(),
3333            status: DumpStatus::Failed,
3334            error_message: Some("disk full".into()),
3335            created_at: None,
3336        };
3337
3338        let client = MockDspClient::new()
3339            .with_resolve_project(Ok(make_project_ref()))
3340            .with_create_exists("del-failed-id")
3341            .with_status_sequence([Ok(failed_task)])
3342            .with_delete_result(Ok(()));
3343
3344        let mut renderer = RecordingRenderer::new();
3345        let mut reporter = RecordingProgressReporter::new();
3346
3347        run_impl(
3348            &args,
3349            &cfg,
3350            &client,
3351            &mut renderer,
3352            &mut reporter,
3353            Some("tok".to_string()),
3354            &no_op_sleeper(),
3355            fixed_now(),
3356            None,
3357            dir.path(),
3358        )
3359        .unwrap();
3360
3361        // Must NOT have downloaded.
3362        assert_eq!(
3363            *client.download_calls.borrow(),
3364            0,
3365            "delete must not download even for a failed dump"
3366        );
3367        // Must have called delete.
3368        assert_eq!(
3369            *client.delete_calls.borrow(),
3370            1,
3371            "delete must be called for a failed dump"
3372        );
3373        // project_dump_deleted must have been called with deleted:true.
3374        let del_outcome = renderer
3375            .dump_deleted_outcome
3376            .expect("project_dump_deleted must have been called");
3377        assert!(del_outcome.deleted, "deleted must be true for failed dump");
3378        assert!(del_outcome.note.is_none());
3379        // project_dump must NOT have been called.
3380        assert!(
3381            renderer.dump_outcome.is_none(),
3382            "project_dump must not be called in delete mode"
3383        );
3384        // Call sequence: Resolve, Create, Status, Delete — no Download
3385        let log = client.call_log();
3386        assert_eq!(log[0], CallRecord::Resolve);
3387        assert_eq!(
3388            log[1],
3389            CallRecord::Create {
3390                project_iri: "http://rdfh.ch/projects/0001".to_string(),
3391            }
3392        );
3393        assert_eq!(
3394            log[2],
3395            CallRecord::Status("http://rdfh.ch/projects/0001".to_string())
3396        );
3397        assert_eq!(
3398            log[3],
3399            CallRecord::Delete("http://rdfh.ch/projects/0001".to_string())
3400        );
3401        assert_eq!(log.len(), 4, "must be exactly 4 calls");
3402    }
3403
3404    #[test]
3405    fn delete_in_progress_returns_conflict() {
3406        // Delete + Exists (in_progress) → cannot delete.
3407        let dir = TempDir::new().unwrap();
3408        let (mut args, cfg) = make_args(&dir);
3409        args.delete = true;
3410
3411        let in_progress_task = DumpTask {
3412            id: "del-ip-id".into(),
3413            status: DumpStatus::InProgress,
3414            error_message: None,
3415            created_at: None,
3416        };
3417
3418        let client = MockDspClient::new()
3419            .with_resolve_project(Ok(make_project_ref()))
3420            .with_create_exists("del-ip-id")
3421            .with_status_sequence([Ok(in_progress_task)]);
3422
3423        let mut renderer = RecordingRenderer::new();
3424        let mut reporter = RecordingProgressReporter::new();
3425
3426        let err = run_impl(
3427            &args,
3428            &cfg,
3429            &client,
3430            &mut renderer,
3431            &mut reporter,
3432            Some("tok".to_string()),
3433            &no_op_sleeper(),
3434            fixed_now(),
3435            None,
3436            dir.path(),
3437        )
3438        .unwrap_err();
3439
3440        assert!(
3441            matches!(err, Diagnostic::Conflict(_)),
3442            "in-progress dump must block delete"
3443        );
3444        let msg = err.to_string();
3445        assert!(
3446            msg.contains("in progress"),
3447            "message must mention in-progress: {msg}"
3448        );
3449        assert_eq!(
3450            *client.delete_calls.borrow(),
3451            0,
3452            "delete must not be called"
3453        );
3454    }
3455
3456    #[test]
3457    fn delete_none_probe_created_reports_probe_and_exits_ok() {
3458        // Delete + Created (nothing existed; probe created in-progress dump).
3459        // Must: report ProbeCreated, render project_dump_deleted{deleted:false}, exit Ok.
3460        let dir = TempDir::new().unwrap();
3461        let (mut args, cfg) = make_args(&dir);
3462        args.delete = true;
3463
3464        let client = MockDspClient::new()
3465            .with_resolve_project(Ok(make_project_ref()))
3466            .with_create_dump(Ok(created_task(DumpStatus::InProgress)));
3467
3468        let mut renderer = RecordingRenderer::new();
3469        let mut reporter = RecordingProgressReporter::new();
3470
3471        run_impl(
3472            &args,
3473            &cfg,
3474            &client,
3475            &mut renderer,
3476            &mut reporter,
3477            Some("tok".to_string()),
3478            &no_op_sleeper(),
3479            fixed_now(),
3480            None,
3481            dir.path(),
3482        )
3483        .unwrap(); // must succeed (exit 0)
3484
3485        // project_dump must NOT be called.
3486        assert!(renderer.dump_outcome.is_none());
3487        // project_dump_deleted must be called with deleted:false and a note.
3488        let del_outcome = renderer
3489            .dump_deleted_outcome
3490            .expect("project_dump_deleted must be called");
3491        assert!(
3492            !del_outcome.deleted,
3493            "deleted must be false (probe, not real delete)"
3494        );
3495        let note = del_outcome.note.expect("note must be set for probe case");
3496        assert!(
3497            note.contains("dump-id-42"),
3498            "note must mention the probe id: {note}"
3499        );
3500        // Reporter must have received ProbeCreated.
3501        assert!(
3502            reporter
3503                .events
3504                .contains(&EventRecord::ProbeCreated("dump-id-42".into())),
3505            "must report ProbeCreated; events: {:?}",
3506            reporter.events
3507        );
3508        // Must NOT have called download or delete.
3509        assert_eq!(*client.download_calls.borrow(), 0);
3510        assert_eq!(*client.delete_calls.borrow(), 0);
3511    }
3512
3513    // ── ExistsForOtherProject mode arms ──────────────────────────────────────
3514
3515    /// The IRI of the foreign (other) project used in cross-project guard tests.
3516    fn foreign_iri() -> &'static str {
3517        "http://rdfh.ch/projects/0002"
3518    }
3519
3520    #[test]
3521    fn default_exists_for_other_project_returns_conflict_no_server_calls() {
3522        // Default + ExistsForOtherProject → Conflict immediately; no status/download call.
3523        let dir = TempDir::new().unwrap();
3524        let (args, cfg) = make_args(&dir);
3525
3526        let client = MockDspClient::new()
3527            .with_resolve_project(Ok(make_project_ref()))
3528            .with_create_exists_other_project("foreign-dump-id", foreign_iri());
3529
3530        let mut renderer = RecordingRenderer::new();
3531        let mut reporter = RecordingProgressReporter::new();
3532
3533        let err = run_impl(
3534            &args,
3535            &cfg,
3536            &client,
3537            &mut renderer,
3538            &mut reporter,
3539            Some("tok".to_string()),
3540            &no_op_sleeper(),
3541            fixed_now(),
3542            None,
3543            dir.path(),
3544        )
3545        .unwrap_err();
3546
3547        assert!(
3548            matches!(err, Diagnostic::Conflict(_)),
3549            "Default + ExistsForOtherProject must yield Conflict, got {err:?}"
3550        );
3551        let msg = err.to_string();
3552        assert!(
3553            msg.contains(foreign_iri()),
3554            "Conflict message must name the foreign IRI: {msg}"
3555        );
3556        assert!(
3557            msg.contains("--replace --discard-other-project"),
3558            "Conflict message must hint at --replace --discard-other-project: {msg}"
3559        );
3560        // No status/download/delete calls (only Resolve + Create).
3561        let log = client.call_log();
3562        assert_eq!(log[0], CallRecord::Resolve);
3563        assert_eq!(
3564            log[1],
3565            CallRecord::Create {
3566                project_iri: "http://rdfh.ch/projects/0001".to_string(),
3567            }
3568        );
3569        assert_eq!(
3570            log.len(),
3571            2,
3572            "must be exactly 2 calls (no status/delete/download)"
3573        );
3574    }
3575
3576    #[test]
3577    fn replace_exists_for_other_project_without_flag_returns_conflict_no_status_delete() {
3578        // Replace + ExistsForOtherProject + no --discard-other-project → Conflict, no status/delete.
3579        let dir = TempDir::new().unwrap();
3580        let (mut args, cfg) = make_args(&dir);
3581        args.replace = true;
3582        // discard_other_project remains false (default from make_args).
3583
3584        let client = MockDspClient::new()
3585            .with_resolve_project(Ok(make_project_ref()))
3586            .with_create_exists_other_project("foreign-dump-id", foreign_iri());
3587
3588        let mut renderer = RecordingRenderer::new();
3589        let mut reporter = RecordingProgressReporter::new();
3590
3591        let err = run_impl(
3592            &args,
3593            &cfg,
3594            &client,
3595            &mut renderer,
3596            &mut reporter,
3597            Some("tok".to_string()),
3598            &no_op_sleeper(),
3599            fixed_now(),
3600            None,
3601            dir.path(),
3602        )
3603        .unwrap_err();
3604
3605        assert!(
3606            matches!(err, Diagnostic::Conflict(_)),
3607            "Replace + ExistsForOtherProject without flag must yield Conflict, got {err:?}"
3608        );
3609        let msg = err.to_string();
3610        assert!(
3611            msg.contains(foreign_iri()),
3612            "Conflict message must name the foreign IRI: {msg}"
3613        );
3614        assert!(
3615            msg.contains("--replace --discard-other-project"),
3616            "Conflict message must hint at the flag: {msg}"
3617        );
3618        // Only Resolve + Create — no status/delete.
3619        let log = client.call_log();
3620        assert_eq!(log[0], CallRecord::Resolve);
3621        assert_eq!(
3622            log[1],
3623            CallRecord::Create {
3624                project_iri: "http://rdfh.ch/projects/0001".to_string(),
3625            }
3626        );
3627        assert_eq!(log.len(), 2, "must be exactly 2 calls (no status/delete)");
3628    }
3629
3630    #[test]
3631    fn replace_exists_for_other_project_with_flag_foreign_completed_discards_and_recreates() {
3632        // Replace + ExistsForOtherProject + --discard-other-project, foreign Completed →
3633        // DiscardingOtherProjectDump event, delete(foreign_iri), create(requested_iri), download.
3634        let dir = TempDir::new().unwrap();
3635        let (mut args, cfg) = make_args(&dir);
3636        args.replace = true;
3637        args.discard_other_project = true;
3638
3639        let foreign_task = DumpTask {
3640            id: "foreign-dump-id".into(),
3641            status: DumpStatus::Completed,
3642            error_message: None,
3643            created_at: None,
3644        };
3645        let new_task = DumpTask {
3646            id: "new-dump-id".into(),
3647            status: DumpStatus::InProgress,
3648            error_message: None,
3649            created_at: None,
3650        };
3651
3652        let client = MockDspClient::new()
3653            .with_resolve_project(Ok(make_project_ref()))
3654            .with_create_exists_other_project("foreign-dump-id", foreign_iri())
3655            // The status check must use the FOREIGN iri (foreign_task comes from status_sequence).
3656            .with_status_sequence([Ok(foreign_task)])
3657            // The create after delete must return Created for the REQUESTED project.
3658            .with_create_sequence([Ok(CreateDumpOutcome::Created(new_task))])
3659            .with_delete_result(Ok(()))
3660            .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
3661            .with_download_bytes(b"dump-data".to_vec());
3662
3663        let mut renderer = RecordingRenderer::new();
3664        let mut reporter = RecordingProgressReporter::new();
3665
3666        run_impl(
3667            &args,
3668            &cfg,
3669            &client,
3670            &mut renderer,
3671            &mut reporter,
3672            Some("tok".to_string()),
3673            &no_op_sleeper(),
3674            fixed_now(),
3675            None,
3676            dir.path(),
3677        )
3678        .unwrap();
3679
3680        // DiscardingOtherProjectDump must have been reported.
3681        assert!(
3682            reporter
3683                .events
3684                .contains(&EventRecord::DiscardingOtherProjectDump {
3685                    id: "foreign-dump-id".into(),
3686                    project_iri: foreign_iri().to_string(),
3687                }),
3688            "must report DiscardingOtherProjectDump; events: {:?}",
3689            reporter.events
3690        );
3691
3692        // Call log: Resolve, Create(1st ExistsForOtherProject), Status(foreign), Delete(foreign),
3693        //           Create(2nd for requested), Poll, Download
3694        let log = client.call_log();
3695        assert_eq!(log[0], CallRecord::Resolve, "first must be Resolve");
3696        assert_eq!(
3697            log[1],
3698            CallRecord::Create {
3699                project_iri: make_project_ref().iri,
3700            },
3701            "second must be Create with REQUESTED project IRI (initial probe)"
3702        );
3703        assert_eq!(
3704            log[2],
3705            CallRecord::Status(foreign_iri().to_string()),
3706            "third must be Status with FOREIGN iri"
3707        );
3708        assert_eq!(
3709            log[3],
3710            CallRecord::Delete(foreign_iri().to_string()),
3711            "fourth must be Delete with FOREIGN iri"
3712        );
3713        assert_eq!(
3714            log[4],
3715            CallRecord::Create {
3716                project_iri: make_project_ref().iri,
3717            },
3718            "fifth must be Create with REQUESTED project IRI (recreate after discard)"
3719        );
3720        assert_eq!(log[5], CallRecord::Poll, "sixth must be Poll");
3721        assert_eq!(log[6], CallRecord::Download, "seventh must be Download");
3722        assert_eq!(log.len(), 7, "must be exactly 7 calls");
3723
3724        // The final dump outcome must exist (we downloaded).
3725        assert!(
3726            renderer.dump_outcome.is_some(),
3727            "project_dump must be called after successful discard+recreate"
3728        );
3729    }
3730
3731    #[test]
3732    fn replace_exists_for_other_project_with_flag_foreign_failed_discards_and_recreates() {
3733        // Replace + ExistsForOtherProject + --discard-other-project, foreign Failed →
3734        // same outcome as Completed: DiscardingOtherProjectDump event, delete(foreign_iri),
3735        // create(requested_iri), download.
3736        let dir = TempDir::new().unwrap();
3737        let (mut args, cfg) = make_args(&dir);
3738        args.replace = true;
3739        args.discard_other_project = true;
3740
3741        let foreign_task = DumpTask {
3742            id: "foreign-dump-id".into(),
3743            status: DumpStatus::Failed,
3744            error_message: Some("out of disk space".into()),
3745            created_at: None,
3746        };
3747        let new_task = DumpTask {
3748            id: "new-dump-id".into(),
3749            status: DumpStatus::InProgress,
3750            error_message: None,
3751            created_at: None,
3752        };
3753
3754        let client = MockDspClient::new()
3755            .with_resolve_project(Ok(make_project_ref()))
3756            .with_create_exists_other_project("foreign-dump-id", foreign_iri())
3757            // The status check must use the FOREIGN iri (foreign_task from status_sequence).
3758            .with_status_sequence([Ok(foreign_task)])
3759            // The create after delete must return Created for the REQUESTED project.
3760            .with_create_sequence([Ok(CreateDumpOutcome::Created(new_task))])
3761            .with_delete_result(Ok(()))
3762            .with_poll_sequence([Ok(make_dump_task(DumpStatus::Completed))])
3763            .with_download_bytes(b"dump-data".to_vec());
3764
3765        let mut renderer = RecordingRenderer::new();
3766        let mut reporter = RecordingProgressReporter::new();
3767
3768        run_impl(
3769            &args,
3770            &cfg,
3771            &client,
3772            &mut renderer,
3773            &mut reporter,
3774            Some("tok".to_string()),
3775            &no_op_sleeper(),
3776            fixed_now(),
3777            None,
3778            dir.path(),
3779        )
3780        .unwrap();
3781
3782        // DiscardingOtherProjectDump must have been reported.
3783        assert!(
3784            reporter
3785                .events
3786                .contains(&EventRecord::DiscardingOtherProjectDump {
3787                    id: "foreign-dump-id".into(),
3788                    project_iri: foreign_iri().to_string(),
3789                }),
3790            "must report DiscardingOtherProjectDump; events: {:?}",
3791            reporter.events
3792        );
3793
3794        // Call log: Resolve, Create(1st ExistsForOtherProject), Status(foreign), Delete(foreign),
3795        //           Create(2nd for requested), Poll, Download
3796        let log = client.call_log();
3797        assert_eq!(log[0], CallRecord::Resolve, "first must be Resolve");
3798        assert_eq!(
3799            log[1],
3800            CallRecord::Create {
3801                project_iri: make_project_ref().iri,
3802            },
3803            "second must be Create with REQUESTED project IRI (initial probe)"
3804        );
3805        assert_eq!(
3806            log[2],
3807            CallRecord::Status(foreign_iri().to_string()),
3808            "third must be Status with FOREIGN iri"
3809        );
3810        assert_eq!(
3811            log[3],
3812            CallRecord::Delete(foreign_iri().to_string()),
3813            "fourth must be Delete with FOREIGN iri"
3814        );
3815        assert_eq!(
3816            log[4],
3817            CallRecord::Create {
3818                project_iri: make_project_ref().iri,
3819            },
3820            "fifth must be Create with REQUESTED project IRI (recreate after discard)"
3821        );
3822        assert_eq!(log[5], CallRecord::Poll, "sixth must be Poll");
3823        assert_eq!(log[6], CallRecord::Download, "seventh must be Download");
3824        assert_eq!(log.len(), 7, "must be exactly 7 calls");
3825
3826        // The final dump outcome must exist (we downloaded).
3827        assert!(
3828            renderer.dump_outcome.is_some(),
3829            "project_dump must be called after successful discard+recreate"
3830        );
3831    }
3832
3833    #[test]
3834    fn replace_exists_for_other_project_with_flag_foreign_in_progress_returns_conflict() {
3835        // Replace + ExistsForOtherProject + --discard-other-project, foreign InProgress →
3836        // Conflict "in progress"; no delete call.
3837        let dir = TempDir::new().unwrap();
3838        let (mut args, cfg) = make_args(&dir);
3839        args.replace = true;
3840        args.discard_other_project = true;
3841
3842        let foreign_task = DumpTask {
3843            id: "foreign-dump-id".into(),
3844            status: DumpStatus::InProgress,
3845            error_message: None,
3846            created_at: None,
3847        };
3848
3849        let client = MockDspClient::new()
3850            .with_resolve_project(Ok(make_project_ref()))
3851            .with_create_exists_other_project("foreign-dump-id", foreign_iri())
3852            .with_status_sequence([Ok(foreign_task)]);
3853
3854        let mut renderer = RecordingRenderer::new();
3855        let mut reporter = RecordingProgressReporter::new();
3856
3857        let err = run_impl(
3858            &args,
3859            &cfg,
3860            &client,
3861            &mut renderer,
3862            &mut reporter,
3863            Some("tok".to_string()),
3864            &no_op_sleeper(),
3865            fixed_now(),
3866            None,
3867            dir.path(),
3868        )
3869        .unwrap_err();
3870
3871        assert!(
3872            matches!(err, Diagnostic::Conflict(_)),
3873            "foreign InProgress must yield Conflict, got {err:?}"
3874        );
3875        let msg = err.to_string();
3876        assert!(
3877            msg.contains("in progress"),
3878            "message must mention in progress: {msg}"
3879        );
3880        assert!(
3881            msg.contains(foreign_iri()),
3882            "message must name the foreign IRI: {msg}"
3883        );
3884        // Status check was done with FOREIGN iri; no delete.
3885        let log = client.call_log();
3886        assert_eq!(
3887            log[2],
3888            CallRecord::Status(foreign_iri().to_string()),
3889            "status must use FOREIGN iri"
3890        );
3891        assert_eq!(
3892            *client.delete_calls.borrow(),
3893            0,
3894            "delete must not be called for in-progress foreign dump"
3895        );
3896    }
3897
3898    #[test]
3899    fn delete_exists_for_other_project_is_noop_no_status_delete_calls() {
3900        // Delete + ExistsForOtherProject → Ok, project_dump_deleted{deleted:false, note:Some},
3901        // no status/delete call.
3902        let dir = TempDir::new().unwrap();
3903        let (mut args, cfg) = make_args(&dir);
3904        args.delete = true;
3905
3906        let client = MockDspClient::new()
3907            .with_resolve_project(Ok(make_project_ref()))
3908            .with_create_exists_other_project("foreign-dump-id", foreign_iri());
3909
3910        let mut renderer = RecordingRenderer::new();
3911        let mut reporter = RecordingProgressReporter::new();
3912
3913        run_impl(
3914            &args,
3915            &cfg,
3916            &client,
3917            &mut renderer,
3918            &mut reporter,
3919            Some("tok".to_string()),
3920            &no_op_sleeper(),
3921            fixed_now(),
3922            None,
3923            dir.path(),
3924        )
3925        .unwrap(); // must succeed (exit 0)
3926
3927        // project_dump must NOT be called.
3928        assert!(renderer.dump_outcome.is_none());
3929        // project_dump_deleted must be called with deleted:false + note.
3930        let del_outcome = renderer
3931            .dump_deleted_outcome
3932            .expect("project_dump_deleted must have been called");
3933        assert!(
3934            !del_outcome.deleted,
3935            "deleted must be false for foreign-slot no-op"
3936        );
3937        let note = del_outcome
3938            .note
3939            .expect("note must be set for foreign-slot case");
3940        assert!(
3941            note.contains(foreign_iri()),
3942            "note must name the foreign project IRI: {note}"
3943        );
3944        // No status/delete calls (only Resolve + Create).
3945        let log = client.call_log();
3946        assert_eq!(log[0], CallRecord::Resolve);
3947        assert_eq!(
3948            log[1],
3949            CallRecord::Create {
3950                project_iri: "http://rdfh.ch/projects/0001".to_string(),
3951            }
3952        );
3953        assert_eq!(log.len(), 2, "must be exactly 2 calls (no status/delete)");
3954        assert_eq!(
3955            *client.delete_calls.borrow(),
3956            0,
3957            "delete must not be called for foreign-slot no-op"
3958        );
3959    }
3960
3961    // ─────────────────────────────────────────────────────────────────────────
3962    // run_list_impl tests
3963    // ─────────────────────────────────────────────────────────────────────────
3964
3965    const LIST_SERVER: &str = "https://api.test.dasch.swiss";
3966
3967    fn make_list_args(filter: Option<&str>) -> ProjectListArgs {
3968        ProjectListArgs {
3969            server: Some(LIST_SERVER.to_string()),
3970            filter: filter.map(str::to_string),
3971            format: FormatArgs {
3972                format: Format::Prose,
3973                json: false,
3974                lines: false,
3975                columns: None,
3976                no_header: false,
3977                header_only: false,
3978            },
3979        }
3980    }
3981
3982    fn make_list_cfg() -> Config {
3983        Config {
3984            server: LIST_SERVER.to_string(),
3985        }
3986    }
3987
3988    fn make_project(shortcode: &str, shortname: &str, longname: Option<&str>) -> Project {
3989        Project {
3990            iri: format!("http://rdfh.ch/projects/{shortcode}"),
3991            shortcode: shortcode.to_string(),
3992            shortname: shortname.to_string(),
3993            longname: longname.map(str::to_string),
3994            status: ProjectStatus::Active,
3995            data_models: 2,
3996        }
3997    }
3998
3999    fn two_project_list() -> Vec<Project> {
4000        vec![
4001            make_project("0002", "images", None),
4002            make_project("0001", "anything", Some("Anything Project")),
4003        ]
4004    }
4005
4006    /// Anonymous: no env token, empty temp cache → auth_state "anonymous",
4007    /// client called with token=None, full list rendered.
4008    #[test]
4009    fn list_anonymous_no_token_no_cache() {
4010        let dir = TempDir::new().unwrap();
4011        let cache_path = dir.path().join("auth.toml");
4012        let args = make_list_args(None);
4013        let cfg = make_list_cfg();
4014
4015        let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
4016        let mut renderer = RecordingRenderer::new();
4017
4018        run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4019            .expect("must succeed anonymously");
4020
4021        let meta = renderer.projects_meta.unwrap();
4022        assert_eq!(meta.auth_state, "anonymous");
4023
4024        let recorded_token = client.list_projects_token();
4025        assert_eq!(
4026            recorded_token, None,
4027            "must call list_projects with token=None when no credentials"
4028        );
4029
4030        let (items, total, filter) = renderer.projects_view.unwrap();
4031        assert_eq!(total, 2);
4032        assert_eq!(items.len(), 2);
4033        assert!(filter.is_none());
4034    }
4035
4036    /// Corrupt/missing cache + no env token → still succeeds anonymously.
4037    /// Locks the auth-optional fallback in run_list_impl (PRD AC 2).
4038    #[test]
4039    fn list_corrupt_cache_falls_back_to_anonymous() {
4040        let dir = TempDir::new().unwrap();
4041        // Write a corrupt (non-TOML) auth.toml to force a parse error.
4042        let cache_path = dir.path().join("auth.toml");
4043        std::fs::write(&cache_path, b"NOT VALID TOML }{").unwrap();
4044
4045        let args = make_list_args(None);
4046        let cfg = make_list_cfg();
4047
4048        let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
4049        let mut renderer = RecordingRenderer::new();
4050
4051        // Must NOT return an error — falls back to anonymous.
4052        run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4053            .expect("corrupt cache must not cause an error for list (auth-optional)");
4054
4055        let meta = renderer.projects_meta.unwrap();
4056        assert_eq!(meta.auth_state, "anonymous");
4057        assert_eq!(client.list_projects_token(), None);
4058    }
4059
4060    /// Env token → auth_state "authenticated via DSP_TOKEN",
4061    /// client called with Some(token).
4062    #[test]
4063    fn list_env_token_authenticated_via_dsp_token() {
4064        let dir = TempDir::new().unwrap();
4065        let cache_path = dir.path().join("auth.toml");
4066        let args = make_list_args(None);
4067        let cfg = make_list_cfg();
4068
4069        let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
4070        let mut renderer = RecordingRenderer::new();
4071
4072        run_list_impl(
4073            &args,
4074            &cfg,
4075            &client,
4076            &mut renderer,
4077            Some("my-env-token".to_string()),
4078            Some(&cache_path),
4079        )
4080        .expect("must succeed with env token");
4081
4082        let meta = renderer.projects_meta.unwrap();
4083        assert_eq!(meta.auth_state, "authenticated via DSP_TOKEN");
4084
4085        // Assert the token was actually forwarded — using a wrong token would fail this.
4086        let recorded_token = client.list_projects_token();
4087        assert_eq!(
4088            recorded_token,
4089            Some("my-env-token".to_string()),
4090            "token must be forwarded to list_projects"
4091        );
4092    }
4093
4094    /// Cache token with user → auth_state "authenticated as {user}".
4095    #[test]
4096    fn list_cache_token_with_user() {
4097        let dir = TempDir::new().unwrap();
4098        let cache_path = dir.path().join("auth.toml");
4099
4100        let mut cache = AuthCache::default();
4101        cache.set_entry(
4102            LIST_SERVER,
4103            ServerEntry {
4104                token: "cache-token-xyz".to_string(),
4105                user: Some("alice@example.com".to_string()),
4106                acquired_at: None,
4107                expires_at: None,
4108            },
4109        );
4110        cache.save_to(&cache_path).unwrap();
4111
4112        let args = make_list_args(None);
4113        let cfg = make_list_cfg();
4114
4115        let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
4116        let mut renderer = RecordingRenderer::new();
4117
4118        run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4119            .expect("must succeed with cache token");
4120
4121        let meta = renderer.projects_meta.unwrap();
4122        assert_eq!(meta.auth_state, "authenticated as alice@example.com");
4123
4124        // Token must be forwarded (not None).
4125        let recorded_token = client.list_projects_token();
4126        assert_eq!(
4127            recorded_token,
4128            Some("cache-token-xyz".to_string()),
4129            "cache token must be forwarded to list_projects"
4130        );
4131    }
4132
4133    /// --filter matches a subset case-insensitively.
4134    /// `total` is pre-filter, shown count is post-filter.
4135    #[test]
4136    fn list_filter_matches_subset_case_insensitively() {
4137        let dir = TempDir::new().unwrap();
4138        let cache_path = dir.path().join("auth.toml");
4139        // Use a filter that matches "anything" case-insensitively but not "images".
4140        let args = make_list_args(Some("ANYTH"));
4141        let cfg = make_list_cfg();
4142
4143        let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
4144        let mut renderer = RecordingRenderer::new();
4145
4146        run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4147            .expect("filter must not cause an error");
4148
4149        let (items, total, filter) = renderer.projects_view.unwrap();
4150        assert_eq!(total, 2, "total must be pre-filter count");
4151        assert_eq!(items.len(), 1, "only one project matches 'ANYTH'");
4152        assert_eq!(items[0].shortname, "anything");
4153        assert_eq!(filter.as_deref(), Some("ANYTH"));
4154    }
4155
4156    /// Non-matching filter → empty items list, `total` is still the full count.
4157    #[test]
4158    fn list_filter_no_match_returns_empty_items() {
4159        let dir = TempDir::new().unwrap();
4160        let cache_path = dir.path().join("auth.toml");
4161        let args = make_list_args(Some("zzz-no-match-zzz"));
4162        let cfg = make_list_cfg();
4163
4164        let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
4165        let mut renderer = RecordingRenderer::new();
4166
4167        run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4168            .expect("no-match filter must not be an error");
4169
4170        let (items, total, _) = renderer.projects_view.unwrap();
4171        assert_eq!(total, 2, "total must still show pre-filter count");
4172        assert!(
4173            items.is_empty(),
4174            "items must be empty when filter matches nothing"
4175        );
4176    }
4177
4178    /// Sort: unsorted mock response → renderer receives shortcode-ascending order.
4179    #[test]
4180    fn list_results_sorted_by_shortcode_ascending() {
4181        let dir = TempDir::new().unwrap();
4182        let cache_path = dir.path().join("auth.toml");
4183        let args = make_list_args(None);
4184        let cfg = make_list_cfg();
4185
4186        // Provide projects in reverse shortcode order.
4187        let unsorted = vec![
4188            make_project("0003", "proj-c", None),
4189            make_project("0001", "proj-a", None),
4190            make_project("0002", "proj-b", None),
4191        ];
4192
4193        let client = MockDspClient::new().with_list_projects_result(Ok(unsorted));
4194        let mut renderer = RecordingRenderer::new();
4195
4196        run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4197            .expect("sort must not error");
4198
4199        let (items, _, _) = renderer.projects_view.unwrap();
4200        let shortcodes: Vec<&str> = items.iter().map(|p| p.shortcode.as_str()).collect();
4201        assert_eq!(shortcodes, vec!["0001", "0002", "0003"]);
4202    }
4203
4204    /// Filter matches via longname (case-insensitive substring).
4205    #[test]
4206    fn list_filter_matches_longname_case_insensitively() {
4207        let dir = TempDir::new().unwrap();
4208        let cache_path = dir.path().join("auth.toml");
4209        // Filter "anything project" in upper case — must match longname "Anything Project".
4210        let args = make_list_args(Some("ANYTHING PROJECT"));
4211        let cfg = make_list_cfg();
4212
4213        // two_project_list() has one project with longname "Anything Project".
4214        let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
4215        let mut renderer = RecordingRenderer::new();
4216
4217        run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4218            .expect("longname filter must not error");
4219
4220        let (items, total, _) = renderer.projects_view.unwrap();
4221        assert_eq!(total, 2);
4222        assert_eq!(items.len(), 1);
4223        assert_eq!(items[0].shortname, "anything");
4224    }
4225
4226    /// Filter matches via shortcode.
4227    #[test]
4228    fn list_filter_matches_shortcode() {
4229        let dir = TempDir::new().unwrap();
4230        let cache_path = dir.path().join("auth.toml");
4231        let args = make_list_args(Some("0002"));
4232        let cfg = make_list_cfg();
4233
4234        let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
4235        let mut renderer = RecordingRenderer::new();
4236
4237        run_list_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4238            .expect("shortcode filter must not error");
4239
4240        let (items, total, _) = renderer.projects_view.unwrap();
4241        assert_eq!(total, 2);
4242        assert_eq!(items.len(), 1);
4243        assert_eq!(items[0].shortcode, "0002");
4244    }
4245
4246    /// `Config::resolve(None)` with no server yields a Usage error (exit 2).
4247    /// Covers PRD AC 6 — no-server check is at the dispatch layer.
4248    #[test]
4249    fn config_resolve_none_returns_usage_error() {
4250        let err = crate::config::Config::resolve(None).unwrap_err();
4251        assert!(
4252            matches!(err, Diagnostic::Usage(_)),
4253            "expected Usage diagnostic for missing server, got {err:?}"
4254        );
4255        let msg = err.to_string();
4256        assert!(
4257            msg.contains("--server") || msg.contains("DSP_SERVER"),
4258            "{msg}"
4259        );
4260    }
4261
4262    /// Token assertion is real: passing the wrong expected token should fail the test.
4263    /// This is a compile/logic check — we assert that "wrong-token" != "my-env-token".
4264    #[test]
4265    fn list_token_assertion_is_real() {
4266        let dir = TempDir::new().unwrap();
4267        let cache_path = dir.path().join("auth.toml");
4268        let args = make_list_args(None);
4269        let cfg = make_list_cfg();
4270
4271        let client = MockDspClient::new().with_list_projects_result(Ok(two_project_list()));
4272        let mut renderer = RecordingRenderer::new();
4273
4274        run_list_impl(
4275            &args,
4276            &cfg,
4277            &client,
4278            &mut renderer,
4279            Some("my-env-token".to_string()),
4280            Some(&cache_path),
4281        )
4282        .unwrap();
4283
4284        let recorded = client.list_projects_token();
4285        // Verify the correct token was forwarded and that "wrong-token" != "my-env-token"
4286        assert_eq!(recorded, Some("my-env-token".to_string()));
4287        assert_ne!(
4288            recorded,
4289            Some("wrong-token".to_string()),
4290            "token assertion must be real: wrong token should not match"
4291        );
4292    }
4293
4294    // run_describe_impl tests
4295    // ─────────────────────────────────────────────────────────────────────────
4296
4297    const DESCRIBE_SERVER: &str = "https://api.test.dasch.swiss";
4298
4299    fn make_describe_args(project: Option<&str>) -> ProjectDescribeArgs {
4300        ProjectDescribeArgs {
4301            server: Some(DESCRIBE_SERVER.to_string()),
4302            project: project.map(str::to_string),
4303            format: FormatArgs {
4304                format: Format::Prose,
4305                json: false,
4306                lines: false,
4307                columns: None,
4308                no_header: false,
4309                header_only: false,
4310            },
4311        }
4312    }
4313
4314    fn make_describe_cfg() -> Config {
4315        Config {
4316            server: DESCRIBE_SERVER.to_string(),
4317        }
4318    }
4319
4320    /// Build a realistic `ProjectDetail` fixture (beol-shaped).
4321    fn make_project_detail() -> ProjectDetail {
4322        ProjectDetail {
4323            iri: "http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF".to_string(),
4324            shortcode: "0801".to_string(),
4325            shortname: "beol".to_string(),
4326            longname: Some("Bernoulli-Euler Online".to_string()),
4327            status: ProjectStatus::Active,
4328            description: vec![ProjectDescription {
4329                value: "A project about Bernoulli and Euler.".to_string(),
4330                language: Some("en".to_string()),
4331            }],
4332            keywords: vec!["Bernoulli".to_string(), "Euler".to_string()],
4333            data_models: vec![
4334                DataModelSummary {
4335                    name: "beol".to_string(),
4336                    iri: "http://api.dasch.swiss/ontology/0801/beol/v2".to_string(),
4337                },
4338                DataModelSummary {
4339                    name: "biblio".to_string(),
4340                    iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".to_string(),
4341                },
4342            ],
4343        }
4344    }
4345
4346    /// Success: mock returns a `ProjectDetail`; renderer records the detail + meta.
4347    #[test]
4348    fn describe_success_records_detail_and_meta() {
4349        let dir = TempDir::new().unwrap();
4350        let cache_path = dir.path().join("auth.toml");
4351        let args = make_describe_args(Some("0801"));
4352        let cfg = make_describe_cfg();
4353        let detail = make_project_detail();
4354
4355        let client = MockDspClient::new().with_describe_project_result(Ok(detail.clone()));
4356        let mut renderer = RecordingRenderer::new();
4357
4358        run_describe_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4359            .expect("describe must succeed");
4360
4361        let recorded_detail = renderer.describe_detail.unwrap();
4362        assert_eq!(
4363            recorded_detail, detail,
4364            "renderer must receive the exact ProjectDetail"
4365        );
4366
4367        let meta = renderer.describe_meta.unwrap();
4368        assert_eq!(meta.server_label, DESCRIBE_SERVER);
4369        assert_eq!(meta.auth_state, "anonymous");
4370        assert!(meta.filter_warning.is_none());
4371    }
4372
4373    /// Success: assert the `--project` arg and token were forwarded to the client.
4374    #[test]
4375    fn describe_forwards_project_and_token_to_client() {
4376        let dir = TempDir::new().unwrap();
4377        let cache_path = dir.path().join("auth.toml");
4378        let args = make_describe_args(Some("0801"));
4379        let cfg = make_describe_cfg();
4380
4381        let client = MockDspClient::new().with_describe_project_result(Ok(make_project_detail()));
4382        let mut renderer = RecordingRenderer::new();
4383
4384        run_describe_impl(
4385            &args,
4386            &cfg,
4387            &client,
4388            &mut renderer,
4389            Some("my-env-token".to_string()),
4390            Some(&cache_path),
4391        )
4392        .expect("describe must succeed");
4393
4394        let (project_arg, token_arg) = client.describe_project_call();
4395        assert_eq!(project_arg, "0801", "project argument must be forwarded");
4396        assert_eq!(
4397            token_arg,
4398            Some("my-env-token".to_string()),
4399            "env token must be forwarded to describe_project"
4400        );
4401    }
4402
4403    /// `not_found`: mock returns `Diagnostic::NotFound`; action propagates the error.
4404    #[test]
4405    fn describe_not_found_propagates_error() {
4406        let dir = TempDir::new().unwrap();
4407        let cache_path = dir.path().join("auth.toml");
4408        let args = make_describe_args(Some("9999"));
4409        let cfg = make_describe_cfg();
4410
4411        let client = MockDspClient::new().with_describe_project_result(Err(Diagnostic::NotFound(
4412            "project '9999' not found".to_string(),
4413        )));
4414        let mut renderer = RecordingRenderer::new();
4415
4416        let err = run_describe_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4417            .unwrap_err();
4418
4419        assert!(
4420            matches!(err, Diagnostic::NotFound(_)),
4421            "expected NotFound, got {err:?}"
4422        );
4423    }
4424
4425    /// Missing `--project` → `Diagnostic::Usage` with the expected message.
4426    /// The guard fires BEFORE any cache/IO — no client call must be made.
4427    #[test]
4428    fn describe_missing_project_returns_usage_error() {
4429        let dir = TempDir::new().unwrap();
4430        let cache_path = dir.path().join("auth.toml");
4431        let args = make_describe_args(None); // no --project
4432        let cfg = make_describe_cfg();
4433
4434        // No canned result — if describe_project is called, the mock returns NotImplemented.
4435        let client = MockDspClient::new();
4436        let mut renderer = RecordingRenderer::new();
4437
4438        let err = run_describe_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4439            .unwrap_err();
4440
4441        assert!(
4442            matches!(err, Diagnostic::Usage(_)),
4443            "expected Usage diagnostic for missing --project, got {err:?}"
4444        );
4445        let msg = err.to_string();
4446        assert!(
4447            msg.contains("--project"),
4448            "--project must appear in the usage message: {msg}"
4449        );
4450
4451        // No server call must have been made (fail-fast guard fires before IO).
4452        assert!(
4453            renderer.describe_detail.is_none(),
4454            "renderer must not be called when --project is missing"
4455        );
4456        // The guard fires BEFORE any client call — fail-fast means no IO.
4457        assert!(
4458            !client.describe_project_was_called(),
4459            "client.describe_project must not be called when --project is missing"
4460        );
4461    }
4462
4463    /// Auth-state anonymous: no env token, empty temp cache → "anonymous".
4464    #[test]
4465    fn describe_anonymous_no_token_no_cache() {
4466        let dir = TempDir::new().unwrap();
4467        let cache_path = dir.path().join("auth.toml");
4468        let args = make_describe_args(Some("0801"));
4469        let cfg = make_describe_cfg();
4470
4471        let client = MockDspClient::new().with_describe_project_result(Ok(make_project_detail()));
4472        let mut renderer = RecordingRenderer::new();
4473
4474        run_describe_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4475            .expect("must succeed anonymously");
4476
4477        let meta = renderer.describe_meta.unwrap();
4478        assert_eq!(meta.auth_state, "anonymous");
4479
4480        let (_, token_arg) = client.describe_project_call();
4481        assert_eq!(token_arg, None, "no token must be forwarded when anonymous");
4482    }
4483
4484    /// Auth-state via env token → "authenticated via DSP_TOKEN".
4485    #[test]
4486    fn describe_env_token_authenticated_via_dsp_token() {
4487        let dir = TempDir::new().unwrap();
4488        let cache_path = dir.path().join("auth.toml");
4489        let args = make_describe_args(Some("0801"));
4490        let cfg = make_describe_cfg();
4491
4492        let client = MockDspClient::new().with_describe_project_result(Ok(make_project_detail()));
4493        let mut renderer = RecordingRenderer::new();
4494
4495        run_describe_impl(
4496            &args,
4497            &cfg,
4498            &client,
4499            &mut renderer,
4500            Some("env-token-xyz".to_string()),
4501            Some(&cache_path),
4502        )
4503        .expect("must succeed with env token");
4504
4505        let meta = renderer.describe_meta.unwrap();
4506        assert_eq!(meta.auth_state, "authenticated via DSP_TOKEN");
4507
4508        let (_, token_arg) = client.describe_project_call();
4509        assert_eq!(
4510            token_arg,
4511            Some("env-token-xyz".to_string()),
4512            "env token must be forwarded to describe_project"
4513        );
4514    }
4515
4516    /// Auth-state via cache token with user → "authenticated as <user>".
4517    #[test]
4518    fn describe_cache_token_with_user() {
4519        let dir = TempDir::new().unwrap();
4520        let cache_path = dir.path().join("auth.toml");
4521
4522        let mut cache = AuthCache::default();
4523        cache.set_entry(
4524            DESCRIBE_SERVER,
4525            ServerEntry {
4526                token: "cache-token-abc".to_string(),
4527                user: Some("bob@example.com".to_string()),
4528                acquired_at: None,
4529                expires_at: None,
4530            },
4531        );
4532        cache.save_to(&cache_path).unwrap();
4533
4534        let args = make_describe_args(Some("0801"));
4535        let cfg = make_describe_cfg();
4536
4537        let client = MockDspClient::new().with_describe_project_result(Ok(make_project_detail()));
4538        let mut renderer = RecordingRenderer::new();
4539
4540        run_describe_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4541            .expect("must succeed with cache token");
4542
4543        let meta = renderer.describe_meta.unwrap();
4544        assert_eq!(meta.auth_state, "authenticated as bob@example.com");
4545
4546        let (_, token_arg) = client.describe_project_call();
4547        assert_eq!(
4548            token_arg,
4549            Some("cache-token-abc".to_string()),
4550            "cache token must be forwarded to describe_project"
4551        );
4552    }
4553
4554    /// Corrupt cache + no env token → still succeeds anonymously (never Err).
4555    /// Locks the auth-optional fallback in run_describe_impl (ADR-0007).
4556    #[test]
4557    fn describe_corrupt_cache_falls_back_to_anonymous() {
4558        let dir = TempDir::new().unwrap();
4559        let cache_path = dir.path().join("auth.toml");
4560        std::fs::write(&cache_path, b"NOT VALID TOML }{").unwrap();
4561
4562        let args = make_describe_args(Some("0801"));
4563        let cfg = make_describe_cfg();
4564
4565        let client = MockDspClient::new().with_describe_project_result(Ok(make_project_detail()));
4566        let mut renderer = RecordingRenderer::new();
4567
4568        // Must NOT return an error — falls back to anonymous.
4569        run_describe_impl(&args, &cfg, &client, &mut renderer, None, Some(&cache_path))
4570            .expect("corrupt cache must not cause an error for describe (auth-optional)");
4571
4572        let meta = renderer.describe_meta.unwrap();
4573        assert_eq!(meta.auth_state, "anonymous");
4574
4575        let (_, token_arg) = client.describe_project_call();
4576        assert_eq!(token_arg, None, "no token must be forwarded when anonymous");
4577    }
4578}