Skip to main content

xbp_cli/
profile.rs

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}