use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::Result;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecentRepo {
pub repo_id: String,
pub path: PathBuf,
pub last_accessed: DateTime<Utc>,
pub last_app: Option<String>,
pub access_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserPreferences {
pub default_app: Option<String>,
pub show_setup_wizard: bool,
pub auto_open_last_repo: bool,
pub max_recent_repos: usize,
pub show_hints: bool,
}
impl Default for UserPreferences {
fn default() -> Self {
Self {
default_app: None,
show_setup_wizard: true,
auto_open_last_repo: false,
max_recent_repos: 10,
show_hints: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VibeState {
pub recent_repos: Vec<RecentRepo>,
pub last_used_apps: HashMap<String, String>,
pub user_preferences: UserPreferences,
pub repo_groups: HashMap<String, Vec<String>>,
pub first_run: Option<DateTime<Utc>>,
pub version: u32,
}
impl Default for VibeState {
fn default() -> Self {
Self {
recent_repos: Vec::new(),
last_used_apps: HashMap::new(),
user_preferences: UserPreferences::default(),
repo_groups: HashMap::new(),
first_run: Some(Utc::now()),
version: 1,
}
}
}
impl VibeState {
pub fn load() -> Result<Self> {
let state_path = Self::default_state_path()?;
if state_path.exists() {
Self::load_from_path(&state_path)
} else {
Ok(Self::default())
}
}
pub fn load_from_path(path: &Path) -> Result<Self> {
let content = fs::read_to_string(path)?;
let state: VibeState = serde_json::from_str(&content)?;
Ok(state)
}
pub fn save(&self) -> Result<()> {
let state_path = Self::default_state_path()?;
self.save_to_path(&state_path)
}
pub fn save_to_path(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(self)?;
let mut file = fs::File::create(path)?;
file.write_all(json.as_bytes())?;
Ok(())
}
fn default_state_path() -> Result<PathBuf> {
let _home =
dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?;
Ok(crate::workspace::constants::get_state_file_path())
}
pub fn add_recent_repo(&mut self, repo_id: String, path: PathBuf, app: Option<String>) {
let now = Utc::now();
if let Some(app_name) = &app {
self.last_used_apps
.insert(repo_id.clone(), app_name.clone());
}
if let Some(existing) = self.recent_repos.iter_mut().find(|r| r.repo_id == repo_id) {
existing.last_accessed = now;
existing.access_count += 1;
if app.is_some() {
existing.last_app = app;
}
} else {
self.recent_repos.push(RecentRepo {
repo_id: repo_id.clone(),
path,
last_accessed: now,
last_app: app,
access_count: 1,
});
}
self.recent_repos
.sort_by(|a, b| b.last_accessed.cmp(&a.last_accessed));
self.recent_repos
.truncate(self.user_preferences.max_recent_repos);
}
pub fn get_recent_repos(&self, limit: usize) -> &[RecentRepo] {
let end = limit.min(self.recent_repos.len());
&self.recent_repos[..end]
}
pub fn get_last_app(&self, repo_id: &str) -> Option<&String> {
self.last_used_apps.get(repo_id)
}
pub fn is_first_run(&self) -> bool {
self.first_run.is_some() && self.recent_repos.is_empty()
}
pub fn complete_setup_wizard(&mut self) {
self.user_preferences.show_setup_wizard = false;
self.first_run = None;
}
pub fn add_repo_group(&mut self, name: String, repos: Vec<String>) {
self.repo_groups.insert(name, repos);
}
pub fn get_repo_group(&self, name: &str) -> Option<&Vec<String>> {
self.repo_groups.get(name)
}
pub fn get_frequent_repos(&self, limit: usize) -> Vec<&RecentRepo> {
let mut repos: Vec<&RecentRepo> = self.recent_repos.iter().collect();
repos.sort_by(|a, b| b.access_count.cmp(&a.access_count));
repos.truncate(limit);
repos
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_state_persistence() {
let dir = tempdir().unwrap();
let state_path = dir.path().join("state.json");
let mut state = VibeState::default();
state.add_recent_repo(
"test-repo".to_string(),
PathBuf::from("/path/to/repo"),
Some("vscode".to_string()),
);
state.save_to_path(&state_path).unwrap();
let loaded = VibeState::load_from_path(&state_path).unwrap();
assert_eq!(loaded.recent_repos.len(), 1);
assert_eq!(loaded.recent_repos[0].repo_id, "test-repo");
assert_eq!(
loaded.get_last_app("test-repo"),
Some(&"vscode".to_string())
);
}
#[test]
fn test_recent_repos_ordering() {
let mut state = VibeState::default();
state.user_preferences.max_recent_repos = 3;
state.add_recent_repo("repo1".to_string(), PathBuf::from("/repo1"), None);
std::thread::sleep(std::time::Duration::from_millis(10));
state.add_recent_repo("repo2".to_string(), PathBuf::from("/repo2"), None);
std::thread::sleep(std::time::Duration::from_millis(10));
state.add_recent_repo("repo3".to_string(), PathBuf::from("/repo3"), None);
state.add_recent_repo("repo1".to_string(), PathBuf::from("/repo1"), None);
let recent = state.get_recent_repos(3);
assert_eq!(recent[0].repo_id, "repo1");
assert_eq!(recent[0].access_count, 2);
assert_eq!(recent[1].repo_id, "repo3");
assert_eq!(recent[2].repo_id, "repo2");
}
#[test]
fn test_repo_groups() {
let mut state = VibeState::default();
state.add_repo_group(
"frontend".to_string(),
vec!["web-app".to_string(), "mobile-app".to_string()],
);
state.add_repo_group(
"backend".to_string(),
vec!["api".to_string(), "services".to_string()],
);
assert_eq!(state.get_repo_group("frontend").unwrap().len(), 2);
assert_eq!(state.get_repo_group("backend").unwrap().len(), 2);
assert!(state.get_repo_group("nonexistent").is_none());
}
}