harn-cli 0.10.49

CLI for the Harn programming language — run, test, REPL, format, and lint
Documentation
//! The `harn.lock` document itself: the entry and provenance records it
//! holds, how it is read and written, and the legacy shapes still accepted on
//! read.

use crate::package::*;

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct LockFile {
    pub(crate) version: u32,
    /// Harn CLI version that resolved this lockfile. Lets downstream
    /// automation flag stale checkouts when the project bumps Harn.
    #[serde(default = "current_generator_version")]
    pub(crate) generator_version: String,
    /// Protocol artifact contract version this resolver shipped against.
    /// Pinning it in the lock means a host can detect when bindings
    /// regenerated by a newer Harn would diverge from what is committed
    /// downstream without running its own generator.
    #[serde(default = "current_protocol_artifact_version")]
    pub(crate) protocol_artifact_version: String,
    #[serde(default, rename = "package")]
    pub(crate) packages: Vec<LockEntry>,
}

impl Default for LockFile {
    fn default() -> Self {
        Self {
            version: LOCK_FILE_VERSION,
            generator_version: current_generator_version(),
            protocol_artifact_version: current_protocol_artifact_version(),
            packages: Vec::new(),
        }
    }
}

pub(crate) fn current_generator_version() -> String {
    env!("CARGO_PKG_VERSION").to_string()
}

pub(crate) fn current_protocol_artifact_version() -> String {
    env!("CARGO_PKG_VERSION").to_string()
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct LockEntry {
    pub(crate) name: String,
    pub(crate) source: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) tag: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) rev_request: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) commit: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) content_hash: Option<String>,
    /// `[package].version` from the resolved package's manifest. Captured so
    /// `harn package outdated` and `harn package audit` can compare without
    /// reopening a materialized package generation.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) package_version: Option<String>,
    /// `[package].harn` compatibility range from the resolved package's
    /// manifest. Used by audit to flag packages that no longer support the
    /// current Harn line.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) harn_compat: Option<String>,
    /// Package-authored provenance URL or identifier from `[package]`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) provenance: Option<String>,
    /// SHA-256 digest (`sha256:<hex>`) of the resolved package's
    /// `harn.toml`, separate from the full-contents `content_hash`. Lets
    /// audit detect manifest tampering without re-hashing the entire tree.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) manifest_digest: Option<String>,
    /// Provenance for entries that were originally added through the
    /// package registry index and lowered to a git source. Preserved so
    /// `harn package outdated` can compare against the registry's latest
    /// version.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) registry: Option<RegistryProvenance>,
    #[serde(default, skip_serializing_if = "PackageLockExports::is_empty")]
    pub(crate) exports: PackageLockExports,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub(crate) permissions: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub(crate) host_requirements: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct RegistryProvenance {
    pub(crate) source: String,
    pub(crate) name: String,
    pub(crate) version: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) provenance_url: Option<String>,
}

impl LockFile {
    pub(crate) fn load(path: &Path) -> Result<Option<Self>, PackageError> {
        let content = match fs::read_to_string(path) {
            Ok(s) => s,
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
            Err(error) => return Err(format!("failed to read {}: {error}", path.display()).into()),
        };

        // Peek at the version field so older lock formats migrate cleanly
        // even when their schema is otherwise compatible with the current
        // structs (e.g. v1 → v2 only added optional fields).
        let raw_version = toml::from_str::<RawVersionedFile>(&content)
            .ok()
            .map(|raw| raw.version);

        match raw_version {
            Some(LOCK_FILE_VERSION) => {
                let mut lock: Self = toml::from_str(&content)
                    .map_err(|error| format!("failed to parse {}: {error}", path.display()))?;
                lock.sort_entries();
                Ok(Some(lock))
            }
            Some(1..=4) => {
                // Older lockfile versions load through the current struct
                // because added fields are optional. Saving stamps the current
                // version and enriches provenance on the next install.
                let mut lock: Self = toml::from_str(&content)
                    .map_err(|error| format!("failed to parse {}: {error}", path.display()))?;
                lock.version = LOCK_FILE_VERSION;
                lock.sort_entries();
                Ok(Some(lock))
            }
            Some(other) => Err(format!(
                "unsupported {} version {} (expected {})",
                path.display(),
                other,
                LOCK_FILE_VERSION
            )
            .into()),
            None => {
                let legacy = toml::from_str::<LegacyLockFile>(&content)
                    .map_err(|error| format!("failed to parse {}: {error}", path.display()))?;
                let mut lock = Self {
                    version: LOCK_FILE_VERSION,
                    generator_version: current_generator_version(),
                    protocol_artifact_version: current_protocol_artifact_version(),
                    packages: legacy
                        .packages
                        .into_iter()
                        .map(|entry| LockEntry {
                            name: entry.name,
                            source: entry
                                .path
                                .map(|path| format!("path+{path}"))
                                .or_else(|| entry.git.map(|git| format!("git+{git}")))
                                .unwrap_or_default(),
                            tag: entry.tag.clone(),
                            rev_request: entry.rev_request.or(entry.tag),
                            commit: entry.commit,
                            content_hash: None,
                            package_version: None,
                            harn_compat: None,
                            provenance: None,
                            manifest_digest: None,
                            registry: None,
                            exports: PackageLockExports::default(),
                            permissions: Vec::new(),
                            host_requirements: Vec::new(),
                        })
                        .collect(),
                };
                lock.sort_entries();
                Ok(Some(lock))
            }
        }
    }

