//! `apss install` command implementation.

use apss_core::config::{self, CONFIG_FILENAME};
use apss_core::distribution::codegen::{self, CodegenOptions};
use apss_core::lockfile::{self, LOCKFILE_FILENAME, LockedPackage, Lockfile};
use apss_core::resolution;
use clap::Args;
use serde::Deserialize;
use std::path::{Path, PathBuf};
use std::process::Command;

const APSS_HOOK_BEGIN: &str = "# BEGIN APSS MANAGED PRE-COMMIT";
const APSS_HOOK_END: &str = "# END APSS MANAGED PRE-COMMIT";

// TODO: add --update <slug> and --update-all flags when registry resolution is implemented
#[derive(Args)]
pub struct InstallArgs {
    /// Fail if lockfile would change (for CI).
    #[arg(long)]
    locked: bool,

    /// Use only cached crates (no network).
    #[arg(long)]
    offline: bool,

    /// Use local checkout path dependencies instead of registry versions.
    ///
    /// Intended for pre-publish testing, for example:
    /// `apss install --local-repo /path/to/agent-paradise-standards-system`.
    #[arg(long, value_name = "PATH")]
    local_repo: Option<PathBuf>,

    /// Install from a local APSS bundle directory.
    ///
    /// Intended for package-manager testing before registry distribution is
    /// implemented. A bundle directory is generated by
    /// `apss-dev v1 bundle <id>`.
    #[arg(long, value_name = "PATH", conflicts_with = "local_repo")]
    bundle_dir: Option<PathBuf>,

    /// Do not install managed Git hooks.
    #[arg(long)]
    no_hooks: bool,
}

