Skip to main content

unsafe_review_core/
freshness.rs

1//! Analysis-run identity for freshness-aware consumers.
2
3use serde::{Deserialize, Serialize};
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::time::{SystemTime, UNIX_EPOCH};
6
7static NEXT_GENERATION: AtomicU64 = AtomicU64::new(0);
8
9/// Lifecycle state produced by the analysis-identity slice.
10///
11/// These are freshness signals only: they describe the relationship between an
12/// output and the source it was computed from (current, in-flight, stale,
13/// partial, capped, or failed). No variant is a safety or correctness claim
14/// beyond this freshness relationship — `Current` does not mean the file is
15/// safe, and none of `Refreshing`/`Stale`/`Partial`/`Capped`/`Failed` mean it is
16/// unsafe. Consumers must not read any state as proof, UB-free status, or
17/// Miri-clean status.
18#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "lowercase")]
20pub enum AnalysisState {
21    /// The output reflects the current source; no known staleness.
22    Current,
23    /// A newer analysis is already in flight; this output may be superseded shortly.
24    Refreshing,
25    /// The source has changed since this output was produced.
26    Stale,
27    /// The analysis completed but covered less than the full requested scope.
28    Partial,
29    /// The analysis stopped early after hitting a configured limit (e.g. max cards).
30    Capped,
31    /// The analysis did not complete; this output should not be treated as current.
32    Failed,
33}
34
35impl AnalysisState {
36    pub fn as_str(&self) -> &'static str {
37        match self {
38            Self::Current => "current",
39            Self::Refreshing => "refreshing",
40            Self::Stale => "stale",
41            Self::Partial => "partial",
42            Self::Capped => "capped",
43            Self::Failed => "failed",
44        }
45    }
46}
47
48/// Facts identifying the analysis run that produced an output envelope.
49///
50/// This is a freshness signal only. Optional source facts remain absent until
51/// their actual producer is available; no currentness or safety is implied.
52#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
53pub struct AnalysisIdentity {
54    pub analysis_id: String,
55    pub generation: u64,
56    pub tool_version: String,
57    pub scope: String,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub base_commit: Option<String>,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub head_commit: Option<String>,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub document_version: Option<u64>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub file_digest: Option<String>,
66    pub state: AnalysisState,
67}
68
69impl AnalysisIdentity {
70    /// Create a fresh per-analysis identity.
71    pub fn new(scope: impl Into<String>) -> Self {
72        let generation = NEXT_GENERATION.fetch_add(1, Ordering::Relaxed) + 1;
73        let nonce = SystemTime::now()
74            .duration_since(UNIX_EPOCH)
75            .map(|duration| duration.as_nanos())
76            .unwrap_or_default();
77        let process = std::process::id();
78        Self {
79            analysis_id: format!("analysis-{process}-{generation}-{nonce}"),
80            generation,
81            tool_version: env!("CARGO_PKG_VERSION").to_string(),
82            scope: scope.into(),
83            base_commit: None,
84            head_commit: None,
85            document_version: None,
86            file_digest: None,
87            state: AnalysisState::Current,
88        }
89    }
90
91    /// Build a deterministic identity for projection tests.
92    pub fn for_test(
93        generation: u64,
94        analysis_id: impl Into<String>,
95        scope: impl Into<String>,
96    ) -> Self {
97        Self {
98            analysis_id: analysis_id.into(),
99            generation,
100            tool_version: env!("CARGO_PKG_VERSION").to_string(),
101            scope: scope.into(),
102            base_commit: None,
103            head_commit: None,
104            document_version: None,
105            file_digest: None,
106            state: AnalysisState::Current,
107        }
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::{AnalysisIdentity, AnalysisState};
114    use std::collections::BTreeSet;
115
116    #[test]
117    fn every_state_serializes_to_a_distinct_lowercase_string() -> Result<(), String> {
118        let variants = [
119            AnalysisState::Current,
120            AnalysisState::Refreshing,
121            AnalysisState::Stale,
122            AnalysisState::Partial,
123            AnalysisState::Capped,
124            AnalysisState::Failed,
125        ];
126        let mut serialized = Vec::new();
127        for variant in &variants {
128            let value = serde_json::to_value(variant).map_err(|err| err.to_string())?;
129            let text = value
130                .as_str()
131                .ok_or_else(|| "state should serialize to a JSON string".to_string())?
132                .to_string();
133            assert_eq!(text, variant.as_str());
134            assert_eq!(text, text.to_lowercase());
135            serialized.push(text);
136        }
137        let unique: BTreeSet<_> = serialized.iter().cloned().collect();
138        assert_eq!(
139            unique.len(),
140            variants.len(),
141            "every AnalysisState variant must serialize to a distinct string"
142        );
143        Ok(())
144    }
145
146    #[test]
147    fn non_current_states_are_distinct_from_current() -> Result<(), String> {
148        let non_current = [
149            AnalysisState::Failed,
150            AnalysisState::Partial,
151            AnalysisState::Capped,
152            AnalysisState::Stale,
153        ];
154        for state in non_current {
155            assert_ne!(state, AnalysisState::Current);
156            let value = serde_json::to_value(&state).map_err(|err| err.to_string())?;
157            assert_ne!(
158                value.as_str(),
159                Some("current"),
160                "{state:?} must not serialize the same as Current"
161            );
162        }
163        Ok(())
164    }
165
166    #[test]
167    fn distinct_runs_do_not_reuse_identity_or_generation() {
168        let first = AnalysisIdentity::new("diff");
169        let second = AnalysisIdentity::new("diff");
170        assert_ne!(first.analysis_id, second.analysis_id);
171        assert_ne!(first.generation, second.generation);
172    }
173
174    #[test]
175    fn test_identity_omits_unknown_source_facts() -> Result<(), String> {
176        let identity = AnalysisIdentity::for_test(7, "test-analysis", "diff");
177        let value = serde_json::to_value(identity).map_err(|err| err.to_string())?;
178        assert!(value.get("base_commit").is_none());
179        assert!(value.get("head_commit").is_none());
180        assert!(value.get("document_version").is_none());
181        assert!(value.get("file_digest").is_none());
182        Ok(())
183    }
184}