github_heatmap/heatmap/
contribution.rs

1use colored::{Color, Colorize};
2use scraper::ElementRef;
3use crate::{ColorValues, HeatmapError};
4
5const LEVEL_ATTR: &str = "data-level";
6
7/// A `Contribution` instance represents an invidividual heatmap node, with
8/// a heat level corresponding to the data-level attribute set on the scraped
9/// SVG Rect element.
10///
11/// `Contribution` instances are typically not constructed explicitly, rather created
12/// implicitly by the higher level `Heatmap` struct via the `from_el` associated method.
13///
14#[derive(Debug, Clone, Eq, PartialEq)]
15pub struct Contribution {
16    /// The `heat_level` property corresponds to the Rect element's data-level attribute,
17    /// which Github uses to determine the intensity when shading the Rect element on
18    /// the front end. 
19    ///
20    /// The `heat_level` property is utilised in the same way when deciding
21    /// on the intensity of the filled Unicode box character.
22    pub heat_level: usize
23}
24
25impl Contribution {
26    /// Constructs a new `Contribution` instance from an HTML element.
27    /// Provided element reference corresponds to scraped Github heatmap
28    /// node.
29    ///
30    /// # Errors
31    /// - [`HeatmapError::QueryAttribute`] fails to query heat level attribute
32    /// - [`HeatmapError::ParseAttribute`] fails to parse heat level attribute
33    ///
34    pub fn from_el(el: &ElementRef) -> Result<Self, HeatmapError> {
35       let heat_level = Self::parse_heat_level(el)?;
36       Ok(Contribution { heat_level })
37    }
38
39    /// Renders a contribution node. 
40    ///
41    /// Returns a formatted string containing a Unicode box character, 
42    /// with a fill color depending on the provided [`ColorValues`] variant, 
43    /// and the `heat_level` property of the `Contribution` instance.
44    ///
45    pub fn render(&self, color: &ColorValues) -> String {
46       let intensity = match self.heat_level {
47           0 => 0,
48           1 => 64,
49           2 => 127,
50           3 => 191,
51           _ => 255,
52       };
53
54       let fill = match color {
55           ColorValues::Red => Color::TrueColor { r: intensity, g: 0, b: 0 },
56           ColorValues::Green => Color::TrueColor { r: 0, g: intensity, b: 0 },
57           ColorValues::Blue => Color::TrueColor { r: 0, g: 0, b: intensity },
58       };
59
60       "\u{025A0} ".color(fill).to_string()
61    }
62
63    fn parse_heat_level(el: &ElementRef) -> Result<usize, HeatmapError> {
64        let heat_level = el
65           .value()
66           .attr(LEVEL_ATTR)
67           .ok_or_else(|| HeatmapError::QueryAttribute { 
68               attr: LEVEL_ATTR.to_string(), 
69               on_alias: "heatmap node".to_string()
70           })?
71           .parse()
72           .map_err(|_| HeatmapError::ParseAttribute { 
73               attr: LEVEL_ATTR.to_string(),
74               on_alias: "heatmap node".to_string()
75           })?;
76
77        Ok(heat_level)
78    }
79}
80
81#[cfg(test)] 
82mod tests {
83    use super::*;
84    use scraper::{Html, Selector};
85
86    #[test]
87    fn constructs_contribution() {
88        let fragment = Html::parse_fragment("<rect y='15' data-level='3' />");
89        let selector = Selector::parse("rect").unwrap();
90        let rect_el = fragment.select(&selector).next().unwrap();
91        let contribution = Contribution::from_el(&rect_el).unwrap();
92
93        assert_eq!(contribution, Contribution { heat_level: 3 })
94    }
95
96    #[test]
97    fn parses_level_attribute() {
98        let fragment = Html::parse_fragment("<rect y='15' data-level='3' />");
99        let selector = Selector::parse("rect").unwrap();
100        let rect_el = fragment.select(&selector).next().unwrap();
101        let heat_level = Contribution::parse_heat_level(&rect_el).unwrap();
102
103        assert_eq!(heat_level, 3)
104    }
105
106    #[test]
107    fn error_if_no_level_attribute() {
108        let fragment = Html::parse_fragment("<rect y='15' data-heat-level='3' />");
109        let selector = Selector::parse("rect").unwrap();
110        let rect_el = fragment.select(&selector).next().unwrap();
111        let heat_level = Contribution::parse_heat_level(&rect_el);
112
113        assert_eq!(
114            heat_level, 
115            Err(HeatmapError::QueryAttribute { attr: LEVEL_ATTR.to_string(), on_alias: "heatmap node".to_string() })
116        )
117    } 
118
119    #[test]
120    fn error_if_invalid_level_attribute() {
121        let fragment = Html::parse_fragment("<rect y='15' data-level='three' />");
122        let selector = Selector::parse("rect").unwrap();
123        let rect_el = fragment.select(&selector).next().unwrap();
124        let heat_level = Contribution::parse_heat_level(&rect_el);
125
126        assert_eq!(
127            heat_level, 
128            Err(HeatmapError::ParseAttribute { attr: LEVEL_ATTR.to_string(), on_alias: "heatmap node".to_string() })
129        )
130    }
131
132    #[test]
133    fn renders_heatmap_node_unfilled() {
134        let contribution = Contribution { heat_level: 0 };
135        let color = ColorValues::Green;
136        let expected = "\u{025A0} ".color(Color::TrueColor { r: 0, g: 0, b: 0 }).to_string();
137
138        assert_eq!(contribution.render(&color), expected);
139    }
140    
141    #[test]
142    fn renders_heatmap_node_red() {
143        let contribution = Contribution { heat_level: 1 };
144        let color = ColorValues::Red;
145        let expected = "\u{025A0} ".color(Color::TrueColor { r: 64, g: 0, b: 0 }).to_string();
146
147        assert_eq!(contribution.render(&color), expected);
148    }
149
150
151    #[test]
152    fn renders_heatmap_node_green() {
153        let contribution = Contribution { heat_level: 2 };
154        let color = ColorValues::Green;
155        let expected = "\u{025A0} ".color(Color::TrueColor { r: 0, g: 127, b: 0 }).to_string();
156
157        assert_eq!(contribution.render(&color), expected);
158    }
159
160    #[test]
161    fn renders_heatmap_node_blue() {
162        let contribution = Contribution { heat_level: 3 };
163        let color = ColorValues::Blue;
164        let expected = "\u{025A0} ".color(Color::TrueColor { r: 0, g: 0, b: 191 }).to_string();
165
166        assert_eq!(contribution.render(&color), expected);
167    }
168}