pub fn run(args: InstallArgs) -> i32 {
    // 1. Find and parse config
    let cwd = match std::env::current_dir() {
        Ok(d) => d,
        Err(e) => {
            eprintln!("Failed to get current directory: {e}");
            return 1;
        }
    };

    let config_path = match config::find_project_config(&cwd) {
        Some(p) => p,
        None => {
            eprintln!("No {CONFIG_FILENAME} found. Run 'apss init' first.");
            return 1;
        }
    };

    let project_config = match config::parse_project_config(&config_path) {
        Ok(c) => c,
        Err(e) => {
            eprintln!("Failed to parse {}: {e}", config_path.display());
            return 1;
        }
    };

    // 2. Validate config
    let diags = apss_core::project_config_validation::validate_project_config(&config_path);
    if diags.has_errors() {
        eprintln!("{diags}");
        eprintln!("\nFix configuration errors before installing.");
        return 1;
    }

    let project_root = config_path.parent().unwrap_or(Path::new("."));
    let resolved = resolution::resolve_single(project_config, config_path.clone());
    let bundle_manifest = match args.bundle_dir.as_deref() {
        Some(bundle_dir) => match read_bundle_manifest(bundle_dir) {
            Ok(manifest) => Some(manifest),
            Err(error) => {
                eprintln!("Failed to read APSS bundle: {error}");
                return 1;
            }
        },
        None => None,
    };

    // 3. Generate/update lockfile
    let lockfile_path = project_root.join(LOCKFILE_FILENAME);
    let existing_lockfile = lockfile::parse_lockfile(&lockfile_path).ok();
    let mut lockfile = generate_lockfile(
        &resolved,
        bundle_manifest.as_ref(),
        existing_lockfile.as_ref(),
    );

    let local_source = args.local_repo.clone().or(args.bundle_dir.clone());

    if args.bundle_dir.is_some() && lockfile_has_unresolved_packages(&lockfile) {
        eprintln!("Local bundle did not resolve every enabled standard in {CONFIG_FILENAME}.");
        eprintln!("Check that the bundle ID matches the configured standard ID.");
        return 1;
    }

    // Registry installs (no --bundle-dir/--local-repo) are now supported: cargo
    // resolves the version requirement during the build below and we read the
    // exact pin + checksum back into apss.lock (ADR-0002). At this point the
    // lockfile carries the version REQUIREMENT, not a hand-resolved pin, so the
    // only "unresolved" state we still refuse is the legacy UNRESOLVED markers
    // (which generate_lockfile no longer produces for registry packages).
    if local_source.is_none() && lockfile_has_unresolved_packages(&lockfile) {
        eprintln!("Refusing to write an unresolved {LOCKFILE_FILENAME}.");
        return 1;
    }

    if args.locked {
        if !lockfile_path.exists() {
            eprintln!("Lockfile would be created but --locked was specified.");
            eprintln!("Run 'apss install' without --locked to create {LOCKFILE_FILENAME}.");
            return 1;
        }

        let existing = match lockfile::parse_lockfile(&lockfile_path) {
            Ok(l) => l,
            Err(e) => {
                eprintln!("Failed to parse existing lockfile: {e}");
                return 1;
            }
        };

        // Compare full lockfile content (not just IDs  -  catch version/checksum/source changes)
        let existing_serialized = toml::to_string_pretty(&existing).unwrap_or_default();
        let new_serialized = toml::to_string_pretty(&lockfile).unwrap_or_default();

        if existing_serialized != new_serialized {
            eprintln!("Lockfile would change but --locked was specified.");
            eprintln!("Run 'apss install' without --locked to update.");
            return 1;
        }
    }

    if let Err(e) = lockfile::write_lockfile(&lockfile_path, &lockfile) {
        eprintln!("Failed to write lockfile: {e}");
        return 1;
    }
    println!("Updated {LOCKFILE_FILENAME}");

    // 4. Generate build crate
    let build_dir = project_root.join(apss_core::distribution::BUILD_DIR);
    let codegen_options = CodegenOptions {
        local_repo: local_source,
    };
    match codegen::generate_build_crate_with_options(
        &resolved,
        Some(&lockfile),
        &build_dir,
        &codegen_options,
    ) {
        Ok(files) => {
            println!("Generated {} files in {}", files.len(), build_dir.display());
        }
        Err(e) => {
            eprintln!("Failed to generate build crate: {e}");
            return 1;
        }
    }

    // 5. Build composed binary
    let bin_dir = project_root.join(resolved.tool.bin_dir.as_str());

    if let Err(e) = std::fs::create_dir_all(&bin_dir) {
        eprintln!("Failed to create bin directory: {e}");
        return 1;
    }

    println!("Building composed binary...");

    let mut cargo_cmd = Command::new("cargo");
    cargo_cmd
        .arg("build")
        .arg("--release")
        .arg("--manifest-path")
        .arg(build_dir.join("Cargo.toml"));

    if args.offline {
        cargo_cmd.arg("--offline");
    }

    match cargo_cmd.status() {
        Ok(status) if status.success() => {
            println!("Build succeeded.");

            // ADR-0002 read-back: cargo has now resolved the registry version
            // requirements and written `.apss/build/Cargo.lock`. Pull the exact
            // versions and checksums back into apss.lock.
            if apply_resolved_pins(&mut lockfile, &build_dir) {
                if args.locked {
                    eprintln!(
                        "Cargo resolved a different version than {LOCKFILE_FILENAME} pins, but --locked was specified."
                    );
                    eprintln!("Run 'apss install' without --locked to update.");
                    return 1;
                }
                if let Err(e) = lockfile::write_lockfile(&lockfile_path, &lockfile) {
                    eprintln!("Failed to update lockfile with resolved versions: {e}");
                    return 1;
                }
                println!("Pinned resolved versions in {LOCKFILE_FILENAME}");
            }
        }
        Ok(status) => {
            eprintln!(
                "Build failed with exit code: {}",
                status.code().unwrap_or(1)
            );
            eprintln!("\nThis may happen if the standard crates haven't been published yet.");
            eprintln!(
                "Check that the declared standards are available on the configured registry."
            );
            return 1;
        }
        Err(e) => {
            eprintln!("Failed to run cargo build: {e}");
            return 1;
        }
    }

    // 6. Copy binary to bin_dir
    let target_binary = build_dir
        .join("target")
        .join("release")
        .join(apss_core::distribution::BIN_NAME);
    let output_binary = bin_dir.join(apss_core::distribution::BIN_NAME);

    if target_binary.exists() {
        if let Err(e) = std::fs::copy(&target_binary, &output_binary) {
            eprintln!("Failed to copy binary: {e}");
            return 1;
        }
        println!("Installed: {}", output_binary.display());
        let build_target_dir = build_dir.join("target");
        if build_target_dir.exists() {
            match std::fs::remove_dir_all(&build_target_dir) {
                Ok(()) => println!("Removed build cache: {}", build_target_dir.display()),
                Err(error) => {
                    eprintln!(
                        "Warning: failed to remove build cache {}: {error}",
                        build_target_dir.display()
                    );
                }
            }
        }
    } else {
        eprintln!(
            "Expected composed binary at {} not found after build.",
            target_binary.display()
        );
        eprintln!(
            "Install failed; no runnable {} was installed.",
            output_binary.display()
        );
        return 1;
    }

    if args.no_hooks {
        eprintln!(
            "Warning: APSS pre-commit hook installation was skipped by --no-hooks. Validation will not run before commits until hooks are installed."
        );
    } else if !resolved.tool.hooks.pre_commit {
        eprintln!(
            "Warning: APSS pre-commit hook installation is disabled by [tool.hooks].pre_commit = false in {CONFIG_FILENAME}. Validation will not run before commits until this is re-enabled."
        );
    } else if should_install_pre_commit_hook(args.no_hooks, resolved.tool.hooks.pre_commit) {
        match install_pre_commit_hook(project_root, &output_binary) {
            Ok(Some(path)) => println!("Installed Git hook: {}", path.display()),
            Ok(None) => println!("Git hook install skipped: not a Git repository"),
            Err(e) => {
                eprintln!("Failed to install Git hook: {e}");
                return 1;
            }
        }
    }

    println!("\nDone! Run 'apss run <standard> <command>' to use your standards.");
    0
}

