Skip to main content

harn_cli/package/
maturity.rs

1//! Maturity surface for the package manager: outdated/audit/artifacts.
2//!
3//! These commands let downstream automation (Burin Code, Harn Cloud, CI) ask
4//! a single Harn binary "is this checkout still current?" — covering registry
5//! versions, branch HEAD drift, lockfile provenance, package compatibility,
6//! and the protocol-artifact contract that hosts vendor.
7//!
8//! All three reports are JSON-serializable so the same surface drives both
9//! human output and machine consumers without a second code path.
10
11use std::collections::BTreeMap;
12use std::fs;
13use std::path::Path;
14use std::process;
15
16use serde::Serialize;
17
18use super::*;
19
20#[derive(Debug, Clone, Serialize)]
21pub struct OutdatedReport {
22    pub manifest_path: String,
23    pub generator_version: String,
24    pub current_harn: String,
25    pub entries: Vec<OutdatedEntry>,
26}
27
28#[derive(Debug, Clone, Serialize)]
29pub struct OutdatedEntry {
30    pub alias: String,
31    pub kind: String,
32    pub source: String,
33    pub current_rev: Option<String>,
34    pub current_version: Option<String>,
35    pub latest_rev: Option<String>,
36    pub latest_version: Option<String>,
37    pub status: OutdatedStatus,
38    pub registry_name: Option<String>,
39    pub note: Option<String>,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
43#[serde(rename_all = "kebab-case")]
44pub enum OutdatedStatus {
45    Current,
46    Outdated,
47    Unknown,
48    Skipped,
49}
50
51#[derive(Debug, Clone, Serialize)]
52pub struct AuditReport {
53    pub manifest_path: String,
54    pub lock_path: String,
55    pub current_harn: String,
56    pub generator_version: String,
57    pub protocol_artifact_version: String,
58    pub findings: Vec<AuditFinding>,
59    pub ok: bool,
60}
61
62#[derive(Debug, Clone, Serialize)]
63pub struct AuditFinding {
64    pub alias: Option<String>,
65    pub severity: AuditSeverity,
66    pub code: AuditCode,
67    pub message: String,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
71#[serde(rename_all = "kebab-case")]
72pub enum AuditSeverity {
73    Error,
74    Warning,
75    Info,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
79#[serde(rename_all = "kebab-case")]
80pub enum AuditCode {
81    LockfileMissing,
82    LockfileStale,
83    LockfileGeneratorMismatch,
84    LockfileProtocolMismatch,
85    EntryMissingProvenance,
86    HarnCompatViolation,
87    PathDependencyInPublishable,
88    YankedRegistryVersion,
89    ContentHashMismatch,
90    ManifestDigestMismatch,
91    PackageMissing,
92    RegistryUnavailable,
93}
94
95#[derive(Debug, Clone, Serialize)]
96pub struct ArtifactDriftReport {
97    pub current_artifact_version: String,
98    pub vendored_artifact_version: Option<String>,
99    pub schema_version: u32,
100    pub vendored_schema_version: Option<u32>,
101    pub differences: Vec<String>,
102    pub ok: bool,
103}
104
105pub fn outdated_packages(refresh: bool, remote: bool, registry_override: Option<&str>, json: bool) {
106    let result = (|| -> Result<OutdatedReport, PackageError> {
107        let workspace = PackageWorkspace::from_current_dir()?;
108        outdated_packages_in(&workspace, refresh, remote, registry_override)
109    })();
110
111    match result {
112        Ok(report) if json => print_json(&report),
113        Ok(report) => print_outdated_report(&report),
114        Err(error) => {
115            eprintln!("error: {error}");
116            process::exit(1);
117        }
118    }
119}
120
121pub(crate) fn outdated_packages_in(
122    workspace: &PackageWorkspace,
123    refresh: bool,
124    remote: bool,
125    registry_override: Option<&str>,
126) -> Result<OutdatedReport, PackageError> {
127    let ctx = workspace.load_manifest_context()?;
128    let lock = LockFile::load(&ctx.lock_path())?.ok_or_else(|| {
129        format!(
130            "{} is missing; run `harn install`",
131            ctx.lock_path().display()
132        )
133    })?;
134
135    // Defer registry loading — `harn package outdated` on a repo with only
136    // path or non-registry git deps shouldn't reach for the network just
137    // to print "skipped" rows.
138    let needs_registry = lock
139        .packages
140        .iter()
141        .any(|entry| entry.registry.is_some() || registry_override.is_some());
142    let registry_index = if needs_registry {
143        try_load_registry_index(workspace, registry_override, refresh).unwrap_or(None)
144    } else {
145        None
146    };
147
148    let mut entries = Vec::new();
149    for entry in &lock.packages {
150        let kind = lock_entry_kind(entry);
151        let alias = entry.name.clone();
152        let mut report = OutdatedEntry {
153            alias: alias.clone(),
154            kind: kind.to_string(),
155            source: entry.source.clone(),
156            current_rev: entry.commit.clone(),
157            current_version: entry.package_version.clone(),
158            latest_rev: None,
159            latest_version: None,
160            status: OutdatedStatus::Unknown,
161            registry_name: entry.registry.as_ref().map(|reg| reg.name.clone()),
162            note: None,
163        };
164
165        match kind {
166            "path" => {
167                report.status = OutdatedStatus::Skipped;
168                report.note = Some(
169                    "path dependencies are live-linked; rebuild to pick up changes".to_string(),
170                );
171            }
172            "registry" => {
173                let reg = entry
174                    .registry
175                    .as_ref()
176                    .expect("registry kind requires registry provenance");
177                match registry_index.as_ref() {
178                    Some(index) => match latest_registry_version_for(index, &reg.name) {
179                        Some(latest) => {
180                            report.latest_version = Some(latest.clone());
181                            report.status = if latest == reg.version {
182                                OutdatedStatus::Current
183                            } else {
184                                OutdatedStatus::Outdated
185                            };
186                        }
187                        None => {
188                            report.status = OutdatedStatus::Unknown;
189                            report.note = Some(format!("registry has no entry for {}", reg.name));
190                        }
191                    },
192                    None => {
193                        report.status = OutdatedStatus::Unknown;
194                        report.note = Some("registry index unavailable".to_string());
195                    }
196                }
197            }
198            "git" => {
199                if remote {
200                    match resolve_remote_branch_head(entry) {
201                        Ok(Some(head)) => {
202                            report.latest_rev = Some(head.clone());
203                            report.status = if Some(head) == entry.commit {
204                                OutdatedStatus::Current
205                            } else {
206                                OutdatedStatus::Outdated
207                            };
208                        }
209                        Ok(None) => {
210                            report.status = OutdatedStatus::Skipped;
211                            report.note = Some(
212                                "git rev pin: pass --remote to probe upstream tags".to_string(),
213                            );
214                        }
215                        Err(error) => {
216                            report.status = OutdatedStatus::Unknown;
217                            report.note = Some(format!("git probe failed: {error}"));
218                        }
219                    }
220                } else {
221                    report.status = OutdatedStatus::Skipped;
222                    report.note = Some(
223                        "pass --remote to probe git remotes for branch HEAD drift".to_string(),
224                    );
225                }
226            }
227            other => {
228                report.status = OutdatedStatus::Unknown;
229                report.note = Some(format!("unsupported lock kind '{other}'"));
230            }
231        }
232        entries.push(report);
233    }
234
235    Ok(OutdatedReport {
236        manifest_path: ctx.manifest_path().display().to_string(),
237        generator_version: lock.generator_version.clone(),
238        current_harn: env!("CARGO_PKG_VERSION").to_string(),
239        entries,
240    })
241}
242
243pub fn audit_packages(registry_override: Option<&str>, skip_materialized: bool, json: bool) {
244    let result = (|| -> Result<AuditReport, PackageError> {
245        let workspace = PackageWorkspace::from_current_dir()?;
246        audit_packages_in(&workspace, registry_override, skip_materialized)
247    })();
248
249    match result {
250        Ok(report) => {
251            let ok = report.ok;
252            if json {
253                print_json(&report);
254            } else {
255                print_audit_report(&report);
256            }
257            if !ok {
258                process::exit(1);
259            }
260        }
261        Err(error) => {
262            eprintln!("error: {error}");
263            process::exit(1);
264        }
265    }
266}
267
268pub(crate) fn audit_packages_in(
269    workspace: &PackageWorkspace,
270    registry_override: Option<&str>,
271    skip_materialized: bool,
272) -> Result<AuditReport, PackageError> {
273    let ctx = workspace.load_manifest_context()?;
274    let lock_path = ctx.lock_path();
275    let manifest_path = ctx.manifest_path();
276
277    let mut findings = Vec::new();
278
279    let lock = match LockFile::load(&lock_path)? {
280        Some(lock) => lock,
281        None => {
282            findings.push(AuditFinding {
283                alias: None,
284                severity: AuditSeverity::Error,
285                code: AuditCode::LockfileMissing,
286                message: format!("{} is missing; run `harn install`", lock_path.display()),
287            });
288            return Ok(AuditReport {
289                manifest_path: manifest_path.display().to_string(),
290                lock_path: lock_path.display().to_string(),
291                current_harn: env!("CARGO_PKG_VERSION").to_string(),
292                generator_version: String::new(),
293                protocol_artifact_version: String::new(),
294                ok: false,
295                findings,
296            });
297        }
298    };
299
300    let current_harn = env!("CARGO_PKG_VERSION").to_string();
301    if lock.generator_version != current_harn {
302        findings.push(AuditFinding {
303            alias: None,
304            severity: AuditSeverity::Warning,
305            code: AuditCode::LockfileGeneratorMismatch,
306            message: format!(
307                "harn.lock generator_version {} != current Harn {current_harn}; rerun `harn install` to refresh provenance",
308                lock.generator_version
309            ),
310        });
311    }
312    if lock.protocol_artifact_version != current_harn {
313        findings.push(AuditFinding {
314            alias: None,
315            severity: AuditSeverity::Warning,
316            code: AuditCode::LockfileProtocolMismatch,
317            message: format!(
318                "harn.lock protocol_artifact_version {} != current Harn {current_harn}; downstream protocol bindings may regenerate",
319                lock.protocol_artifact_version
320            ),
321        });
322    }
323
324    if let Err(error) = validate_lock_matches_manifest(&ctx, &lock) {
325        findings.push(AuditFinding {
326            alias: None,
327            severity: AuditSeverity::Error,
328            code: AuditCode::LockfileStale,
329            message: error.to_string(),
330        });
331    }
332
333    let needs_registry = lock
334        .packages
335        .iter()
336        .any(|entry| entry.registry.is_some() || registry_override.is_some());
337    let registry_index = if needs_registry {
338        try_load_registry_index(workspace, registry_override, false).unwrap_or_else(|error| {
339            findings.push(AuditFinding {
340                alias: None,
341                severity: AuditSeverity::Info,
342                code: AuditCode::RegistryUnavailable,
343                message: format!("registry probe skipped: {error}"),
344            });
345            None
346        })
347    } else {
348        None
349    };
350
351    let manifest_aliases: BTreeMap<&String, &Dependency> =
352        ctx.manifest.dependencies.iter().collect();
353
354    for entry in &lock.packages {
355        let alias = entry.name.clone();
356        let kind = lock_entry_kind(entry);
357
358        if entry.manifest_digest.is_none() || entry.package_version.is_none() {
359            findings.push(AuditFinding {
360                alias: Some(alias.clone()),
361                severity: AuditSeverity::Warning,
362                code: AuditCode::EntryMissingProvenance,
363                message: "lock entry has no resolved package version or manifest digest; run `harn install` to backfill".to_string(),
364            });
365        }
366
367        if let Some(range) = entry.harn_compat.as_deref() {
368            if !supports_current_harn(range) {
369                findings.push(AuditFinding {
370                    alias: Some(alias.clone()),
371                    severity: AuditSeverity::Error,
372                    code: AuditCode::HarnCompatViolation,
373                    message: format!(
374                        "{alias} declares harn = \"{range}\" which does not include the current Harn {current_harn}"
375                    ),
376                });
377            }
378        }
379
380        if matches!(kind, "git" | "registry") {
381            if let Err(error) = audit_git_entry_integrity(workspace, entry, skip_materialized) {
382                findings.push(AuditFinding {
383                    alias: Some(alias.clone()),
384                    severity: AuditSeverity::Error,
385                    code: AuditCode::ContentHashMismatch,
386                    message: error.to_string(),
387                });
388            }
389            if !skip_materialized {
390                if let Some((expected, actual)) =
391                    detect_manifest_digest_drift(&ctx, entry, workspace)
392                {
393                    findings.push(AuditFinding {
394                        alias: Some(alias.clone()),
395                        severity: AuditSeverity::Error,
396                        code: AuditCode::ManifestDigestMismatch,
397                        message: format!(
398                            "{alias} harn.toml digest drifted: lock recorded {expected}, materialized package now {actual}"
399                        ),
400                    });
401                }
402            }
403        }
404
405        if let (Some(reg), Some(index)) = (entry.registry.as_ref(), registry_index.as_ref()) {
406            if registry_version_is_yanked(index, &reg.name, &reg.version) {
407                findings.push(AuditFinding {
408                    alias: Some(alias.clone()),
409                    severity: AuditSeverity::Error,
410                    code: AuditCode::YankedRegistryVersion,
411                    message: format!("registry now lists {}@{} as yanked", reg.name, reg.version),
412                });
413            }
414        }
415
416        if let Some(dep) = manifest_aliases.get(&alias) {
417            if dep.local_path().is_some() && manifest_is_publishable(&ctx.manifest) {
418                findings.push(AuditFinding {
419                    alias: Some(alias.clone()),
420                    severity: AuditSeverity::Warning,
421                    code: AuditCode::PathDependencyInPublishable,
422                    message: format!(
423                        "{alias} is a path dependency; replace with a git or registry pin before publishing"
424                    ),
425                });
426            }
427        }
428    }
429
430    let ok = !findings
431        .iter()
432        .any(|finding| matches!(finding.severity, AuditSeverity::Error));
433
434    Ok(AuditReport {
435        manifest_path: manifest_path.display().to_string(),
436        lock_path: lock_path.display().to_string(),
437        current_harn,
438        generator_version: lock.generator_version.clone(),
439        protocol_artifact_version: lock.protocol_artifact_version.clone(),
440        findings,
441        ok,
442    })
443}
444
445pub fn artifacts_manifest(output: Option<&Path>) {
446    let body = match crate::commands::dump_protocol_artifacts::manifest_json() {
447        Ok(body) => body,
448        Err(error) => {
449            eprintln!("error: failed to render protocol manifest: {error}");
450            process::exit(1);
451        }
452    };
453    let body = if body.ends_with('\n') {
454        body
455    } else {
456        format!("{body}\n")
457    };
458    if let Some(path) = output {
459        if let Err(error) = harn_vm::atomic_io::atomic_write(path, body.as_bytes()) {
460            eprintln!("error: failed to write {}: {error}", path.display());
461            process::exit(1);
462        }
463    } else {
464        print!("{body}");
465    }
466}
467
468pub fn artifacts_check(manifest: &Path, json: bool) {
469    let report = match check_artifact_manifest(manifest) {
470        Ok(report) => report,
471        Err(error) => {
472            eprintln!("error: {error}");
473            process::exit(1);
474        }
475    };
476    let ok = report.ok;
477    if json {
478        print_json(&report);
479    } else {
480        print_artifact_drift_report(&report);
481    }
482    if !ok {
483        process::exit(1);
484    }
485}
486
487pub(crate) fn check_artifact_manifest(
488    manifest_path: &Path,
489) -> Result<ArtifactDriftReport, PackageError> {
490    let body = fs::read_to_string(manifest_path).map_err(|error| {
491        PackageError::Ops(format!(
492            "failed to read {}: {error}",
493            manifest_path.display()
494        ))
495    })?;
496    let vendored: serde_json::Value = serde_json::from_str(&body)
497        .map_err(|error| format!("failed to parse {}: {error}", manifest_path.display()))?;
498    let current_text =
499        crate::commands::dump_protocol_artifacts::manifest_json().map_err(|error| {
500            PackageError::Ops(format!("failed to render protocol manifest: {error}"))
501        })?;
502    let current: serde_json::Value = serde_json::from_str(&current_text).map_err(|error| {
503        PackageError::Ops(format!("failed to parse generated manifest: {error}"))
504    })?;
505
506    let current_artifact_version = current
507        .get("artifactVersion")
508        .and_then(|value| value.as_str())
509        .unwrap_or_default()
510        .to_string();
511    let vendored_artifact_version = vendored
512        .get("artifactVersion")
513        .and_then(|value| value.as_str())
514        .map(str::to_string);
515    let schema_version = current
516        .get("schemaVersion")
517        .and_then(|value| value.as_u64())
518        .unwrap_or(1) as u32;
519    let vendored_schema_version = vendored
520        .get("schemaVersion")
521        .and_then(|value| value.as_u64())
522        .map(|value| value as u32);
523
524    let mut differences = diff_json("", &vendored, &current);
525    differences.sort();
526    differences.dedup();
527    let ok = differences.is_empty();
528    Ok(ArtifactDriftReport {
529        current_artifact_version,
530        vendored_artifact_version,
531        schema_version,
532        vendored_schema_version,
533        differences,
534        ok,
535    })
536}
537
538fn diff_json(path: &str, left: &serde_json::Value, right: &serde_json::Value) -> Vec<String> {
539    let mut out = Vec::new();
540    match (left, right) {
541        (serde_json::Value::Object(left_map), serde_json::Value::Object(right_map)) => {
542            let mut keys: Vec<&String> =
543                left_map.keys().chain(right_map.keys()).collect::<Vec<_>>();
544            keys.sort();
545            keys.dedup();
546            for key in keys {
547                let next = if path.is_empty() {
548                    key.clone()
549                } else {
550                    format!("{path}.{key}")
551                };
552                match (left_map.get(key), right_map.get(key)) {
553                    (Some(left_value), Some(right_value)) => {
554                        out.extend(diff_json(&next, left_value, right_value));
555                    }
556                    (Some(_), None) => out.push(format!("{next}: only in vendored manifest")),
557                    (None, Some(_)) => out.push(format!("{next}: only in current Harn")),
558                    (None, None) => {}
559                }
560            }
561        }
562        (serde_json::Value::Array(left_arr), serde_json::Value::Array(right_arr)) => {
563            if left_arr != right_arr {
564                out.push(format!("{path}: array contents differ"));
565            }
566        }
567        _ => {
568            if left != right {
569                out.push(format!(
570                    "{path}: vendored {left} -> current {right}",
571                    left = compact_value(left),
572                    right = compact_value(right)
573                ));
574            }
575        }
576    }
577    out
578}
579
580fn compact_value(value: &serde_json::Value) -> String {
581    serde_json::to_string(value).unwrap_or_else(|_| "<unprintable>".to_string())
582}
583
584fn lock_entry_kind(entry: &LockEntry) -> &'static str {
585    if entry.source.starts_with("path+") {
586        "path"
587    } else if entry.source.starts_with("git+") {
588        if entry.registry.is_some() {
589            "registry"
590        } else {
591            "git"
592        }
593    } else {
594        "unknown"
595    }
596}
597
598fn try_load_registry_index(
599    workspace: &PackageWorkspace,
600    registry_override: Option<&str>,
601    _refresh: bool,
602) -> Result<Option<PackageRegistryIndex>, PackageError> {
603    match load_package_registry_in(workspace, registry_override) {
604        Ok((_, index)) => Ok(Some(index)),
605        Err(error) => Err(error),
606    }
607}
608
609fn latest_registry_version_for(index: &PackageRegistryIndex, name: &str) -> Option<String> {
610    index
611        .latest_unyanked_version(name)
612        .map(|version| version.to_string())
613}
614
615fn registry_version_is_yanked(index: &PackageRegistryIndex, name: &str, version: &str) -> bool {
616    index.is_version_yanked(name, version)
617}
618
619fn resolve_remote_branch_head(entry: &LockEntry) -> Result<Option<String>, PackageError> {
620    let Some(rev) = entry.rev_request.as_deref() else {
621        return Ok(None);
622    };
623    if !entry.source.starts_with("git+") {
624        return Ok(None);
625    }
626    let url = entry.source.trim_start_matches("git+");
627    let head = git_ls_remote_ref(url, rev)?;
628    Ok(head)
629}
630
631fn git_ls_remote_ref(url: &str, refname: &str) -> Result<Option<String>, PackageError> {
632    let output = process::Command::new("git")
633        .args(["ls-remote", url, refname])
634        .env_remove("GIT_DIR")
635        .env_remove("GIT_WORK_TREE")
636        .env_remove("GIT_INDEX_FILE")
637        .output()
638        .map_err(|error| format!("failed to run `git ls-remote`: {error}"))?;
639    if !output.status.success() {
640        return Err(format!(
641            "git ls-remote {url} {refname} failed: {}",
642            String::from_utf8_lossy(&output.stderr).trim()
643        )
644        .into());
645    }
646    let stdout = String::from_utf8_lossy(&output.stdout);
647    let head = stdout
648        .lines()
649        .next()
650        .and_then(|line| line.split_whitespace().next())
651        .map(str::to_string);
652    Ok(head)
653}
654
655fn audit_git_entry_integrity(
656    workspace: &PackageWorkspace,
657    entry: &LockEntry,
658    skip_materialized: bool,
659) -> Result<(), PackageError> {
660    let Some(commit) = entry.commit.as_deref() else {
661        return Err(format!("{} is missing a locked commit", entry.name).into());
662    };
663    let Some(expected_hash) = entry.content_hash.as_deref() else {
664        return Err(format!("{} is missing a content hash", entry.name).into());
665    };
666    let cache_dir = git_cache_dir_in(workspace, &entry.source, commit)?;
667    if !cache_dir.exists() {
668        return Err(format!(
669            "{}: git cache entry missing at {}",
670            entry.name,
671            cache_dir.display()
672        )
673        .into());
674    }
675    verify_content_hash_or_compute(&cache_dir, expected_hash)?;
676    if !skip_materialized {
677        let workspace_pkg = workspace.manifest_dir().join(PKG_DIR).join(&entry.name);
678        if workspace_pkg.exists() {
679            verify_content_hash_or_compute(&workspace_pkg, expected_hash)?;
680        }
681    }
682    Ok(())
683}
684
685fn detect_manifest_digest_drift(
686    ctx: &ManifestContext,
687    entry: &LockEntry,
688    workspace: &PackageWorkspace,
689) -> Option<(String, String)> {
690    let expected = entry.manifest_digest.as_deref()?;
691    let materialized = ctx.packages_dir().join(&entry.name);
692    let manifest_path = materialized.join(MANIFEST);
693    let bytes = fs::read(&manifest_path).ok()?;
694    let actual = format!("sha256:{}", sha256_hex(&bytes));
695    if actual == expected {
696        return None;
697    }
698    let _ = workspace; // workspace reserved for future cache-only audit modes
699    Some((expected.to_string(), actual))
700}
701
702fn manifest_is_publishable(manifest: &Manifest) -> bool {
703    manifest
704        .package
705        .as_ref()
706        .and_then(|pkg| pkg.name.as_deref())
707        .is_some()
708}
709
710fn print_outdated_report(report: &OutdatedReport) {
711    if report.entries.is_empty() {
712        println!("No dependencies recorded in harn.lock.");
713        return;
714    }
715    println!("alias\tkind\tcurrent\tlatest\tstatus\tnote");
716    for entry in &report.entries {
717        let current = entry
718            .current_version
719            .as_deref()
720            .or(entry.current_rev.as_deref())
721            .unwrap_or("-");
722        let latest = entry
723            .latest_version
724            .as_deref()
725            .or(entry.latest_rev.as_deref())
726            .unwrap_or("-");
727        let status = match entry.status {
728            OutdatedStatus::Current => "current",
729            OutdatedStatus::Outdated => "outdated",
730            OutdatedStatus::Unknown => "unknown",
731            OutdatedStatus::Skipped => "skipped",
732        };
733        println!(
734            "{}\t{}\t{}\t{}\t{}\t{}",
735            entry.alias,
736            entry.kind,
737            current,
738            latest,
739            status,
740            entry.note.as_deref().unwrap_or("")
741        );
742    }
743}
744
745fn print_audit_report(report: &AuditReport) {
746    println!("manifest: {}", report.manifest_path);
747    println!("lock:     {}", report.lock_path);
748    println!(
749        "harn:     {} (lock generator {} / protocol {})",
750        report.current_harn, report.generator_version, report.protocol_artifact_version
751    );
752    if report.findings.is_empty() {
753        println!("No issues found.");
754        return;
755    }
756    for finding in &report.findings {
757        let severity = match finding.severity {
758            AuditSeverity::Error => "error",
759            AuditSeverity::Warning => "warn",
760            AuditSeverity::Info => "info",
761        };
762        if let Some(alias) = &finding.alias {
763            println!("[{severity}] {alias}: {}", finding.message);
764        } else {
765            println!("[{severity}] {}", finding.message);
766        }
767    }
768}
769
770fn print_artifact_drift_report(report: &ArtifactDriftReport) {
771    println!(
772        "current artifact version: {}",
773        report.current_artifact_version
774    );
775    if let Some(version) = &report.vendored_artifact_version {
776        println!("vendored artifact version: {version}");
777    } else {
778        println!("vendored artifact version: <missing>");
779    }
780    println!("schema version:           {}", report.schema_version);
781    if let Some(version) = report.vendored_schema_version {
782        println!("vendored schema version:  {version}");
783    }
784    if report.differences.is_empty() {
785        println!("Vendored manifest matches the current Harn protocol contract.");
786    } else {
787        println!("Differences:");
788        for diff in &report.differences {
789            println!("- {diff}");
790        }
791    }
792}
793
794fn print_json<T: Serialize>(value: &T) {
795    let body = serde_json::to_string_pretty(value)
796        .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#));
797    println!("{body}");
798}
799
800#[cfg(test)]
801mod tests {
802    use super::*;
803    use crate::package::test_support::*;
804
805    #[test]
806    fn lockfile_records_generator_protocol_and_per_entry_provenance() {
807        let (_repo_tmp, repo, _branch) = create_git_package_repo();
808        let project_tmp = tempfile::tempdir().unwrap();
809        let root = project_tmp.path();
810        let workspace = TestWorkspace::new(root);
811        fs::create_dir_all(root.join(".git")).unwrap();
812        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
813        fs::write(
814            root.join(MANIFEST),
815            format!(
816                r#"
817[package]
818name = "workspace"
819version = "0.1.0"
820
821[dependencies]
822acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
823"#
824            ),
825        )
826        .unwrap();
827
828        install_packages_in(workspace.env(), false, None, false).unwrap();
829        let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
830        assert_eq!(lock.version, LOCK_FILE_VERSION);
831        assert_eq!(lock.generator_version, env!("CARGO_PKG_VERSION"));
832        assert_eq!(lock.protocol_artifact_version, env!("CARGO_PKG_VERSION"));
833        let entry = lock.find("acme-lib").unwrap();
834        assert_eq!(entry.package_version.as_deref(), Some("0.1.0"));
835        assert!(entry
836            .manifest_digest
837            .as_deref()
838            .is_some_and(|digest| digest.starts_with("sha256:")));
839    }
840
841    #[test]
842    fn lockfile_v1_loads_and_v2_save_backfills_provenance() {
843        let (_repo_tmp, repo, _branch) = create_git_package_repo();
844        let project_tmp = tempfile::tempdir().unwrap();
845        let root = project_tmp.path();
846        let workspace = TestWorkspace::new(root);
847        fs::create_dir_all(root.join(".git")).unwrap();
848        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
849        fs::write(
850            root.join(MANIFEST),
851            format!(
852                r#"
853[package]
854name = "workspace"
855version = "0.1.0"
856
857[dependencies]
858acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
859"#
860            ),
861        )
862        .unwrap();
863
864        install_packages_in(workspace.env(), false, None, false).unwrap();
865        let lock_path = root.join(LOCK_FILE);
866        let lock = LockFile::load(&lock_path).unwrap().unwrap();
867        let entry = lock.find("acme-lib").unwrap();
868
869        // Hand-write a v1 lock missing provenance to simulate an older
870        // checkout, then re-run install and verify the rewrite includes
871        // the new fields without forcing the user to delete the file.
872        let v1 = format!(
873            "version = 1\n\n[[package]]\nname = \"acme-lib\"\nsource = \"{}\"\nrev_request = \"v1.0.0\"\ncommit = \"{}\"\ncontent_hash = \"{}\"\n",
874            entry.source,
875            entry.commit.as_deref().unwrap(),
876            entry.content_hash.as_deref().unwrap(),
877        );
878        fs::write(&lock_path, v1).unwrap();
879
880        install_packages_in(workspace.env(), false, None, false).unwrap();
881        let upgraded = LockFile::load(&lock_path).unwrap().unwrap();
882        assert_eq!(upgraded.version, LOCK_FILE_VERSION);
883        let upgraded_entry = upgraded.find("acme-lib").unwrap();
884        assert!(upgraded_entry.package_version.is_some());
885        assert!(upgraded_entry.manifest_digest.is_some());
886    }
887
888    #[test]
889    fn outdated_marks_path_dependencies_as_skipped() {
890        let dependency_tmp = tempfile::tempdir().unwrap();
891        let dep_root = dependency_tmp.path().join("openapi");
892        fs::create_dir_all(&dep_root).unwrap();
893        fs::write(
894            dep_root.join(MANIFEST),
895            r#"
896[package]
897name = "openapi"
898version = "0.1.0"
899"#,
900        )
901        .unwrap();
902        fs::write(
903            dep_root.join("lib.harn"),
904            "pub fn version() -> string { return \"v1\" }\n",
905        )
906        .unwrap();
907
908        let project_tmp = tempfile::tempdir().unwrap();
909        let root = project_tmp.path();
910        let workspace = TestWorkspace::new(root);
911        fs::create_dir_all(root.join(".git")).unwrap();
912        let dep_path = dep_root.display().to_string();
913        fs::write(
914            root.join(MANIFEST),
915            format!(
916                r#"
917[package]
918name = "workspace"
919version = "0.1.0"
920
921[dependencies]
922openapi = {{ path = "{dep_path}" }}
923"#
924            ),
925        )
926        .unwrap();
927
928        install_packages_in(workspace.env(), false, None, false).unwrap();
929        let report = outdated_packages_in(workspace.env(), false, false, None).unwrap();
930        let entry = report
931            .entries
932            .iter()
933            .find(|entry| entry.alias == "openapi")
934            .expect("openapi entry");
935        assert_eq!(entry.kind, "path");
936        assert!(matches!(entry.status, OutdatedStatus::Skipped));
937    }
938
939    #[test]
940    fn audit_reports_missing_lock_as_error() {
941        let project_tmp = tempfile::tempdir().unwrap();
942        let root = project_tmp.path();
943        let workspace = TestWorkspace::new(root);
944        fs::create_dir_all(root.join(".git")).unwrap();
945        fs::write(
946            root.join(MANIFEST),
947            r#"
948[package]
949name = "workspace"
950version = "0.1.0"
951"#,
952        )
953        .unwrap();
954
955        let report = audit_packages_in(workspace.env(), None, true).unwrap();
956        assert!(!report.ok);
957        assert!(report
958            .findings
959            .iter()
960            .any(|finding| matches!(finding.code, AuditCode::LockfileMissing)));
961    }
962
963    #[test]
964    fn audit_flags_content_hash_tampering() {
965        let (_repo_tmp, repo, _branch) = create_git_package_repo();
966        let project_tmp = tempfile::tempdir().unwrap();
967        let root = project_tmp.path();
968        let workspace = TestWorkspace::new(root);
969        fs::create_dir_all(root.join(".git")).unwrap();
970        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
971        fs::write(
972            root.join(MANIFEST),
973            format!(
974                r#"
975[package]
976name = "workspace"
977version = "0.1.0"
978
979[dependencies]
980acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
981"#
982            ),
983        )
984        .unwrap();
985        install_packages_in(workspace.env(), false, None, false).unwrap();
986        let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
987        let entry = lock.find("acme-lib").unwrap();
988        let cache_dir = git_cache_dir_in(
989            workspace.env(),
990            &entry.source,
991            entry.commit.as_deref().unwrap(),
992        )
993        .unwrap();
994        fs::write(
995            cache_dir.join("lib.harn"),
996            "pub fn value() { return \"pwned\" }\n",
997        )
998        .unwrap();
999
1000        let report = audit_packages_in(workspace.env(), None, false).unwrap();
1001        assert!(!report.ok);
1002        assert!(report
1003            .findings
1004            .iter()
1005            .any(|finding| matches!(finding.code, AuditCode::ContentHashMismatch)));
1006    }
1007
1008    #[test]
1009    fn artifacts_check_detects_drift_against_stale_vendored_manifest() {
1010        let tmp = tempfile::tempdir().unwrap();
1011        let path = tmp.path().join("manifest.json");
1012        let stale = serde_json::json!({
1013            "schemaVersion": 1,
1014            "artifactVersion": "0.0.0",
1015            "generatedBy": "harn dump-protocol-artifacts",
1016        });
1017        fs::write(&path, serde_json::to_string_pretty(&stale).unwrap() + "\n").unwrap();
1018        let report = check_artifact_manifest(&path).unwrap();
1019        assert!(!report.ok);
1020        assert_eq!(report.vendored_artifact_version.as_deref(), Some("0.0.0"));
1021        assert!(!report.differences.is_empty());
1022    }
1023
1024    #[test]
1025    fn artifacts_check_passes_for_current_manifest() {
1026        let tmp = tempfile::tempdir().unwrap();
1027        let path = tmp.path().join("manifest.json");
1028        let current = crate::commands::dump_protocol_artifacts::manifest_json().unwrap();
1029        fs::write(&path, current).unwrap();
1030        let report = check_artifact_manifest(&path).unwrap();
1031        assert!(report.ok, "expected no drift, got {:?}", report.differences);
1032    }
1033
1034    #[test]
1035    fn install_is_deterministic_across_repeated_runs() {
1036        let (_repo_tmp, repo, _branch) = create_git_package_repo();
1037        let project_tmp = tempfile::tempdir().unwrap();
1038        let root = project_tmp.path();
1039        let workspace = TestWorkspace::new(root);
1040        fs::create_dir_all(root.join(".git")).unwrap();
1041        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1042        fs::write(
1043            root.join(MANIFEST),
1044            format!(
1045                r#"
1046[package]
1047name = "workspace"
1048version = "0.1.0"
1049
1050[dependencies]
1051acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
1052"#
1053            ),
1054        )
1055        .unwrap();
1056
1057        install_packages_in(workspace.env(), false, None, false).unwrap();
1058        let first = fs::read(root.join(LOCK_FILE)).unwrap();
1059        install_packages_in(workspace.env(), false, None, false).unwrap();
1060        let second = fs::read(root.join(LOCK_FILE)).unwrap();
1061        assert_eq!(
1062            first, second,
1063            "harn.lock must be byte-for-byte stable across repeated installs"
1064        );
1065    }
1066
1067    #[test]
1068    fn outdated_reports_registry_provenance_when_index_lists_newer_version() {
1069        let (_repo_tmp, repo, _branch) = create_git_package_repo();
1070        let project_tmp = tempfile::tempdir().unwrap();
1071        let root = project_tmp.path();
1072        let registry_path = root.join("index.toml");
1073        let workspace =
1074            TestWorkspace::new(root).with_registry_source(registry_path.display().to_string());
1075        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1076        let harn_range = current_harn_range_example();
1077        fs::write(
1078            &registry_path,
1079            format!(
1080                r#"
1081version = 1
1082
1083[[package]]
1084name = "@burin/acme-lib"
1085description = "Acme package for tests"
1086repository = "{git}"
1087license = "MIT"
1088harn = "{harn_range}"
1089
1090[[package.version]]
1091version = "1.0.0"
1092git = "{git}"
1093rev = "v1.0.0"
1094package = "acme-lib"
1095
1096[[package.version]]
1097version = "1.1.0"
1098git = "{git}"
1099rev = "v1.0.0"
1100package = "acme-lib"
1101"#
1102            ),
1103        )
1104        .unwrap();
1105        fs::create_dir_all(root.join(".git")).unwrap();
1106        fs::write(
1107            root.join(MANIFEST),
1108            r#"
1109[package]
1110name = "workspace"
1111version = "0.1.0"
1112"#,
1113        )
1114        .unwrap();
1115
1116        add_package_to(
1117            workspace.env(),
1118            "@burin/acme-lib@1.0.0",
1119            None,
1120            None,
1121            None,
1122            None,
1123            None,
1124            None,
1125            None,
1126        )
1127        .unwrap();
1128
1129        let report = outdated_packages_in(workspace.env(), false, false, None).unwrap();
1130        let entry = report
1131            .entries
1132            .iter()
1133            .find(|entry| entry.alias == "acme-lib")
1134            .expect("acme-lib entry");
1135        assert_eq!(entry.kind, "registry");
1136        assert_eq!(entry.registry_name.as_deref(), Some("@burin/acme-lib"));
1137        assert_eq!(entry.latest_version.as_deref(), Some("1.1.0"));
1138        assert!(matches!(entry.status, OutdatedStatus::Outdated));
1139    }
1140}