1use crate::config::global_xbp_paths;
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use std::fs;
5use std::path::PathBuf;
6use walkdir::WalkDir;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct ProjectInfo {
10 pub path: PathBuf,
11 pub name: String,
12 pub last_accessed: DateTime<Utc>,
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize, Default)]
16pub struct Profile {
17 pub last_project_path: Option<PathBuf>,
18 #[serde(default)]
19 pub recent_projects: Vec<ProjectInfo>,
20}
21
22impl Profile {
23 pub fn get_profile_path() -> Result<PathBuf, String> {
24 Ok(global_xbp_paths()?.root_dir.join("profile.yaml"))
25 }
26
27 pub fn load() -> Result<Self, String> {
28 let profile_path = Self::get_profile_path()?;
29
30 if !profile_path.exists() {
31 #[cfg(target_os = "windows")]
32 if let Some(legacy_path) = legacy_windows_profile_path() {
33 if legacy_path.exists() {
34 let content = fs::read_to_string(&legacy_path)
35 .map_err(|e| format!("Failed to read legacy profile: {}", e))?;
36 let profile: Profile = serde_yaml::from_str(&content)
37 .map_err(|e| format!("Failed to parse legacy profile: {}", e))?;
38 let _ = profile.save();
39 return Ok(profile);
40 }
41 }
42 return Ok(Profile::default());
43 }
44
45 let content = fs::read_to_string(&profile_path)
46 .map_err(|e| format!("Failed to read profile: {}", e))?;
47
48 serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse profile: {}", e))
49 }
50
51 pub fn save(&self) -> Result<(), String> {
52 let profile_path = Self::get_profile_path()?;
53
54 let yaml = serde_yaml::to_string(self)
55 .map_err(|e| format!("Failed to serialize profile: {}", e))?;
56
57 fs::write(&profile_path, yaml).map_err(|e| format!("Failed to write profile: {}", e))
58 }
59
60 pub fn update_last_project(&mut self, path: PathBuf, name: String) {
61 self.last_project_path = Some(path.clone());
62
63 let now = Utc::now();
64
65 if let Some(existing) = self.recent_projects.iter_mut().find(|p| p.path == path) {
66 existing.last_accessed = now;
67 existing.name = name;
68 } else {
69 self.recent_projects.push(ProjectInfo {
70 path,
71 name,
72 last_accessed: now,
73 });
74 }
75
76 self.recent_projects
77 .sort_by_key(|project| std::cmp::Reverse(project.last_accessed));
78
79 if self.recent_projects.len() > 10 {
80 self.recent_projects.truncate(10);
81 }
82 }
83}
84
85#[cfg(target_os = "windows")]
86fn legacy_windows_profile_path() -> Option<PathBuf> {
87 dirs::config_dir().map(|config_dir| config_dir.join("xbp").join("profile.yaml"))
88}
89
90pub fn find_all_xbp_projects() -> Vec<ProjectInfo> {
91 let mut projects = Vec::new();
92 let search_dirs = get_search_directories();
93
94 for search_dir in search_dirs {
95 if !search_dir.exists() {
96 continue;
97 }
98
99 for entry in WalkDir::new(&search_dir)
100 .max_depth(5)
101 .follow_links(false)
102 .into_iter()
103 .filter_map(|e| e.ok())
104 {
105 let path = entry.path();
106
107 if path.file_name() == Some(std::ffi::OsStr::new(".xbp")) && path.is_dir() {
108 let xbp_yaml = path.join("xbp.yaml");
109 let xbp_yml = path.join("xbp.yml");
110 let xbp_json = path.join("xbp.json");
111 let chosen = if xbp_yaml.exists() {
112 Some(xbp_yaml)
113 } else if xbp_yml.exists() {
114 Some(xbp_yml)
115 } else if xbp_json.exists() {
116 Some(xbp_json)
117 } else {
118 None
119 };
120
121 if let Some(config_path) = chosen {
122 if let Some(parent) = path.parent() {
123 let name =
124 extract_project_name(config_path.as_path()).unwrap_or_else(|| {
125 parent
126 .file_name()
127 .and_then(|n| n.to_str())
128 .unwrap_or("unknown")
129 .to_string()
130 });
131
132 projects.push(ProjectInfo {
133 path: parent.to_path_buf(),
134 name,
135 last_accessed: Utc::now(),
136 });
137 }
138 }
139 } else if (path.file_name() == Some(std::ffi::OsStr::new("xbp.json"))
140 || path.file_name() == Some(std::ffi::OsStr::new("xbp.yaml"))
141 || path.file_name() == Some(std::ffi::OsStr::new("xbp.yml")))
142 && path.is_file()
143 {
144 if let Some(parent) = path.parent() {
145 let name = extract_project_name(path).unwrap_or_else(|| {
146 parent
147 .file_name()
148 .and_then(|n| n.to_str())
149 .unwrap_or("unknown")
150 .to_string()
151 });
152
153 projects.push(ProjectInfo {
154 path: parent.to_path_buf(),
155 name,
156 last_accessed: Utc::now(),
157 });
158 }
159 }
160 }
161 }
162
163 projects.sort_by(|a, b| a.name.cmp(&b.name));
164
165 let mut deduplicated = Vec::new();
166 let mut seen_paths = std::collections::HashSet::new();
167
168 for project in projects {
169 let canonical_path = project
170 .path
171 .canonicalize()
172 .unwrap_or_else(|_| project.path.clone());
173
174 let should_skip = seen_paths.iter().any(|seen: &PathBuf| {
175 let seen_canonical = seen.canonicalize().unwrap_or_else(|_| seen.clone());
176
177 if canonical_path == seen_canonical {
178 return true;
179 }
180
181 if let (Some(seen_parent), Some(current_parent)) =
182 (seen_canonical.parent(), canonical_path.parent())
183 {
184 if seen_parent == canonical_path
185 && seen_canonical.file_name() == Some(std::ffi::OsStr::new(".xbp"))
186 {
187 return true;
188 }
189 if current_parent == seen_canonical
190 && canonical_path.file_name() == Some(std::ffi::OsStr::new(".xbp"))
191 {
192 return true;
193 }
194 }
195
196 false
197 });
198
199 if !should_skip {
200 seen_paths.insert(canonical_path);
201 deduplicated.push(project);
202 }
203 }
204
205 deduplicated
206}
207
208fn get_search_directories() -> Vec<PathBuf> {
209 let mut dirs = Vec::new();
210
211 if let Some(home) = dirs::home_dir() {
212 dirs.push(home.join("projects"));
213 dirs.push(home.join("dev"));
214 dirs.push(home.join("Documents"));
215 dirs.push(home.join("src"));
216 dirs.push(home.clone());
217 }
218
219 if cfg!(target_os = "windows") {
220 if let Ok(current_dir) = std::env::current_dir() {
221 if let Some(root) = current_dir.ancestors().last() {
222 dirs.push(root.to_path_buf());
223 }
224 }
225 }
226
227 dirs
228}
229
230fn extract_project_name(xbp_json_path: &std::path::Path) -> Option<String> {
231 let content = fs::read_to_string(xbp_json_path).ok()?;
232 let value = if xbp_json_path
233 .extension()
234 .and_then(|ext| ext.to_str())
235 .map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
236 .unwrap_or(false)
237 {
238 let yaml: serde_yaml::Value = serde_yaml::from_str(&content).ok()?;
239 serde_json::to_value(yaml).ok()?
240 } else {
241 serde_json::from_str::<serde_json::Value>(&content).ok()?
242 };
243
244 value
245 .get("project_name")
246 .and_then(|v| v.as_str())
247 .map(|s| s.to_string())
248}
249
250pub fn rank_projects_by_proximity(
251 mut projects: Vec<ProjectInfo>,
252 current_dir: PathBuf,
253) -> Vec<ProjectInfo> {
254 projects.sort_by_key(|project| {
255 let common_components = current_dir
256 .components()
257 .zip(project.path.components())
258 .take_while(|(a, b)| a == b)
259 .count();
260
261 std::cmp::Reverse(common_components)
262 });
263
264 projects
265}