1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::fs;
4use std::path::Path;
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use crate::iterm2::{list_available_schemes, lookup_variant_pair, Iterm2Error, SchemeVariant};
8
9const CACHE_TTL_DAYS: u64 = 5;
10const CACHE_FILENAME: &str = "iterm2_schemes.json";
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ThemeGroup {
14 pub name: String,
15 pub dark: Option<String>,
16 pub light: Option<String>,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct CachedSchemeList {
21 pub timestamp: u64,
22 pub schemes: Vec<String>,
23 pub groups: Vec<ThemeGroup>,
24}
25
26impl CachedSchemeList {
27 pub fn is_expired(&self) -> bool {
28 let now = SystemTime::now()
29 .duration_since(UNIX_EPOCH)
30 .unwrap_or_default()
31 .as_secs();
32 let age_days = (now - self.timestamp) / 86400;
33 age_days >= CACHE_TTL_DAYS
34 }
35
36 pub fn age_description(&self) -> String {
37 let now = SystemTime::now()
38 .duration_since(UNIX_EPOCH)
39 .unwrap_or_default()
40 .as_secs();
41 let age_secs = now.saturating_sub(self.timestamp);
42
43 if age_secs < 60 {
44 "just now".to_string()
45 } else if age_secs < 3600 {
46 format!("{} minutes ago", age_secs / 60)
47 } else if age_secs < 86400 {
48 format!("{} hours ago", age_secs / 3600)
49 } else {
50 format!("{} days ago", age_secs / 86400)
51 }
52 }
53}
54
55fn cache_path(cache_dir: &Path) -> std::path::PathBuf {
56 cache_dir.join(CACHE_FILENAME)
57}
58
59pub fn load_cached_schemes(cache_dir: &Path) -> Option<CachedSchemeList> {
60 let path = cache_path(cache_dir);
61 if !path.exists() {
62 return None;
63 }
64
65 let content = fs::read_to_string(&path).ok()?;
66 serde_json::from_str(&content).ok()
67}
68
69pub fn save_cached_schemes(cache_dir: &Path, cache: &CachedSchemeList) -> std::io::Result<()> {
70 fs::create_dir_all(cache_dir)?;
71 let path = cache_path(cache_dir);
72 let content = serde_json::to_string_pretty(cache)
73 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
74 fs::write(path, content)
75}
76
77pub fn group_schemes(schemes: &[String]) -> Vec<ThemeGroup> {
78 let scheme_set: std::collections::HashSet<&str> = schemes.iter().map(|s| s.as_str()).collect();
79 let mut grouped: HashMap<String, ThemeGroup> = HashMap::new();
80 let mut processed: std::collections::HashSet<&str> = std::collections::HashSet::new();
81
82 for scheme in schemes {
83 if processed.contains(scheme.as_str()) {
84 continue;
85 }
86
87 if let Some((dark, light)) = lookup_variant_pair(scheme) {
88 let group_name = derive_group_name(dark, light);
89 let has_dark = scheme_set.contains(dark);
90 let has_light = scheme_set.contains(light);
91
92 grouped.insert(
93 group_name.clone(),
94 ThemeGroup {
95 name: group_name,
96 dark: if has_dark {
97 Some(dark.to_string())
98 } else {
99 None
100 },
101 light: if has_light {
102 Some(light.to_string())
103 } else {
104 None
105 },
106 },
107 );
108
109 processed.insert(dark);
110 processed.insert(light);
111 }
112 }
113
114 for scheme in schemes {
115 if processed.contains(scheme.as_str()) {
116 continue;
117 }
118
119 let group_name = scheme.clone();
120 let variant = detect_variant_from_name(scheme);
121
122 grouped.insert(
123 group_name.clone(),
124 ThemeGroup {
125 name: group_name,
126 dark: if variant != SchemeVariant::Light {
127 Some(scheme.clone())
128 } else {
129 None
130 },
131 light: if variant == SchemeVariant::Light {
132 Some(scheme.clone())
133 } else {
134 None
135 },
136 },
137 );
138 processed.insert(scheme);
139 }
140
141 let mut groups: Vec<ThemeGroup> = grouped.into_values().collect();
142 groups.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
143 groups
144}
145
146fn derive_group_name(dark: &str, light: &str) -> String {
147 let dark_lower = dark.to_lowercase();
148 let light_lower = light.to_lowercase();
149
150 for suffix in [" dark", " light", " night", " day", " moon", " dawn"] {
151 if dark_lower.ends_with(suffix) {
152 return dark[..dark.len() - suffix.len()].to_string();
153 }
154 if light_lower.ends_with(suffix) {
155 return light[..light.len() - suffix.len()].to_string();
156 }
157 }
158
159 if dark.len() <= light.len() {
160 dark.to_string()
161 } else {
162 light.to_string()
163 }
164}
165
166fn detect_variant_from_name(name: &str) -> SchemeVariant {
167 let lower = name.to_lowercase();
168 if lower.contains("light") || lower.contains("day") || lower.contains("dawn") {
169 SchemeVariant::Light
170 } else if lower.contains("dark") || lower.contains("night") || lower.contains("moon") {
171 SchemeVariant::Dark
172 } else {
173 SchemeVariant::Unknown
174 }
175}
176
177pub fn fetch_and_cache_schemes(
178 cache_dir: &Path,
179 force: bool,
180) -> Result<CachedSchemeList, Iterm2Error> {
181 if !force {
182 if let Some(cached) = load_cached_schemes(cache_dir) {
183 if !cached.is_expired() {
184 return Ok(cached);
185 }
186 }
187 }
188
189 let schemes = list_available_schemes()?;
190 let groups = group_schemes(&schemes);
191
192 let timestamp = SystemTime::now()
193 .duration_since(UNIX_EPOCH)
194 .unwrap_or_default()
195 .as_secs();
196
197 let cache = CachedSchemeList {
198 timestamp,
199 schemes,
200 groups,
201 };
202
203 let _ = save_cached_schemes(cache_dir, &cache);
204
205 Ok(cache)
206}
207
208pub fn get_cached_or_empty(cache_dir: &Path) -> CachedSchemeList {
209 load_cached_schemes(cache_dir).unwrap_or_else(|| CachedSchemeList {
210 timestamp: 0,
211 schemes: Vec::new(),
212 groups: Vec::new(),
213 })
214}