Skip to main content

git_same/config/
workspace_store.rs

1//! Workspace persistence — stores workspace config inside the sync folder.
2//!
3//! Each workspace has a `.git-same/` directory inside its root that contains:
4//! - `config.toml`       — workspace configuration
5//! - `cache.json`        — discovery cache
6//! - `sync-history.json` — sync history
7
8use super::parser::Config;
9use super::workspace::{tilde_collapse_path, WorkspaceConfig};
10use crate::errors::AppError;
11use std::path::{Path, PathBuf};
12
13/// Name of the hidden workspace metadata directory.
14pub const DOT_DIR: &str = ".git-same";
15/// Config file name inside the `.git-same/` directory.
16pub const CONFIG_FILE: &str = "config.toml";
17/// Cache file name inside the `.git-same/` directory.
18pub const CACHE_FILE: &str = "cache.json";
19/// Sync history file name inside the `.git-same/` directory.
20pub const SYNC_HISTORY_FILE: &str = "sync-history.json";
21
22/// Filesystem-backed workspace store.
23pub struct WorkspaceStore;
24
25impl WorkspaceStore {
26    /// Returns the `.git-same/` directory for a workspace root.
27    pub fn dot_dir(root: &Path) -> PathBuf {
28        root.join(DOT_DIR)
29    }
30
31    /// Returns the config file path for a workspace root.
32    pub fn config_path(root: &Path) -> PathBuf {
33        Self::dot_dir(root).join(CONFIG_FILE)
34    }
35
36    /// Returns the cache file path for a workspace root.
37    pub fn cache_path(root: &Path) -> PathBuf {
38        Self::dot_dir(root).join(CACHE_FILE)
39    }
40
41    /// Returns the sync history file path for a workspace root.
42    pub fn sync_history_path(root: &Path) -> PathBuf {
43        Self::dot_dir(root).join(SYNC_HISTORY_FILE)
44    }
45
46    /// Load a workspace config from the given root directory.
47    ///
48    /// Reads `<root>/.git-same/config.toml` and sets `root_path` from the directory.
49    pub fn load(root: &Path) -> Result<WorkspaceConfig, AppError> {
50        let expanded = expand_path(root);
51        let config_path = Self::config_path(&expanded);
52        if !config_path.exists() {
53            return Err(AppError::config(format!(
54                "No workspace config found at '{}'",
55                config_path.display()
56            )));
57        }
58        Self::load_from_path(&config_path)
59    }
60
61    /// Save a workspace config to `<root>/.git-same/config.toml`.
62    ///
63    /// Creates the `.git-same/` directory if necessary and registers the workspace
64    /// in the global config registry.
65    pub fn save(workspace: &WorkspaceConfig) -> Result<(), AppError> {
66        let global_config_path = Config::default_path()?;
67        Self::save_with_registry_config_path(workspace, &global_config_path)
68    }
69
70    /// Save a workspace config and register it in a specific global config file.
71    pub fn save_with_registry_config_path(
72        workspace: &WorkspaceConfig,
73        global_config_path: &Path,
74    ) -> Result<(), AppError> {
75        // Preflight: avoid partial workspace writes when global config is missing.
76        if !global_config_path.exists() {
77            return Err(AppError::config(
78                "Config file not found. Run 'gisa init' first.",
79            ));
80        }
81
82        let dot_dir = Self::dot_dir(&workspace.root_path);
83        let dot_dir_existed = dot_dir.exists();
84        std::fs::create_dir_all(&dot_dir).map_err(|e| {
85            AppError::config(format!(
86                "Failed to create workspace directory '{}': {}",
87                dot_dir.display(),
88                e
89            ))
90        })?;
91
92        let config_path = dot_dir.join(CONFIG_FILE);
93        let previous_config_content = if config_path.exists() {
94            Some(std::fs::read_to_string(&config_path).map_err(|e| {
95                AppError::config(format!(
96                    "Failed to read existing workspace config at '{}': {}",
97                    config_path.display(),
98                    e
99                ))
100            })?)
101        } else {
102            None
103        };
104
105        let content = workspace.to_toml()?;
106        std::fs::write(&config_path, content).map_err(|e| {
107            AppError::config(format!(
108                "Failed to write workspace config at '{}': {}",
109                config_path.display(),
110                e
111            ))
112        })?;
113
114        // Register in global config
115        let tilde_path = tilde_collapse_path(&workspace.root_path);
116        if let Err(err) = Config::add_to_registry_at(global_config_path, &tilde_path) {
117            rollback_workspace_write(
118                &config_path,
119                previous_config_content.as_deref(),
120                &dot_dir,
121                !dot_dir_existed,
122            );
123            return Err(err);
124        }
125
126        Ok(())
127    }
128
129    /// List all registered workspace configs.
130    ///
131    /// Reads the global `workspaces` registry and loads each entry.
132    /// Stale entries (where the config file no longer exists) are silently skipped.
133    pub fn list() -> Result<Vec<WorkspaceConfig>, AppError> {
134        let global = Config::load()?;
135        let mut workspaces = Vec::new();
136
137        for path_str in &global.workspaces {
138            let expanded = shellexpand::tilde(path_str);
139            let root = Path::new(expanded.as_ref());
140            let config_path = Self::config_path(root);
141            if !config_path.exists() {
142                tracing::debug!(
143                    path = %path_str,
144                    "Skipping stale workspace registry entry"
145                );
146                continue;
147            }
148            match Self::load_from_path(&config_path) {
149                Ok(ws) => workspaces.push(ws),
150                Err(e) => {
151                    tracing::warn!(
152                        path = %config_path.display(),
153                        error = %e,
154                        "Skipping invalid workspace config"
155                    );
156                }
157            }
158        }
159
160        Ok(workspaces)
161    }
162
163    /// Delete a workspace by removing its `.git-same/` directory.
164    ///
165    /// Also removes the workspace from the global registry.
166    pub fn delete(root: &Path) -> Result<(), AppError> {
167        let expanded_root = expand_path(root);
168        let dot_dir = Self::dot_dir(&expanded_root);
169        if !dot_dir.exists() {
170            return Err(AppError::config(format!(
171                "No workspace config found at '{}'",
172                dot_dir.display()
173            )));
174        }
175
176        // Unregister from global config first so we don't leave stale registry entries
177        // when registry writes fail.
178        let tilde_path = tilde_collapse_path(&expanded_root);
179        Config::remove_from_registry(&tilde_path)?;
180
181        std::fs::remove_dir_all(&dot_dir).map_err(|e| {
182            AppError::config(format!(
183                "Failed to remove workspace at '{}': {}",
184                dot_dir.display(),
185                e
186            ))
187        })?;
188
189        Ok(())
190    }
191
192    /// Load a workspace config from a specific `.git-same/config.toml` path.
193    ///
194    /// Sets `root_path` from the parent of the `.git-same/` directory.
195    pub fn load_from_path(config_path: &Path) -> Result<WorkspaceConfig, AppError> {
196        let content = std::fs::read_to_string(config_path).map_err(|e| {
197            AppError::config(format!(
198                "Failed to read workspace config at '{}': {}",
199                config_path.display(),
200                e
201            ))
202        })?;
203        let mut ws = WorkspaceConfig::from_toml(&content)?;
204
205        // Derive root_path: parent of `.git-same/` directory
206        // config_path = <root>/.git-same/config.toml
207        // parent = <root>/.git-same/
208        // parent.parent = <root>/
209        if let Some(dot_dir) = config_path.parent() {
210            if let Some(root) = dot_dir.parent() {
211                ws.root_path = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
212            }
213        }
214
215        Ok(ws)
216    }
217}
218
219fn rollback_workspace_write(
220    config_path: &Path,
221    previous_config_content: Option<&str>,
222    dot_dir: &Path,
223    remove_dot_dir: bool,
224) {
225    match previous_config_content {
226        Some(previous) => {
227            if let Err(e) = std::fs::write(config_path, previous) {
228                tracing::warn!(
229                    path = %config_path.display(),
230                    error = %e,
231                    "Failed to restore previous workspace config during rollback"
232                );
233            }
234        }
235        None => {
236            if let Err(e) = std::fs::remove_file(config_path) {
237                if e.kind() != std::io::ErrorKind::NotFound {
238                    tracing::warn!(
239                        path = %config_path.display(),
240                        error = %e,
241                        "Failed to remove workspace config during rollback"
242                    );
243                }
244            }
245        }
246    }
247
248    if remove_dot_dir {
249        if let Err(e) = std::fs::remove_dir(dot_dir) {
250            if e.kind() != std::io::ErrorKind::NotFound {
251                tracing::warn!(
252                    path = %dot_dir.display(),
253                    error = %e,
254                    "Failed to remove workspace directory during rollback"
255                );
256            }
257        }
258    }
259}
260
261/// Expand a path: resolve `~` and make absolute.
262fn expand_path(path: &Path) -> PathBuf {
263    let s = path.to_string_lossy();
264    let expanded = shellexpand::tilde(&s);
265    let p = Path::new(expanded.as_ref());
266    std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
267}
268
269#[cfg(test)]
270#[path = "workspace_store_tests.rs"]
271mod tests;