1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::fs;
5use std::io::Write;
6use std::path::{Path, PathBuf};
7
8use anyhow::Result;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct RecentRepo {
13 pub repo_id: String,
15 pub path: PathBuf,
17 pub last_accessed: DateTime<Utc>,
19 pub last_app: Option<String>,
21 pub access_count: u32,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct UserPreferences {
28 pub default_app: Option<String>,
30 pub show_setup_wizard: bool,
32 pub auto_open_last_repo: bool,
34 pub max_recent_repos: usize,
36 pub show_hints: bool,
38}
39
40impl Default for UserPreferences {
41 fn default() -> Self {
42 Self {
43 default_app: None,
44 show_setup_wizard: true,
45 auto_open_last_repo: false,
46 max_recent_repos: 10,
47 show_hints: true,
48 }
49 }
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct VibeState {
55 pub recent_repos: Vec<RecentRepo>,
57 pub last_used_apps: HashMap<String, String>,
59 pub user_preferences: UserPreferences,
61 pub repo_groups: HashMap<String, Vec<String>>,
63 pub first_run: Option<DateTime<Utc>>,
65 pub version: u32,
67}
68
69impl Default for VibeState {
70 fn default() -> Self {
71 Self {
72 recent_repos: Vec::new(),
73 last_used_apps: HashMap::new(),
74 user_preferences: UserPreferences::default(),
75 repo_groups: HashMap::new(),
76 first_run: Some(Utc::now()),
77 version: 1,
78 }
79 }
80}
81
82impl VibeState {
83 pub fn load() -> Result<Self> {
85 let state_path = Self::default_state_path()?;
86 if state_path.exists() {
87 Self::load_from_path(&state_path)
88 } else {
89 Ok(Self::default())
90 }
91 }
92
93 pub fn load_from_path(path: &Path) -> Result<Self> {
95 let content = fs::read_to_string(path)?;
96 let state: VibeState = serde_json::from_str(&content)?;
97 Ok(state)
98 }
99
100 pub fn save(&self) -> Result<()> {
102 let state_path = Self::default_state_path()?;
103 self.save_to_path(&state_path)
104 }
105
106 pub fn save_to_path(&self, path: &Path) -> Result<()> {
108 if let Some(parent) = path.parent() {
110 fs::create_dir_all(parent)?;
111 }
112
113 let json = serde_json::to_string_pretty(self)?;
114 let mut file = fs::File::create(path)?;
115 file.write_all(json.as_bytes())?;
116 Ok(())
117 }
118
119 fn default_state_path() -> Result<PathBuf> {
121 let _home =
122 dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?;
123 Ok(crate::workspace::constants::get_state_file_path())
124 }
125
126 pub fn add_recent_repo(&mut self, repo_id: String, path: PathBuf, app: Option<String>) {
128 let now = Utc::now();
129
130 if let Some(app_name) = &app {
132 self.last_used_apps
133 .insert(repo_id.clone(), app_name.clone());
134 }
135
136 if let Some(existing) = self.recent_repos.iter_mut().find(|r| r.repo_id == repo_id) {
138 existing.last_accessed = now;
139 existing.access_count += 1;
140 if app.is_some() {
141 existing.last_app = app;
142 }
143 } else {
144 self.recent_repos.push(RecentRepo {
146 repo_id: repo_id.clone(),
147 path,
148 last_accessed: now,
149 last_app: app,
150 access_count: 1,
151 });
152 }
153
154 self.recent_repos
156 .sort_by(|a, b| b.last_accessed.cmp(&a.last_accessed));
157 self.recent_repos
158 .truncate(self.user_preferences.max_recent_repos);
159 }
160
161 pub fn get_recent_repos(&self, limit: usize) -> &[RecentRepo] {
163 let end = limit.min(self.recent_repos.len());
164 &self.recent_repos[..end]
165 }
166
167 pub fn get_last_app(&self, repo_id: &str) -> Option<&String> {
169 self.last_used_apps.get(repo_id)
170 }
171
172 pub fn is_first_run(&self) -> bool {
174 self.first_run.is_some() && self.recent_repos.is_empty()
175 }
176
177 pub fn complete_setup_wizard(&mut self) {
179 self.user_preferences.show_setup_wizard = false;
180 self.first_run = None;
181 }
182
183 pub fn add_repo_group(&mut self, name: String, repos: Vec<String>) {
185 self.repo_groups.insert(name, repos);
186 }
187
188 pub fn get_repo_group(&self, name: &str) -> Option<&Vec<String>> {
190 self.repo_groups.get(name)
191 }
192
193 pub fn get_frequent_repos(&self, limit: usize) -> Vec<&RecentRepo> {
195 let mut repos: Vec<&RecentRepo> = self.recent_repos.iter().collect();
196 repos.sort_by(|a, b| b.access_count.cmp(&a.access_count));
197 repos.truncate(limit);
198 repos
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use tempfile::tempdir;
206
207 #[test]
208 fn test_state_persistence() {
209 let dir = tempdir().unwrap();
210 let state_path = dir.path().join("state.json");
211
212 let mut state = VibeState::default();
214 state.add_recent_repo(
215 "test-repo".to_string(),
216 PathBuf::from("/path/to/repo"),
217 Some("vscode".to_string()),
218 );
219 state.save_to_path(&state_path).unwrap();
220
221 let loaded = VibeState::load_from_path(&state_path).unwrap();
223 assert_eq!(loaded.recent_repos.len(), 1);
224 assert_eq!(loaded.recent_repos[0].repo_id, "test-repo");
225 assert_eq!(
226 loaded.get_last_app("test-repo"),
227 Some(&"vscode".to_string())
228 );
229 }
230
231 #[test]
232 fn test_recent_repos_ordering() {
233 let mut state = VibeState::default();
234 state.user_preferences.max_recent_repos = 3;
235
236 state.add_recent_repo("repo1".to_string(), PathBuf::from("/repo1"), None);
238 std::thread::sleep(std::time::Duration::from_millis(10));
239 state.add_recent_repo("repo2".to_string(), PathBuf::from("/repo2"), None);
240 std::thread::sleep(std::time::Duration::from_millis(10));
241 state.add_recent_repo("repo3".to_string(), PathBuf::from("/repo3"), None);
242
243 state.add_recent_repo("repo1".to_string(), PathBuf::from("/repo1"), None);
245
246 let recent = state.get_recent_repos(3);
247 assert_eq!(recent[0].repo_id, "repo1");
248 assert_eq!(recent[0].access_count, 2);
249 assert_eq!(recent[1].repo_id, "repo3");
250 assert_eq!(recent[2].repo_id, "repo2");
251 }
252
253 #[test]
254 fn test_repo_groups() {
255 let mut state = VibeState::default();
256
257 state.add_repo_group(
258 "frontend".to_string(),
259 vec!["web-app".to_string(), "mobile-app".to_string()],
260 );
261 state.add_repo_group(
262 "backend".to_string(),
263 vec!["api".to_string(), "services".to_string()],
264 );
265
266 assert_eq!(state.get_repo_group("frontend").unwrap().len(), 2);
267 assert_eq!(state.get_repo_group("backend").unwrap().len(), 2);
268 assert!(state.get_repo_group("nonexistent").is_none());
269 }
270}