diff --git a/src/cmd/info.rs b/src/cmd/info.rs index 7972545c..487db71a 100644 --- a/src/cmd/info.rs +++ b/src/cmd/info.rs @@ -83,6 +83,11 @@ pub fn format_info(pkg: &Package, platform: &Platform, nu: Option<&NuVersion>) - out.push_str(" note: built from a commit snapshot, not a tagged release\n"); } + if ver.is_provisional() { + let reason = ver.deferral_reason_display(); + out.push_str(&format!(" evidence: provisional — {reason}\n")); + } + if let Some(ref source) = ver.source { out.push_str(&format!(" source git: {}\n", source.git)); out.push_str(&format!(" source rev: {}\n", source.rev)); @@ -166,6 +171,8 @@ mod tests { dependencies: BTreeMap::new(), activation: None, provenance: None, + evidence_tier: None, + deferral_reason: None, }], } } @@ -226,6 +233,42 @@ mod tests { assert!(!out.contains("commit snapshot"), "{out}"); } + #[test] + fn format_info_notes_provisional_with_reason() { + let mut pkg = sample_plugin(false); + pkg.versions[0].evidence_tier = Some(crate::core::package::EvidenceTier::Provisional); + pkg.versions[0].deferral_reason = Some("requires cloud credentials".to_string()); + let out = format_info(&pkg, &linux_platform(), None); + assert!( + out.contains("evidence: provisional — requires cloud credentials"), + "{out}" + ); + } + + #[test] + fn format_info_notes_provisional_without_reason() { + let mut pkg = sample_plugin(false); + pkg.versions[0].evidence_tier = Some(crate::core::package::EvidenceTier::Provisional); + let out = format_info(&pkg, &linux_platform(), None); + assert!(out.contains("reason not recorded"), "{out}"); + } + + #[test] + fn format_info_notes_provisional_with_empty_reason_falls_back() { + let mut pkg = sample_plugin(false); + pkg.versions[0].evidence_tier = Some(crate::core::package::EvidenceTier::Provisional); + pkg.versions[0].deferral_reason = Some(" ".to_string()); + let out = format_info(&pkg, &linux_platform(), None); + assert!(out.contains("reason not recorded"), "{out}"); + } + + #[test] + fn format_info_omits_evidence_line_when_proven() { + let pkg = sample_plugin(false); + let out = format_info(&pkg, &linux_platform(), None); + assert!(!out.contains("evidence:"), "{out}"); + } + #[test] fn format_info_omits_source_lines_when_absent() { let pkg = sample_plugin(false); diff --git a/src/cmd/search.rs b/src/cmd/search.rs index 783d3046..828760df 100644 --- a/src/cmd/search.rs +++ b/src/cmd/search.rs @@ -1,5 +1,5 @@ use crate::core::nu_version::NuVersion; -use crate::core::package::PackageType; +use crate::core::package::{PackageType, VersionEntry}; use crate::core::platform::Platform; use crate::core::registry::RegistryManager; use crate::core::resolve::{Incompatibility, Resolver}; @@ -101,10 +101,18 @@ pub fn execute(args: &SearchArgs, root: &Path) -> Result<()> { } }; + let provisional_marker = provisional_marker(display_entry); + println!( - " {}/{} v{} [{}]{} + " {}/{} v{} [{}]{}{} {}", - pkg.id.owner, pkg.id.name, version_label, pkg.package_type, status, pkg.description + pkg.id.owner, + pkg.id.name, + version_label, + pkg.package_type, + status, + provisional_marker, + pkg.description ); } @@ -140,6 +148,14 @@ fn format_search_header(nu: Option<&NuVersion>, triple: &str) -> String { } } +fn provisional_marker(entry: Option<&VersionEntry>) -> &'static str { + if entry.map(|v| v.is_provisional()).unwrap_or(false) { + " [provisional]" + } else { + "" + } +} + /// Row status suffix (leading space + brackets), or empty. /// /// Plugins get a hard evaluated verdict. Modules use not-ABI-locked wording. @@ -252,6 +268,8 @@ mod tests { dependencies: BTreeMap::new(), activation: None, provenance: None, + evidence_tier: None, + deferral_reason: None, }], } } @@ -337,6 +355,24 @@ mod tests { assert!(status.is_empty()); } + #[test] + fn provisional_marker_shown_for_provisional_entry() { + let mut pkg = sample_pkg_typed(PackageType::Plugin, "*", vec![]); + pkg.versions[0].evidence_tier = Some(crate::core::package::EvidenceTier::Provisional); + assert_eq!(provisional_marker(Some(&pkg.versions[0])), " [provisional]"); + } + + #[test] + fn provisional_marker_empty_for_proven_entry() { + let pkg = sample_pkg_typed(PackageType::Plugin, "*", vec![]); + assert_eq!(provisional_marker(Some(&pkg.versions[0])), ""); + } + + #[test] + fn provisional_marker_empty_for_none() { + assert_eq!(provisional_marker(None), ""); + } + #[test] fn hidden_footer_points_at_info_not_sync() { let footer = format_hidden_footer( diff --git a/src/cmd/try_cmd.rs b/src/cmd/try_cmd.rs index 39f1bd7b..4bccfb5b 100644 --- a/src/cmd/try_cmd.rs +++ b/src/cmd/try_cmd.rs @@ -469,6 +469,8 @@ mod tests { dependencies: BTreeMap::new(), activation: None, provenance: None, + evidence_tier: None, + deferral_reason: None, }], } } diff --git a/src/core/package.rs b/src/core/package.rs index 718f467e..0f64c3dc 100644 --- a/src/core/package.rs +++ b/src/core/package.rs @@ -116,6 +116,50 @@ pub struct VersionEntry { /// built from a pinned commit with no upstream tag. #[serde(default)] pub provenance: Option, + /// Evidence tier for this version. `None` means "proven" (legacy + /// entries and any entry with full lifecycle evidence). + #[serde(default)] + pub evidence_tier: Option, + /// Required alongside `evidence_tier: "provisional"`: why + /// lifecycle-prove was deferred for this version. + #[serde(default)] + pub deferral_reason: Option, +} + +/// Closed set of evidence tiers; unrecognized values fail deserialization +/// instead of silently being treated as proven. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum EvidenceTier { + Proven, + Provisional, +} + +impl VersionEntry { + /// Whether this version is provisional (structural validation only, no + /// full lifecycle evidence). Absent `evidence_tier` means proven. + pub fn is_provisional(&self) -> bool { + self.evidence_tier == Some(EvidenceTier::Provisional) + } + + /// Sanitized, non-empty deferral reason for display, or the fallback + /// text when the reason is missing, blank, or contains control + /// characters that would corrupt terminal output. + pub fn deferral_reason_display(&self) -> String { + let cleaned: String = self + .deferral_reason + .as_deref() + .unwrap_or("") + .chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .collect(); + let trimmed = cleaned.trim(); + if trimmed.is_empty() { + "reason not recorded".to_string() + } else { + trimmed.to_string() + } + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -273,6 +317,17 @@ mod tests { assert!(entry.source.is_none()); } + #[test] + fn version_entry_absent_evidence_tier_is_not_provisional() { + let json = r#"{ + "version": "0.25.2", + "nu_version": ">=0.113.0 <0.114.0", + "artifact": { "kind": "binary", "targets": {} } + }"#; + let entry: VersionEntry = serde_json::from_str(json).unwrap(); + assert!(!entry.is_provisional()); + } + #[test] fn parse_version_entry_defaults_provenance_to_none() { let json = r#"{ @@ -284,6 +339,77 @@ mod tests { assert!(entry.provenance.is_none()); } + #[test] + fn version_entry_provisional_evidence_tier() { + let json = r#"{ + "version": "0.25.2", + "nu_version": ">=0.113.0 <0.114.0", + "evidence_tier": "provisional", + "deferral_reason": "requires cloud credentials", + "artifact": { "kind": "binary", "targets": {} } + }"#; + let entry: VersionEntry = serde_json::from_str(json).unwrap(); + assert!(entry.is_provisional()); + assert_eq!( + entry.deferral_reason.as_deref(), + Some("requires cloud credentials") + ); + } + + #[test] + fn deferral_reason_display_falls_back_when_missing() { + let json = r#"{ + "version": "0.25.2", + "nu_version": ">=0.113.0 <0.114.0", + "evidence_tier": "provisional", + "artifact": { "kind": "binary", "targets": {} } + }"#; + let entry: VersionEntry = serde_json::from_str(json).unwrap(); + assert_eq!(entry.deferral_reason_display(), "reason not recorded"); + } + + #[test] + fn deferral_reason_display_falls_back_when_blank() { + let mut entry: VersionEntry = + serde_json::from_str(r#"{"version":"0.25.2","nu_version":">=0.113.0 <0.114.0","artifact":{"kind":"binary","targets":{}}}"#).unwrap(); + entry.deferral_reason = Some(" ".to_string()); + assert_eq!(entry.deferral_reason_display(), "reason not recorded"); + } + + #[test] + fn deferral_reason_display_strips_control_characters() { + let mut entry: VersionEntry = + serde_json::from_str(r#"{"version":"0.25.2","nu_version":">=0.113.0 <0.114.0","artifact":{"kind":"binary","targets":{}}}"#).unwrap(); + entry.deferral_reason = Some("line one\nfake success line".to_string()); + assert_eq!( + entry.deferral_reason_display(), + "line one fake success line" + ); + } + + #[test] + fn version_entry_proven_evidence_tier_is_not_provisional() { + let json = r#"{ + "version": "0.25.2", + "nu_version": ">=0.113.0 <0.114.0", + "evidence_tier": "proven", + "artifact": { "kind": "binary", "targets": {} } + }"#; + let entry: VersionEntry = serde_json::from_str(json).unwrap(); + assert!(!entry.is_provisional()); + } + + #[test] + fn version_entry_rejects_unknown_evidence_tier() { + let json = r#"{ + "version": "0.25.2", + "nu_version": ">=0.113.0 <0.114.0", + "evidence_tier": "provisonal", + "artifact": { "kind": "binary", "targets": {} } + }"#; + assert!(serde_json::from_str::(json).is_err()); + } + #[test] fn parse_version_entry_with_commit_snapshot_provenance() { let json = r#"{ diff --git a/src/core/registry.rs b/src/core/registry.rs index 41f362fe..7e8d81e6 100644 --- a/src/core/registry.rs +++ b/src/core/registry.rs @@ -407,6 +407,8 @@ mod tests { dependencies: BTreeMap::new(), activation: None, provenance: None, + evidence_tier: None, + deferral_reason: None, }], }], } diff --git a/src/core/resolve.rs b/src/core/resolve.rs index e1dcea22..43626f21 100644 --- a/src/core/resolve.rs +++ b/src/core/resolve.rs @@ -727,6 +727,8 @@ mod tests { dependencies: BTreeMap::new(), activation: None, provenance: None, + evidence_tier: None, + deferral_reason: None, }, VersionEntry { version: semver::Version::new(1, 0, 0), @@ -745,6 +747,8 @@ mod tests { dependencies: BTreeMap::new(), activation: None, provenance: None, + evidence_tier: None, + deferral_reason: None, }, ], } @@ -1058,6 +1062,8 @@ mod tests { dependencies: BTreeMap::new(), activation: None, provenance: None, + evidence_tier: None, + deferral_reason: None, }], } } diff --git a/src/install/transaction.rs b/src/install/transaction.rs index fd489de0..732c7587 100644 --- a/src/install/transaction.rs +++ b/src/install/transaction.rs @@ -408,6 +408,14 @@ pub fn install_package( install_dir.display() ); + if resolved.is_provisional() { + let reason = resolved.deferral_reason_display(); + println!( + "{} This package has not been lifecycle-tested. It passed integrity checks. ({reason})", + console::style("!").yellow() + ); + } + Ok(InstallResult { installed: true, package: lock_key, diff --git a/tests/install_test.rs b/tests/install_test.rs index 50af71ab..ae80fe59 100644 --- a/tests/install_test.rs +++ b/tests/install_test.rs @@ -138,6 +138,8 @@ fn integration_full_install_from_signed_registry() { dependencies: BTreeMap::new(), activation: None, provenance: None, + evidence_tier: None, + deferral_reason: None, }], }; @@ -383,6 +385,8 @@ fn integration_resolve_exact_rejects_incompatible() { dependencies: BTreeMap::new(), activation: None, provenance: None, + evidence_tier: None, + deferral_reason: None, }], }; @@ -466,6 +470,8 @@ fn integration_snapshot_before_install() { dependencies: BTreeMap::new(), activation: None, provenance: None, + evidence_tier: None, + deferral_reason: None, }], }; @@ -537,6 +543,8 @@ fn integration_snapshot_before_install() { dependencies: BTreeMap::new(), activation: None, provenance: None, + evidence_tier: None, + deferral_reason: None, }], }; diff --git a/tests/support/active_update/mod.rs b/tests/support/active_update/mod.rs index 0e5aa745..9984eade 100644 --- a/tests/support/active_update/mod.rs +++ b/tests/support/active_update/mod.rs @@ -507,6 +507,8 @@ fn write_dual_version_registry( dependencies: BTreeMap::new(), activation: None, provenance: None, + evidence_tier: None, + deferral_reason: None, } };