1crate::ix!();
2
3#[derive(Builder,Debug)]
4#[builder(setter(into))]
5pub struct CrateActivitySummary {
6 date_interval_1d: NaiveDate,
7 date_interval_3d: NaiveDate,
8 date_interval_full_start: NaiveDate,
9 date_interval_full_end: NaiveDate,
10
11 total_downloads: i64,
12 avg_daily_downloads: f64,
13 avg_daily_downloads_per_crate: f64,
14 median_daily_downloads: i64,
15 crates_analyzed: usize,
16
17 top_crates_1d: Vec<(String, i64)>,
18 top_crates_3d: Vec<(String, i64)>,
19 top_crates_7d: Vec<(String, i64)>,
20
21 expand_groups: bool,
23
24 min_group_size: usize,
26}
27
28impl CrateActivitySummary {
29 pub fn new(
30 summaries: &[CrateUsageSummary],
31 interval_downloads_1d: HashMap<String, i64>,
32 interval_downloads_3d: HashMap<String, i64>,
33 interval_downloads_7d: HashMap<String, i64>,
34 one_day_ago: NaiveDate,
35 three_days_ago: NaiveDate,
36 seven_days_ago: NaiveDate,
37 expand_groups: bool,
38 min_group_size: usize,
39 ) -> Self {
40 let (full_start, full_end) = summaries
42 .iter()
43 .flat_map(|s| s.version_downloads())
44 .map(|d| d.date())
45 .minmax()
46 .into_option()
47 .unwrap_or((&one_day_ago, &one_day_ago));
48
49 let total_downloads: i64 = summaries.iter().map(|s| s.total_downloads()).sum();
51 let avg_daily_downloads: f64 =
52 summaries.iter().map(|s| s.average_daily_downloads()).sum::<f64>();
53 let avg_daily_downloads_per_crate = if summaries.is_empty() {
54 0.0
55 } else {
56 avg_daily_downloads / summaries.len() as f64
57 };
58
59 let mut daily_downloads: Vec<i64> =
61 summaries.iter().map(|s| *s.total_downloads()).collect();
62 daily_downloads.sort();
63 let median_daily_downloads = if daily_downloads.is_empty() {
64 0
65 } else if daily_downloads.len() % 2 == 0 {
66 let mid = daily_downloads.len() / 2;
67 (daily_downloads[mid - 1] + daily_downloads[mid]) / 2
68 } else {
69 daily_downloads[daily_downloads.len() / 2]
70 };
71
72 let mut top_crates_1d: Vec<_> = interval_downloads_1d.into_iter().collect();
74 let mut top_crates_3d: Vec<_> = interval_downloads_3d.into_iter().collect();
75 let mut top_crates_7d: Vec<_> = interval_downloads_7d.into_iter().collect();
76
77 top_crates_1d.sort_by_key(|&(_, downloads)| std::cmp::Reverse(downloads));
78 top_crates_3d.sort_by_key(|&(_, downloads)| std::cmp::Reverse(downloads));
79 top_crates_7d.sort_by_key(|&(_, downloads)| std::cmp::Reverse(downloads));
80
81 CrateActivitySummary {
82 date_interval_1d: one_day_ago,
83 date_interval_3d: three_days_ago,
84 date_interval_full_end: *full_end,
85 date_interval_full_start: *full_start,
86
87 total_downloads,
88 avg_daily_downloads,
89 avg_daily_downloads_per_crate,
90 median_daily_downloads,
91 crates_analyzed: summaries.len(),
92
93 top_crates_1d,
94 top_crates_3d,
95 top_crates_7d,
96
97 expand_groups,
98 min_group_size,
99 }
100 }
101}
102
103impl fmt::Display for CrateActivitySummary {
104 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105 use std::collections::HashMap;
106 use std::fmt::Write as _;
107
108 fn extract_prefix(crate_name: &str) -> String {
111 if let Some(idx) = crate_name.find('-') {
112 crate_name[..idx].to_string()
113 } else {
114 crate_name.to_string()
115 }
116 }
117
118 #[derive(Clone)]
120 struct GroupStats {
121 group_label: String,
122 max_downloads: i64,
123 sum_downloads: i64,
124 avg_downloads: f64,
125 n_crates: usize,
126 members: Vec<(String, i64)>,
127 }
128
129 #[tracing::instrument(level = "debug", skip(crates))]
130 fn group_crates_compact(
131 crates: &[(String, i64)],
132 min_group_size: usize,
133 ) -> (Vec<GroupStats>, Vec<(String, i64)>) {
134 let mut group_map: HashMap<String, Vec<(String, i64)>> = HashMap::new();
135
136 for (crate_name, downloads) in crates {
138 let prefix = extract_prefix(crate_name);
139 group_map
140 .entry(prefix)
141 .or_default()
142 .push((crate_name.clone(), *downloads));
143 }
144
145 let mut groups = Vec::new();
146 let mut single_items = Vec::new();
147
148 for (prefix, members) in group_map {
149 if members.len() >= min_group_size {
150 let sum_downloads: i64 = members.iter().map(|m| m.1).sum();
152 let max_downloads: i64 = members.iter().map(|m| m.1).max().unwrap_or(0);
153 let n_crates = members.len();
154 let avg_downloads = if n_crates > 0 {
155 sum_downloads as f64 / n_crates as f64
156 } else {
157 0.0
158 };
159 let group_label = format!("{}-*", prefix);
161
162 let mut sorted_members = members.clone();
164 sorted_members.sort_by(|a, b| {
165 b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))
166 });
167
168 groups.push(GroupStats {
169 group_label,
170 max_downloads,
171 sum_downloads,
172 avg_downloads,
173 n_crates,
174 members: sorted_members,
175 });
176 } else {
177 for (crate_name, downloads) in members {
179 single_items.push((crate_name, downloads));
180 }
181 }
182 }
183
184 groups.sort_by(|a, b| {
186 b.max_downloads
187 .cmp(&a.max_downloads)
188 .then_with(|| b.sum_downloads.cmp(&a.sum_downloads))
189 .then_with(|| a.group_label.cmp(&b.group_label))
190 });
191
192 single_items.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
194
195 (groups, single_items)
196 }
197
198 #[tracing::instrument(level = "debug", skip(f, crates))]
199 fn display_grouped_crates_compact(
200 f: &mut fmt::Formatter<'_>,
201 heading: &str,
202 crates: &[(String, i64)],
203 min_group_size: usize,
204 expand_groups: bool,
205 ) -> fmt::Result {
206 writeln!(f, "\n{}", heading)?;
207
208 let (groups, single_items) = group_crates_compact(crates, min_group_size);
209
210 let mut total_for_display = 0i64;
214 for g in &groups {
215 total_for_display += g.max_downloads;
216 }
217 let single_total: i64 = single_items.iter().map(|x| x.1).sum();
218 total_for_display += single_total;
219
220 let group_coverage: usize = groups.iter().map(|g| g.n_crates).sum();
221 let overall_count = group_coverage + single_items.len();
222
223 writeln!(
224 f,
225 " {} distinct prefix group(s) covering {} crates, total {} downloads (by max+singles)",
226 groups.len(),
227 overall_count,
228 total_for_display
229 )?;
230
231 for g in &groups {
233 writeln!(
236 f,
237 " {:<24} max={:<5} avg={:>6.2} sum={:<6} n_crates={}",
238 g.group_label,
239 g.max_downloads,
240 g.avg_downloads,
241 g.sum_downloads,
242 g.n_crates
243 )?;
244 if expand_groups {
246 for (crate_name, dl) in &g.members {
247 writeln!(f, " {:<24} {:>5} downloads", crate_name, dl)?;
248 }
249 }
250 }
251
252 for (crate_name, downloads) in &single_items {
254 writeln!(f, " {:<24} {} downloads", crate_name, downloads)?;
255 }
256
257 Ok(())
258 }
259
260 writeln!(f, "Crate Activity Summary:")?;
262 writeln!(f, " Full Data Range: {} to {}",
263 self.date_interval_full_start, self.date_interval_full_end)?;
264 writeln!(f, " Date Interval (Last 1 Day): {}", self.date_interval_1d)?;
265 writeln!(f, " Date Interval (Last 3 Days): {}", self.date_interval_3d)?;
266
267 writeln!(f, " Total Downloads: {}", self.total_downloads)?;
268 writeln!(f, " Average Daily Downloads: {:.2}", self.avg_daily_downloads)?;
269 writeln!(f, " Average Daily Downloads per Crate: {:.2}", self.avg_daily_downloads_per_crate)?;
270 writeln!(f, " Median Daily Downloads: {}", self.median_daily_downloads)?;
271 writeln!(f, " Crates Analyzed: {}", self.crates_analyzed)?;
272
273 display_grouped_crates_compact(
275 f,
276 "Top Crates (Last 1 Day):",
277 &self.top_crates_1d,
278 self.min_group_size,
279 self.expand_groups,
280 )?;
281 display_grouped_crates_compact(
282 f,
283 "Top Crates (Last 3 Days):",
284 &self.top_crates_3d,
285 self.min_group_size,
286 self.expand_groups,
287 )?;
288 display_grouped_crates_compact(
289 f,
290 "Top Crates (Last 7 Days):",
291 &self.top_crates_7d,
292 self.min_group_size,
293 self.expand_groups,
294 )?;
295
296 Ok(())
297 }
298}