Skip to main content

torii_lib/workspace/
workspace.rs

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/// Per-repo status inside a workspace sweep. When the repo couldn't be
81/// inspected, `error` is set and the numeric fields are zeroed.
82#[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/// Outcome of saving one repo in a workspace sweep.
96#[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/// Outcome of syncing one repo in a workspace sweep — `error` is `None`
110/// on success.
111#[derive(Debug, Serialize)]
112pub struct WorkspaceSyncResult {
113    pub name: String,
114    pub error: Option<String>,
115}
116
117/// One workspace with the existence state of each member repo.
118#[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    /// Status of every repo in the workspace. Per-repo failures land in
139    /// the entry's `error` field instead of aborting the sweep.
140    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    /// Commit pending changes in every repo of the workspace. `on_repo`
166    /// fires as each repo finishes so callers can stream progress (CLI
167    /// prints a line, the IDE emits an event).
168    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    /// Pull + push every repo of the workspace. `on_repo` fires as each
197    /// repo finishes so callers can stream progress.
198    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    /// Every configured workspace with the on-disk existence of its repos.
222    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    /// Register a repo in a workspace. Returns the expanded path.
242    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        // Ahead/behind vs origin
303        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        // Check for changes
320        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        // Re-check after staging
334        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        // Check if there's actually something staged
340        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}