1use crate::scanner::ScanResult;
4use serde::Serialize;
5use std::cmp::Ordering;
6use std::collections::HashMap;
7
8const UNKNOWN_VERSION: &str = "-";
10
11fn compare_versions(current: &str, latest: &str) -> Ordering {
16 fn parse_version(v: &str) -> (Vec<u64>, bool) {
18 let pos = v.find(|c: char| c == '-' || c.is_ascii_alphabetic());
20 let version_part = match pos {
21 Some(p) => &v[..p],
22 None => v,
23 };
24 let has_suffix = pos.is_some();
25
26 let parts: Vec<u64> = version_part
27 .split('.')
28 .filter_map(|p| p.parse().ok())
29 .collect();
30
31 (parts, has_suffix)
32 }
33
34 let (current_parts, current_has_suffix) = parse_version(current);
35 let (latest_parts, latest_has_suffix) = parse_version(latest);
36
37 let max_len = current_parts.len().max(latest_parts.len());
39 for i in 0..max_len {
40 let c = current_parts.get(i).copied().unwrap_or(0);
41 let l = latest_parts.get(i).copied().unwrap_or(0);
42 match c.cmp(&l) {
43 Ordering::Equal => continue,
44 other => return other,
45 }
46 }
47
48 match (current_has_suffix, latest_has_suffix) {
52 (false, true) => Ordering::Greater,
53 (true, false) => Ordering::Less,
54 _ => Ordering::Equal,
55 }
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
60#[serde(rename_all = "lowercase")]
61pub enum ComponentType {
62 Core,
64 Theme,
66 Plugin,
68}
69
70impl std::fmt::Display for ComponentType {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 match self {
73 Self::Core => write!(f, "Core"),
74 Self::Theme => write!(f, "Theme"),
75 Self::Plugin => write!(f, "Plugin"),
76 }
77 }
78}
79
80#[derive(Debug, Clone, Serialize)]
82pub struct ComponentAnalysis {
83 pub component_type: ComponentType,
85
86 pub name: String,
88
89 pub version: String,
91
92 pub latest_version: String,
94
95 pub status: ComponentStatus,
97}
98
99impl ComponentAnalysis {
100 fn new(
101 component_type: ComponentType,
102 name: impl Into<String>,
103 version: Option<String>,
104 latest_version: Option<String>,
105 ) -> Self {
106 let version_str = version.unwrap_or_else(|| UNKNOWN_VERSION.to_string());
107 let latest_str = latest_version.unwrap_or_else(|| UNKNOWN_VERSION.to_string());
108
109 let status = if version_str == UNKNOWN_VERSION {
110 ComponentStatus::Unknown
111 } else if latest_str == UNKNOWN_VERSION {
112 ComponentStatus::Ok
114 } else {
115 match compare_versions(&version_str, &latest_str) {
116 Ordering::Less => ComponentStatus::Outdated,
117 Ordering::Equal | Ordering::Greater => ComponentStatus::Ok,
118 }
119 };
120
121 Self {
122 component_type,
123 name: name.into(),
124 version: version_str,
125 latest_version: latest_str,
126 status,
127 }
128 }
129
130 fn not_detected(component_type: ComponentType, name: impl Into<String>) -> Self {
131 Self {
132 component_type,
133 name: name.into(),
134 version: UNKNOWN_VERSION.to_string(),
135 latest_version: UNKNOWN_VERSION.to_string(),
136 status: ComponentStatus::NotDetected,
137 }
138 }
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
143#[serde(rename_all = "lowercase")]
144pub enum ComponentStatus {
145 Ok,
147 Unknown,
149 Outdated,
151 NotDetected,
153}
154
155#[derive(Debug, Clone, Serialize)]
157pub struct Analysis {
158 pub url: String,
160
161 pub wordpress: ComponentAnalysis,
163
164 pub theme: ComponentAnalysis,
166
167 pub plugins: HashMap<String, ComponentAnalysis>,
169}
170
171impl Analysis {
172 pub fn is_wordpress(&self) -> bool {
174 self.wordpress.status != ComponentStatus::NotDetected
175 }
176
177 pub fn plugin_count(&self) -> usize {
179 self.plugins.len()
180 }
181
182 pub fn outdated_count(&self) -> usize {
184 let core_outdated = (self.wordpress.status == ComponentStatus::Outdated) as usize;
185 let theme_outdated = (self.theme.status == ComponentStatus::Outdated) as usize;
186 let plugins_outdated = self
187 .plugins
188 .values()
189 .filter(|p| p.status == ComponentStatus::Outdated)
190 .count();
191
192 core_outdated + theme_outdated + plugins_outdated
193 }
194}
195
196pub struct Analyzer {
198 scan: ScanResult,
199}
200
201impl Analyzer {
202 pub fn new(scan: ScanResult) -> Self {
204 Self { scan }
205 }
206
207 pub fn analyze(self) -> Analysis {
209 Analysis {
210 url: self.scan.url.to_string(),
211 wordpress: self.analyze_wordpress(),
212 theme: self.analyze_theme(),
213 plugins: self.analyze_plugins(),
214 }
215 }
216
217 fn analyze_wordpress(&self) -> ComponentAnalysis {
218 match &self.scan.wordpress_version {
219 Some(version) => ComponentAnalysis::new(
220 ComponentType::Core,
221 "WordPress",
222 Some(version.clone()),
223 self.scan.wordpress_latest.clone(),
224 ),
225 None if self.scan.wordpress_detected => {
226 ComponentAnalysis::new(
228 ComponentType::Core,
229 "WordPress",
230 None,
231 self.scan.wordpress_latest.clone(),
232 )
233 }
234 None => ComponentAnalysis::not_detected(ComponentType::Core, "WordPress"),
235 }
236 }
237
238 fn analyze_theme(&self) -> ComponentAnalysis {
239 match &self.scan.theme {
240 Some(theme) => ComponentAnalysis::new(
241 ComponentType::Theme,
242 &theme.slug,
243 theme.version.clone(),
244 theme.latest_version.clone(),
245 ),
246 None => ComponentAnalysis::not_detected(ComponentType::Theme, "-"),
247 }
248 }
249
250 fn analyze_plugins(&self) -> HashMap<String, ComponentAnalysis> {
251 self.scan
252 .plugins
253 .iter()
254 .map(|plugin| {
255 let analysis = ComponentAnalysis::new(
256 ComponentType::Plugin,
257 &plugin.slug,
258 plugin.version.clone(),
259 plugin.latest_version.clone(),
260 );
261 (plugin.slug.clone(), analysis)
262 })
263 .collect()
264 }
265}