git_same/config/
workspace_store.rs1use super::parser::Config;
9use super::workspace::{tilde_collapse_path, WorkspaceConfig};
10use crate::errors::AppError;
11use std::path::{Path, PathBuf};
12
13pub const DOT_DIR: &str = ".git-same";
15pub const CONFIG_FILE: &str = "config.toml";
17pub const CACHE_FILE: &str = "cache.json";
19pub const SYNC_HISTORY_FILE: &str = "sync-history.json";
21
22pub struct WorkspaceStore;
24
25impl WorkspaceStore {
26 pub fn dot_dir(root: &Path) -> PathBuf {
28 root.join(DOT_DIR)
29 }
30
31 pub fn config_path(root: &Path) -> PathBuf {
33 Self::dot_dir(root).join(CONFIG_FILE)
34 }
35
36 pub fn cache_path(root: &Path) -> PathBuf {
38 Self::dot_dir(root).join(CACHE_FILE)
39 }
40
41 pub fn sync_history_path(root: &Path) -> PathBuf {
43 Self::dot_dir(root).join(SYNC_HISTORY_FILE)
44 }
45
46 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 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 pub fn save_with_registry_config_path(
72 workspace: &WorkspaceConfig,
73 global_config_path: &Path,
74 ) -> Result<(), AppError> {
75 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 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 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 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 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 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 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
261fn 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;