fn should_install_pre_commit_hook(no_hooks: bool, config_pre_commit: bool) -> bool {
    !no_hooks && config_pre_commit
}

/// Source string used for registry-resolved packages.
const REGISTRY_SOURCE: &str = "registry+https://crates.io";

fn generate_lockfile(
    config: &resolution::ResolvedProjectConfig,
    bundle_manifest: Option<&BundleManifest>,
    existing: Option<&Lockfile>,
) -> Lockfile {
    let mut lockfile = Lockfile::new(lockfile::APSS_CORE_VERSION.to_string());

    for (slug, standard) in &config.standards {
        if !standard.enabled {
            continue;
        }

        if let Some(bundle) = bundle_manifest.filter(|bundle| bundle.id == standard.id) {
            lockfile.packages.push(LockedPackage {
                id: standard.id.clone(),
                slug: slug.clone(),
                crate_name: standard.crate_name.clone(),
                version: bundle.version.clone(),
                checksum: "local-bundle".to_string(),
                source: format!("bundle+file://{}", bundle.bundle_dir.display()),
                substandards: Vec::new(),
            });
            continue;
        }

        // ADR-0002 makes cargo the registry resolver. We seed each registry
        // package with the version REQUIREMENT (so codegen emits
        // `version = "<req>"` into the build Cargo.toml). `cargo build` then
        // resolves the exact version and writes `.apss/build/Cargo.lock`, which
        // we read back into this lockfile via `apply_resolved_pins`. We never
        // hand-roll a crates.io index client.
        //
        // If a prior apss.lock already pinned an exact version that still
        // satisfies the requirement, reuse it so `--locked` stays stable and we
        // do not silently float the pin on every install.
        let (version, checksum) = existing
            .and_then(|l| l.find_package(&standard.id))
            .filter(|prev| prev.source.starts_with("registry+"))
            .filter(|prev| pin_satisfies_req(&prev.version, &standard.version_req))
            .map(|prev| (prev.version.clone(), prev.checksum.clone()))
            .unwrap_or_else(|| (standard.version_req.clone(), String::new()));

        lockfile.packages.push(LockedPackage {
            id: standard.id.clone(),
            slug: slug.clone(),
            crate_name: standard.crate_name.clone(),
            version,
            checksum,
            source: REGISTRY_SOURCE.to_string(),
            substandards: Vec::new(),
        });
    }

    lockfile
}

/// True when `version` is an exact semver that satisfies `req`.
fn pin_satisfies_req(version: &str, req: &str) -> bool {
    match (
        semver::Version::parse(version),
        semver::VersionReq::parse(req),
    ) {
        (Ok(v), Ok(r)) => r.matches(&v),
        _ => false,
    }
}

/// Read cargo's resolved exact versions and checksums from the build crate's
/// `Cargo.lock` back into the registry packages of `lockfile`.
///
/// See ADR-0002: cargo is the resolver; this is the read-back step.
fn apply_resolved_pins(lockfile: &mut Lockfile, build_dir: &Path) -> bool {
    let cargo_lock_path = build_dir.join("Cargo.lock");
    let Ok(content) = std::fs::read_to_string(&cargo_lock_path) else {
        return false;
    };

    let mut changed = false;
    for package in &mut lockfile.packages {
        if !package.source.starts_with("registry+") {
            continue;
        }
        if let Some(resolved) = lockfile::read_cargo_locked_package(&content, &package.crate_name) {
            if package.version != resolved.version {
                package.version = resolved.version;
                changed = true;
            }
            let checksum = resolved
                .checksum
                .map(|c| format!("sha256:{c}"))
                .unwrap_or_default();
            if package.checksum != checksum {
                package.checksum = checksum;
                changed = true;
            }
        }
    }
    changed
}

