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