npm_run_scripts/history/
storage.rs

1//! History storage and persistence.
2
3use std::collections::HashMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result};
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10
11use crate::config::HistoryConfig;
12use crate::package::Script;
13
14/// Default maximum number of projects to track.
15pub const DEFAULT_MAX_PROJECTS: usize = 100;
16
17/// Default maximum number of scripts per project.
18pub const DEFAULT_MAX_SCRIPTS: usize = 50;
19
20/// Weight for run count in scoring (30%).
21const RUN_COUNT_WEIGHT: f64 = 0.3;
22
23/// Weight for recency in scoring (70%).
24const RECENCY_WEIGHT: f64 = 0.7;
25
26/// Days after which recency score fully decays.
27const RECENCY_DECAY_DAYS: i64 = 30;
28
29/// History for a single script.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct ScriptHistory {
32    /// Number of times the script has been run.
33    pub count: u32,
34    /// Last time the script was run.
35    pub last_run: DateTime<Utc>,
36    /// Last arguments passed to the script.
37    pub last_args: Option<String>,
38}
39
40impl ScriptHistory {
41    /// Create a new script history entry.
42    pub fn new() -> Self {
43        Self {
44            count: 1,
45            last_run: Utc::now(),
46            last_args: None,
47        }
48    }
49
50    /// Create with specific values (for testing).
51    pub fn with_values(count: u32, last_run: DateTime<Utc>, last_args: Option<String>) -> Self {
52        Self {
53            count,
54            last_run,
55            last_args,
56        }
57    }
58
59    /// Record a new execution of the script.
60    pub fn record_run(&mut self, args: Option<String>) {
61        self.count += 1;
62        self.last_run = Utc::now();
63        self.last_args = args;
64    }
65
66    /// Calculate the score for this script based on run count and recency.
67    ///
68    /// Score = (run_count * 0.3) + (recency_score * 0.7)
69    /// Where recency_score decays from 1.0 to 0.0 over RECENCY_DECAY_DAYS.
70    pub fn score(&self) -> f64 {
71        self.score_at(Utc::now())
72    }
73
74    /// Calculate the score at a specific time (for testing).
75    pub fn score_at(&self, now: DateTime<Utc>) -> f64 {
76        let days_ago = (now - self.last_run).num_days();
77        let recency_score = if days_ago <= 0 {
78            1.0
79        } else if days_ago >= RECENCY_DECAY_DAYS {
80            0.0
81        } else {
82            1.0 - (days_ago as f64 / RECENCY_DECAY_DAYS as f64)
83        };
84
85        // Normalize run count (cap at 100 for scoring purposes)
86        let normalized_count = (self.count.min(100) as f64) / 100.0;
87
88        (normalized_count * RUN_COUNT_WEIGHT) + (recency_score * RECENCY_WEIGHT)
89    }
90}
91
92impl Default for ScriptHistory {
93    fn default() -> Self {
94        Self::new()
95    }
96}
97
98/// History for a project.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct ProjectHistory {
101    /// Last script executed in this project.
102    pub last_script: Option<String>,
103    /// Last time any script was run in this project.
104    pub last_run: DateTime<Utc>,
105    /// History for each script.
106    #[serde(default)]
107    pub scripts: HashMap<String, ScriptHistory>,
108}
109
110impl ProjectHistory {
111    /// Create a new project history.
112    pub fn new() -> Self {
113        Self {
114            last_script: None,
115            last_run: Utc::now(),
116            scripts: HashMap::new(),
117        }
118    }
119
120    /// Record a script execution.
121    pub fn record_run(&mut self, script: &str, args: Option<String>) {
122        self.last_script = Some(script.to_string());
123        self.last_run = Utc::now();
124
125        self.scripts
126            .entry(script.to_string())
127            .and_modify(|h| h.record_run(args.clone()))
128            .or_insert_with(|| {
129                let mut h = ScriptHistory::new();
130                h.last_args = args;
131                h
132            });
133    }
134
135    /// Get the last executed script name.
136    pub fn last_script(&self) -> Option<&str> {
137        self.last_script.as_deref()
138    }
139
140    /// Get the last executed script with its arguments.
141    pub fn last_script_with_args(&self) -> Option<(&str, Option<&str>)> {
142        self.last_script.as_deref().map(|name| {
143            let args = self.scripts.get(name).and_then(|h| h.last_args.as_deref());
144            (name, args)
145        })
146    }
147
148    /// Get history for a specific script.
149    pub fn get_script(&self, name: &str) -> Option<&ScriptHistory> {
150        self.scripts.get(name)
151    }
152
153    /// Enforce max_scripts limit using LRU eviction.
154    pub fn cleanup(&mut self, max_scripts: usize) {
155        if self.scripts.len() <= max_scripts {
156            return;
157        }
158
159        // Sort scripts by last_run (oldest first)
160        let mut scripts: Vec<_> = self.scripts.iter().collect();
161        scripts.sort_by(|a, b| a.1.last_run.cmp(&b.1.last_run));
162
163        // Calculate how many to remove
164        let to_remove = self.scripts.len() - max_scripts;
165
166        // Collect keys to remove (oldest ones)
167        let keys_to_remove: Vec<String> = scripts
168            .into_iter()
169            .take(to_remove)
170            .map(|(k, _)| k.clone())
171            .collect();
172
173        for key in keys_to_remove {
174            self.scripts.remove(&key);
175        }
176    }
177}
178
179impl Default for ProjectHistory {
180    fn default() -> Self {
181        Self::new()
182    }
183}
184
185/// Global history storage.
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct History {
188    /// Version of the history format.
189    pub version: u32,
190    /// History per project path.
191    #[serde(default)]
192    pub projects: HashMap<PathBuf, ProjectHistory>,
193}
194
195impl History {
196    /// Current history format version.
197    pub const VERSION: u32 = 1;
198
199    /// Create a new history.
200    pub fn new() -> Self {
201        Self {
202            version: Self::VERSION,
203            projects: HashMap::new(),
204        }
205    }
206
207    /// Get the history file path.
208    pub fn file_path() -> Option<PathBuf> {
209        dirs::config_dir().map(|p| p.join("nrs").join("history.json"))
210    }
211
212    /// Get the backup file path.
213    fn backup_path() -> Option<PathBuf> {
214        Self::file_path().map(|p| p.with_extension("json.bak"))
215    }
216
217    /// Load history from the default location.
218    ///
219    /// Handles missing files gracefully (returns empty history).
220    /// Handles corrupt files by backing up and returning empty history.
221    pub fn load() -> Result<Self> {
222        let path = Self::file_path().context("Could not determine config directory")?;
223
224        if !path.exists() {
225            return Ok(Self::new());
226        }
227
228        let content = match fs::read_to_string(&path) {
229            Ok(c) => c,
230            Err(e) => {
231                eprintln!(
232                    "Warning: Failed to read history file {}: {}",
233                    path.display(),
234                    e
235                );
236                return Ok(Self::new());
237            }
238        };
239
240        match serde_json::from_str::<History>(&content) {
241            Ok(history) => Ok(history),
242            Err(e) => {
243                // Corrupt file - backup and return empty
244                eprintln!(
245                    "Warning: History file is corrupt, backing up and starting fresh: {}",
246                    e
247                );
248
249                if let Some(backup_path) = Self::backup_path() {
250                    if let Err(backup_err) = fs::rename(&path, &backup_path) {
251                        eprintln!(
252                            "Warning: Failed to backup corrupt history file: {}",
253                            backup_err
254                        );
255                    } else {
256                        eprintln!("Corrupt history backed up to {}", backup_path.display());
257                    }
258                }
259
260                Ok(Self::new())
261            }
262        }
263    }
264
265    /// Save history to the default location.
266    pub fn save(&self) -> Result<()> {
267        let path = Self::file_path().context("Could not determine config directory")?;
268
269        // Ensure directory exists
270        if let Some(parent) = path.parent() {
271            fs::create_dir_all(parent)
272                .with_context(|| format!("Failed to create directory {}", parent.display()))?;
273        }
274
275        let content = serde_json::to_string_pretty(self).context("Failed to serialize history")?;
276
277        fs::write(&path, content)
278            .with_context(|| format!("Failed to write history to {}", path.display()))?;
279
280        Ok(())
281    }
282
283    /// Get history for a project.
284    pub fn get_project(&self, project_dir: &Path) -> Option<&ProjectHistory> {
285        self.projects.get(project_dir)
286    }
287
288    /// Get mutable history for a project.
289    pub fn get_project_mut(&mut self, project_dir: &Path) -> Option<&mut ProjectHistory> {
290        self.projects.get_mut(project_dir)
291    }
292
293    /// Get or create history for a project.
294    pub fn get_or_create_project(&mut self, project_dir: &Path) -> &mut ProjectHistory {
295        self.projects.entry(project_dir.to_path_buf()).or_default()
296    }
297
298    /// Record a script execution.
299    pub fn record_run(&mut self, project_dir: &Path, script: &str, args: Option<String>) {
300        self.get_or_create_project(project_dir)
301            .record_run(script, args);
302    }
303
304    /// Get the last executed script for a project with its arguments.
305    pub fn get_last_script(&self, project_dir: &Path) -> Option<(String, Option<String>)> {
306        self.get_project(project_dir)
307            .and_then(|p| p.last_script_with_args())
308            .map(|(name, args)| (name.to_string(), args.map(String::from)))
309    }
310
311    /// Get statistics for a specific script in a project.
312    pub fn get_script_stats(&self, project_dir: &Path, script: &str) -> Option<&ScriptHistory> {
313        self.get_project(project_dir)
314            .and_then(|p| p.get_script(script))
315    }
316
317    /// Sort scripts by recent usage (most recently/frequently used first).
318    ///
319    /// Scripts with history are sorted by score, scripts without history
320    /// are placed at the end in their original order.
321    pub fn get_sorted_by_recent<'a>(
322        &self,
323        project_dir: &Path,
324        scripts: &'a [Script],
325    ) -> Vec<&'a Script> {
326        self.get_sorted_by_recent_at(project_dir, scripts, Utc::now())
327    }
328
329    /// Sort scripts by recent usage at a specific time (for testing).
330    pub fn get_sorted_by_recent_at<'a>(
331        &self,
332        project_dir: &Path,
333        scripts: &'a [Script],
334        now: DateTime<Utc>,
335    ) -> Vec<&'a Script> {
336        let project_history = self.get_project(project_dir);
337
338        let mut scored: Vec<(&Script, f64)> = scripts
339            .iter()
340            .map(|s| {
341                let score = project_history
342                    .and_then(|p| p.get_script(s.name()))
343                    .map(|h| h.score_at(now))
344                    .unwrap_or(0.0);
345                (s, score)
346            })
347            .collect();
348
349        // Sort by score descending, then by name for stability
350        scored.sort_by(|a, b| {
351            b.1.partial_cmp(&a.1)
352                .unwrap_or(std::cmp::Ordering::Equal)
353                .then_with(|| a.0.name().cmp(b.0.name()))
354        });
355
356        scored.into_iter().map(|(s, _)| s).collect()
357    }
358
359    /// Enforce max_projects and max_scripts limits using LRU eviction.
360    pub fn cleanup(&mut self, config: &HistoryConfig) {
361        self.cleanup_with_limits(config.max_projects, config.max_scripts);
362    }
363
364    /// Cleanup with specific limits.
365    pub fn cleanup_with_limits(&mut self, max_projects: usize, max_scripts: usize) {
366        // First, cleanup scripts within each project
367        for project in self.projects.values_mut() {
368            project.cleanup(max_scripts);
369        }
370
371        // Then, cleanup projects if needed
372        if self.projects.len() <= max_projects {
373            return;
374        }
375
376        // Sort projects by last_run (oldest first)
377        let mut projects: Vec<_> = self.projects.iter().collect();
378        projects.sort_by(|a, b| a.1.last_run.cmp(&b.1.last_run));
379
380        // Calculate how many to remove
381        let to_remove = self.projects.len() - max_projects;
382
383        // Collect keys to remove (oldest ones)
384        let keys_to_remove: Vec<PathBuf> = projects
385            .into_iter()
386            .take(to_remove)
387            .map(|(k, _)| k.clone())
388            .collect();
389
390        for key in keys_to_remove {
391            self.projects.remove(&key);
392        }
393    }
394}
395
396impl Default for History {
397    fn default() -> Self {
398        Self::new()
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405    use chrono::Duration;
406    use tempfile::TempDir;
407
408    fn create_test_scripts() -> Vec<Script> {
409        vec![
410            Script::new("dev", "vite"),
411            Script::new("build", "vite build"),
412            Script::new("test", "vitest"),
413            Script::new("lint", "eslint ."),
414        ]
415    }
416
417    #[test]
418    fn test_script_history_new() {
419        let history = ScriptHistory::new();
420        assert_eq!(history.count, 1);
421        assert!(history.last_args.is_none());
422    }
423
424    #[test]
425    fn test_script_history_record_run() {
426        let mut history = ScriptHistory::new();
427        history.record_run(Some("--watch".to_string()));
428
429        assert_eq!(history.count, 2);
430        assert_eq!(history.last_args, Some("--watch".to_string()));
431    }
432
433    #[test]
434    fn test_script_history_score_today() {
435        let history = ScriptHistory::new();
436        let score = history.score();
437
438        // Brand new script: count=1, recency=1.0
439        // score = (1/100 * 0.3) + (1.0 * 0.7) = 0.003 + 0.7 = 0.703
440        assert!(score > 0.7);
441        assert!(score < 0.71);
442    }
443
444    #[test]
445    fn test_script_history_score_old() {
446        let now = Utc::now();
447        let old_date = now - Duration::days(30);
448
449        let history = ScriptHistory::with_values(50, old_date, None);
450        let score = history.score_at(now);
451
452        // 30 days old: recency = 0.0
453        // count = 50: normalized = 0.5
454        // score = (0.5 * 0.3) + (0.0 * 0.7) = 0.15
455        assert!((score - 0.15).abs() < 0.01);
456    }
457
458    #[test]
459    fn test_script_history_score_medium() {
460        let now = Utc::now();
461        let medium_date = now - Duration::days(15);
462
463        let history = ScriptHistory::with_values(20, medium_date, None);
464        let score = history.score_at(now);
465
466        // 15 days old: recency = 0.5
467        // count = 20: normalized = 0.2
468        // score = (0.2 * 0.3) + (0.5 * 0.7) = 0.06 + 0.35 = 0.41
469        assert!((score - 0.41).abs() < 0.01);
470    }
471
472    #[test]
473    fn test_project_history_record_run() {
474        let mut history = ProjectHistory::new();
475        history.record_run("dev", None);
476        history.record_run("dev", Some("--host".to_string()));
477        history.record_run("build", None);
478
479        assert_eq!(history.last_script(), Some("build"));
480        assert_eq!(history.scripts.len(), 2);
481
482        let dev = history.get_script("dev").unwrap();
483        assert_eq!(dev.count, 2);
484        assert_eq!(dev.last_args, Some("--host".to_string()));
485    }
486
487    #[test]
488    fn test_project_history_last_script_with_args() {
489        let mut history = ProjectHistory::new();
490        history.record_run("dev", Some("--host".to_string()));
491
492        let (name, args) = history.last_script_with_args().unwrap();
493        assert_eq!(name, "dev");
494        assert_eq!(args, Some("--host"));
495    }
496
497    #[test]
498    fn test_project_history_cleanup() {
499        let mut history = ProjectHistory::new();
500        let now = Utc::now();
501
502        // Add scripts with different ages
503        history.scripts.insert(
504            "old1".to_string(),
505            ScriptHistory::with_values(1, now - Duration::days(10), None),
506        );
507        history.scripts.insert(
508            "old2".to_string(),
509            ScriptHistory::with_values(1, now - Duration::days(9), None),
510        );
511        history.scripts.insert(
512            "recent1".to_string(),
513            ScriptHistory::with_values(1, now - Duration::days(1), None),
514        );
515        history.scripts.insert(
516            "recent2".to_string(),
517            ScriptHistory::with_values(1, now, None),
518        );
519
520        assert_eq!(history.scripts.len(), 4);
521
522        // Cleanup to max 2 scripts
523        history.cleanup(2);
524
525        assert_eq!(history.scripts.len(), 2);
526        // Should keep the most recent ones
527        assert!(history.scripts.contains_key("recent1"));
528        assert!(history.scripts.contains_key("recent2"));
529        assert!(!history.scripts.contains_key("old1"));
530        assert!(!history.scripts.contains_key("old2"));
531    }
532
533    #[test]
534    fn test_history_record_run() {
535        let mut history = History::new();
536        let project = PathBuf::from("/test/project");
537
538        history.record_run(&project, "dev", None);
539        history.record_run(&project, "dev", Some("--host".to_string()));
540        history.record_run(&project, "build", None);
541
542        let proj = history.get_project(&project).unwrap();
543        assert_eq!(proj.last_script(), Some("build"));
544
545        let dev = proj.get_script("dev").unwrap();
546        assert_eq!(dev.count, 2);
547        assert_eq!(dev.last_args, Some("--host".to_string()));
548    }
549
550    #[test]
551    fn test_history_get_last_script() {
552        let mut history = History::new();
553        let project = PathBuf::from("/test/project");
554
555        history.record_run(&project, "dev", Some("--host".to_string()));
556
557        let (name, args) = history.get_last_script(&project).unwrap();
558        assert_eq!(name, "dev");
559        assert_eq!(args, Some("--host".to_string()));
560    }
561
562    #[test]
563    fn test_history_get_script_stats() {
564        let mut history = History::new();
565        let project = PathBuf::from("/test/project");
566
567        history.record_run(&project, "dev", None);
568        history.record_run(&project, "dev", None);
569
570        let stats = history.get_script_stats(&project, "dev").unwrap();
571        assert_eq!(stats.count, 2);
572
573        assert!(history.get_script_stats(&project, "unknown").is_none());
574    }
575
576    #[test]
577    fn test_history_get_sorted_by_recent() {
578        let mut history = History::new();
579        let project = PathBuf::from("/test/project");
580        let now = Utc::now();
581
582        // Create scripts
583        let scripts = create_test_scripts();
584
585        // Add history: dev (recent, high count), build (old, low count), test (no history)
586        history
587            .projects
588            .insert(project.clone(), ProjectHistory::new());
589        let proj = history.get_project_mut(&project).unwrap();
590
591        proj.scripts
592            .insert("dev".to_string(), ScriptHistory::with_values(10, now, None));
593        proj.scripts.insert(
594            "build".to_string(),
595            ScriptHistory::with_values(2, now - Duration::days(20), None),
596        );
597
598        let sorted = history.get_sorted_by_recent_at(&project, &scripts, now);
599
600        // dev should be first (high recency + count)
601        assert_eq!(sorted[0].name(), "dev");
602        // build should be second (has some history)
603        assert_eq!(sorted[1].name(), "build");
604        // test and lint should be last (no history, sorted alphabetically)
605        assert!(sorted[2].name() == "lint" || sorted[3].name() == "lint");
606    }
607
608    #[test]
609    fn test_history_get_sorted_no_history() {
610        let history = History::new();
611        let project = PathBuf::from("/test/project");
612        let scripts = create_test_scripts();
613
614        let sorted = history.get_sorted_by_recent(&project, &scripts);
615
616        // All should have 0 score, so sorted alphabetically
617        assert_eq!(sorted.len(), 4);
618        assert_eq!(sorted[0].name(), "build");
619        assert_eq!(sorted[1].name(), "dev");
620        assert_eq!(sorted[2].name(), "lint");
621        assert_eq!(sorted[3].name(), "test");
622    }
623
624    #[test]
625    fn test_history_cleanup_projects() {
626        let mut history = History::new();
627        let now = Utc::now();
628
629        // Add 5 projects with different ages
630        for i in 0..5 {
631            let path = PathBuf::from(format!("/project/{}", i));
632            let mut proj = ProjectHistory::new();
633            proj.last_run = now - Duration::days(i as i64);
634            history.projects.insert(path, proj);
635        }
636
637        assert_eq!(history.projects.len(), 5);
638
639        // Cleanup to max 3 projects
640        history.cleanup_with_limits(3, 50);
641
642        assert_eq!(history.projects.len(), 3);
643        // Should keep the 3 most recent (0, 1, 2)
644        assert!(history.projects.contains_key(&PathBuf::from("/project/0")));
645        assert!(history.projects.contains_key(&PathBuf::from("/project/1")));
646        assert!(history.projects.contains_key(&PathBuf::from("/project/2")));
647        assert!(!history.projects.contains_key(&PathBuf::from("/project/3")));
648        assert!(!history.projects.contains_key(&PathBuf::from("/project/4")));
649    }
650
651    #[test]
652    fn test_history_cleanup_with_config() {
653        let mut history = History::new();
654        let now = Utc::now();
655
656        // Add project with many scripts
657        let project = PathBuf::from("/test/project");
658        history
659            .projects
660            .insert(project.clone(), ProjectHistory::new());
661        let proj = history.get_project_mut(&project).unwrap();
662
663        for i in 0..10 {
664            proj.scripts.insert(
665                format!("script{}", i),
666                ScriptHistory::with_values(1, now - Duration::days(i as i64), None),
667            );
668        }
669
670        let config = HistoryConfig {
671            enabled: true,
672            max_projects: 100,
673            max_scripts: 5,
674        };
675
676        history.cleanup(&config);
677
678        let proj = history.get_project(&project).unwrap();
679        assert_eq!(proj.scripts.len(), 5);
680    }
681
682    #[test]
683    fn test_history_save_and_load() {
684        let temp = TempDir::new().unwrap();
685        let history_path = temp.path().join("history.json");
686
687        // Create and populate history
688        let mut history = History::new();
689        let project = PathBuf::from("/test/project");
690        history.record_run(&project, "dev", Some("--host".to_string()));
691
692        // Save manually to temp location
693        let content = serde_json::to_string_pretty(&history).unwrap();
694        fs::write(&history_path, content).unwrap();
695
696        // Load from temp location
697        let loaded_content = fs::read_to_string(&history_path).unwrap();
698        let loaded: History = serde_json::from_str(&loaded_content).unwrap();
699
700        assert_eq!(loaded.projects.len(), 1);
701        let proj = loaded.get_project(&project).unwrap();
702        assert_eq!(proj.last_script(), Some("dev"));
703    }
704
705    #[test]
706    fn test_history_corrupt_file_handling() {
707        let temp = TempDir::new().unwrap();
708        let history_path = temp.path().join("history.json");
709
710        // Write corrupt JSON
711        fs::write(&history_path, "{ invalid json }}}").unwrap();
712
713        // Try to parse it (simulating what load does)
714        let content = fs::read_to_string(&history_path).unwrap();
715        let result: Result<History, _> = serde_json::from_str(&content);
716
717        assert!(result.is_err());
718    }
719
720    #[test]
721    fn test_history_missing_file() {
722        // When file doesn't exist, load should return empty history
723        // This is tested in the actual load function which checks for file existence
724        let history = History::new();
725        assert!(history.projects.is_empty());
726    }
727
728    #[test]
729    fn test_score_high_count_beats_old_recent() {
730        let now = Utc::now();
731
732        // Script A: run once today
733        let script_a = ScriptHistory::with_values(1, now, None);
734
735        // Script B: run 100 times, 5 days ago
736        let script_b = ScriptHistory::with_values(100, now - Duration::days(5), None);
737
738        let score_a = script_a.score_at(now);
739        let score_b = script_b.score_at(now);
740
741        // A: (1/100 * 0.3) + (1.0 * 0.7) = 0.003 + 0.7 = 0.703
742        // B: (1.0 * 0.3) + (0.833 * 0.7) = 0.3 + 0.583 = 0.883
743        // High count should win when recency is close
744        assert!(score_b > score_a);
745    }
746
747    #[test]
748    fn test_score_very_recent_beats_high_count_old() {
749        let now = Utc::now();
750
751        // Script A: run once today
752        let script_a = ScriptHistory::with_values(1, now, None);
753
754        // Script B: run 100 times, 25 days ago
755        let script_b = ScriptHistory::with_values(100, now - Duration::days(25), None);
756
757        let score_a = script_a.score_at(now);
758        let score_b = script_b.score_at(now);
759
760        // A: (0.01 * 0.3) + (1.0 * 0.7) = 0.003 + 0.7 = 0.703
761        // B: (1.0 * 0.3) + (0.167 * 0.7) = 0.3 + 0.117 = 0.417
762        // Very recent should beat old high count
763        assert!(score_a > score_b);
764    }
765
766    #[test]
767    fn test_project_history_cleanup_preserves_recent() {
768        let mut history = ProjectHistory::new();
769        let now = Utc::now();
770
771        // Add a mix of old and recent scripts
772        history.scripts.insert(
773            "very_old".to_string(),
774            ScriptHistory::with_values(100, now - Duration::days(100), None),
775        );
776        history.scripts.insert(
777            "today".to_string(),
778            ScriptHistory::with_values(1, now, None),
779        );
780        history.scripts.insert(
781            "yesterday".to_string(),
782            ScriptHistory::with_values(1, now - Duration::days(1), None),
783        );
784
785        history.cleanup(2);
786
787        // Should keep today and yesterday, remove very_old despite high count
788        assert!(!history.scripts.contains_key("very_old"));
789        assert!(history.scripts.contains_key("today"));
790        assert!(history.scripts.contains_key("yesterday"));
791    }
792
793    #[test]
794    fn test_history_version() {
795        let history = History::new();
796        assert_eq!(history.version, History::VERSION);
797    }
798}