Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

agent/cli/src/lib.rs

//! **The courier** (`holger-cli agent`), as a library: `holger-cli` carries it, so there is
//! no separate `holger-agent` program.

use agent_lib::*;
use anyhow::Result;
use clap::{Args, ValueEnum};

// The network Source/Target variants (World / Nexus / Artifactory / Holger) are the
// ONLY ones that construct a reqwest-backed connector, so they are compiled in ONLY
// under the `online` feature. An offline (`--no-default-features`) agent exposes just
// the archive/directory formats — the zero-egress airgap courier — and every match
// arm that reads an online variant is `#[cfg(feature = "online")]` in lockstep.
#[derive(Debug, Clone, ValueEnum)]
pub enum Source {
    /// Download from crates.io (requires --lockfile)
    #[cfg(feature = "online")]
    World,
    /// Read from a Nexus repository (requires --nexus-url, uses basic auth)
    #[cfg(feature = "online")]
    Nexus,
    /// Read from a JFrog Artifactory repository (requires --artifactory-url, basic auth)
    #[cfg(feature = "online")]
    Artifactory,
    /// Read from a Holger server (requires --holger-url, uses OIDC/mTLS)
    #[cfg(feature = "online")]
    Holger,
    /// Read from a znippy archive (requires --input)
    Znippy,
    /// Read from a tar.zst archive (requires --input)
    TarZstd,
    /// Read from a directory of .crate files (requires --input)
    Directory,
}

#[derive(Debug, Clone, ValueEnum)]
pub enum Target {
    /// Pack into a znippy archive (requires --output)
    Znippy,
    /// Pack into a zstd-compressed tar archive (requires --output)
    TarZstd,
    /// Write .crate files to a directory (requires --output)
    Directory,
    /// Push to a Nexus repository (requires --nexus-url, uses basic auth)
    #[cfg(feature = "online")]
    Nexus,
    /// Push to a JFrog Artifactory repository (requires --artifactory-url, basic auth)
    #[cfg(feature = "online")]
    Artifactory,
    /// Push to a Holger server (requires --holger-url, uses OIDC/mTLS)
    #[cfg(feature = "online")]
    Holger,
}

#[derive(Debug, Clone, ValueEnum)]
pub enum AuthMode {
    /// OIDC Bearer token auth
    Oidc,
    /// Mutual TLS (client certificate)
    Mtls,
}

/// Package ecosystem lockfile format.
#[derive(Debug, Clone, ValueEnum, Default)]
pub enum LockfileKind {
    /// Rust Cargo.lock
    #[default]
    Cargo,
    /// Python requirements.txt with pinned versions (name==version)
    RequirementsTxt,
    /// Python uv.lock (uv package manager)
    UvLock,
}

/// `holger-cli agent`: the courier. Transfer artifacts between registries, archives and
/// directories (this was the `holger-agent` program). Offline by default: the archive and
/// directory routes only; `--features online` adds crates.io, Nexus, Artifactory and Holger.
#[derive(Args, Debug)]
pub struct AgentArgs {
    /// Source to read artifacts from
    #[cfg(feature = "online")]
    #[arg(long, value_enum, default_value_t = Source::World)]
    from: Source,

    /// Source to read artifacts from (offline build: archive/directory only, so the
    /// default is `znippy` — the online default `world` is compiled out here).
    #[cfg(not(feature = "online"))]
    #[arg(long, value_enum, default_value_t = Source::Znippy)]
    from: Source,

    /// Destination to write artifacts to
    #[arg(long, value_enum, default_value_t = Target::Znippy)]
    to: Target,

    /// Path to Cargo.lock file (required when --from world)
    #[arg(long)]
    lockfile: Option<std::path::PathBuf>,

    /// Lockfile format (cargo, requirements-txt, uv-lock). Default: cargo
    #[arg(long, value_enum, default_value_t = LockfileKind::Cargo)]
    lockfile_kind: LockfileKind,

    /// Input path: znippy archive or directory (required when --from znippy/directory)
    #[arg(long)]
    input: Option<std::path::PathBuf>,

    /// Output path (required when --to znippy/directory)
    #[arg(short, long)]
    output: Option<std::path::PathBuf>,