    pub(crate) fn encode(&self) -> Result<Vec<u8>, PackageError> {
        let mut normalized = self.clone();
        normalized.version = LOCK_FILE_VERSION;
        normalized.generator_version = current_generator_version();
        normalized.protocol_artifact_version = current_protocol_artifact_version();
        normalized.sort_entries();
        if normalized.requires_git_hash_migration() {
            return Err(format!(
                "cannot write harn.lock version {LOCK_FILE_VERSION} with an unversioned Git content hash"
            )
            .into());
        }
        let body = toml::to_string_pretty(&normalized)
            .map_err(|error| format!("failed to encode package lock file: {error}"))?;
        let mut out = String::from("# This file is auto-generated by Harn. Do not edit.\n\n");
        out.push_str(&body);
        Ok(out.into_bytes())
    }

    pub(super) fn save(&self, path: &Path) -> Result<(), PackageError> {
        let bytes = self.encode()?;
        harn_vm::atomic_io::atomic_write(path, &bytes).map_err(|error| {
            PackageError::Lockfile(format!("failed to write {}: {error}", path.display()))
        })
    }

    /// Whether two lockfiles resolve the same dependency set.
    ///
    /// Compares only the resolution content (`packages`), not the
    /// `generator_version` / `protocol_artifact_version` provenance stamps.
    /// Those stamps carry the CLI version that last *wrote* the file, so a
    /// Harn release bump rewrites them even when every resolved dependency
    /// is identical — and a frozen (`--locked` / `--frozen` / `--offline`)
    /// install that included them in the comparison would fail on every
    /// bump with "harn.lock would need to change" despite nothing
    /// substantive changing. Provenance freshness stays softly enforced by
    /// `harn package audit` (a warning, not a gate).
    pub(crate) fn same_resolution(&self, other: &Self) -> bool {
        self.packages == other.packages
    }

    pub(crate) fn requires_git_hash_migration(&self) -> bool {
        self.packages.iter().any(|entry| {
            entry.source.starts_with("git+")
                && entry
                    .content_hash
                    .as_deref()
                    .is_none_or(|hash| !is_canonical_content_hash(hash))
        })
    }

    pub(crate) fn sort_entries(&mut self) {
        self.packages
            .sort_by(|left, right| left.name.cmp(&right.name));
    }

    pub(crate) fn find(&self, name: &str) -> Option<&LockEntry> {
        self.packages.iter().find(|entry| entry.name == name)
    }

    pub(super) fn replace(&mut self, entry: LockEntry) {
        if let Some(existing) = self.packages.iter_mut().find(|pkg| pkg.name == entry.name) {
            *existing = entry;
        } else {
            self.packages.push(entry);
        }
        self.sort_entries();
    }

    pub(super) fn remove(&mut self, name: &str) {
        self.packages.retain(|entry| entry.name != name);
    }
}

#[derive(Debug, Deserialize)]
struct RawVersionedFile {
    version: u32,
}

#[derive(Debug, Deserialize)]
pub(crate) struct LegacyLockFile {
    #[serde(default, rename = "package")]
    packages: Vec<LegacyLockEntry>,
}

#[derive(Debug, Deserialize)]
pub(crate) struct LegacyLockEntry {
    pub(crate) name: String,
    #[serde(default)]
    git: Option<String>,
    #[serde(default)]
    tag: Option<String>,
    #[serde(default)]
    pub(crate) rev_request: Option<String>,
    #[serde(default)]
    pub(crate) commit: Option<String>,
    #[serde(default)]
    path: Option<String>,
}