#[derive(Debug, Deserialize)]
struct BundleManifest {
    schema: String,
    id: String,
    version: String,
    #[serde(skip)]
    bundle_dir: PathBuf,
}

fn read_bundle_manifest(bundle_dir: &Path) -> Result<BundleManifest, String> {
    let manifest_path = bundle_dir.join("bundle.toml");
    let content = std::fs::read_to_string(&manifest_path)
        .map_err(|error| format!("failed to read {}: {error}", manifest_path.display()))?;
    let mut manifest: BundleManifest = toml::from_str(&content)
        .map_err(|error| format!("failed to parse {}: {error}", manifest_path.display()))?;
    if manifest.schema != "apss.bundle/v1" {
        return Err(format!(
            "invalid bundle schema '{}', expected 'apss.bundle/v1'",
            manifest.schema
        ));
    }
    manifest.bundle_dir = bundle_dir
        .canonicalize()
        .map_err(|error| format!("failed to canonicalize {}: {error}", bundle_dir.display()))?;
    Ok(manifest)
}

fn lockfile_has_unresolved_packages(lockfile: &Lockfile) -> bool {
    lockfile.packages.iter().any(|package| {
        package.version.starts_with("UNRESOLVED(")
            || package.checksum == "UNRESOLVED"
            || package.source.starts_with("unresolved+")
            || package.substandards.iter().any(|substandard| {
                substandard.profile == "UNRESOLVED"
                    || substandard.crate_name == "UNRESOLVED"
                    || substandard.version == "UNRESOLVED"
                    || substandard.checksum == "UNRESOLVED"
            })
    })
}

fn install_pre_commit_hook(
    project_root: &Path,
    composed_binary: &Path,
) -> Result<Option<PathBuf>, String> {
    let hook_path = git_hook_path(project_root, "pre-commit")?;
    let Some(hook_path) = hook_path else {
        return Ok(None);
    };

    if let Some(parent) = hook_path.parent() {
        std::fs::create_dir_all(parent)
            .map_err(|error| format!("failed to create hook directory: {error}"))?;
    }

    let bootstrap_binary = std::env::current_exe()
        .ok()
        .filter(|path| path.is_file())
        .unwrap_or_else(|| PathBuf::from("apss"));
    let managed_block = pre_commit_hook_block(&bootstrap_binary, composed_binary);

    let existing = std::fs::read_to_string(&hook_path).unwrap_or_default();
    let next = if existing.contains(APSS_HOOK_BEGIN) && existing.contains(APSS_HOOK_END) {
        replace_managed_block(&existing, &managed_block)
    } else if existing.trim().is_empty() {
        format!("#!/bin/sh\n\n{managed_block}\n")
    } else {
        format!("{existing}\n\n{managed_block}\n")
    };

    std::fs::write(&hook_path, next)
        .map_err(|error| format!("failed to write {}: {error}", hook_path.display()))?;

    make_executable(&hook_path)?;
    Ok(Some(hook_path))
}

fn git_hook_path(project_root: &Path, hook_name: &str) -> Result<Option<PathBuf>, String> {
    if !project_root.join(".git").exists() {
        return Ok(None);
    }

    let output = Command::new("git")
        .arg("rev-parse")
        .arg("--git-path")
        .arg(format!("hooks/{hook_name}"))
        .current_dir(project_root)
        .output()
        .map_err(|error| format!("failed to run git rev-parse: {error}"))?;

    if !output.status.success() {
        return Ok(None);
    }

    let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
    if path.is_empty() {
        return Ok(None);
    }

    let hook_path = PathBuf::from(path);
    if hook_path.is_absolute() {
        Ok(Some(hook_path))
    } else {
        Ok(Some(project_root.join(hook_path)))
    }
}