    /// Ecosystem override for `--from directory --to znippy`: tag every packed
    /// file with this ecosystem (rust, maven, python, npm, nuget, …) so its index
    /// is built. Omit to derive each file's ecosystem from its extension.
    #[arg(long)]
    format: Option<String>,

    /// Download repos one at a time instead of in parallel (lower memory, lower network usage)
    #[arg(long, default_value_t = false)]
    sequential: bool,

    /// Strict transfer mode (for --to znippy): any failed asset, hostile path, or
    /// size mismatch fails the whole operation with a non-zero exit and leaves no
    /// archive under the output name. Default is the legacy best-effort tally mode.
    #[arg(long, default_value_t = false)]
    strict: bool,

    /// MIGRATION PREVIEW (`--from nexus|artifactory --to holger`): connect,
    /// list every source repository with its asset count and size, print exactly
    /// which holger repositories would be CREATED and which already exist, and
    /// then STOP. Nothing is created and no artifact byte is read. A migration
    /// moves a whole registry at once, so seeing the repository set it is about
    /// to grow is the last cheap moment to say no.
    #[arg(long, default_value_t = false)]
    dry_run: bool,

    /// Write a provenance transfer receipt (JSON) to this path. Suitable for
    /// embedding as evidence/source-receipt.json in a signed update bundle.
    #[arg(long)]
    receipt: Option<std::path::PathBuf>,

    // --- Nexus (basic auth) ---
    /// Nexus base URL (basic auth with --username/--password)
    #[arg(long)]
    nexus_url: Option<String>,

    /// Nexus repository name
    #[arg(long)]
    repository: Option<String>,

    /// Artifactory base URL up to (not including) /artifactory, e.g.
    /// `https://art.example.com`. Basic auth with --username/--password.
    #[arg(long, env = "ARTIFACTORY_URL")]
    artifactory_url: Option<String>,

    /// Nexus username (or set NEXUS_USERNAME env var)
    #[arg(long, env = "NEXUS_USERNAME")]
    username: Option<String>,

    /// Nexus password (or set NEXUS_PASSWORD env var)
    #[arg(long, env = "NEXUS_PASSWORD")]
    password: Option<String>,

    // --- Holger server (strong auth) ---
    /// Holger server URL
    #[arg(long, env = "HOLGER_URL")]
    holger_url: Option<String>,

    /// Authentication method for Holger server
    #[arg(long, value_enum)]
    auth: Option<AuthMode>,

    /// OIDC token endpoint URL (required when --auth oidc)
    #[arg(long, env = "HOLGER_TOKEN_URL")]
    token_url: Option<String>,

    /// OIDC client ID / username for token request
    #[arg(long, env = "HOLGER_CLIENT_ID")]
    client_id: Option<String>,

    /// Client certificate PEM path (required when --auth mtls)
    #[arg(long, env = "HOLGER_CLIENT_CERT")]
    client_cert: Option<std::path::PathBuf>,

    /// Client key PEM path (required when --auth mtls)
    #[arg(long, env = "HOLGER_CLIENT_KEY")]
    client_key: Option<std::path::PathBuf>,
}

/// **Introspection / emit marker** — record one functional-status row for the
/// nornir test matrix. Wraps `nornir_testmatrix::functional_status` behind the
/// `testmatrix` feature (a compiled-out `#[inline]` no-op otherwise, with no
/// nornir dep in a release build). Each transfer route the agent actually runs
/// emits one so `nornir test --features testmatrix` SEES the real command
/// surface fire. Mirrors the emitter in `server/cli/src/main.rs`.
#[inline]
fn functional_status(component: &str, check: &str, ok: bool, detail: &str) {
    #[cfg(feature = "testmatrix")]
    nornir_testmatrix::functional_status(component, check, ok, detail);
    #[cfg(not(feature = "testmatrix"))]
    {
        let _ = (component, check, ok, detail);
    }
}

