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