Coverage for src/workstack/core/global_config_ops.py: 72%
133 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-19 09:31 -0400
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-19 09:31 -0400
1"""Global configuration operations interface and implementations."""
3import tomllib
4from abc import ABC, abstractmethod
5from pathlib import Path
6from typing import Final
8import click
11class _UnchangedType:
12 """Sentinel type for unchanged config values."""
14 pass
17_UNCHANGED: Final = _UnchangedType()
20class GlobalConfigOps(ABC):
21 """Abstract interface for global configuration operations.
23 All implementations (real and fake) must implement this interface.
24 This interface provides direct access to config fields via getters and a single
25 setter method using sentinel pattern for optional updates.
26 """
28 @abstractmethod
29 def get_workstacks_root(self) -> Path:
30 """Get workstacks root directory.
32 Returns:
33 Path to workstacks root directory
35 Raises:
36 FileNotFoundError: If config file doesn't exist
37 """
38 ...
40 @abstractmethod
41 def get_use_graphite(self) -> bool:
42 """Get graphite usage preference.
44 Returns:
45 True if graphite should be used, False otherwise
47 Raises:
48 FileNotFoundError: If config file doesn't exist
49 """
50 ...
52 @abstractmethod
53 def get_shell_setup_complete(self) -> bool:
54 """Get shell setup completion status.
56 Returns:
57 True if shell setup is complete, False otherwise
59 Raises:
60 FileNotFoundError: If config file doesn't exist
61 """
62 ...
64 @abstractmethod
65 def get_show_pr_info(self) -> bool:
66 """Get whether to show PR information in ls output.
68 Returns:
69 True if PR info should be shown, False otherwise
71 Raises:
72 FileNotFoundError: If config file doesn't exist
73 """
74 ...
76 @abstractmethod
77 def get_show_pr_checks(self) -> bool:
78 """Get whether to show CI check status in PR info.
80 When True, fetches CI status from GitHub API (slower).
81 When False, only shows basic PR info from Graphite cache (faster).
83 Returns:
84 True if CI check status should be shown, False otherwise
86 Raises:
87 FileNotFoundError: If config file doesn't exist
88 """
89 ...
91 @abstractmethod
92 def set(
93 self,
94 *,
95 workstacks_root: Path | _UnchangedType = _UNCHANGED,
96 use_graphite: bool | _UnchangedType = _UNCHANGED,
97 shell_setup_complete: bool | _UnchangedType = _UNCHANGED,
98 show_pr_info: bool | _UnchangedType = _UNCHANGED,
99 show_pr_checks: bool | _UnchangedType = _UNCHANGED,
100 ) -> None:
101 """Update config fields. Only provided fields are changed.
103 Creates config file if it doesn't exist. Only fields that are not _UNCHANGED
104 will be updated. At least one field must be provided.
106 Args:
107 workstacks_root: New workstacks root, or _UNCHANGED to keep current
108 use_graphite: New graphite preference, or _UNCHANGED to keep current
109 shell_setup_complete: New shell setup status, or _UNCHANGED to keep current
110 show_pr_info: New PR info display preference, or _UNCHANGED to keep current
111 show_pr_checks: New CI check display preference, or _UNCHANGED to keep current
113 Raises:
114 ValueError: If all fields are _UNCHANGED (nothing to update)
115 """
116 ...
118 @abstractmethod
119 def exists(self) -> bool:
120 """Check if global config file exists.
122 Returns:
123 True if config exists, False otherwise
124 """
125 ...
127 @abstractmethod
128 def get_path(self) -> Path:
129 """Get the path to the global config file.
131 Returns:
132 Path to config file (for error messages and debugging)
133 """
134 ...
137# ============================================================================
138# Production Implementation
139# ============================================================================
142class RealGlobalConfigOps(GlobalConfigOps):
143 """Production implementation using ~/.workstack/config.toml with lazy loading."""
145 def __init__(self) -> None:
146 self._path = Path.home() / ".workstack" / "config.toml"
147 self._cache: dict[str, Path | bool] | None = None
149 def _load_cache(self) -> dict[str, Path | bool]:
150 """Load config from disk and cache it."""
151 if not self._path.exists():
152 raise FileNotFoundError(f"Global config not found at {self._path}")
154 data = tomllib.loads(self._path.read_text(encoding="utf-8"))
155 root = data.get("workstacks_root")
156 if not root:
157 raise ValueError(f"Missing 'workstacks_root' in {self._path}")
159 return {
160 "workstacks_root": Path(root).expanduser().resolve(),
161 "use_graphite": bool(data.get("use_graphite", False)),
162 "shell_setup_complete": bool(data.get("shell_setup_complete", False)),
163 "show_pr_info": bool(data.get("show_pr_info", True)),
164 "show_pr_checks": bool(data.get("show_pr_checks", False)),
165 }
167 def _ensure_cache(self) -> dict[str, Path | bool]:
168 """Ensure cache is loaded and return it."""
169 if self._cache is None:
170 self._cache = self._load_cache()
171 return self._cache
173 def _invalidate_cache(self) -> None:
174 """Invalidate cache after writes."""
175 self._cache = None
177 def get_workstacks_root(self) -> Path:
178 cache = self._ensure_cache()
179 result = cache["workstacks_root"]
180 if not isinstance(result, Path):
181 raise TypeError(f"Expected Path, got {type(result)}")
182 return result
184 def get_use_graphite(self) -> bool:
185 cache = self._ensure_cache()
186 result = cache["use_graphite"]
187 if not isinstance(result, bool):
188 raise TypeError(f"Expected bool, got {type(result)}")
189 return result
191 def get_shell_setup_complete(self) -> bool:
192 cache = self._ensure_cache()
193 result = cache["shell_setup_complete"]
194 if not isinstance(result, bool):
195 raise TypeError(f"Expected bool, got {type(result)}")
196 return result
198 def get_show_pr_info(self) -> bool:
199 cache = self._ensure_cache()
200 result = cache["show_pr_info"]
201 if not isinstance(result, bool):
202 raise TypeError(f"Expected bool, got {type(result)}")
203 return result
205 def get_show_pr_checks(self) -> bool:
206 cache = self._ensure_cache()
207 result = cache["show_pr_checks"]
208 if not isinstance(result, bool):
209 raise TypeError(f"Expected bool, got {type(result)}")
210 return result
212 def set(
213 self,
214 *,
215 workstacks_root: Path | _UnchangedType = _UNCHANGED,
216 use_graphite: bool | _UnchangedType = _UNCHANGED,
217 shell_setup_complete: bool | _UnchangedType = _UNCHANGED,
218 show_pr_info: bool | _UnchangedType = _UNCHANGED,
219 show_pr_checks: bool | _UnchangedType = _UNCHANGED,
220 ) -> None:
221 # Check if at least one field is being updated
222 if (
223 isinstance(workstacks_root, _UnchangedType)
224 and isinstance(use_graphite, _UnchangedType)
225 and isinstance(shell_setup_complete, _UnchangedType)
226 and isinstance(show_pr_info, _UnchangedType)
227 and isinstance(show_pr_checks, _UnchangedType)
228 ):
229 raise ValueError("At least one field must be provided")
231 # Get current values (if config exists), or use defaults
232 if self.exists():
233 current_root = self.get_workstacks_root()
234 current_graphite = self.get_use_graphite()
235 current_shell = self.get_shell_setup_complete()
236 current_pr_info = self.get_show_pr_info()
237 current_pr_checks = self.get_show_pr_checks()
238 else:
239 # For new config, all fields must be provided (no defaults)
240 if isinstance(workstacks_root, _UnchangedType):
241 raise ValueError("workstacks_root must be provided for new config")
242 current_root = workstacks_root
243 current_graphite = False
244 current_shell = False
245 current_pr_info = True
246 current_pr_checks = False
248 # Apply updates
249 final_root = (
250 current_root if isinstance(workstacks_root, _UnchangedType) else workstacks_root
251 )
252 final_graphite = (
253 current_graphite if isinstance(use_graphite, _UnchangedType) else use_graphite
254 )
255 final_shell = (
256 current_shell
257 if isinstance(shell_setup_complete, _UnchangedType)
258 else shell_setup_complete
259 )
260 final_pr_info = (
261 current_pr_info if isinstance(show_pr_info, _UnchangedType) else show_pr_info
262 )
263 final_pr_checks = (
264 current_pr_checks if isinstance(show_pr_checks, _UnchangedType) else show_pr_checks
265 )
267 # Write to disk
268 self._path.parent.mkdir(parents=True, exist_ok=True)
269 content = f"""# Global workstack configuration
270workstacks_root = "{final_root}"
271use_graphite = {str(final_graphite).lower()}
272shell_setup_complete = {str(final_shell).lower()}
273show_pr_info = {str(final_pr_info).lower()}
274show_pr_checks = {str(final_pr_checks).lower()}
275"""
276 self._path.write_text(content, encoding="utf-8")
277 self._invalidate_cache()
279 def exists(self) -> bool:
280 return self._path.exists()
282 def get_path(self) -> Path:
283 return self._path
286# ============================================================================
287# Dry-Run Wrapper
288# ============================================================================
291class DryRunGlobalConfigOps(GlobalConfigOps):
292 """Dry-run wrapper for global config operations.
294 Read operations are delegated to the wrapped implementation.
295 Write operations print dry-run messages instead of modifying the config file.
296 """
298 def __init__(self, wrapped: GlobalConfigOps) -> None:
299 """Initialize dry-run wrapper with a real implementation.
301 Args:
302 wrapped: The real global config operations implementation to wrap
303 """
304 self._wrapped = wrapped
306 def get_workstacks_root(self) -> Path:
307 """Delegate read operation to wrapped implementation."""
308 return self._wrapped.get_workstacks_root()
310 def get_use_graphite(self) -> bool:
311 """Delegate read operation to wrapped implementation."""
312 return self._wrapped.get_use_graphite()
314 def get_shell_setup_complete(self) -> bool:
315 """Delegate read operation to wrapped implementation."""
316 return self._wrapped.get_shell_setup_complete()
318 def get_show_pr_info(self) -> bool:
319 """Delegate read operation to wrapped implementation."""
320 return self._wrapped.get_show_pr_info()
322 def get_show_pr_checks(self) -> bool:
323 """Delegate read operation to wrapped implementation."""
324 return self._wrapped.get_show_pr_checks()
326 def set(
327 self,
328 *,
329 workstacks_root: Path | _UnchangedType = _UNCHANGED,
330 use_graphite: bool | _UnchangedType = _UNCHANGED,
331 shell_setup_complete: bool | _UnchangedType = _UNCHANGED,
332 show_pr_info: bool | _UnchangedType = _UNCHANGED,
333 show_pr_checks: bool | _UnchangedType = _UNCHANGED,
334 ) -> None:
335 """Print dry-run message instead of updating config."""
336 updates: list[str] = []
337 if not isinstance(workstacks_root, _UnchangedType):
338 updates.append(f"workstacks_root={workstacks_root}")
339 if not isinstance(use_graphite, _UnchangedType):
340 updates.append(f"use_graphite={use_graphite}")
341 if not isinstance(shell_setup_complete, _UnchangedType):
342 updates.append(f"shell_setup_complete={shell_setup_complete}")
343 if not isinstance(show_pr_info, _UnchangedType):
344 updates.append(f"show_pr_info={show_pr_info}")
345 if not isinstance(show_pr_checks, _UnchangedType):
346 updates.append(f"show_pr_checks={show_pr_checks}")
348 if updates:
349 click.echo(f"[DRY RUN] Would update config: {', '.join(updates)}", err=True)
351 def exists(self) -> bool:
352 """Delegate read operation to wrapped implementation."""
353 return self._wrapped.exists()
355 def get_path(self) -> Path:
356 """Delegate read operation to wrapped implementation."""
357 return self._wrapped.get_path()