/// Emit a `.znippy` from a fetched asset list, honoring `--strict` / `--receipt`.
/// With neither flag set this calls the unchanged legacy best-effort path, so
/// default behavior is byte-for-byte preserved. Only the online routes fetch from a
/// connector, so this helper is compiled in with them.
#[cfg(feature = "online")]
async fn emit_znippy(
    strict: bool,
    receipt: Option<std::path::PathBuf>,
    connector: &dyn ConnectorTrait,
    source_type: &str,
    source_endpoint: &str,
    repository: &str,
    assets: Vec<RemoteAsset>,
    output: std::path::PathBuf,
) -> Result<()> {
    if strict {
        let have_receipt = receipt.is_some();
        let mut opts = TransferOptions::new(TransferMode::Strict, source_type, source_endpoint);
        opts.receipt_path = receipt;
        let report =
            transfer_assets_to_znippy_with_options(connector, repository, &assets, &output, &opts)
                .await?;
        eprintln!(
            "✅ strict transfer: {}/{} assets, {} bytes{}",
            report.successful_assets,
            report.requested_assets,
            report.bytes_transferred,
            if have_receipt {
                ", receipt written"
            } else {
                ""
            }
        );
    } else if receipt.is_some() {
        let mut opts = TransferOptions::new(TransferMode::BestEffort, source_type, source_endpoint);
        opts.receipt_path = receipt;
        transfer_assets_to_znippy_with_options(connector, repository, &assets, &output, &opts)
            .await?;
    } else {
        operations::transfer_to_znippy(connector, assets, output).await?;
    }
    Ok(())
}

/// **A flag the chosen `--from`/`--to` route needs, or a usage error.** The route
/// decides which flags are required, so clap cannot check them up front; a missing
/// one used to be a panic (`expect`, exit 101 and a backtrace hint). It is now clap's
/// own usage error: `error: <what is missing>` plus the usage line on stderr, and
/// exit code 2, the same as any other bad command line.
fn required<T>(value: Option<T>, what: &str) -> T {
    value.unwrap_or_else(|| {
        <AgentArgs as Args>::augment_args(clap::Command::new("holger-cli agent"))
            .error(clap::error::ErrorKind::MissingRequiredArgument, what)
            .exit()
    })
}

