1use crate::email::Email;
6use crate::message::AlertItem;
7use askama::Template;
8
9#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
11pub struct SeverityCounts {
12 pub critical: u32,
13 pub warning: u32,
14 pub notice: u32,
15}
16
17impl SeverityCounts {
18 pub fn total(&self) -> u32 {
19 self.critical + self.warning + self.notice
20 }
21
22 pub fn summary(&self) -> Option<String> {
25 let parts: Vec<String> = [
26 (self.critical, "critical", "critical"),
27 (self.warning, "warning", "warnings"),
28 (self.notice, "notice", "notices"),
29 ]
30 .into_iter()
31 .filter(|(n, _, _)| *n > 0)
32 .map(|(n, one, many)| format!("{n} {}", if n == 1 { one } else { many }))
33 .collect();
34 (!parts.is_empty()).then(|| parts.join(", "))
35 }
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct SiteDigest {
41 pub domain: String,
42 pub score: u8,
43 pub score_delta: Option<i32>,
46 pub checks_passed: u32,
47 pub checks_total: u32,
48 pub new_issues: Vec<(String, u32)>,
50 pub resolved_issues: Vec<(String, u32)>,
52 pub changes_by_severity: SeverityCounts,
53 pub top_changes: Vec<AlertItem>,
55 pub dashboard_url: String,
56 pub rankorg_url: Option<String>,
58}
59
60impl SiteDigest {
61 pub fn headline(&self) -> String {
63 let passed = format!(
64 "Your site passed {}/{} checks.",
65 self.checks_passed, self.checks_total
66 );
67 if self.score_delta.is_none() {
68 return passed;
69 }
70 match self.new_issues.len() {
71 0 => format!("{passed} No new issues."),
72 1 => format!("{passed} 1 new issue."),
73 n => format!("{passed} {n} new issues."),
74 }
75 }
76
77 pub fn score_label(&self) -> String {
79 match self.score_delta {
80 None => self.score.to_string(),
81 Some(0) => format!("{} (no change)", self.score),
82 Some(d) => format!("{} ({d:+})", self.score),
83 }
84 }
85
86 pub fn changes_line(&self) -> String {
88 match self.changes_by_severity.summary() {
89 Some(s) => format!("Changes this week: {s}."),
90 None => "No changes this week.".to_owned(),
91 }
92 }
93
94 pub fn more_changes(&self) -> u32 {
96 self.changes_by_severity
97 .total()
98 .saturating_sub(u32::try_from(self.top_changes.len()).unwrap_or(u32::MAX))
99 }
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct DigestView {
105 pub account_email: String,
106 pub week_label: String,
108 pub sites: Vec<SiteDigest>,
109 pub settings_url: String,
111}
112
113#[derive(Template)]
114#[template(path = "digest.html")]
115struct HtmlDigest<'a> {
116 view: &'a DigestView,
117}
118
119#[derive(Template)]
120#[template(path = "digest.txt")]
121struct TextDigest<'a> {
122 view: &'a DigestView,
123}
124
125impl DigestView {
126 pub fn subject(&self) -> String {
129 match self.sites.as_slice() {
130 [one] => format!("CodoSEO weekly: {} {}", one.domain, one.score_label()),
131 many => format!("CodoSEO weekly: {} sites", many.len()),
132 }
133 }
134
135 pub fn render(&self) -> Result<Email, askama::Error> {
137 Ok(Email {
138 to: self.account_email.clone(),
139 subject: self.subject(),
140 text: TextDigest { view: self }.render()?,
141 html: Some(HtmlDigest { view: self }.render()?),
142 })
143 }
144}