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