crate_activity/
summary.rs

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    /// If true, we'll print each individual crate in a group
22    expand_groups: bool,
23
24    /// Minimum group size required to treat them as a “group”
25    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        // Compute the full date range
41        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        // Overall stats
50        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        // Median daily downloads
60        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        // Convert the HashMaps into sorted vecs
73        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        // Helper: extract the group prefix from a crate name
109        // e.g. "surgefx-allpass" => "surgefx", "workspacer-3p" => "workspacer"
110        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        // We'll store stats in a struct for convenience
119        #[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            // Collect members by prefix
137            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                    // We form a group
151                    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                    // e.g. "surgefx-" or "workspacer-"
160                    let group_label = format!("{}-*", prefix);
161
162                    // Sort the group's members by descending downloads
163                    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                    // If group < min_group_size, treat each crate individually
178                    for (crate_name, downloads) in members {
179                        single_items.push((crate_name, downloads));
180                    }
181                }
182            }
183
184            // Sort groups by descending max_downloads, then by descending sum, then alpha
185            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            // Then sort single items (descending)
193            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            // The total download count we show at the top line
211            // is the sum of each group's max_downloads plus the sum of each single item.
212            // Because the user wants to avoid "double-counting" the same prefix family.
213            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            // Display each group
232            for g in &groups {
233                // example line:
234                //   "workspacer-*   max=87  avg=27.76  sum=1721  n_crates=xx"
235                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 is true, show each member
245                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            // Finally, display single items (which didn't meet min_group_size)
253            for (crate_name, downloads) in &single_items {
254                writeln!(f, "  {:<24} {} downloads", crate_name, downloads)?;
255            }
256
257            Ok(())
258        }
259
260        // 1) Print the main summary lines
261        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        // 2) Group + display for each interval
274        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}