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 cwd = std::env::current_dir().map_err(|error| {
508        PackageError::Ops(format!("failed to inspect current directory: {error}"))
509    })?;
510    check_artifact_manifest_from(manifest_path, &cwd)
511}
512
513fn check_artifact_manifest_from(
514    manifest_path: &Path,
515    source_anchor: &Path,
516) -> Result<ArtifactDriftReport, PackageError> {
517    let body = fs::read_to_string(manifest_path).map_err(|error| {
518        PackageError::Ops(format!(
519            "failed to read {}: {error}",
520            manifest_path.display()
521        ))
522    })?;
523    let vendored: serde_json::Value = serde_json::from_str(&body)
524        .map_err(|error| format!("failed to parse {}: {error}", manifest_path.display()))?;
525    let current_text = crate::commands::dump_protocol_artifacts::manifest_json_from(source_anchor)
526        .map_err(|error| {
527            PackageError::Ops(format!("failed to render protocol manifest: {error}"))
528        })?;
529    let current: serde_json::Value = serde_json::from_str(&current_text).map_err(|error| {
530        PackageError::Ops(format!("failed to parse generated manifest: {error}"))
531    })?;
532
533    let current_artifact_version = current
534        .get("artifactVersion")
535        .and_then(|value| value.as_str())
536        .unwrap_or_default()
537        .to_string();
538    let vendored_artifact_version = vendored
539        .get("artifactVersion")
540        .and_then(|value| value.as_str())
541        .map(str::to_string);
542    let schema_version = current
543        .get("schemaVersion")
544        .and_then(|value| value.as_u64())
545        .unwrap_or(1) as u32;
546    let vendored_schema_version = vendored
547        .get("schemaVersion")
548        .and_then(|value| value.as_u64())
549        .map(|value| value as u32);
550
551    let mut differences = diff_json("", &vendored, &current);
552    differences.sort();
553    differences.dedup();
554    let ok = differences.is_empty();
555    Ok(ArtifactDriftReport {
556        current_artifact_version,
557        vendored_artifact_version,
558        schema_version,
559        vendored_schema_version,
560        differences,
561        ok,
562    })
563}
564
565fn diff_json(path: &str, left: &serde_json::Value, right: &serde_json::Value) -> Vec<String> {
566    let mut out = Vec::new();
567    match (left, right) {
568        (serde_json::Value::Object(left_map), serde_json::Value::Object(right_map)) => {
569            let mut keys: Vec<&String> =
570                left_map.keys().chain(right_map.keys()).collect::<Vec<_>>();
571            keys.sort();
572            keys.dedup();
573            for key in keys {
574                let next = if path.is_empty() {
575                    key.clone()
576                } else {
577                    format!("{path}.{key}")
578                };
579                match (left_map.get(key), right_map.get(key)) {
580                    (Some(left_value), Some(right_value)) => {
581                        out.extend(diff_json(&next, left_value, right_value));
582                    }
583                    (Some(_), None) => out.push(format!("{next}: only in vendored manifest")),
584                    (None, Some(_)) => out.push(format!("{next}: only in current Harn")),
585                    (None, None) => {}
586                }
587            }
588        }
589        (serde_json::Value::Array(left_arr), serde_json::Value::Array(right_arr)) => {
590            if left_arr != right_arr {
591                out.push(format!("{path}: array contents differ"));
592            }
593        }
594        _ => {
595            if left != right {
596                out.push(format!(
597                    "{path}: vendored {left} -> current {right}",
598                    left = compact_value(left),
599                    right = compact_value(right)
600                ));
601            }
602        }
603    }
604    out
605}
606
607fn compact_value(value: &serde_json::Value) -> String {
608    serde_json::to_string(value).unwrap_or_else(|_| "<unprintable>".to_string())
609}
610
611fn lock_entry_kind(entry: &LockEntry) -> &'static str {
612    if entry.source.starts_with("path+") {
613        "path"
614    } else if entry.registry.is_some() {
615        "registry"
616    } else if entry.source.starts_with("git+") {
617        "git"
618    } else if entry.source.starts_with("archive+") {
619        "archive"
620    } else {
621        "unknown"
622    }
623}
624
625fn try_load_registry_index(
626    workspace: &PackageWorkspace,
627    registry_override: Option<&str>,
628    _refresh: bool,
629) -> Result<Option<PackageRegistryIndex>, PackageError> {
630    match load_package_registry_in(workspace, registry_override) {
631        Ok((_, index)) => Ok(Some(index)),
632        Err(error) => Err(error),
633    }
634}
635
636fn latest_registry_version_for(index: &PackageRegistryIndex, name: &str) -> Option<String> {
637    index
638        .latest_unyanked_version(name)
639        .map(|version| version.to_string())
640}
641
642fn registry_version_is_yanked(index: &PackageRegistryIndex, name: &str, version: &str) -> bool {
643    index.is_version_yanked(name, version)
644}
645
646fn resolve_remote_branch_head(entry: &LockEntry) -> Result<Option<String>, PackageError> {
647    let Some(rev) = entry.rev_request.as_deref() else {
648        return Ok(None);
649    };
650    if !entry.source.starts_with("git+") {
651        return Ok(None);
652    }
653    let url = entry.source.trim_start_matches("git+");
654    let head = git_ls_remote_ref(url, rev)?;
655    Ok(head)
656}
657
658fn git_ls_remote_ref(url: &str, refname: &str) -> Result<Option<String>, PackageError> {
659    let output = git_output(["ls-remote", url, refname], None)?;
660    if !output.status.success() {
661        return Err(format!(
662            "git ls-remote {url} {refname} failed: {}",
663            String::from_utf8_lossy(&output.stderr).trim()
664        )
665        .into());
666    }
667    let stdout = String::from_utf8_lossy(&output.stdout);
668    let head = stdout
669        .lines()
670        .next()
671        .and_then(|line| line.split_whitespace().next())
672        .map(str::to_string);
673    Ok(head)
674}
675
676fn audit_git_entry_integrity(
677    workspace: &PackageWorkspace,
678    packages_root: Option<&Path>,
679    entry: &LockEntry,
680) -> Result<(), PackageError> {
681    let Some(commit) = entry.commit.as_deref() else {
682        return Err(format!("{} is missing a locked commit", entry.name).into());
683    };
684    let Some(expected_hash) = entry.content_hash.as_deref() else {
685        return Err(format!("{} is missing a content hash", entry.name).into());
686    };
687    let cache_dir = git_cache_dir_in(workspace, &entry.source, commit)?;
688    if !cache_dir.exists() {
689        return Err(format!(
690            "{}: git cache entry missing at {}",
691            entry.name,
692            cache_dir.display()
693        )
694        .into());
695    }
696    verify_content_hash_or_compute(&cache_dir, expected_hash)?;
697    if let Some(packages_root) = packages_root {
698        let workspace_pkg = packages_root.join(&entry.name);
699        if workspace_pkg.exists() {
700            verify_content_hash_or_compute(&workspace_pkg, expected_hash)?;
701        }
702    }
703    Ok(())
704}
705
706fn detect_manifest_digest_drift(
707    packages_root: &Path,
708    entry: &LockEntry,
709    workspace: &PackageWorkspace,
710) -> Option<(String, String)> {
711    let expected = entry.manifest_digest.as_deref()?;
712    let materialized = packages_root.join(&entry.name);
713    let manifest_path = materialized.join(MANIFEST);
714    let bytes = fs::read(&manifest_path).ok()?;
715    let actual = format!("sha256:{}", sha256_hex(&bytes));
716    if actual == expected {
717        return None;
718    }
719    let _ = workspace; // workspace reserved for future cache-only audit modes
720    Some((expected.to_string(), actual))
721}
722
723fn manifest_is_publishable(manifest: &Manifest) -> bool {
724    manifest
725        .package
726        .as_ref()
727        .and_then(|pkg| pkg.name.as_deref())
728        .is_some()
729}
730
731fn print_outdated_report(report: &OutdatedReport) {
732    if report.entries.is_empty() {
733        println!("No dependencies recorded in harn.lock.");
734        return;
735    }
736    println!("alias\tkind\tcurrent\tlatest\tstatus\tnote");
737    for entry in &report.entries {
738        let current = entry
739            .current_version
740            .as_deref()
741            .or(entry.current_rev.as_deref())
742            .unwrap_or("-");
743        let latest = entry
744            .latest_version
745            .as_deref()
746            .or(entry.latest_rev.as_deref())
747            .unwrap_or("-");
748        let status = match entry.status {
749            OutdatedStatus::Current => "current",
750            OutdatedStatus::Outdated => "outdated",
751            OutdatedStatus::Unknown => "unknown",
752            OutdatedStatus::Skipped => "skipped",
753        };
754        println!(
755            "{}\t{}\t{}\t{}\t{}\t{}",
756            entry.alias,
757            entry.kind,
758            current,
759            latest,
760            status,
761            entry.note.as_deref().unwrap_or("")
762        );
763    }
764}
765
766fn print_audit_report(report: &AuditReport) {
767    println!("manifest: {}", report.manifest_path);
768    println!("lock:     {}", report.lock_path);
769    println!(
770        "harn:     {} (lock generator {} / protocol {})",
771        report.current_harn, report.generator_version, report.protocol_artifact_version
772    );
773    if report.findings.is_empty() {
774        println!("No issues found.");
775        return;
776    }
777    for finding in &report.findings {
778        let severity = match finding.severity {
779            AuditSeverity::Error => "error",
780            AuditSeverity::Warning => "warn",
781            AuditSeverity::Info => "info",
782        };
783        if let Some(alias) = &finding.alias {
784            println!("[{severity}] {alias}: {}", finding.message);
785        } else {
786            println!("[{severity}] {}", finding.message);
787        }
788    }
789}
790
791fn print_artifact_drift_report(report: &ArtifactDriftReport) {
792    println!(
793        "current artifact version: {}",
794        report.current_artifact_version
795    );
796    if let Some(version) = &report.vendored_artifact_version {
797        println!("vendored artifact version: {version}");
798    } else {
799        println!("vendored artifact version: <missing>");
800    }
801    println!("schema version:           {}", report.schema_version);
802    if let Some(version) = report.vendored_schema_version {
803        println!("vendored schema version:  {version}");
804    }
805    if report.differences.is_empty() {
806        println!("Vendored manifest matches the current Harn protocol contract.");
807    } else {
808        println!("Differences:");
809        for diff in &report.differences {
810            println!("- {diff}");
811        }
812    }
813}
814
815fn print_json<T: Serialize>(value: &T) {
816    let body = serde_json::to_string_pretty(value)
817        .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#));
818    println!("{body}");
819}
820
821#[cfg(test)]
822mod tests {
823    use super::*;
824    use crate::package::test_support::*;
825
826    #[test]
827    fn lockfile_records_generator_protocol_and_per_entry_provenance() {
828        let (_repo_tmp, repo, _branch) = create_git_package_repo();
829        let project_tmp = tempfile::tempdir().unwrap();
830        let root = project_tmp.path();
831        let workspace = TestWorkspace::new(root);
832        fs::create_dir_all(root.join(".git")).unwrap();
833        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
834        fs::write(
835            root.join(MANIFEST),
836            format!(
837                r#"
838[package]
839name = "workspace"
840version = "0.1.0"
841
842[dependencies]
843acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
844"#
845            ),
846        )
847        .unwrap();
848
849        install_packages_in(workspace.env(), false, None, false).unwrap();
850        let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
851        assert_eq!(lock.version, LOCK_FILE_VERSION);
852        assert_eq!(lock.generator_version, env!("CARGO_PKG_VERSION"));
853        assert_eq!(lock.protocol_artifact_version, env!("CARGO_PKG_VERSION"));
854        let entry = lock.find("acme-lib").unwrap();
855        assert_eq!(entry.package_version.as_deref(), Some("0.1.0"));
856        assert!(entry
857            .manifest_digest
858            .as_deref()
859            .is_some_and(|digest| digest.starts_with("sha256:")));
860    }
861
862    #[test]
863    fn lockfile_v1_loads_and_v2_save_backfills_provenance() {
864        let (_repo_tmp, repo, _branch) = create_git_package_repo();
865        let project_tmp = tempfile::tempdir().unwrap();
866        let root = project_tmp.path();
867        let workspace = TestWorkspace::new(root);
868        fs::create_dir_all(root.join(".git")).unwrap();
869        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
870        fs::write(
871            root.join(MANIFEST),
872            format!(
873                r#"
874[package]
875name = "workspace"
876version = "0.1.0"
877
878[dependencies]
879acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
880"#
881            ),
882        )
883        .unwrap();
884
885        install_packages_in(workspace.env(), false, None, false).unwrap();
886        let lock_path = root.join(LOCK_FILE);
887        let lock = LockFile::load(&lock_path).unwrap().unwrap();
888        let entry = lock.find("acme-lib").unwrap();
889
890        // Hand-write a v1 lock missing provenance to simulate an older
891        // checkout, then re-run install and verify the rewrite includes
892        // the new fields without forcing the user to delete the file.
893        let v1 = format!(
894            "version = 1\n\n[[package]]\nname = \"acme-lib\"\nsource = \"{}\"\nrev_request = \"v1.0.0\"\ncommit = \"{}\"\ncontent_hash = \"{}\"\n",
895            entry.source,
896            entry.commit.as_deref().unwrap(),
897            entry.content_hash.as_deref().unwrap(),
898        );
899        fs::write(&lock_path, v1).unwrap();
900
901        install_packages_in(workspace.env(), false, None, false).unwrap();
902        let upgraded = LockFile::load(&lock_path).unwrap().unwrap();
903        assert_eq!(upgraded.version, LOCK_FILE_VERSION);
904        let upgraded_entry = upgraded.find("acme-lib").unwrap();
905        assert!(upgraded_entry.package_version.is_some());
906        assert!(upgraded_entry.manifest_digest.is_some());
907    }
908
909    #[test]
910    fn outdated_marks_path_dependencies_as_skipped() {
911        let dependency_tmp = tempfile::tempdir().unwrap();
912        let dep_root = dependency_tmp.path().join("openapi");
913        fs::create_dir_all(&dep_root).unwrap();
914        fs::write(
915            dep_root.join(MANIFEST),
916            r#"
917[package]
918name = "openapi"
919version = "0.1.0"
920"#,
921        )
922        .unwrap();
923        fs::write(
924            dep_root.join("lib.harn"),
925            "pub fn version() -> string { return \"v1\" }\n",
926        )
927        .unwrap();
928
929        let project_tmp = tempfile::tempdir().unwrap();
930        let root = project_tmp.path();
931        let workspace = TestWorkspace::new(root);
932        fs::create_dir_all(root.join(".git")).unwrap();
933        let dep_path = crate::format::toml_basic_string_literal(&dep_root.display().to_string());
934        fs::write(
935            root.join(MANIFEST),
936            format!(
937                r#"
938[package]
939name = "workspace"
940version = "0.1.0"
941
942[dependencies]
943openapi = {{ path = {dep_path} }}
944"#
945            ),
946        )
947        .unwrap();
948
949        install_packages_in(workspace.env(), false, None, false).unwrap();
950        let report = outdated_packages_in(workspace.env(), false, false, None).unwrap();
951        let entry = report
952            .entries
953            .iter()
954            .find(|entry| entry.alias == "openapi")
955            .expect("openapi entry");
956        assert_eq!(entry.kind, "path");
957        assert!(matches!(entry.status, OutdatedStatus::Skipped));
958    }
959
960    #[test]
961    fn audit_reports_missing_lock_as_error() {
962        let project_tmp = tempfile::tempdir().unwrap();
963        let root = project_tmp.path();
964        let workspace = TestWorkspace::new(root);
965        fs::create_dir_all(root.join(".git")).unwrap();
966        fs::write(
967            root.join(MANIFEST),
968            r#"
969[package]
970name = "workspace"
971version = "0.1.0"
972"#,
973        )
974        .unwrap();
975
976        let report = audit_packages_in(workspace.env(), None, true).unwrap();
977        assert!(!report.ok);
978        assert!(report
979            .findings
980            .iter()
981            .any(|finding| matches!(finding.code, AuditCode::LockfileMissing)));
982    }
983
984    #[test]
985    fn audit_flags_content_hash_tampering() {
986        let (_repo_tmp, repo, _branch) = create_git_package_repo();
987        let project_tmp = tempfile::tempdir().unwrap();
988        let root = project_tmp.path();
989        let workspace = TestWorkspace::new(root);
990        fs::create_dir_all(root.join(".git")).unwrap();
991        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
992        fs::write(
993            root.join(MANIFEST),
994            format!(
995                r#"
996[package]
997name = "workspace"
998version = "0.1.0"
999
1000[dependencies]
1001acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
1002"#
1003            ),
1004        )
1005        .unwrap();
1006        install_packages_in(workspace.env(), false, None, false).unwrap();
1007        let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
1008        let entry = lock.find("acme-lib").unwrap();
1009        let cache_dir = git_cache_dir_in(
1010            workspace.env(),
1011            &entry.source,
1012            entry.commit.as_deref().unwrap(),
1013        )
1014        .unwrap();
1015        fs::write(
1016            cache_dir.join("lib.harn"),
1017            "pub fn value() { return \"pwned\" }\n",
1018        )
1019        .unwrap();
1020
1021        let report = audit_packages_in(workspace.env(), None, false).unwrap();
1022        assert!(!report.ok);
1023        assert!(report
1024            .findings
1025            .iter()
1026            .any(|finding| matches!(finding.code, AuditCode::ContentHashMismatch)));
1027    }
1028
1029    #[test]
1030    fn artifacts_check_detects_drift_against_stale_vendored_manifest() {
1031        let tmp = tempfile::tempdir().unwrap();
1032        let path = tmp.path().join("manifest.json");
1033        let stale = serde_json::json!({
1034            "schemaVersion": 1,
1035            "artifactVersion": "0.0.0",
1036            "generatedBy": "harn dump-protocol-artifacts",
1037        });
1038        fs::write(&path, serde_json::to_string_pretty(&stale).unwrap() + "\n").unwrap();
1039        let report =
1040            check_artifact_manifest_from(&path, Path::new(env!("CARGO_MANIFEST_DIR"))).unwrap();
1041        assert!(!report.ok);
1042        assert_eq!(report.vendored_artifact_version.as_deref(), Some("0.0.0"));
1043        assert!(!report.differences.is_empty());
1044    }
1045
1046    #[test]
1047    fn artifacts_check_passes_for_current_manifest() {
1048        let tmp = tempfile::tempdir().unwrap();
1049        let path = tmp.path().join("manifest.json");
1050        let source_anchor = Path::new(env!("CARGO_MANIFEST_DIR"));
1051        let current =
1052            crate::commands::dump_protocol_artifacts::manifest_json_from(source_anchor).unwrap();
1053        fs::write(&path, current).unwrap();
1054        let report = check_artifact_manifest_from(&path, source_anchor).unwrap();
1055        assert!(report.ok, "expected no drift, got {:?}", report.differences);
1056    }
1057
1058    #[test]
1059    fn install_is_deterministic_across_repeated_runs() {
1060        let (_repo_tmp, repo, _branch) = create_git_package_repo();
1061        let project_tmp = tempfile::tempdir().unwrap();
1062        let root = project_tmp.path();
1063        let workspace = TestWorkspace::new(root);
1064        fs::create_dir_all(root.join(".git")).unwrap();
1065        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1066        fs::write(
1067            root.join(MANIFEST),
1068            format!(
1069                r#"
1070[package]
1071name = "workspace"
1072version = "0.1.0"
1073
1074[dependencies]
1075acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
1076"#
1077            ),
1078        )
1079        .unwrap();
1080
1081        install_packages_in(workspace.env(), false, None, false).unwrap();
1082        let first = fs::read(root.join(LOCK_FILE)).unwrap();
1083        install_packages_in(workspace.env(), false, None, false).unwrap();
1084        let second = fs::read(root.join(LOCK_FILE)).unwrap();
1085        assert_eq!(
1086            first, second,
1087            "harn.lock must be byte-for-byte stable across repeated installs"
1088        );
1089    }
1090
1091    #[test]
1092    fn outdated_reports_registry_provenance_when_index_lists_newer_version() {
1093        let (_repo_tmp, repo, _branch) = create_git_package_repo();
1094        let project_tmp = tempfile::tempdir().unwrap();
1095        let root = project_tmp.path();
1096        let registry_path = root.join("index.toml");
1097        let workspace = TestWorkspace::new(root)
1098            .with_registry_source(registry_path.to_string_lossy().to_string());
1099        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1100        let harn_range = current_harn_range_example();
1101        fs::write(
1102            &registry_path,
1103            format!(
1104                r#"
1105version = 1
1106
1107[[package]]
1108name = "@burin/acme-lib"
1109description = "Acme package for tests"
1110repository = "{git}"
1111license = "MIT"
1112harn = "{harn_range}"
1113
1114[[package.version]]
1115version = "1.0.0"
1116git = "{git}"
1117rev = "v1.0.0"
1118package = "acme-lib"
1119
1120[[package.version]]
1121version = "1.1.0"
1122git = "{git}"
1123rev = "v1.0.0"
1124package = "acme-lib"
1125"#
1126            ),
1127        )
1128        .unwrap();
1129        fs::create_dir_all(root.join(".git")).unwrap();
1130        fs::write(
1131            root.join(MANIFEST),
1132            r#"
1133[package]
1134name = "workspace"
1135version = "0.1.0"
1136"#,
1137        )
1138        .unwrap();
1139
1140        add_package_to(
1141            workspace.env(),
1142            "@burin/acme-lib@1.0.0",
1143            None,
1144            None,
1145            None,
1146            None,
1147            None,
1148            None,
1149            None,
1150        )
1151        .unwrap();
1152
1153        let report = outdated_packages_in(workspace.env(), false, false, None).unwrap();
1154        let entry = report
1155            .entries
1156            .iter()
1157            .find(|entry| entry.alias == "acme-lib")
1158            .expect("acme-lib entry");
1159        assert_eq!(entry.kind, "registry");
1160        assert_eq!(entry.registry_name.as_deref(), Some("@burin/acme-lib"));
1161        assert_eq!(entry.latest_version.as_deref(), Some("1.1.0"));
1162        assert!(matches!(entry.status, OutdatedStatus::Outdated));
1163    }
1164}