fn pre_commit_hook_block(bootstrap_binary: &Path, composed_binary: &Path) -> String {
    format!(
        r#"{APSS_HOOK_BEGIN}
echo "APSS pre-commit: validating project"
APSS_BOOTSTRAP='{bootstrap}'
APSS_COMPOSED='{composed}'
if [ -d "standards/v1/APS-V1-0000-meta" ] && [ -f "Cargo.toml" ]; then
  if command -v just >/dev/null 2>&1; then
    just qa
  else
    cargo fmt --all --check
    cargo clippy --workspace --all-targets -- -D warnings
    cargo check --workspace --all-targets
    cargo test --workspace
    cargo build --workspace --release
    cargo run -p aps-cli --bin apss-dev -- v1 validate repo
    cargo run -p aps-cli --bin apss-dev -- v1 validate distribution
  fi
elif [ -x "$APSS_BOOTSTRAP" ]; then
  "$APSS_BOOTSTRAP" validate
elif command -v apss >/dev/null 2>&1; then
  apss validate
elif [ -x "$APSS_COMPOSED" ]; then
  "$APSS_COMPOSED" list >/dev/null
  echo "APSS bootstrap was not found; checked installed composed binary only."
else
  echo "APSS is not installed. Run 'apss install'." >&2
  exit 1
fi
{APSS_HOOK_END}"#,
        bootstrap = shell_single_quote_path(bootstrap_binary),
        composed = shell_single_quote_path(composed_binary)
    )
}

fn replace_managed_block(existing: &str, managed_block: &str) -> String {
    let Some(begin) = existing.find(APSS_HOOK_BEGIN) else {
        return existing.to_string();
    };
    let Some(end_start) = existing.find(APSS_HOOK_END) else {
        return existing.to_string();
    };
    let end = end_start + APSS_HOOK_END.len();
    format!(
        "{}{}{}",
        &existing[..begin],
        managed_block,
        &existing[end..]
    )
}

