1use std::collections::HashMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4use serde::{Deserialize, Serialize};
5use crate::error::{Result, ToriiError};
6
7#[derive(Debug, Serialize, Deserialize, Clone, Default)]
8pub struct WorkspaceConfig {
9 #[serde(default)]
10 pub workspace: HashMap<String, WorkspaceEntry>,
11}
12
13#[derive(Debug, Serialize, Deserialize, Clone)]
14pub struct WorkspaceEntry {
15 pub repos: Vec<String>,
16}
17
18impl WorkspaceConfig {
19 fn path() -> Result<PathBuf> {
20 let dir = dirs::config_dir()
21 .ok_or_else(|| ToriiError::InvalidConfig("Could not determine config directory".to_string()))?
22 .join("torii");
23 fs::create_dir_all(&dir)?;
24 Ok(dir.join("workspaces.toml"))
25 }
26
27 pub fn load() -> Result<Self> {
28 let path = Self::path()?;
29 if !path.exists() {
30 return Ok(Self::default());
31 }
32 let s = fs::read_to_string(&path)?;
33 toml::from_str(&s).map_err(|e| ToriiError::Workspace(format!("Failed to parse workspaces.toml: {}", e)))
34 }
35
36 pub fn save(&self) -> Result<()> {
37 let path = Self::path()?;
38 let s = toml::to_string_pretty(self)
39 .map_err(|e| ToriiError::Workspace(format!("Failed to serialize workspaces: {}", e)))?;
40 fs::write(&path, s)?;
41 Ok(())
42 }
43
44 pub fn add_repo(&mut self, workspace: &str, repo_path: &str) -> Result<()> {
45 let expanded = expand_path(repo_path)?;
46 let entry = self.workspace.entry(workspace.to_string()).or_insert(WorkspaceEntry { repos: vec![] });
47 let canonical = expanded.to_string_lossy().to_string();
48 if !entry.repos.contains(&canonical) {
49 entry.repos.push(canonical);
50 }
51 Ok(())
52 }
53
54 pub fn remove_repo(&mut self, workspace: &str, repo_path: &str) -> Result<()> {
55 let expanded = expand_path(repo_path)?;
56 let canonical = expanded.to_string_lossy().to_string();
57 if let Some(entry) = self.workspace.get_mut(workspace) {
58 entry.repos.retain(|r| r != &canonical);
59 }
60 Ok(())
61 }
62
63 pub fn get(&self, workspace: &str) -> Option<&WorkspaceEntry> {
64 self.workspace.get(workspace)
65 }
66}
67
68fn expand_path(path: &str) -> Result<PathBuf> {
69 if path.starts_with("~/") {
70 let home = dirs::home_dir()
71 .ok_or_else(|| ToriiError::InvalidConfig("Could not determine home directory".to_string()))?;
72 Ok(home.join(&path[2..]))
73 } else {
74 Ok(PathBuf::from(path))
75 }
76}
77
78pub struct WorkspaceManager;
79
80#[derive(Debug, Serialize)]
83pub struct WorkspaceRepoStatus {
84 pub path: String,
85 pub name: String,
86 pub branch: String,
87 pub ahead: usize,
88 pub behind: usize,
89 pub staged: usize,
90 pub unstaged: usize,
91 pub untracked: usize,
92 pub error: Option<String>,
93}
94
95#[derive(Debug, Serialize)]
97pub enum SaveOutcome {
98 Saved,
99 NoChanges,
100 Failed(String),
101}
102
103#[derive(Debug, Serialize)]
104pub struct WorkspaceSaveResult {
105 pub name: String,
106 pub outcome: SaveOutcome,
107}
108
109#[derive(Debug, Serialize)]
112pub struct WorkspaceSyncResult {
113 pub name: String,
114 pub error: Option<String>,
115}
116
117#[derive(Debug, Serialize)]
119pub struct WorkspaceListEntry {
120 pub name: String,
121 pub repos: Vec<WorkspaceRepoEntry>,
122}
123
124#[derive(Debug, Serialize)]
125pub struct WorkspaceRepoEntry {
126 pub path: String,
127 pub exists: bool,
128}
129
130fn repo_display_name(repo_path: &str) -> String {
131 Path::new(repo_path)
132 .file_name()
133 .map(|n| n.to_string_lossy().to_string())
134 .unwrap_or_else(|| repo_path.to_string())
135}
136
137impl WorkspaceManager {
138 pub fn status(workspace_name: &str) -> Result<Vec<WorkspaceRepoStatus>> {
141 let cfg = WorkspaceConfig::load()?;
142 let entry = cfg.get(workspace_name)
143 .ok_or_else(|| ToriiError::Workspace(format!("Workspace '{}' not found", workspace_name)))?;
144
145 Ok(entry
146 .repos
147 .iter()
148 .map(|repo_path| match Self::repo_status(repo_path) {
149 Ok(s) => s,
150 Err(e) => WorkspaceRepoStatus {
151 path: repo_path.clone(),
152 name: repo_display_name(repo_path),
153 branch: String::new(),
154 ahead: 0,
155 behind: 0,
156 staged: 0,
157 unstaged: 0,
158 untracked: 0,
159 error: Some(e.to_string()),
160 },
161 })
162 .collect())
163 }
164
165 pub fn save(
169 workspace_name: &str,
170 message: &str,
171 all: bool,
172 mut on_repo: impl FnMut(&WorkspaceSaveResult),
173 ) -> Result<Vec<WorkspaceSaveResult>> {
174 let cfg = WorkspaceConfig::load()?;
175 let entry = cfg.get(workspace_name)
176 .ok_or_else(|| ToriiError::Workspace(format!("Workspace '{}' not found", workspace_name)))?;
177
178 Ok(entry
179 .repos
180 .iter()
181 .map(|repo_path| {
182 let result = WorkspaceSaveResult {
183 name: repo_display_name(repo_path),
184 outcome: match Self::repo_save(repo_path, message, all) {
185 Ok(true) => SaveOutcome::Saved,
186 Ok(false) => SaveOutcome::NoChanges,
187 Err(e) => SaveOutcome::Failed(e.to_string()),
188 },
189 };
190 on_repo(&result);
191 result
192 })
193 .collect())
194 }
195
196 pub fn sync(
199 workspace_name: &str,
200 force: bool,
201 mut on_repo: impl FnMut(&WorkspaceSyncResult),
202 ) -> Result<Vec<WorkspaceSyncResult>> {
203 let cfg = WorkspaceConfig::load()?;
204 let entry = cfg.get(workspace_name)
205 .ok_or_else(|| ToriiError::Workspace(format!("Workspace '{}' not found", workspace_name)))?;
206
207 Ok(entry
208 .repos
209 .iter()
210 .map(|repo_path| {
211 let result = WorkspaceSyncResult {
212 name: repo_display_name(repo_path),
213 error: Self::repo_sync(repo_path, force).err().map(|e| e.to_string()),
214 };
215 on_repo(&result);
216 result
217 })
218 .collect())
219 }
220
221 pub fn list() -> Result<Vec<WorkspaceListEntry>> {
223 let cfg = WorkspaceConfig::load()?;
224 Ok(cfg
225 .workspace
226 .iter()
227 .map(|(name, entry)| WorkspaceListEntry {
228 name: name.clone(),
229 repos: entry
230 .repos
231 .iter()
232 .map(|repo| WorkspaceRepoEntry {
233 path: repo.clone(),
234 exists: Path::new(repo).exists(),
235 })
236 .collect(),
237 })
238 .collect())
239 }
240
241 pub fn add(workspace: &str, repo_path: &str) -> Result<PathBuf> {
243 let mut cfg = WorkspaceConfig::load()?;
244 let expanded = expand_path(repo_path)?;
245
246 if !expanded.exists() {
247 return Err(ToriiError::Usage(format!("Path does not exist: {}", expanded.display())));
248 }
249
250 cfg.add_repo(workspace, repo_path)?;
251 cfg.save()?;
252 Ok(expanded)
253 }
254
255 pub fn remove(workspace: &str, repo_path: &str) -> Result<()> {
256 let mut cfg = WorkspaceConfig::load()?;
257 cfg.remove_repo(workspace, repo_path)?;
258 cfg.save()?;
259 Ok(())
260 }
261
262 pub fn delete(workspace: &str) -> Result<()> {
263 let mut cfg = WorkspaceConfig::load()?;
264 if cfg.workspace.remove(workspace).is_none() {
265 return Err(ToriiError::Workspace(format!("Workspace '{}' not found", workspace)));
266 }
267 cfg.save()?;
268 Ok(())
269 }
270
271 fn repo_status(repo_path: &str) -> Result<WorkspaceRepoStatus> {
272 let name = repo_display_name(repo_path);
273
274 let repo = git2::Repository::discover(repo_path)
275 .map_err(|_| ToriiError::Usage(format!("Not a git repo: {}", repo_path)))?;
276
277 let branch = repo.head().ok()
278 .and_then(|h| h.shorthand().map(|s| s.to_string()))
279 .unwrap_or_else(|| "detached".to_string());
280
281 let mut opts = git2::StatusOptions::new();
282 opts.include_untracked(true);
283 let statuses = repo.statuses(Some(&mut opts))
284 .map_err(|e| ToriiError::Git(e))?;
285
286 let mut staged = 0usize;
287 let mut unstaged = 0usize;
288 let mut untracked = 0usize;
289
290 for entry in statuses.iter() {
291 let s = entry.status();
292 if s.intersects(
293 git2::Status::INDEX_NEW | git2::Status::INDEX_MODIFIED |
294 git2::Status::INDEX_DELETED | git2::Status::INDEX_RENAMED
295 ) { staged += 1; }
296 if s.intersects(
297 git2::Status::WT_MODIFIED | git2::Status::WT_DELETED | git2::Status::WT_RENAMED
298 ) { unstaged += 1; }
299 if s.contains(git2::Status::WT_NEW) { untracked += 1; }
300 }
301
302 let (ahead, behind) = Self::ahead_behind(&repo, &branch).unwrap_or((0, 0));
304
305 Ok(WorkspaceRepoStatus { path: repo_path.to_string(), name, branch, ahead, behind, staged, unstaged, untracked, error: None })
306 }
307
308 fn ahead_behind(repo: &git2::Repository, branch: &str) -> Option<(usize, usize)> {
309 let local_ref = format!("refs/heads/{}", branch);
310 let remote_ref = format!("refs/remotes/origin/{}", branch);
311 let local = repo.find_reference(&local_ref).ok()?.target()?;
312 let remote = repo.find_reference(&remote_ref).ok()?.target()?;
313 repo.graph_ahead_behind(local, remote).ok()
314 }
315
316 fn repo_save(repo_path: &str, message: &str, all: bool) -> Result<bool> {
317 let repo = crate::core::GitRepo::open(repo_path)?;
318
319 let mut opts = git2::StatusOptions::new();
321 opts.include_untracked(false);
322 let statuses = repo.repository().statuses(Some(&mut opts))
323 .map_err(|e| ToriiError::Git(e))?;
324
325 if statuses.is_empty() {
326 return Ok(false);
327 }
328
329 if all {
330 repo.add_all()?;
331 }
332
333 let mut index = repo.repository().index()
335 .map_err(|e| ToriiError::Git(e))?;
336 index.read(true).map_err(|e| ToriiError::Git(e))?;
337 let tree_oid = index.write_tree().map_err(|e| ToriiError::Git(e))?;
338
339 let head_tree = repo.repository().head().ok()
341 .and_then(|h| h.peel_to_tree().ok());
342 if let Some(head) = head_tree {
343 if head.id() == tree_oid {
344 return Ok(false);
345 }
346 }
347
348 repo.commit(message)?;
349 Ok(true)
350 }
351
352 fn repo_sync(repo_path: &str, force: bool) -> Result<()> {
353 let repo = crate::core::GitRepo::open(repo_path)?;
354 repo.pull()?;
355 repo.push(force)?;
356 Ok(())
357 }
358}