adrscope/domain/
facets.rs

1//! Faceted filtering data structures.
2//!
3//! Facets provide aggregated counts for filterable fields in the ADR collection,
4//! enabling the UI to show filter options with their counts.
5
6use std::collections::HashMap;
7
8use serde::Serialize;
9
10use super::{Adr, Status};
11
12/// A single facet value with its count.
13#[derive(Debug, Clone, Serialize)]
14pub struct FacetValue {
15    /// The value (e.g., "accepted", "architecture", "database").
16    pub value: String,
17    /// Number of ADRs with this value.
18    pub count: usize,
19}
20
21impl FacetValue {
22    /// Creates a new facet value.
23    #[must_use]
24    pub fn new(value: impl Into<String>, count: usize) -> Self {
25        Self {
26            value: value.into(),
27            count,
28        }
29    }
30}
31
32/// A facet is a filterable dimension with all its possible values.
33#[derive(Debug, Clone, Serialize)]
34pub struct Facet {
35    /// Name of the facet (e.g., "status", "category").
36    pub name: String,
37    /// All possible values for this facet, sorted by count descending.
38    pub values: Vec<FacetValue>,
39}
40
41impl Facet {
42    /// Creates a new facet with the given name and values.
43    #[must_use]
44    pub fn new(name: impl Into<String>, mut values: Vec<FacetValue>) -> Self {
45        // Sort by count descending, then alphabetically
46        values.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.value.cmp(&b.value)));
47        Self {
48            name: name.into(),
49            values,
50        }
51    }
52
53    /// Creates a facet from a frequency map.
54    #[must_use]
55    pub fn from_counts(name: impl Into<String>, counts: HashMap<String, usize>) -> Self {
56        let values = counts
57            .into_iter()
58            .map(|(value, count)| FacetValue::new(value, count))
59            .collect();
60        Self::new(name, values)
61    }
62}
63
64/// Collection of all facets computed from ADRs.
65#[derive(Debug, Clone, Serialize)]
66pub struct Facets {
67    /// Status facet with all lifecycle states.
68    pub statuses: Vec<FacetValue>,
69    /// Category facet.
70    pub categories: Vec<FacetValue>,
71    /// Tags facet.
72    pub tags: Vec<FacetValue>,
73    /// Authors facet.
74    pub authors: Vec<FacetValue>,
75    /// Projects facet.
76    pub projects: Vec<FacetValue>,
77    /// Technologies facet.
78    pub technologies: Vec<FacetValue>,
79}
80
81impl Facets {
82    /// Computes facets from a collection of ADRs.
83    #[must_use]
84    pub fn from_adrs(adrs: &[Adr]) -> Self {
85        let mut statuses: HashMap<String, usize> = HashMap::new();
86        let mut categories: HashMap<String, usize> = HashMap::new();
87        let mut tags: HashMap<String, usize> = HashMap::new();
88        let mut authors: HashMap<String, usize> = HashMap::new();
89        let mut projects: HashMap<String, usize> = HashMap::new();
90        let mut technologies: HashMap<String, usize> = HashMap::new();
91
92        // Initialize all status values with 0
93        for status in Status::all() {
94            statuses.insert(status.as_str().to_string(), 0);
95        }
96
97        for adr in adrs {
98            // Count status
99            *statuses
100                .entry(adr.status().as_str().to_string())
101                .or_insert(0) += 1;
102
103            // Count category
104            if !adr.category().is_empty() {
105                *categories.entry(adr.category().to_string()).or_insert(0) += 1;
106            }
107
108            // Count tags
109            for tag in adr.tags() {
110                *tags.entry(tag.clone()).or_insert(0) += 1;
111            }
112
113            // Count author
114            if !adr.author().is_empty() {
115                *authors.entry(adr.author().to_string()).or_insert(0) += 1;
116            }
117
118            // Count project
119            if !adr.project().is_empty() {
120                *projects.entry(adr.project().to_string()).or_insert(0) += 1;
121            }
122
123            // Count technologies
124            for tech in adr.technologies() {
125                *technologies.entry(tech.clone()).or_insert(0) += 1;
126            }
127        }
128
129        Self {
130            statuses: sorted_facet_values(statuses),
131            categories: sorted_facet_values(categories),
132            tags: sorted_facet_values(tags),
133            authors: sorted_facet_values(authors),
134            projects: sorted_facet_values(projects),
135            technologies: sorted_facet_values(technologies),
136        }
137    }
138}
139
140/// Converts a count map to sorted facet values.
141fn sorted_facet_values(counts: HashMap<String, usize>) -> Vec<FacetValue> {
142    let mut values: Vec<_> = counts
143        .into_iter()
144        .map(|(value, count)| FacetValue::new(value, count))
145        .collect();
146    values.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.value.cmp(&b.value)));
147    values
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn test_facet_value_creation() {
156        let fv = FacetValue::new("accepted", 10);
157        assert_eq!(fv.value, "accepted");
158        assert_eq!(fv.count, 10);
159    }
160
161    #[test]
162    fn test_facet_sorting() {
163        let values = vec![
164            FacetValue::new("a", 1),
165            FacetValue::new("b", 5),
166            FacetValue::new("c", 3),
167        ];
168        let facet = Facet::new("test", values);
169
170        assert_eq!(facet.values[0].value, "b"); // count 5
171        assert_eq!(facet.values[1].value, "c"); // count 3
172        assert_eq!(facet.values[2].value, "a"); // count 1
173    }
174
175    #[test]
176    fn test_facet_from_counts() {
177        let mut counts = HashMap::new();
178        counts.insert("proposed".to_string(), 5);
179        counts.insert("accepted".to_string(), 10);
180
181        let facet = Facet::from_counts("status", counts);
182
183        assert_eq!(facet.name, "status");
184        assert_eq!(facet.values[0].value, "accepted");
185        assert_eq!(facet.values[0].count, 10);
186    }
187
188    #[test]
189    fn test_sorted_facet_values_alphabetical_tie() {
190        let mut counts = HashMap::new();
191        counts.insert("zebra".to_string(), 5);
192        counts.insert("apple".to_string(), 5);
193
194        let values = sorted_facet_values(counts);
195
196        // Same count, should be alphabetically sorted
197        assert_eq!(values[0].value, "apple");
198        assert_eq!(values[1].value, "zebra");
199    }
200
201    #[test]
202    #[allow(clippy::too_many_lines)]
203    fn test_facets_from_adrs_with_all_fields() {
204        use crate::domain::{Adr, AdrId, Frontmatter, Status};
205        use std::path::PathBuf;
206
207        // Create ADRs with all facet fields populated
208        let frontmatter1 = Frontmatter::new("ADR 1")
209            .with_status(Status::Accepted)
210            .with_category("architecture")
211            .with_author("Alice")
212            .with_project("project-alpha")
213            .with_tags(vec!["database".to_string(), "performance".to_string()])
214            .with_technologies(vec!["rust".to_string(), "postgres".to_string()]);
215
216        let frontmatter2 = Frontmatter::new("ADR 2")
217            .with_status(Status::Proposed)
218            .with_category("api")
219            .with_author("Bob")
220            .with_project("project-beta")
221            .with_tags(vec!["rest".to_string(), "database".to_string()])
222            .with_technologies(vec!["rust".to_string(), "redis".to_string()]);
223
224        let adr1 = Adr::new(
225            AdrId::new("adr_0001"),
226            "adr_0001.md".to_string(),
227            PathBuf::from("adr_0001.md"),
228            frontmatter1,
229            String::new(),
230            String::new(),
231            String::new(),
232        );
233
234        let adr2 = Adr::new(
235            AdrId::new("adr_0002"),
236            "adr_0002.md".to_string(),
237            PathBuf::from("adr_0002.md"),
238            frontmatter2,
239            String::new(),
240            String::new(),
241            String::new(),
242        );
243
244        let facets = Facets::from_adrs(&[adr1, adr2]);
245
246        // Check statuses
247        assert!(
248            facets
249                .statuses
250                .iter()
251                .any(|f| f.value == "accepted" && f.count == 1)
252        );
253        assert!(
254            facets
255                .statuses
256                .iter()
257                .any(|f| f.value == "proposed" && f.count == 1)
258        );
259
260        // Check categories
261        assert!(
262            facets
263                .categories
264                .iter()
265                .any(|f| f.value == "architecture" && f.count == 1)
266        );
267        assert!(
268            facets
269                .categories
270                .iter()
271                .any(|f| f.value == "api" && f.count == 1)
272        );
273
274        // Check authors
275        assert!(
276            facets
277                .authors
278                .iter()
279                .any(|f| f.value == "Alice" && f.count == 1)
280        );
281        assert!(
282            facets
283                .authors
284                .iter()
285                .any(|f| f.value == "Bob" && f.count == 1)
286        );
287
288        // Check projects
289        assert!(
290            facets
291                .projects
292                .iter()
293                .any(|f| f.value == "project-alpha" && f.count == 1)
294        );
295        assert!(
296            facets
297                .projects
298                .iter()
299                .any(|f| f.value == "project-beta" && f.count == 1)
300        );
301
302        // Check tags (database appears in both ADRs)
303        assert!(
304            facets
305                .tags
306                .iter()
307                .any(|f| f.value == "database" && f.count == 2)
308        );
309
310        // Check technologies (rust appears in both ADRs)
311        assert!(
312            facets
313                .technologies
314                .iter()
315                .any(|f| f.value == "rust" && f.count == 2)
316        );
317        assert!(
318            facets
319                .technologies
320                .iter()
321                .any(|f| f.value == "postgres" && f.count == 1)
322        );
323        assert!(
324            facets
325                .technologies
326                .iter()
327                .any(|f| f.value == "redis" && f.count == 1)
328        );
329    }
330}