fn shell_single_quote_path(path: &Path) -> String {
    path.display().to_string().replace('\'', r#"'\''"#)
}

#[cfg(unix)]
fn make_executable(path: &Path) -> Result<(), String> {
    use std::os::unix::fs::PermissionsExt;

    let metadata =
        std::fs::metadata(path).map_err(|error| format!("failed to stat hook: {error}"))?;
    let mut permissions = metadata.permissions();
    permissions.set_mode(permissions.mode() | 0o755);
    std::fs::set_permissions(path, permissions)
        .map_err(|error| format!("failed to chmod hook: {error}"))
}

#[cfg(not(unix))]
fn make_executable(_path: &Path) -> Result<(), String> {
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn pre_commit_block_runs_validation() {
        let block = pre_commit_hook_block(Path::new("/tmp/apss"), Path::new(".apss/bin/apss"));

        assert!(block.contains(APSS_HOOK_BEGIN));
        assert!(block.contains("just qa"));
        assert!(block.contains("cargo clippy --workspace --all-targets -- -D warnings"));
        assert!(block.contains("apss-dev -- v1 validate repo"));
        assert!(block.contains("apss-dev -- v1 validate distribution"));
        assert!(block.contains("\"$APSS_BOOTSTRAP\" validate"));
        assert!(block.contains("apss validate"));
        assert!(block.contains("\"$APSS_COMPOSED\" list"));
    }

    #[test]
    fn hook_installation_defaults_to_enabled() {
        assert!(should_install_pre_commit_hook(false, true));
    }

    #[test]
    fn hook_installation_respects_cli_skip() {
        assert!(!should_install_pre_commit_hook(true, true));
    }

    #[test]
    fn hook_installation_respects_config_skip() {
        assert!(!should_install_pre_commit_hook(false, false));
    }

    #[test]
    fn replace_pre_commit_block_preserves_user_content() {
        let existing = "#!/bin/sh\necho before\n# BEGIN APSS MANAGED PRE-COMMIT\nold\n# END APSS MANAGED PRE-COMMIT\necho after\n";
        let replaced = replace_managed_block(existing, "new-block");

        assert!(replaced.contains("echo before"));
        assert!(replaced.contains("new-block"));
        assert!(!replaced.contains("\nold\n"));
        assert!(replaced.contains("echo after"));
    }

    #[test]
    fn unresolved_lockfile_packages_are_detected() {
        let mut lockfile = Lockfile::new("1.0.0".to_string());
        assert!(!lockfile_has_unresolved_packages(&lockfile));

        lockfile.packages.push(LockedPackage {
            id: "APS-V1-0001".to_string(),
            slug: "code-topology".to_string(),
            crate_name: "apss-v1-0001-code-topology".to_string(),
            version: "UNRESOLVED(>=1.0.0)".to_string(),
            checksum: "UNRESOLVED".to_string(),
            source: "unresolved+registry+https://crates.io".to_string(),
            substandards: vec![],
        });

        assert!(lockfile_has_unresolved_packages(&lockfile));
    }

    fn registry_config() -> resolution::ResolvedProjectConfig {
        use apss_core::config::{ProjectInfo, ToolConfig};
        use apss_core::resolution::ResolvedStandard;
        use std::collections::BTreeMap;

        resolution::ResolvedProjectConfig {
            project: ProjectInfo {
                name: "consumer".to_string(),
                apss_version: "v1".to_string(),
            },
            standards: BTreeMap::from([(
                "code-topology".to_string(),
                ResolvedStandard {
                    id: "APS-V1-0001".to_string(),
                    slug: "code-topology".to_string(),
                    version_req: "0.2".to_string(),
                    enabled: true,
                    substandards: None,
                    config: toml::Value::Table(Default::default()),
                    crate_name: "apss-v1-0001-code-topology".to_string(),
                },
            )]),
            tool: ToolConfig::default(),
            source_files: vec![],
        }
    }

    #[test]
    fn registry_lockfile_seeds_requirement_not_unresolved() {
        let lockfile = generate_lockfile(&registry_config(), None, None);
        let pkg = lockfile.find_package("APS-V1-0001").unwrap();

        assert_eq!(pkg.version, "0.2");
        assert_eq!(pkg.checksum, "");
        assert_eq!(pkg.source, REGISTRY_SOURCE);
        assert!(!lockfile_has_unresolved_packages(&lockfile));
    }

    #[test]
    fn registry_lockfile_reuses_satisfying_existing_pin() {
        let mut existing = Lockfile::new("1.0.0".to_string());
        existing.packages.push(LockedPackage {
            id: "APS-V1-0001".to_string(),
            slug: "code-topology".to_string(),
            crate_name: "apss-v1-0001-code-topology".to_string(),
            version: "0.2.4".to_string(),
            checksum: "sha256:abc".to_string(),
            source: REGISTRY_SOURCE.to_string(),
            substandards: vec![],
        });

        let lockfile = generate_lockfile(&registry_config(), None, Some(&existing));
        let pkg = lockfile.find_package("APS-V1-0001").unwrap();

        // 0.2.4 satisfies the "0.2" requirement, so the existing pin is reused.
        assert_eq!(pkg.version, "0.2.4");
        assert_eq!(pkg.checksum, "sha256:abc");
    }

    #[test]
    fn registry_lockfile_drops_existing_pin_that_violates_req() {
        let mut existing = Lockfile::new("1.0.0".to_string());
        existing.packages.push(LockedPackage {
            id: "APS-V1-0001".to_string(),
            slug: "code-topology".to_string(),
            crate_name: "apss-v1-0001-code-topology".to_string(),
            version: "1.5.0".to_string(),
            checksum: "sha256:old".to_string(),
            source: REGISTRY_SOURCE.to_string(),
            substandards: vec![],
        });

        let lockfile = generate_lockfile(&registry_config(), None, Some(&existing));
        let pkg = lockfile.find_package("APS-V1-0001").unwrap();

        // 1.5.0 does not satisfy "0.2", so we fall back to the requirement.
        assert_eq!(pkg.version, "0.2");
        assert_eq!(pkg.checksum, "");
    }

    #[test]
    fn apply_resolved_pins_reads_cargo_lock() {
        let temp = tempfile::tempdir().unwrap();
        let build_dir = temp.path();
        std::fs::write(
            build_dir.join("Cargo.lock"),
            r#"
version = 3

[[package]]
name = "apss-v1-0001-code-topology"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "deadbeef"
"#,
        )
        .unwrap();

        let mut lockfile = generate_lockfile(&registry_config(), None, None);
        let changed = apply_resolved_pins(&mut lockfile, build_dir);

        assert!(changed);
        let pkg = lockfile.find_package("APS-V1-0001").unwrap();
        assert_eq!(pkg.version, "0.2.7");
        assert_eq!(pkg.checksum, "sha256:deadbeef");
        assert_eq!(pkg.source, REGISTRY_SOURCE);
    }

    #[test]
    fn pin_satisfies_req_basics() {
        assert!(pin_satisfies_req("0.2.4", "0.2"));
        assert!(!pin_satisfies_req("1.5.0", "0.2"));
        assert!(!pin_satisfies_req("not-a-version", "0.2"));
    }
}
