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