1#[cfg(feature = "llm")]
10pub mod cloud;
11#[cfg(feature = "llm")]
12pub mod recommendations;
13
14use chrono::{NaiveDateTime, Utc};
15use regex::Regex;
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18
19use crate::db::AppDb;
20use crate::error::KardoResult;
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
24#[serde(tag = "type", rename_all = "snake_case")]
25pub enum LicenseStatus {
26 Free,
27 Trial {
28 days_remaining: u32,
29 started_at: String,
30 },
31 TrialExpired,
32 Pro {
33 license_key: String,
34 activated_at: String,
35 },
36 ProExpired,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
41#[serde(rename_all = "snake_case")]
42pub enum ProFeature {
43 AiRecommendations,
44 AiFixSuggestions,
45 ExportReports,
46 DynamicBadge,
47 ScoreHistory,
48 CustomRules,
49}
50
51const KEY_TRIAL_STARTED: &str = "pro.trial_started_at";
54const KEY_LICENSE_KEY: &str = "pro.license_key";
55const KEY_LICENSE_ACTIVATED: &str = "pro.license_activated_at";
56
57const TRIAL_DAYS: i64 = 14;
59
60const LICENSE_KEY_PATTERN: &str = r"^VV-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$";
62
63pub struct ProManager {
65 app_db: AppDb,
66}
67
68impl ProManager {
69 pub fn new(app_db: AppDb) -> Self {
71 Self { app_db }
72 }
73
74 pub fn get_status(&self) -> LicenseStatus {
76 if let Ok(Some(key)) = self.app_db.get_setting(KEY_LICENSE_KEY) {
78 if let Ok(Some(activated_at)) = self.app_db.get_setting(KEY_LICENSE_ACTIVATED) {
79 return LicenseStatus::Pro {
81 license_key: key,
82 activated_at,
83 };
84 }
85 }
86
87 if let Ok(Some(started_str)) = self.app_db.get_setting(KEY_TRIAL_STARTED) {
89 if let Ok(started) = NaiveDateTime::parse_from_str(&started_str, "%Y-%m-%dT%H:%M:%S") {
90 let now = Utc::now().naive_utc();
91 let elapsed_days = (now - started).num_days();
92 if elapsed_days < TRIAL_DAYS {
93 let remaining = (TRIAL_DAYS - elapsed_days) as u32;
94 return LicenseStatus::Trial {
95 days_remaining: remaining,
96 started_at: started_str,
97 };
98 }
99 return LicenseStatus::TrialExpired;
100 }
101 }
102
103 LicenseStatus::Free
104 }
105
106 pub fn start_trial(&self) -> KardoResult<LicenseStatus> {
110 if let Ok(Some(_)) = self.app_db.get_setting(KEY_TRIAL_STARTED) {
112 return Err("Trial has already been started".into());
113 }
114
115 if let Ok(Some(_)) = self.app_db.get_setting(KEY_LICENSE_KEY) {
117 return Err("Already has a Pro license".into());
118 }
119
120 let now = Utc::now().naive_utc();
121 let started_at = now.format("%Y-%m-%dT%H:%M:%S").to_string();
122
123 self.app_db
124 .set_setting(KEY_TRIAL_STARTED, &started_at)
125 .map_err(|e| format!("Failed to save trial start: {}", e))?;
126
127 Ok(LicenseStatus::Trial {
128 days_remaining: TRIAL_DAYS as u32,
129 started_at,
130 })
131 }
132
133 pub fn activate_license(&self, key: &str) -> KardoResult<LicenseStatus> {
137 let re = Regex::new(LICENSE_KEY_PATTERN)
139 .map_err(|e| format!("Internal regex error: {}", e))?;
140
141 if !re.is_match(key) {
142 return Err(format!(
143 "Invalid license key format. Expected: VV-XXXX-XXXX-XXXX-XXXX, got: {}",
144 key
145 ).into());
146 }
147
148 let now = Utc::now().naive_utc();
149 let activated_at = now.format("%Y-%m-%dT%H:%M:%S").to_string();
150
151 self.app_db
152 .set_setting(KEY_LICENSE_KEY, key)
153 .map_err(|e| format!("Failed to save license key: {}", e))?;
154
155 self.app_db
156 .set_setting(KEY_LICENSE_ACTIVATED, &activated_at)
157 .map_err(|e| format!("Failed to save activation date: {}", e))?;
158
159 Ok(LicenseStatus::Pro {
160 license_key: key.to_string(),
161 activated_at,
162 })
163 }
164
165 pub fn is_feature_available(&self, feature: &ProFeature) -> bool {
170 match self.get_status() {
171 LicenseStatus::Pro { .. } | LicenseStatus::Trial { .. } => true,
172 LicenseStatus::Free | LicenseStatus::TrialExpired | LicenseStatus::ProExpired => {
173 matches!(feature, ProFeature::ScoreHistory)
175 }
176 }
177 }
178
179 pub fn device_fingerprint() -> String {
184 let hostname = hostname::get()
185 .map(|h| h.to_string_lossy().to_string())
186 .unwrap_or_else(|_| "unknown-host".to_string());
187
188 let username = whoami::username();
189
190 let mut hasher = Sha256::new();
191 hasher.update(format!("{}:{}", hostname, username));
192 let result = hasher.finalize();
193 format!("{:x}", result)
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use tempfile::TempDir;
201
202 fn make_test_db() -> (AppDb, TempDir) {
203 let tmp = TempDir::new().unwrap();
204 let db_path = tmp.path().join("test_app.db");
205 let db = AppDb::open(&db_path).unwrap();
206 (db, tmp)
207 }
208
209 #[test]
210 fn test_initial_status_is_free() {
211 let (db, _tmp) = make_test_db();
212 let mgr = ProManager::new(db);
213 assert_eq!(mgr.get_status(), LicenseStatus::Free);
214 }
215
216 #[test]
217 fn test_start_trial() {
218 let (db, _tmp) = make_test_db();
219 let mgr = ProManager::new(db);
220 let result = mgr.start_trial();
221 assert!(result.is_ok());
222 match result.unwrap() {
223 LicenseStatus::Trial { days_remaining, .. } => {
224 assert_eq!(days_remaining, 14);
225 }
226 other => panic!("Expected Trial, got {:?}", other),
227 }
228 }
229
230 #[test]
231 fn test_cannot_start_trial_twice() {
232 let (db, _tmp) = make_test_db();
233 let mgr = ProManager::new(db);
234 assert!(mgr.start_trial().is_ok());
235 let second = mgr.start_trial();
236 assert!(second.is_err());
237 assert!(second.unwrap_err().to_string().contains("already been started"));
238 }
239
240 #[test]
241 fn test_activate_valid_license() {
242 let (db, _tmp) = make_test_db();
243 let mgr = ProManager::new(db);
244 let result = mgr.activate_license("VV-ABC1-2DEF-3GH4-5IJK");
245 assert!(result.is_ok());
246 match result.unwrap() {
247 LicenseStatus::Pro { license_key, .. } => {
248 assert_eq!(license_key, "VV-ABC1-2DEF-3GH4-5IJK");
249 }
250 other => panic!("Expected Pro, got {:?}", other),
251 }
252 }
253
254 #[test]
255 fn test_reject_invalid_license_format() {
256 let (db, _tmp) = make_test_db();
257 let mgr = ProManager::new(db);
258
259 assert!(mgr.activate_license("ABCD-1234-5678-9012").is_err());
261 assert!(mgr.activate_license("VV_ABCD_1234_5678_9012").is_err());
263 assert!(mgr.activate_license("VV-ABC-123-456-789").is_err());
265 assert!(mgr.activate_license("").is_err());
267 }
268
269 #[test]
270 fn test_pro_status_after_activation() {
271 let (db, _tmp) = make_test_db();
272 let mgr = ProManager::new(db);
273 mgr.activate_license("VV-AAAA-BBBB-CCCC-DDDD").unwrap();
274 match mgr.get_status() {
275 LicenseStatus::Pro { license_key, .. } => {
276 assert_eq!(license_key, "VV-AAAA-BBBB-CCCC-DDDD");
277 }
278 other => panic!("Expected Pro, got {:?}", other),
279 }
280 }
281
282 #[test]
283 fn test_feature_available_during_trial() {
284 let (db, _tmp) = make_test_db();
285 let mgr = ProManager::new(db);
286 mgr.start_trial().unwrap();
287 assert!(mgr.is_feature_available(&ProFeature::AiRecommendations));
288 assert!(mgr.is_feature_available(&ProFeature::ExportReports));
289 assert!(mgr.is_feature_available(&ProFeature::CustomRules));
290 }
291
292 #[test]
293 fn test_feature_limited_on_free() {
294 let (db, _tmp) = make_test_db();
295 let mgr = ProManager::new(db);
296 assert!(mgr.is_feature_available(&ProFeature::ScoreHistory));
298 assert!(!mgr.is_feature_available(&ProFeature::AiRecommendations));
299 assert!(!mgr.is_feature_available(&ProFeature::ExportReports));
300 }
301
302 #[test]
303 fn test_device_fingerprint_not_empty() {
304 let fp = ProManager::device_fingerprint();
305 assert!(!fp.is_empty());
306 assert_eq!(fp.len(), 64);
308 }
309
310 #[test]
311 fn test_device_fingerprint_deterministic() {
312 let fp1 = ProManager::device_fingerprint();
313 let fp2 = ProManager::device_fingerprint();
314 assert_eq!(fp1, fp2);
315 }
316
317 #[test]
318 fn test_trial_expired() {
319 let (db, _tmp) = make_test_db();
320 let past = Utc::now().naive_utc() - chrono::Duration::days(15);
322 let past_str = past.format("%Y-%m-%dT%H:%M:%S").to_string();
323 db.set_setting(KEY_TRIAL_STARTED, &past_str).unwrap();
324
325 let mgr = ProManager::new(db);
326 assert_eq!(mgr.get_status(), LicenseStatus::TrialExpired);
327 assert!(!mgr.is_feature_available(&ProFeature::AiRecommendations));
329 }
330}