use serde::{Deserialize, Serialize};
pub const GLOB_WARN_THRESHOLD: u64 = 10_000;
pub const GLOB_REFUSE_THRESHOLD: u64 = 50_000;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArtifactPhaseTimings {
pub collection_ms: u64,
pub compression_ms: u64,
pub transfer_ms: u64,
pub extract_ms: u64,
}
impl ArtifactPhaseTimings {
#[must_use]
pub const fn total_ms(&self) -> u64 {
self.collection_ms
.saturating_add(self.compression_ms)
.saturating_add(self.transfer_ms)
.saturating_add(self.extract_ms)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RetrievalMode {
Glob,
Manifest,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "advice")]
pub enum GlobAdvice {
Proceed,
Warn {
file_count: u64,
threshold: u64,
suggestion: String,
},
Refuse {
file_count: u64,
hard_cap: u64,
suggestion: String,
},
}
impl GlobAdvice {
#[must_use]
pub const fn is_refused(&self) -> bool {
matches!(self, Self::Refuse { .. })
}
#[must_use]
pub const fn recommended_mode(&self) -> RetrievalMode {
match self {
Self::Proceed => RetrievalMode::Glob,
Self::Warn { .. } | Self::Refuse { .. } => RetrievalMode::Manifest,
}
}
}
#[must_use]
pub fn is_broad_recursive_glob(pattern: &str) -> bool {
pattern.contains("**") || pattern.ends_with("/*") || pattern.ends_with('/')
}
#[must_use]
pub fn assess_glob_expansion(pattern: &str, file_count: u64) -> GlobAdvice {
if !is_broad_recursive_glob(pattern) {
return GlobAdvice::Proceed;
}
if file_count >= GLOB_REFUSE_THRESHOLD {
return GlobAdvice::Refuse {
file_count,
hard_cap: GLOB_REFUSE_THRESHOLD,
suggestion:
"use a manifest or explicit artifact list instead of a broad recursive glob"
.to_string(),
};
}
if file_count >= GLOB_WARN_THRESHOLD {
return GlobAdvice::Warn {
file_count,
threshold: GLOB_WARN_THRESHOLD,
suggestion: "a manifest of exact artifacts would transfer far fewer files".to_string(),
};
}
GlobAdvice::Proceed
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ArtifactCostReport {
pub file_count: u64,
pub total_bytes: u64,
pub files_per_sec: f64,
pub bytes_per_sec: f64,
pub timings: ArtifactPhaseTimings,
pub wall_clock_ms: u64,
}
impl ArtifactCostReport {
#[must_use]
pub fn render(&self) -> String {
format!(
"artifact retrieval cost: {} files, {} bytes in {} ms \
({:.0} files/s, {:.0} bytes/s) \
[collect {}ms, compress {}ms, transfer {}ms, extract {}ms]",
self.file_count,
self.total_bytes,
self.wall_clock_ms,
self.files_per_sec,
self.bytes_per_sec,
self.timings.collection_ms,
self.timings.compression_ms,
self.timings.transfer_ms,
self.timings.extract_ms,
)
}
}
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn compute_artifact_cost(
file_count: u64,
total_bytes: u64,
timings: ArtifactPhaseTimings,
) -> ArtifactCostReport {
let wall_clock_ms = timings.total_ms();
let secs = wall_clock_ms as f64 / 1000.0;
let (files_per_sec, bytes_per_sec) = if secs > 0.0 {
(file_count as f64 / secs, total_bytes as f64 / secs)
} else {
(0.0, 0.0)
};
ArtifactCostReport {
file_count,
total_bytes,
files_per_sec,
bytes_per_sec,
timings,
wall_clock_ms,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn timings_total_is_sum_of_phases() {
let t = ArtifactPhaseTimings {
collection_ms: 100,
compression_ms: 200,
transfer_ms: 300,
extract_ms: 50,
};
assert_eq!(t.total_ms(), 650);
}
#[test]
fn cost_rates_computed_from_timings() {
let t = ArtifactPhaseTimings {
collection_ms: 0,
compression_ms: 0,
transfer_ms: 2000,
extract_ms: 0,
};
let r = compute_artifact_cost(1000, 2_000_000, t);
assert_eq!(r.wall_clock_ms, 2000);
assert!((r.files_per_sec - 500.0).abs() < 1e-6);
assert!((r.bytes_per_sec - 1_000_000.0).abs() < 1e-6);
}
#[test]
fn zero_time_yields_zero_rates_not_infinity() {
let r = compute_artifact_cost(5, 100, ArtifactPhaseTimings::default());
assert_eq!(r.wall_clock_ms, 0);
assert_eq!(r.files_per_sec, 0.0);
assert_eq!(r.bytes_per_sec, 0.0);
assert!(r.files_per_sec.is_finite());
}
#[test]
fn broad_recursive_glob_detection() {
assert!(is_broad_recursive_glob("target/**/*.rlib"));
assert!(is_broad_recursive_glob("target/debug/*"));
assert!(is_broad_recursive_glob("target/debug/"));
assert!(!is_broad_recursive_glob("target/debug/app"));
assert!(!is_broad_recursive_glob(".rch-target/debug/libfoo.rlib"));
}
#[test]
fn many_small_files_under_broad_glob_warns() {
let advice = assess_glob_expansion("target/**/*", 15_000);
match advice {
GlobAdvice::Warn {
file_count,
threshold,
..
} => {
assert_eq!(file_count, 15_000);
assert_eq!(threshold, GLOB_WARN_THRESHOLD);
}
other => panic!("expected warn, got {other:?}"),
}
assert_eq!(advice.recommended_mode(), RetrievalMode::Manifest);
assert!(!advice.is_refused());
}
#[test]
fn enormous_broad_glob_is_refused() {
let advice = assess_glob_expansion("target/**/*", 80_000);
assert!(advice.is_refused());
assert_eq!(advice.recommended_mode(), RetrievalMode::Manifest);
if let GlobAdvice::Refuse { hard_cap, .. } = advice {
assert_eq!(hard_cap, GLOB_REFUSE_THRESHOLD);
} else {
panic!("expected refuse");
}
}
#[test]
fn explicit_pattern_never_warns_even_with_many_files() {
let advice = assess_glob_expansion("target/debug/app", 99_999);
assert_eq!(advice, GlobAdvice::Proceed);
assert_eq!(advice.recommended_mode(), RetrievalMode::Glob);
}
#[test]
fn small_broad_glob_proceeds() {
assert_eq!(
assess_glob_expansion("target/**/*.rlib", 200),
GlobAdvice::Proceed
);
}
#[test]
fn report_render_and_serde_are_stable() {
let r = compute_artifact_cost(
1000,
2_000_000,
ArtifactPhaseTimings {
collection_ms: 10,
compression_ms: 20,
transfer_ms: 1970,
extract_ms: 0,
},
);
let text = r.render();
assert!(text.contains("1000 files"));
assert!(text.contains("transfer 1970ms"));
let v = serde_json::to_value(&r).unwrap();
let mut keys: Vec<&str> = v.as_object().unwrap().keys().map(String::as_str).collect();
keys.sort_unstable();
assert_eq!(
keys,
vec![
"bytes_per_sec",
"file_count",
"files_per_sec",
"timings",
"total_bytes",
"wall_clock_ms",
]
);
let back: ArtifactCostReport = serde_json::from_value(v).unwrap();
assert_eq!(r, back);
}
#[test]
fn glob_advice_serializes_with_tagged_variant() {
let v = serde_json::to_value(assess_glob_expansion("target/**/*", 15_000)).unwrap();
assert_eq!(v["advice"], "warn");
assert_eq!(v["file_count"], 15_000);
}
}