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(workspace, &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 = git_output(["ls-remote", url, refname], None)?;
633    if !output.status.success() {
634        return Err(format!(
635            "git ls-remote {url} {refname} failed: {}",
636            String::from_utf8_lossy(&output.stderr).trim()
637        )
638        .into());
639    }
640    let stdout = String::from_utf8_lossy(&output.stdout);
641    let head = stdout
642        .lines()
643        .next()
644        .and_then(|line| line.split_whitespace().next())
645        .map(str::to_string);
646    Ok(head)
647}
648
649fn audit_git_entry_integrity(
650    workspace: &PackageWorkspace,
651    entry: &LockEntry,
652    skip_materialized: bool,
653) -> Result<(), PackageError> {
654    let Some(commit) = entry.commit.as_deref() else {
655        return Err(format!("{} is missing a locked commit", entry.name).into());
656    };
657    let Some(expected_hash) = entry.content_hash.as_deref() else {
658        return Err(format!("{} is missing a content hash", entry.name).into());
659    };
660    let cache_dir = git_cache_dir_in(workspace, &entry.source, commit)?;
661    if !cache_dir.exists() {
662        return Err(format!(
663            "{}: git cache entry missing at {}",
664            entry.name,
665            cache_dir.display()
666        )
667        .into());
668    }
669    verify_content_hash_or_compute(&cache_dir, expected_hash)?;
670    if !skip_materialized {
671        let workspace_pkg = workspace.manifest_dir().join(PKG_DIR).join(&entry.name);
672        if workspace_pkg.exists() {
673            verify_content_hash_or_compute(&workspace_pkg, expected_hash)?;
674        }
675    }
676    Ok(())
677}
678
679fn detect_manifest_digest_drift(
680    ctx: &ManifestContext,
681    entry: &LockEntry,
682    workspace: &PackageWorkspace,
683) -> Option<(String, String)> {
684    let expected = entry.manifest_digest.as_deref()?;
685    let materialized = ctx.packages_dir().join(&entry.name);
686    let manifest_path = materialized.join(MANIFEST);
687    let bytes = fs::read(&manifest_path).ok()?;
688    let actual = format!("sha256:{}", sha256_hex(&bytes));
689    if actual == expected {
690        return None;
691    }
692    let _ = workspace; // workspace reserved for future cache-only audit modes
693    Some((expected.to_string(), actual))
694}
695
696fn manifest_is_publishable(manifest: &Manifest) -> bool {
697    manifest
698        .package
699        .as_ref()
700        .and_then(|pkg| pkg.name.as_deref())
701        .is_some()
702}
703
704fn print_outdated_report(report: &OutdatedReport) {
705    if report.entries.is_empty() {
706        println!("No dependencies recorded in harn.lock.");
707        return;
708    }
709    println!("alias\tkind\tcurrent\tlatest\tstatus\tnote");
710    for entry in &report.entries {
711        let current = entry
712            .current_version
713            .as_deref()
714            .or(entry.current_rev.as_deref())
715            .unwrap_or("-");
716        let latest = entry
717            .latest_version
718            .as_deref()
719            .or(entry.latest_rev.as_deref())
720            .unwrap_or("-");
721        let status = match entry.status {
722            OutdatedStatus::Current => "current",
723            OutdatedStatus::Outdated => "outdated",
724            OutdatedStatus::Unknown => "unknown",
725            OutdatedStatus::Skipped => "skipped",
726        };
727        println!(
728            "{}\t{}\t{}\t{}\t{}\t{}",
729            entry.alias,
730            entry.kind,
731            current,
732            latest,
733            status,
734            entry.note.as_deref().unwrap_or("")
735        );
736    }
737}
738
739fn print_audit_report(report: &AuditReport) {
740    println!("manifest: {}", report.manifest_path);
741    println!("lock:     {}", report.lock_path);
742    println!(
743        "harn:     {} (lock generator {} / protocol {})",
744        report.current_harn, report.generator_version, report.protocol_artifact_version
745    );
746    if report.findings.is_empty() {
747        println!("No issues found.");
748        return;
749    }
750    for finding in &report.findings {
751        let severity = match finding.severity {
752            AuditSeverity::Error => "error",
753            AuditSeverity::Warning => "warn",
754            AuditSeverity::Info => "info",
755        };
756        if let Some(alias) = &finding.alias {
757            println!("[{severity}] {alias}: {}", finding.message);
758        } else {
759            println!("[{severity}] {}", finding.message);
760        }
761    }
762}
763
764fn print_artifact_drift_report(report: &ArtifactDriftReport) {
765    println!(
766        "current artifact version: {}",
767        report.current_artifact_version
768    );
769    if let Some(version) = &report.vendored_artifact_version {
770        println!("vendored artifact version: {version}");
771    } else {
772        println!("vendored artifact version: <missing>");
773    }
774    println!("schema version:           {}", report.schema_version);
775    if let Some(version) = report.vendored_schema_version {
776        println!("vendored schema version:  {version}");
777    }
778    if report.differences.is_empty() {
779        println!("Vendored manifest matches the current Harn protocol contract.");
780    } else {
781        println!("Differences:");
782        for diff in &report.differences {
783            println!("- {diff}");
784        }
785    }
786}
787
788fn print_json<T: Serialize>(value: &T) {
789    let body = serde_json::to_string_pretty(value)
790        .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#));
791    println!("{body}");
792}
793
794#[cfg(test)]
795mod tests {
796    use super::*;
797    use crate::package::test_support::*;
798
799    #[test]
800    fn lockfile_records_generator_protocol_and_per_entry_provenance() {
801        let (_repo_tmp, repo, _branch) = create_git_package_repo();
802        let project_tmp = tempfile::tempdir().unwrap();
803        let root = project_tmp.path();
804        let workspace = TestWorkspace::new(root);
805        fs::create_dir_all(root.join(".git")).unwrap();
806        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
807        fs::write(
808            root.join(MANIFEST),
809            format!(
810                r#"
811[package]
812name = "workspace"
813version = "0.1.0"
814
815[dependencies]
816acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
817"#
818            ),
819        )
820        .unwrap();
821
822        install_packages_in(workspace.env(), false, None, false).unwrap();
823        let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
824        assert_eq!(lock.version, LOCK_FILE_VERSION);
825        assert_eq!(lock.generator_version, env!("CARGO_PKG_VERSION"));
826        assert_eq!(lock.protocol_artifact_version, env!("CARGO_PKG_VERSION"));
827        let entry = lock.find("acme-lib").unwrap();
828        assert_eq!(entry.package_version.as_deref(), Some("0.1.0"));
829        assert!(entry
830            .manifest_digest
831            .as_deref()
832            .is_some_and(|digest| digest.starts_with("sha256:")));
833    }
834
835    #[test]
836    fn lockfile_v1_loads_and_v2_save_backfills_provenance() {
837        let (_repo_tmp, repo, _branch) = create_git_package_repo();
838        let project_tmp = tempfile::tempdir().unwrap();
839        let root = project_tmp.path();
840        let workspace = TestWorkspace::new(root);
841        fs::create_dir_all(root.join(".git")).unwrap();
842        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
843        fs::write(
844            root.join(MANIFEST),
845            format!(
846                r#"
847[package]
848name = "workspace"
849version = "0.1.0"
850
851[dependencies]
852acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
853"#
854            ),
855        )
856        .unwrap();
857
858        install_packages_in(workspace.env(), false, None, false).unwrap();
859        let lock_path = root.join(LOCK_FILE);
860        let lock = LockFile::load(&lock_path).unwrap().unwrap();
861        let entry = lock.find("acme-lib").unwrap();
862
863        // Hand-write a v1 lock missing provenance to simulate an older
864        // checkout, then re-run install and verify the rewrite includes
865        // the new fields without forcing the user to delete the file.
866        let v1 = format!(
867            "version = 1\n\n[[package]]\nname = \"acme-lib\"\nsource = \"{}\"\nrev_request = \"v1.0.0\"\ncommit = \"{}\"\ncontent_hash = \"{}\"\n",
868            entry.source,
869            entry.commit.as_deref().unwrap(),
870            entry.content_hash.as_deref().unwrap(),
871        );
872        fs::write(&lock_path, v1).unwrap();
873
874        install_packages_in(workspace.env(), false, None, false).unwrap();
875        let upgraded = LockFile::load(&lock_path).unwrap().unwrap();
876        assert_eq!(upgraded.version, LOCK_FILE_VERSION);
877        let upgraded_entry = upgraded.find("acme-lib").unwrap();
878        assert!(upgraded_entry.package_version.is_some());
879        assert!(upgraded_entry.manifest_digest.is_some());
880    }
881
882    #[test]
883    fn outdated_marks_path_dependencies_as_skipped() {
884        let dependency_tmp = tempfile::tempdir().unwrap();
885        let dep_root = dependency_tmp.path().join("openapi");
886        fs::create_dir_all(&dep_root).unwrap();
887        fs::write(
888            dep_root.join(MANIFEST),
889            r#"
890[package]
891name = "openapi"
892version = "0.1.0"
893"#,
894        )
895        .unwrap();
896        fs::write(
897            dep_root.join("lib.harn"),
898            "pub fn version() -> string { return \"v1\" }\n",
899        )
900        .unwrap();
901
902        let project_tmp = tempfile::tempdir().unwrap();
903        let root = project_tmp.path();
904        let workspace = TestWorkspace::new(root);
905        fs::create_dir_all(root.join(".git")).unwrap();
906        let dep_path = dep_root.display().to_string();
907        fs::write(
908            root.join(MANIFEST),
909            format!(
910                r#"
911[package]
912name = "workspace"
913version = "0.1.0"
914
915[dependencies]
916openapi = {{ path = "{dep_path}" }}
917"#
918            ),
919        )
920        .unwrap();
921
922        install_packages_in(workspace.env(), false, None, false).unwrap();
923        let report = outdated_packages_in(workspace.env(), false, false, None).unwrap();
924        let entry = report
925            .entries
926            .iter()
927            .find(|entry| entry.alias == "openapi")
928            .expect("openapi entry");
929        assert_eq!(entry.kind, "path");
930        assert!(matches!(entry.status, OutdatedStatus::Skipped));
931    }
932
933    #[test]
934    fn audit_reports_missing_lock_as_error() {
935        let project_tmp = tempfile::tempdir().unwrap();
936        let root = project_tmp.path();
937        let workspace = TestWorkspace::new(root);
938        fs::create_dir_all(root.join(".git")).unwrap();
939        fs::write(
940            root.join(MANIFEST),
941            r#"
942[package]
943name = "workspace"
944version = "0.1.0"
945"#,
946        )
947        .unwrap();
948
949        let report = audit_packages_in(workspace.env(), None, true).unwrap();
950        assert!(!report.ok);
951        assert!(report
952            .findings
953            .iter()
954            .any(|finding| matches!(finding.code, AuditCode::LockfileMissing)));
955    }
956
957    #[test]
958    fn audit_flags_content_hash_tampering() {
959        let (_repo_tmp, repo, _branch) = create_git_package_repo();
960        let project_tmp = tempfile::tempdir().unwrap();
961        let root = project_tmp.path();
962        let workspace = TestWorkspace::new(root);
963        fs::create_dir_all(root.join(".git")).unwrap();
964        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
965        fs::write(
966            root.join(MANIFEST),
967            format!(
968                r#"
969[package]
970name = "workspace"
971version = "0.1.0"
972
973[dependencies]
974acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
975"#
976            ),
977        )
978        .unwrap();
979        install_packages_in(workspace.env(), false, None, false).unwrap();
980        let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
981        let entry = lock.find("acme-lib").unwrap();
982        let cache_dir = git_cache_dir_in(
983            workspace.env(),
984            &entry.source,
985            entry.commit.as_deref().unwrap(),
986        )
987        .unwrap();
988        fs::write(
989            cache_dir.join("lib.harn"),
990            "pub fn value() { return \"pwned\" }\n",
991        )
992        .unwrap();
993
994        let report = audit_packages_in(workspace.env(), None, false).unwrap();
995        assert!(!report.ok);
996        assert!(report
997            .findings
998            .iter()
999            .any(|finding| matches!(finding.code, AuditCode::ContentHashMismatch)));
1000    }
1001
1002    #[test]
1003    fn artifacts_check_detects_drift_against_stale_vendored_manifest() {
1004        let tmp = tempfile::tempdir().unwrap();
1005        let path = tmp.path().join("manifest.json");
1006        let stale = serde_json::json!({
1007            "schemaVersion": 1,
1008            "artifactVersion": "0.0.0",
1009            "generatedBy": "harn dump-protocol-artifacts",
1010        });
1011        fs::write(&path, serde_json::to_string_pretty(&stale).unwrap() + "\n").unwrap();
1012        let report = check_artifact_manifest(&path).unwrap();
1013        assert!(!report.ok);
1014        assert_eq!(report.vendored_artifact_version.as_deref(), Some("0.0.0"));
1015        assert!(!report.differences.is_empty());
1016    }
1017
1018    #[test]
1019    fn artifacts_check_passes_for_current_manifest() {
1020        let tmp = tempfile::tempdir().unwrap();
1021        let path = tmp.path().join("manifest.json");
1022        let current = crate::commands::dump_protocol_artifacts::manifest_json().unwrap();
1023        fs::write(&path, current).unwrap();
1024        let report = check_artifact_manifest(&path).unwrap();
1025        assert!(report.ok, "expected no drift, got {:?}", report.differences);
1026    }
1027
1028    #[test]
1029    fn install_is_deterministic_across_repeated_runs() {
1030        let (_repo_tmp, repo, _branch) = create_git_package_repo();
1031        let project_tmp = tempfile::tempdir().unwrap();
1032        let root = project_tmp.path();
1033        let workspace = TestWorkspace::new(root);
1034        fs::create_dir_all(root.join(".git")).unwrap();
1035        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1036        fs::write(
1037            root.join(MANIFEST),
1038            format!(
1039                r#"
1040[package]
1041name = "workspace"
1042version = "0.1.0"
1043
1044[dependencies]
1045acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
1046"#
1047            ),
1048        )
1049        .unwrap();
1050
1051        install_packages_in(workspace.env(), false, None, false).unwrap();
1052        let first = fs::read(root.join(LOCK_FILE)).unwrap();
1053        install_packages_in(workspace.env(), false, None, false).unwrap();
1054        let second = fs::read(root.join(LOCK_FILE)).unwrap();
1055        assert_eq!(
1056            first, second,
1057            "harn.lock must be byte-for-byte stable across repeated installs"
1058        );
1059    }
1060
1061    #[test]
1062    fn outdated_reports_registry_provenance_when_index_lists_newer_version() {
1063        let (_repo_tmp, repo, _branch) = create_git_package_repo();
1064        let project_tmp = tempfile::tempdir().unwrap();
1065        let root = project_tmp.path();
1066        let registry_path = root.join("index.toml");
1067        let workspace =
1068            TestWorkspace::new(root).with_registry_source(registry_path.display().to_string());
1069        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1070        let harn_range = current_harn_range_example();
1071        fs::write(
1072            &registry_path,
1073            format!(
1074                r#"
1075version = 1
1076
1077[[package]]
1078name = "@burin/acme-lib"
1079description = "Acme package for tests"
1080repository = "{git}"
1081license = "MIT"
1082harn = "{harn_range}"
1083
1084[[package.version]]
1085version = "1.0.0"
1086git = "{git}"
1087rev = "v1.0.0"
1088package = "acme-lib"
1089
1090[[package.version]]
1091version = "1.1.0"
1092git = "{git}"
1093rev = "v1.0.0"
1094package = "acme-lib"
1095"#
1096            ),
1097        )
1098        .unwrap();
1099        fs::create_dir_all(root.join(".git")).unwrap();
1100        fs::write(
1101            root.join(MANIFEST),
1102            r#"
1103[package]
1104name = "workspace"
1105version = "0.1.0"
1106"#,
1107        )
1108        .unwrap();
1109
1110        add_package_to(
1111            workspace.env(),
1112            "@burin/acme-lib@1.0.0",
1113            None,
1114            None,
1115            None,
1116            None,
1117            None,
1118            None,
1119            None,
1120        )
1121        .unwrap();
1122
1123        let report = outdated_packages_in(workspace.env(), false, false, None).unwrap();
1124        let entry = report
1125            .entries
1126            .iter()
1127            .find(|entry| entry.alias == "acme-lib")
1128            .expect("acme-lib entry");
1129        assert_eq!(entry.kind, "registry");
1130        assert_eq!(entry.registry_name.as_deref(), Some("@burin/acme-lib"));
1131        assert_eq!(entry.latest_version.as_deref(), Some("1.1.0"));
1132        assert!(matches!(entry.status, OutdatedStatus::Outdated));
1133    }
1134}