1use serde::{Deserialize, Serialize};
13
14pub const GLOB_WARN_THRESHOLD: u64 = 10_000;
16pub const GLOB_REFUSE_THRESHOLD: u64 = 50_000;
19
20#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
22pub struct ArtifactPhaseTimings {
23 pub collection_ms: u64,
24 pub compression_ms: u64,
25 pub transfer_ms: u64,
26 pub extract_ms: u64,
27}
28
29impl ArtifactPhaseTimings {
30 #[must_use]
32 pub const fn total_ms(&self) -> u64 {
33 self.collection_ms
34 .saturating_add(self.compression_ms)
35 .saturating_add(self.transfer_ms)
36 .saturating_add(self.extract_ms)
37 }
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum RetrievalMode {
44 Glob,
46 Manifest,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case", tag = "advice")]
53pub enum GlobAdvice {
54 Proceed,
56 Warn {
58 file_count: u64,
59 threshold: u64,
60 suggestion: String,
61 },
62 Refuse {
64 file_count: u64,
65 hard_cap: u64,
66 suggestion: String,
67 },
68}
69
70impl GlobAdvice {
71 #[must_use]
73 pub const fn is_refused(&self) -> bool {
74 matches!(self, Self::Refuse { .. })
75 }
76
77 #[must_use]
79 pub const fn recommended_mode(&self) -> RetrievalMode {
80 match self {
81 Self::Proceed => RetrievalMode::Glob,
82 Self::Warn { .. } | Self::Refuse { .. } => RetrievalMode::Manifest,
83 }
84 }
85}
86
87#[must_use]
91pub fn is_broad_recursive_glob(pattern: &str) -> bool {
92 pattern.contains("**") || pattern.ends_with("/*") || pattern.ends_with('/')
93}
94
95#[must_use]
99pub fn assess_glob_expansion(pattern: &str, file_count: u64) -> GlobAdvice {
100 if !is_broad_recursive_glob(pattern) {
101 return GlobAdvice::Proceed;
102 }
103 if file_count >= GLOB_REFUSE_THRESHOLD {
104 return GlobAdvice::Refuse {
105 file_count,
106 hard_cap: GLOB_REFUSE_THRESHOLD,
107 suggestion:
108 "use a manifest or explicit artifact list instead of a broad recursive glob"
109 .to_string(),
110 };
111 }
112 if file_count >= GLOB_WARN_THRESHOLD {
113 return GlobAdvice::Warn {
114 file_count,
115 threshold: GLOB_WARN_THRESHOLD,
116 suggestion: "a manifest of exact artifacts would transfer far fewer files".to_string(),
117 };
118 }
119 GlobAdvice::Proceed
120}
121
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
124pub struct ArtifactCostReport {
125 pub file_count: u64,
126 pub total_bytes: u64,
127 pub files_per_sec: f64,
128 pub bytes_per_sec: f64,
129 pub timings: ArtifactPhaseTimings,
130 pub wall_clock_ms: u64,
131}
132
133impl ArtifactCostReport {
134 #[must_use]
136 pub fn render(&self) -> String {
137 format!(
138 "artifact retrieval cost: {} files, {} bytes in {} ms \
139 ({:.0} files/s, {:.0} bytes/s) \
140 [collect {}ms, compress {}ms, transfer {}ms, extract {}ms]",
141 self.file_count,
142 self.total_bytes,
143 self.wall_clock_ms,
144 self.files_per_sec,
145 self.bytes_per_sec,
146 self.timings.collection_ms,
147 self.timings.compression_ms,
148 self.timings.transfer_ms,
149 self.timings.extract_ms,
150 )
151 }
152}
153
154#[must_use]
157#[allow(clippy::cast_precision_loss)]
158pub fn compute_artifact_cost(
159 file_count: u64,
160 total_bytes: u64,
161 timings: ArtifactPhaseTimings,
162) -> ArtifactCostReport {
163 let wall_clock_ms = timings.total_ms();
164 let secs = wall_clock_ms as f64 / 1000.0;
165 let (files_per_sec, bytes_per_sec) = if secs > 0.0 {
166 (file_count as f64 / secs, total_bytes as f64 / secs)
167 } else {
168 (0.0, 0.0)
169 };
170 ArtifactCostReport {
171 file_count,
172 total_bytes,
173 files_per_sec,
174 bytes_per_sec,
175 timings,
176 wall_clock_ms,
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 #[test]
185 fn timings_total_is_sum_of_phases() {
186 let t = ArtifactPhaseTimings {
187 collection_ms: 100,
188 compression_ms: 200,
189 transfer_ms: 300,
190 extract_ms: 50,
191 };
192 assert_eq!(t.total_ms(), 650);
193 }
194
195 #[test]
196 fn cost_rates_computed_from_timings() {
197 let t = ArtifactPhaseTimings {
198 collection_ms: 0,
199 compression_ms: 0,
200 transfer_ms: 2000,
201 extract_ms: 0,
202 };
203 let r = compute_artifact_cost(1000, 2_000_000, t);
205 assert_eq!(r.wall_clock_ms, 2000);
206 assert!((r.files_per_sec - 500.0).abs() < 1e-6);
207 assert!((r.bytes_per_sec - 1_000_000.0).abs() < 1e-6);
208 }
209
210 #[test]
211 fn zero_time_yields_zero_rates_not_infinity() {
212 let r = compute_artifact_cost(5, 100, ArtifactPhaseTimings::default());
213 assert_eq!(r.wall_clock_ms, 0);
214 assert_eq!(r.files_per_sec, 0.0);
215 assert_eq!(r.bytes_per_sec, 0.0);
216 assert!(r.files_per_sec.is_finite());
217 }
218
219 #[test]
220 fn broad_recursive_glob_detection() {
221 assert!(is_broad_recursive_glob("target/**/*.rlib"));
222 assert!(is_broad_recursive_glob("target/debug/*"));
223 assert!(is_broad_recursive_glob("target/debug/"));
224 assert!(!is_broad_recursive_glob("target/debug/app"));
225 assert!(!is_broad_recursive_glob(".rch-target/debug/libfoo.rlib"));
226 }
227
228 #[test]
229 fn many_small_files_under_broad_glob_warns() {
230 let advice = assess_glob_expansion("target/**/*", 15_000);
232 match advice {
233 GlobAdvice::Warn {
234 file_count,
235 threshold,
236 ..
237 } => {
238 assert_eq!(file_count, 15_000);
239 assert_eq!(threshold, GLOB_WARN_THRESHOLD);
240 }
241 other => panic!("expected warn, got {other:?}"),
242 }
243 assert_eq!(advice.recommended_mode(), RetrievalMode::Manifest);
244 assert!(!advice.is_refused());
245 }
246
247 #[test]
248 fn enormous_broad_glob_is_refused() {
249 let advice = assess_glob_expansion("target/**/*", 80_000);
250 assert!(advice.is_refused());
251 assert_eq!(advice.recommended_mode(), RetrievalMode::Manifest);
252 if let GlobAdvice::Refuse { hard_cap, .. } = advice {
253 assert_eq!(hard_cap, GLOB_REFUSE_THRESHOLD);
254 } else {
255 panic!("expected refuse");
256 }
257 }
258
259 #[test]
260 fn explicit_pattern_never_warns_even_with_many_files() {
261 let advice = assess_glob_expansion("target/debug/app", 99_999);
263 assert_eq!(advice, GlobAdvice::Proceed);
264 assert_eq!(advice.recommended_mode(), RetrievalMode::Glob);
265 }
266
267 #[test]
268 fn small_broad_glob_proceeds() {
269 assert_eq!(
270 assess_glob_expansion("target/**/*.rlib", 200),
271 GlobAdvice::Proceed
272 );
273 }
274
275 #[test]
276 fn report_render_and_serde_are_stable() {
277 let r = compute_artifact_cost(
278 1000,
279 2_000_000,
280 ArtifactPhaseTimings {
281 collection_ms: 10,
282 compression_ms: 20,
283 transfer_ms: 1970,
284 extract_ms: 0,
285 },
286 );
287 let text = r.render();
288 assert!(text.contains("1000 files"));
289 assert!(text.contains("transfer 1970ms"));
290 let v = serde_json::to_value(&r).unwrap();
292 let mut keys: Vec<&str> = v.as_object().unwrap().keys().map(String::as_str).collect();
293 keys.sort_unstable();
294 assert_eq!(
295 keys,
296 vec![
297 "bytes_per_sec",
298 "file_count",
299 "files_per_sec",
300 "timings",
301 "total_bytes",
302 "wall_clock_ms",
303 ]
304 );
305 let back: ArtifactCostReport = serde_json::from_value(v).unwrap();
306 assert_eq!(r, back);
307 }
308
309 #[test]
310 fn glob_advice_serializes_with_tagged_variant() {
311 let v = serde_json::to_value(assess_glob_expansion("target/**/*", 15_000)).unwrap();
312 assert_eq!(v["advice"], "warn");
313 assert_eq!(v["file_count"], 15_000);
314 }
315}