github_heatmap/heatmap/
mod.rs

1mod contribution_week;
2mod contribution;
3
4pub use contribution_week::ContributionWeek;
5pub use contribution::Contribution;
6use scraper::{Selector, Html, ElementRef};
7use crate::{ColorValues, HeatmapError};
8
9const WEEK_SELECTOR: &str = "svg.js-calendar-graph-svg g g";
10const DAY_SELECTOR: &str = "rect.ContributionCalendar-day";
11const DAYS_IN_WEEK: usize = 7;
12
13/// A `Heatmap` instance represents a fully scraped and parsed Github
14/// constribution heatmap.
15///
16/// A `Heatmap` is constructed from a reference to a parsed HTML document
17/// corresponding to a Github profile.
18///
19#[derive(Debug, Eq, PartialEq)]
20pub struct Heatmap {
21    /// A vector of [`ContributionWeek`] instances spanning across the entire
22    /// year of contributions.
23    pub contribution_weeks: Vec<ContributionWeek>,
24}
25
26impl Heatmap {
27    /// Constructs a new `Heatmap` instance from a parsed HTML document.
28    /// Provided reference to HTML document corresponds to the markup of a
29    /// Github profile page (e.g. <https://github.com/torvalds>).
30    ///
31    /// [`ContributionWeek`] instances will be constructed and pushed to the 
32    /// `contribution_weeks` vector for as many columns are evident in the parsed
33    /// markup.
34    ///
35    /// # Errors
36    /// - [`HeatmapError::QueryElement`] fails to query Heatmap SVG element.
37    /// - [`HeatmapError::QueryElement`] fails to query Heatmap node elements
38    /// 
39    /// See [`ContributionWeek`] for errors related to constructing [`ContributionWeek`]
40    /// instances.
41    ///
42    /// # Panics
43    /// A panic will occur in the unlikely event that `Selector::parse` fails to parse 
44    /// CSS selector constants.
45    ///
46    pub fn from_document(document: &Html) -> Result<Self, HeatmapError> {
47        let contribution_week_selector = Selector::parse(WEEK_SELECTOR).unwrap();
48        let day_selector = Selector::parse(DAY_SELECTOR).unwrap();
49        let mut contribution_weeks = vec![];
50
51        for el in document.select(&contribution_week_selector) {
52            let week = Self::get_contribution_week(&el, &day_selector)?;
53            contribution_weeks.push(week);
54        }
55
56        match &contribution_weeks.is_empty() {
57            false => Ok(Heatmap { contribution_weeks }),
58            true => Err(HeatmapError::QueryElement {
59                alias: "heatmap".to_string(),
60                selector: WEEK_SELECTOR.to_string()
61            })
62        }
63    }
64
65    /// Generates visual representation of Heatmap data structure,
66    /// and writes it to standard output.
67    ///
68    /// Resulting Unicode will have a fill color depending on provided
69    /// [`ColorValues`] color variant.
70    ///
71    pub fn render(&self, color: &ColorValues) {
72        for day in 0..DAYS_IN_WEEK {
73            let week: String = self.contribution_weeks
74                .iter()
75                .map(|week| match &week.contributions[day] {
76                    Some(day) => day.render(color),
77                    None => String::from("  ")
78                })
79                .collect();
80
81            println!("{week}");
82        };
83    }
84
85    fn get_contribution_week(el: &ElementRef, selector: &Selector) -> Result<ContributionWeek, HeatmapError> {
86        let day_els: Vec<_> = el.select(selector).collect();
87        
88        if day_els.is_empty() {
89            return Err(HeatmapError::QueryElement {
90                alias: "heatmap node".to_string(),
91                selector: DAY_SELECTOR.to_string()
92            });
93        }
94
95        ContributionWeek::from_days(&day_els)
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*; 
102
103    #[test]
104    fn constructs_heatmap() {
105        let fragment = Html::parse_fragment(r#"
106            <svg class="js-calendar-graph-svg">
107                <g>
108                    <g>
109                        <rect y='45' data-level='1' class="ContributionCalendar-day" />
110                        <rect y='60' data-level='2' class="ContributionCalendar-day" />
111                        <rect y='75' data-level='3' class="ContributionCalendar-day" />
112                        <rect y='90' data-level='4' class="ContributionCalendar-day" />
113                    </g>
114                    <g>
115                        <rect y='0' data-level='1' class="ContributionCalendar-day" />
116                        <rect y='15' data-level='2' class="ContributionCalendar-day" />
117                        <rect y='30' data-level='3' class="ContributionCalendar-day" />
118                        <rect y='45' data-level='4' class="ContributionCalendar-day" />
119                        <rect y='60' data-level='4' class="ContributionCalendar-day" />
120                        <rect y='75' data-level='4' class="ContributionCalendar-day" />
121                        <rect y='90' data-level='4' class="ContributionCalendar-day" />
122                    </g>
123               </g> 
124            </svg>
125        "#);
126
127        let heatmap = Heatmap::from_document(&fragment).unwrap();
128        let expected = Heatmap { 
129            contribution_weeks: vec![
130                ContributionWeek {
131                    contributions: vec![
132                        None,
133                        None,
134                        None,
135                        Some(Contribution { heat_level: 1 }),
136                        Some(Contribution { heat_level: 2 }),
137                        Some(Contribution { heat_level: 3 }),
138                        Some(Contribution { heat_level: 4 }),
139                    ]
140                },
141                ContributionWeek {
142                    contributions: vec![
143                        Some(Contribution { heat_level: 1 }),
144                        Some(Contribution { heat_level: 2 }),
145                        Some(Contribution { heat_level: 3 }),
146                        Some(Contribution { heat_level: 4 }),
147                        Some(Contribution { heat_level: 4 }),
148                        Some(Contribution { heat_level: 4 }),
149                        Some(Contribution { heat_level: 4 }),
150                    ]
151                }
152            ]
153        };
154
155        assert_eq!(heatmap, expected) 
156    }
157
158    #[test]
159    fn gets_contribution_week() {
160        let fragment = Html::parse_fragment(r#"
161            <g>
162                <rect y='0' data-level='1' class="ContributionCalendar-day" />
163                <rect y='15' data-level='2' class="ContributionCalendar-day" />
164                <rect y='30' data-level='3' class="ContributionCalendar-day" />
165                <rect y='45' data-level='4' class="ContributionCalendar-day" />
166                <rect y='60' data-level='4' class="ContributionCalendar-day" />
167                <rect y='75' data-level='4' class="ContributionCalendar-day" />
168                <rect y='90' data-level='4' class="ContributionCalendar-day" />
169            </g>
170        "#);
171
172        let el = fragment.root_element();
173        let selector = Selector::parse(DAY_SELECTOR).unwrap();
174        let contribution_week = Heatmap::get_contribution_week(&el, &selector).unwrap();
175
176        let expected = ContributionWeek {
177            contributions: vec![
178                Some(Contribution { heat_level: 1 }),
179                Some(Contribution { heat_level: 2 }),
180                Some(Contribution { heat_level: 3 }),
181                Some(Contribution { heat_level: 4 }),
182                Some(Contribution { heat_level: 4 }), 
183                Some(Contribution { heat_level: 4 }),
184                Some(Contribution { heat_level: 4 }),
185            ]
186        };
187
188        assert_eq!(contribution_week, expected) 
189    }
190
191    #[test]
192    fn error_if_cannot_parse_contribution_week() {
193        let fragment = Html::parse_fragment(r#"
194            <rect y='0' data-level='1' class="InvalidClass" />
195            <rect y='15' data-level='2' class="InvalidClass" />
196            <rect y='30' data-level='3' class="InvalidClass" />
197            <rect y='45' data-level='4' class="InvalidClass" />
198            <rect y='60' data-level='4' class="InvalidClass" />
199            <rect y='75' data-level='4' class="InvalidClass" />
200            <rect y='90' data-level='4' class="InvalidClass" />
201        "#);
202
203        let el = fragment.root_element();
204        let selector = Selector::parse(DAY_SELECTOR).unwrap();
205        let contribution_week = Heatmap::get_contribution_week(&el, &selector);
206        let expected = Err(HeatmapError::QueryElement { 
207            alias: "heatmap node".to_string(), 
208            selector: DAY_SELECTOR.to_string(), 
209        });
210
211        assert_eq!(contribution_week, expected)
212    }
213}
214
215