/// Run the courier.
pub async fn run(cli: AgentArgs) -> Result<()> {
    // Extract auth params (used by reference in the online Holger paths only).
    #[cfg(feature = "online")]
    let auth_mode = cli.auth;
    #[cfg(feature = "online")]
    let token_url = cli.token_url;
    #[cfg(feature = "online")]
    let client_id = cli.client_id;
    #[cfg(feature = "online")]
    let client_cert = cli.client_cert;
    #[cfg(feature = "online")]
    let client_key = cli.client_key;

    match (&cli.from, &cli.to) {
        #[cfg(feature = "online")]
        (Source::World, Target::Znippy)
        | (Source::World, Target::TarZstd)
        | (Source::World, Target::Directory) => {
            let lockfile = required(cli.lockfile, "--lockfile is required when --from world");
            let output = required(
                cli.output,
                "--output is required when --to znippy/tar-zstd/directory",
            );

            let assets = match cli.lockfile_kind {
                LockfileKind::Cargo => {
                    let connector = WorldConnector::new()?;
                    let packages = parse_lockfile(&lockfile)?;
                    println!("📦 Found {} crates from crates.io", packages.len());
                    let assets = packages_to_assets(&packages);
                    match cli.to {
                        Target::Znippy => {
                            emit_znippy(
                                cli.strict,
                                cli.receipt.clone(),
                                &connector,
                                "world",
                                "https://static.crates.io",
                                "crates.io",
                                assets,
                                output,
                            )
                            .await?;
                        }
                        Target::TarZstd => {
                            operations::transfer_to_tar_zstd(&connector, assets, output).await?;
                        }
                        Target::Directory => {
                            operations::transfer_to_directory(&connector, assets, output).await?;
                        }
                        _ => unreachable!(),
                    }
                    functional_status(
                        "holger-agent/airgap",
                        "world_cargo_export",
                        true,
                        &format!(
                            "crates.io Cargo.lock → {:?} ({} crates)",
                            cli.to,
                            packages.len()
                        ),
                    );
                    return Ok(());
                }
                LockfileKind::RequirementsTxt => {
                    let packages = parse_requirements_txt(&lockfile)
                        .map_err(|e| anyhow::anyhow!("Failed to parse requirements.txt: {}", e))?;
                    let index = PypiJsonClient::new();
                    let packages = resolve_with_index(packages, &index).await.map_err(|e| {
                        anyhow::anyhow!("Failed to resolve packages from PyPI: {}", e)
                    })?;
                    println!(
                        "📦 Found {} Python packages from PyPI",
                        packages.iter().map(|p| p.files.len()).sum::<usize>()
                    );
                    pip_packages_to_assets(&packages)
                }
                LockfileKind::UvLock => {
                    let packages = parse_uv_lockfile(&lockfile)
                        .map_err(|e| anyhow::anyhow!("Failed to parse uv.lock: {}", e))?;
                    println!(
                        "📦 Found {} Python packages from uv.lock",
                        packages.iter().map(|p| p.files.len()).sum::<usize>()
                    );
                    pip_packages_to_assets(&packages)
                }
            };

            // For pip cases, we need a connector just for download
            let connector = WorldConnector::new()?;
            match cli.to {
                Target::Znippy => {
                    emit_znippy(
                        cli.strict,
                        cli.receipt.clone(),
                        &connector,
                        "world",
                        "https://pypi.org",
                        "pypi",
                        assets,
                        output,
                    )
                    .await?;
                }
                Target::TarZstd => {
                    operations::transfer_to_tar_zstd(&connector, assets, output).await?;
                }
                Target::Directory => {
                    operations::transfer_to_directory(&connector, assets, output).await?;
                }
                _ => unreachable!(),
            }
        }

        #[cfg(feature = "online")]
        (Source::Nexus, Target::Znippy)
        | (Source::Nexus, Target::TarZstd)
        | (Source::Nexus, Target::Directory) => {
            let nexus_url = required(cli.nexus_url, "--nexus-url is required when --from nexus");
            let output = required(
                cli.output,
                "--output is required when --to znippy/tar-zstd/directory",
            );

            let connector =
                std::sync::Arc::new(NexusConnector::new(nexus_url, cli.username, cli.password)?);
            let repos = connector.list_repositories().await?;

            let repos: Vec<_> = if let Some(ref filter) = cli.repository {
                repos.into_iter().filter(|r| &r.name == filter).collect()
            } else {
                repos
            };

            println!(
                "📦 Exporting {} repositories ({})",
                repos.len(),
                if cli.sequential {
                    "sequential"
                } else {
                    "parallel"
                }
            );

            match cli.to {
                Target::Znippy => {
                    operations::dump_all_repos_to_znippy(connector, repos, output, cli.sequential)
                        .await?;
                }
                Target::TarZstd => {
                    // One archive PER REPO under `output/` — a single shared
                    // output path would be File::create-truncated once per repo,
                    // silently keeping only the last repo (data loss).
                    std::fs::create_dir_all(&output)?;
                    for repo in &repos {
                        println!("\n→ {} (format: {})", repo.name, repo.format);
                        let assets = connector.list_assets(&repo.name).await?;
                        println!("  {} assets", assets.len());
                        let repo_output = output.join(format!("{}.tar.zst", repo.name));
                        operations::transfer_to_tar_zstd(&*connector, assets, repo_output).await?;
                    }
                }
                Target::Directory => {
                    for repo in &repos {
                        println!("\n→ {} (format: {})", repo.name, repo.format);
                        let assets = connector.list_assets(&repo.name).await?;
                        println!("  {} assets", assets.len());
                        let repo_output = output.join(&repo.name);
                        operations::transfer_to_directory(&*connector, assets, repo_output).await?;
                    }
                }
                _ => unreachable!(),
            }
        }

        #[cfg(feature = "online")]
        (Source::Artifactory, Target::Znippy)
        | (Source::Artifactory, Target::TarZstd)
        | (Source::Artifactory, Target::Directory) => {
            let art_url = required(
                cli.artifactory_url,
                "--artifactory-url is required when --from artifactory",
            );
            let output = required(
                cli.output,
                "--output is required when --to znippy/tar-zstd/directory",
            );

            let connector = std::sync::Arc::new(ArtifactoryConnector::new(
                art_url,
                cli.username,
                cli.password,
            )?);
            let repos = connector.list_repositories().await?;

            let repos: Vec<_> = if let Some(ref filter) = cli.repository {
                repos.into_iter().filter(|r| &r.name == filter).collect()
            } else {
                repos
            };

            println!(
                "📦 Exporting {} repositories from Artifactory ({})",
                repos.len(),
                if cli.sequential {
                    "sequential"
                } else {
                    "parallel"
                }
            );

            match cli.to {
                Target::Znippy => {
                    operations::dump_all_repos_to_znippy(connector, repos, output, cli.sequential)
                        .await?;
                }
                Target::TarZstd => {
                    // One archive PER REPO under `output/` — a single shared
                    // output path would be File::create-truncated once per repo,
                    // silently keeping only the last repo (data loss).
                    std::fs::create_dir_all(&output)?;
                    for repo in &repos {
                        println!("\n→ {} (format: {})", repo.name, repo.format);
                        let assets = connector.list_assets(&repo.name).await?;
                        println!("  {} assets", assets.len());
                        let repo_output = output.join(format!("{}.tar.zst", repo.name));
                        operations::transfer_to_tar_zstd(&*connector, assets, repo_output).await?;
                    }
                }
                Target::Directory => {
                    for repo in &repos {
                        println!("\n→ {} (format: {})", repo.name, repo.format);
                        let assets = connector.list_assets(&repo.name).await?;
                        println!("  {} assets", assets.len());
                        let repo_output = output.join(&repo.name);
                        operations::transfer_to_directory(&*connector, assets, repo_output).await?;
                    }
                }
                _ => unreachable!(),
            }
        }

        #[cfg(feature = "online")]
        (Source::Znippy, Target::Artifactory)
        | (Source::TarZstd, Target::Artifactory)
        | (Source::Directory, Target::Artifactory) => {
            let art_url = required(
                cli.artifactory_url,
                "--artifactory-url is required when --to artifactory",
            );
            let repository = required(
                cli.repository,
                "--repository is required when --to artifactory",
            );
            let connector = ArtifactoryConnector::new(art_url, cli.username, cli.password)?;
            match cli.from {
                Source::Znippy => {
                    let input = required(cli.input, "--input is required when --from znippy");
                    operations::push_znippy_to_connector(&connector, input, &repository).await?;
                }
                Source::TarZstd => {
                    let input = required(cli.input, "--input is required when --from tar-zstd");
                    operations::push_tar_zstd_to_connector(&connector, input, &repository).await?;
                }
                Source::Directory => {
                    let input = required(cli.input, "--input is required when --from directory");
                    operations::push_directory_to_connector(&connector, input, &repository).await?;
                }
                _ => unreachable!(),
            }
        }

        #[cfg(feature = "online")]
        (Source::Holger, Target::Znippy)
        | (Source::Holger, Target::TarZstd)
        | (Source::Holger, Target::Directory) => {
            let holger_url = required(
                cli.holger_url,
                "--holger-url is required when --from holger",
            );
            let output = required(
                cli.output,
                "--output is required when --to znippy/tar-zstd/directory",
            );

            let connector = std::sync::Arc::new(
                build_holger_connector(
                    &auth_mode,
                    &holger_url,
                    &token_url,
                    &client_id,
                    &client_cert,
                    &client_key,
                )
                .await?,
            );
            let repos = connector.list_repositories().await?;

            let repos: Vec<_> = if let Some(ref filter) = cli.repository {
                repos.into_iter().filter(|r| &r.name == filter).collect()
            } else {
                repos
            };

            println!(
                "🔐 Exporting {} repositories from Holger ({})",
                repos.len(),
                if cli.sequential {
                    "sequential"
                } else {
                    "parallel"
                }
            );

            match cli.to {
                Target::Znippy => {
                    operations::dump_all_repos_to_znippy(connector, repos, output, cli.sequential)
                        .await?;
                }
                Target::TarZstd => {
                    // One archive PER REPO under `output/` — a single shared
                    // output path would be File::create-truncated once per repo,
                    // silently keeping only the last repo (data loss).
                    std::fs::create_dir_all(&output)?;
                    for repo in &repos {
                        println!("\n→ {} (format: {})", repo.name, repo.format);
                        let assets = connector.list_assets(&repo.name).await?;
                        println!("  {} assets", assets.len());
                        let repo_output = output.join(format!("{}.tar.zst", repo.name));
                        operations::transfer_to_tar_zstd(&*connector, assets, repo_output).await?;
                    }
                }
                Target::Directory => {
                    for repo in &repos {
                        println!("\n→ {} (format: {})", repo.name, repo.format);
                        let assets = connector.list_assets(&repo.name).await?;
                        println!("  {} assets", assets.len());
                        let repo_output = output.join(&repo.name);
                        operations::transfer_to_directory(&*connector, assets, repo_output).await?;
                    }
                }
                _ => unreachable!(),
            }
        }

        #[cfg(feature = "online")]
        (Source::Znippy, Target::Nexus) => {
            let input = required(cli.input, "--input is required when --from znippy");
            let nexus_url = required(cli.nexus_url, "--nexus-url is required when --to nexus");
            let repository = required(cli.repository, "--repository is required when --to nexus");

            let connector = NexusConnector::new(nexus_url, cli.username, cli.password)?;
            operations::push_znippy_to_connector(&connector, input, &repository).await?;
        }

        #[cfg(feature = "online")]
        (Source::TarZstd, Target::Nexus) => {
            let input = required(cli.input, "--input is required when --from tar-zstd");
            let nexus_url = required(cli.nexus_url, "--nexus-url is required when --to nexus");
            let repository = required(cli.repository, "--repository is required when --to nexus");

            let connector = NexusConnector::new(nexus_url, cli.username, cli.password)?;
            operations::push_tar_zstd_to_connector(&connector, input, &repository).await?;
        }

        #[cfg(feature = "online")]
        (Source::Znippy, Target::Holger)
        | (Source::Directory, Target::Holger)
        | (Source::TarZstd, Target::Holger) => {
            let holger_url = required(cli.holger_url, "--holger-url is required when --to holger");
            let repository = required(cli.repository, "--repository is required when --to holger");

            let connector = build_holger_connector(
                &auth_mode,
                &holger_url,
                &token_url,
                &client_id,
                &client_cert,
                &client_key,
            )
            .await?;

            match cli.from {
                Source::Znippy => {
                    let input = required(cli.input, "--input is required when --from znippy");
                    operations::push_znippy_to_connector(&connector, input, &repository).await?;
                }
                Source::TarZstd => {
                    let input = required(cli.input, "--input is required when --from tar-zstd");
                    operations::push_tar_zstd_to_connector(&connector, input, &repository).await?;
                }
                Source::Directory => {
                    let input = required(cli.input, "--input is required when --from directory");
                    operations::push_directory_to_connector(&connector, input, &repository).await?;
                }
                _ => unreachable!(),
            }
        }

        #[cfg(feature = "online")]
        (Source::Directory, Target::Nexus) => {
            let input = required(cli.input, "--input is required when --from directory");
            let nexus_url = required(cli.nexus_url, "--nexus-url is required when --to nexus");
            let repository = required(cli.repository, "--repository is required when --to nexus");

            let connector = NexusConnector::new(nexus_url, cli.username, cli.password)?;
            operations::push_directory_to_connector(&connector, input, &repository).await?;
        }

        // ─── MIGRATE: an existing registry into holger, ONE TIME, in one pass ───
        //
        // These two combinations used to fall through to the wildcard below and
        // `bail!("Unsupported source/target combination.")`. The move was possible
        // — `--from nexus --to znippy` then `--from znippy --to holger` — but it
        // cost a full second copy of the registry on a local disk and a manual
        // step between two commands. The archive in the middle is the AIRGAP
        // courier, and it earns its place exactly when the two ends are on
        // different networks. When both ends are reachable at once it is a tax.
        //
        // This is a MIGRATION: it runs once and it finishes. It is not a mirror,
        // and widening it into one would be a different feature with a different
        // shape (a schedule, a watermark, a standing relationship).
        //
        // Re-running is safe and is the intended way to recover: every asset is
        // checked against the destination before it is fetched, keyed on path +
        // the sha256 the SOURCE published, so what already landed is skipped.
        #[cfg(feature = "online")]
        (Source::Nexus, Target::Holger) | (Source::Artifactory, Target::Holger) => {
            use std::sync::atomic::{AtomicBool, Ordering};
            use std::sync::Arc;

            let holger_url = required(cli.holger_url, "--holger-url is required when --to holger");

            let source: Arc<dyn ConnectorTrait> = match cli.from {
                Source::Nexus => {
                    let url = required(cli.nexus_url, "--nexus-url is required when --from nexus");
                    Arc::new(NexusConnector::new(url, cli.username, cli.password)?)
                }
                Source::Artifactory => {
                    let url = required(
                        cli.artifactory_url,
                        "--artifactory-url is required when --from artifactory",
                    );
                    Arc::new(ArtifactoryConnector::new(url, cli.username, cli.password)?)
                }
                _ => unreachable!(),
            };

            let target: Arc<dyn ConnectorTrait> = Arc::new(
                build_holger_connector(
                    &auth_mode,
                    &holger_url,
                    &token_url,
                    &client_id,
                    &client_cert,
                    &client_key,
                )
                .await?,
            );

            println!("🔎 Discovering {} …", source.name());
            let destination_repos = target.list_repositories().await.map_err(|e| {
                anyhow::anyhow!("could not list the destination holger repositories: {e}")
            })?;
            let (plan, assets) = migrate::discover(
                source.as_ref(),
                &destination_repos,
                cli.repository.as_deref(),
            )
            .await?;

            // The preview is printed on EVERY run, not only under --dry-run: an
            // operator who is about to move a registry should read the repository
            // set first whether or not he asked for it.
            print!("{}", plan.dry_run_report());

            if cli.dry_run {
                functional_status(
                    "holger-agent/migrate",
                    "migrate_dry_run",
                    plan.refuse_collisions().is_ok(),
                    &format!(
                        "{:?} → holger: {} repos, {} would be created",
                        cli.from,
                        plan.repos.len(),
                        plan.to_create().len()
                    ),
                );
                return Ok(());
            }

            // Refuse BEFORE a byte moves. `run_migration` checks this too; doing
            // it here makes the CLI exit non-zero with the reason rather than
            // with a generic engine error.
            plan.refuse_collisions()?;

            // Ctrl-C is a CANCEL, not a kill: it stops admitting new transfers and
            // lets the in-flight ones finish, because tearing down a half-written
            // upload is how a destination gets a truncated artifact. Re-run to
            // resume.
            let cancel = Arc::new(AtomicBool::new(false));
            let ctrlc = cancel.clone();
            tokio::spawn(async move {
                if tokio::signal::ctrl_c().await.is_ok() {
                    eprintln!("\n⏹  cancelling — finishing transfers already in flight. Re-run to resume.");
                    ctrlc.store(true, Ordering::Relaxed);
                }
            });

            let opts = MigrationOptions {
                // `--sequential` means one at a time here too: lower memory, lower
                // load on a source that is still serving its own users.
                concurrency: if cli.sequential {
                    1
                } else {
                    DEFAULT_MIGRATION_CONCURRENCY
                },
                verify_digests: true,
            };

            println!("\n🚚 Migrating …");
            let sink = Arc::new(migrate::ConnectorSink::new(target));
            let report = migrate::run_migration(source, sink, &plan, &assets, &opts, cancel, |p| {
                if p.repo_finished {
                    println!("   → {}", p.message);
                }
            })
            .await?;

            for failure in &report.failures {
                println!("   ✗ {failure}");
            }
            println!(
                "\n{} migration: {} moved, {} already present, {} failed, {} bytes, {} repositories created{}",
                if report.is_complete() { "✅" } else { "⚠️ " },
                report.moved,
                report.skipped,
                report.failed,
                report.bytes,
                report.repos_created.len(),
                if report.cancelled { " (CANCELLED — re-run to resume)" } else { "" },
            );

            functional_status(
                "holger-agent/migrate",
                "migrate_completed",
                report.is_complete(),
                &format!(
                    "{:?} → holger: {} moved, {} failed",
                    cli.from, report.moved, report.failed
                ),
            );

            if !report.is_complete() {
                anyhow::bail!(
                    "migration did not complete: {} assets failed, cancelled={}. \
                     Re-run the same command to resume — everything already moved is skipped.",
                    report.failed,
                    report.cancelled
                );
            }
            return Ok(());
        }

        #[cfg(feature = "online")]
        (Source::World, Target::Nexus)
        | (Source::World, Target::Holger)
        | (Source::World, Target::Artifactory) => {
            anyhow::bail!("Cannot push directly from world to registry. Airgap first, then push.");
        }
        #[cfg(feature = "online")]
        (Source::Nexus, Target::Nexus)
        | (Source::Holger, Target::Holger)
        | (Source::Artifactory, Target::Artifactory) => {
            anyhow::bail!("Cannot transfer directly between same type. Export first, then import.");
        }
        (Source::Znippy, Target::Znippy)
        | (Source::Directory, Target::Directory)
        | (Source::TarZstd, Target::TarZstd) => {
            anyhow::bail!("Source and destination are the same format — nothing to do.");
        }
        (Source::Directory, Target::Znippy) => {
            let input = required(cli.input, "--input is required when --from directory");
            let output = required(cli.output, "--output is required when --to znippy");
            operations::pack_directory_to_znippy(input, output, cli.format.as_deref()).await?;
        }
        (Source::Znippy, Target::Directory) => {
            let input = required(cli.input, "--input is required when --from znippy");
            let output = required(cli.output, "--output is required when --to directory");
            operations::unpack_znippy_to_directory(input, output).await?;
        }
        (Source::TarZstd, Target::Directory) => {
            let input = required(cli.input, "--input is required when --from tar-zstd");
            let output = required(cli.output, "--output is required when --to directory");
            operations::unpack_tar_zstd_to_directory(input, output).await?;
        }
        (Source::Directory, Target::TarZstd) => {
            let input = required(cli.input, "--input is required when --from directory");
            let output = required(cli.output, "--output is required when --to tar-zstd");
            operations::pack_directory_to_tar_zstd(input, output).await?;
        }
        (Source::Znippy, Target::TarZstd) => {
            let input = required(cli.input, "--input is required when --from znippy");
            let output = required(cli.output, "--output is required when --to tar-zstd");
            operations::convert_znippy_to_tar_zstd(input, output).await?;
        }
        (Source::TarZstd, Target::Znippy) => {
            let input = required(cli.input, "--input is required when --from tar-zstd");
            let output = required(cli.output, "--output is required when --to znippy");
            operations::convert_tar_zstd_to_znippy(input, output, cli.format.as_deref()).await?;
        }
        // Online-only: with the online variants compiled in, the 3×3 offline arms
        // above are not exhaustive, so this catches the unsupported online combos.
        // In an offline build the offline arms ARE exhaustive over the remaining
        // (archive/directory) variants, so this wildcard would be unreachable — hence
        // it is compiled out there rather than left as a dead pattern.
        #[cfg(feature = "online")]
        _ => {
            anyhow::bail!("Unsupported source/target combination.");
        }
    }

    // Reaching here means a real transfer route completed (every dead-end
    // combination `bail!`s above, so no green row is emitted for a no-op). Record
    // the executed route as the agent's command-surface functional check.
    functional_status(
        "holger-agent/transfer",
        "route_completed",
        true,
        &format!("{:?} → {:?}", cli.from, cli.to),
    );

    Ok(())
}

