unsafe_review_core/
freshness.rs1use 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "lowercase")]
20pub enum AnalysisState {
21 Current,
23 Refreshing,
25 Stale,
27 Partial,
29 Capped,
31 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#[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 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 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}