use std::collections::BTreeMap;
use std::time::Duration;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScanKind {
Config,
Projects,
}
impl ScanKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Config => "config",
Self::Projects => "projects",
}
}
}
pub fn scan_lock_path(kind: ScanKind) -> String {
format!("/data/api/v1/scan-lock/{}", kind.as_str())
}
pub fn scan_path(kind: ScanKind) -> String {
format!("/data/api/v1/scan/{}", kind.as_str())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct LockRequest {
#[serde(rename = "acquireTimeout")]
pub acquire_timeout: u64,
#[serde(rename = "holdTimeout")]
pub hold_timeout: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ScanLockInfo {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timestamp: Option<i64>,
#[serde(default)]
pub remaining: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actor: Option<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum LockObservation {
Held(ScanLockInfo),
Free,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LockVerdict {
Held {
remaining_s: i64,
},
Expiring {
remaining_s: i64,
},
NotHeld,
}
pub fn lock_verdict(obs: &LockObservation, margin_s: i64) -> LockVerdict {
match obs {
LockObservation::Free => LockVerdict::NotHeld,
LockObservation::Held(info) if info.remaining >= margin_s => LockVerdict::Held {
remaining_s: info.remaining,
},
LockObservation::Held(info) => LockVerdict::Expiring {
remaining_s: info.remaining,
},
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ScanStatus {
#[serde(rename = "scanActive")]
pub scan_active: bool,
#[serde(rename = "lastScanTimestamp")]
pub last_scan_timestamp: i64,
#[serde(
rename = "lastScanDuration",
default,
skip_serializing_if = "Option::is_none"
)]
pub last_scan_duration: Option<i64>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
pub fn scan_complete(before: &ScanStatus, now: &ScanStatus) -> bool {
!now.scan_active && now.last_scan_timestamp > before.last_scan_timestamp
}
#[derive(Debug, Clone, PartialEq)]
pub enum ScanOutcome {
Complete(ScanStatus),
NotComplete {
last: ScanStatus,
waited: Duration,
},
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
fn status(active: bool, ts: i64) -> ScanStatus {
ScanStatus {
scan_active: active,
last_scan_timestamp: ts,
last_scan_duration: None,
extra: BTreeMap::new(),
}
}
fn held(timestamp: Option<i64>, remaining: i64) -> LockObservation {
LockObservation::Held(ScanLockInfo {
timestamp,
remaining,
actor: Some("api-token:cap-a:203.0.113.1".to_string()),
extra: BTreeMap::new(),
})
}
#[test]
fn scan_complete_truth_table() {
let before = status(false, 100);
assert!(!scan_complete(&before, &status(true, 100)));
assert!(!scan_complete(&before, &status(false, 100)));
assert!(scan_complete(&before, &status(false, 101)));
assert!(!scan_complete(&before, &status(true, 101)));
}
#[test]
fn lock_verdict_boundary_is_inclusive() {
assert_eq!(
lock_verdict(&held(None, 5), 5),
LockVerdict::Held { remaining_s: 5 }
);
assert_eq!(
lock_verdict(&held(None, 4), 5),
LockVerdict::Expiring { remaining_s: 4 }
);
assert_eq!(
lock_verdict(&LockObservation::Free, 5),
LockVerdict::NotHeld
);
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(512))]
#[test]
fn lock_verdict_ignores_timestamp(
timestamp in any::<i64>(),
remaining in -10i64..=10_000,
margin in 0i64..=60,
) {
prop_assert_eq!(
lock_verdict(&held(Some(timestamp), remaining), margin),
lock_verdict(&held(None, remaining), margin)
);
}
}
#[test]
fn scan_rs_reads_no_clock() {
let source = include_str!("scan.rs");
let production = source
.split("#[cfg(test)]")
.next()
.expect("split yields a head");
let code: String = production
.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n");
for forbidden in ["SystemTime", "Instant", "Utc", "chrono", "jiff", "now("] {
assert!(
!code.contains(forbidden),
"scan.rs must not read a clock, found {forbidden:?}"
);
}
}
#[test]
fn captured_bodies_parse() {
let info: ScanLockInfo = serde_json::from_str(
"{\n \"timestamp\": 380308558,\n \"remaining\": 59,\n \"actor\": \"api-token:cap-a:203.0.113.1\"\n}",
)
.expect("C1c parses");
assert_eq!(info.remaining, 59);
assert_eq!(info.timestamp, Some(380_308_558));
let idle: ScanStatus = serde_json::from_str(
"{\n \"scanActive\": false,\n \"lastScanTimestamp\": 1791815654089,\n \"lastScanDuration\": 2081\n}",
)
.expect("C1i parses");
assert!(!idle.scan_active);
assert_eq!(idle.last_scan_timestamp, 1_791_815_654_089);
assert!(serde_json::from_str::<ScanStatus>("{}").is_err());
}
#[test]
fn paths_follow_the_kind() {
assert_eq!(
scan_lock_path(ScanKind::Projects),
"/data/api/v1/scan-lock/projects"
);
assert_eq!(scan_path(ScanKind::Config), "/data/api/v1/scan/config");
}
}