/// Build a NexusConnector with OIDC or mTLS auth for Holger server. Online-only —
/// it constructs a reqwest-backed connector, absent from the offline build.
#[cfg(feature = "online")]
async fn build_holger_connector(
    auth: &Option<AuthMode>,
    holger_url: &str,
    token_url: &Option<String>,
    client_id: &Option<String>,
    client_cert: &Option<std::path::PathBuf>,
    client_key: &Option<std::path::PathBuf>,
) -> Result<NexusConnector> {
    match auth {
        Some(AuthMode::Oidc) => {
            let token_url = required(
                token_url.as_ref(),
                "--token-url is required when --auth oidc",
            );
            let client_id = required(
                client_id.as_ref(),
                "--client-id is required when --auth oidc",
            );
            println!("🔑 Fetching OIDC token from {}", token_url);
            let token = fetch_oidc_token(token_url, client_id, "").await?;
            println!("✅ Got token");
            NexusConnector::with_oidc_token(holger_url.to_string(), token)
        }
        Some(AuthMode::Mtls) => {
            let cert_path = required(
                client_cert.as_ref(),
                "--client-cert is required when --auth mtls",
            );
            let key_path = required(
                client_key.as_ref(),
                "--client-key is required when --auth mtls",
            );
            let cert_pem = std::fs::read(cert_path)?;
            let key_pem = std::fs::read(key_path)?;
            println!("🔐 Using mTLS client cert: {}", cert_path.display());
            NexusConnector::with_mtls(holger_url.to_string(), &cert_pem, &key_pem)
        }
        None => {
            anyhow::bail!("--auth (oidc or mtls) is required when using --from/--to holger");
        }
    }
}