1use 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
14pub const DEFAULT_MAX_PROJECTS: usize = 100;
16
17pub const DEFAULT_MAX_SCRIPTS: usize = 50;
19
20const RUN_COUNT_WEIGHT: f64 = 0.3;
22
23const RECENCY_WEIGHT: f64 = 0.7;
25
26const RECENCY_DECAY_DAYS: i64 = 30;
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct ScriptHistory {
32 pub count: u32,
34 pub last_run: DateTime<Utc>,
36 pub last_args: Option<String>,
38}
39
40impl ScriptHistory {
41 pub fn new() -> Self {
43 Self {
44 count: 1,
45 last_run: Utc::now(),
46 last_args: None,
47 }
48 }
49
50 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 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 pub fn score(&self) -> f64 {
71 self.score_at(Utc::now())
72 }
73
74 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct ProjectHistory {
101 pub last_script: Option<String>,
103 pub last_run: DateTime<Utc>,
105 #[serde(default)]
107 pub scripts: HashMap<String, ScriptHistory>,
108}
109
110impl ProjectHistory {
111 pub fn new() -> Self {
113 Self {
114 last_script: None,
115 last_run: Utc::now(),
116 scripts: HashMap::new(),
117 }
118 }
119
120 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 pub fn last_script(&self) -> Option<&str> {
137 self.last_script.as_deref()
138 }
139
140 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 pub fn get_script(&self, name: &str) -> Option<&ScriptHistory> {
150 self.scripts.get(name)
151 }
152
153 pub fn cleanup(&mut self, max_scripts: usize) {
155 if self.scripts.len() <= max_scripts {
156 return;
157 }
158
159 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 let to_remove = self.scripts.len() - max_scripts;
165
166 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#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct History {
188 pub version: u32,
190 #[serde(default)]
192 pub projects: HashMap<PathBuf, ProjectHistory>,
193}
194
195impl History {
196 pub const VERSION: u32 = 1;
198
199 pub fn new() -> Self {
201 Self {
202 version: Self::VERSION,
203 projects: HashMap::new(),
204 }
205 }
206
207 pub fn file_path() -> Option<PathBuf> {
209 dirs::config_dir().map(|p| p.join("nrs").join("history.json"))
210 }
211
212 fn backup_path() -> Option<PathBuf> {
214 Self::file_path().map(|p| p.with_extension("json.bak"))
215 }
216
217 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 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 pub fn save(&self) -> Result<()> {
267 let path = Self::file_path().context("Could not determine config directory")?;
268
269 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 pub fn get_project(&self, project_dir: &Path) -> Option<&ProjectHistory> {
285 self.projects.get(project_dir)
286 }
287
288 pub fn get_project_mut(&mut self, project_dir: &Path) -> Option<&mut ProjectHistory> {
290 self.projects.get_mut(project_dir)
291 }
292
293 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 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 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 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 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 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 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 pub fn cleanup(&mut self, config: &HistoryConfig) {
361 self.cleanup_with_limits(config.max_projects, config.max_scripts);
362 }
363
364 pub fn cleanup_with_limits(&mut self, max_projects: usize, max_scripts: usize) {
366 for project in self.projects.values_mut() {
368 project.cleanup(max_scripts);
369 }
370
371 if self.projects.len() <= max_projects {
373 return;
374 }
375
376 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 let to_remove = self.projects.len() - max_projects;
382
383 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 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 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 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 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 history.cleanup(2);
524
525 assert_eq!(history.scripts.len(), 2);
526 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 let scripts = create_test_scripts();
584
585 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 assert_eq!(sorted[0].name(), "dev");
602 assert_eq!(sorted[1].name(), "build");
604 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 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 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 history.cleanup_with_limits(3, 50);
641
642 assert_eq!(history.projects.len(), 3);
643 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 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 let mut history = History::new();
689 let project = PathBuf::from("/test/project");
690 history.record_run(&project, "dev", Some("--host".to_string()));
691
692 let content = serde_json::to_string_pretty(&history).unwrap();
694 fs::write(&history_path, content).unwrap();
695
696 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 fs::write(&history_path, "{ invalid json }}}").unwrap();
712
713 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 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 let script_a = ScriptHistory::with_values(1, now, None);
734
735 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 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 let script_a = ScriptHistory::with_values(1, now, None);
753
754 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 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 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 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}