Skip to main content

dsp_cli/actions/vre/
project.rs

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