Coverage for src / moai_adk / core / context_manager.py: 23.60%
89 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-20 20:52 +0900
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-20 20:52 +0900
1"""
2Context Management Module for Commands Layer
4Provides utilities for:
51. Path validation and absolute path conversion
62. Atomic JSON file operations
73. Phase result persistence and loading
84. Template variable substitution
9"""
11import json
12import os
13import re
14import tempfile
15from datetime import datetime, timezone
16from typing import Any, Dict, Optional
18# Constants
19PROJECT_ROOT_SAFETY_MSG = "Path outside project root: {}"
20PARENT_DIR_MISSING_MSG = "Parent directory not found: {}"
23def _is_path_within_root(abs_path: str, project_root: str) -> bool:
24 """
25 Check if absolute path is within project root.
27 Resolves symlinks to prevent escape attacks.
29 Args:
30 abs_path: Absolute path to check
31 project_root: Project root directory
33 Returns:
34 True if path is within root, False otherwise
35 """
36 try:
37 real_abs_path = os.path.realpath(abs_path)
38 real_project_root = os.path.realpath(project_root)
40 return real_abs_path == real_project_root or real_abs_path.startswith(
41 real_project_root + os.sep
42 )
43 except OSError:
44 return False
47def validate_and_convert_path(relative_path: str, project_root: str) -> str:
48 """
49 Convert relative path to absolute path and validate it.
51 Ensures path stays within project root and parent directories exist
52 for file paths.
54 Args:
55 relative_path: Path to validate and convert (relative or absolute)
56 project_root: Project root directory for relative path resolution
58 Returns:
59 Validated absolute path
61 Raises:
62 ValueError: If path is outside project root
63 FileNotFoundError: If parent directory doesn't exist for file paths
64 """
65 # Convert to absolute path
66 abs_path = os.path.abspath(os.path.join(project_root, relative_path))
67 project_root_abs = os.path.abspath(project_root)
69 # Security check: ensure path stays within project root
70 if not _is_path_within_root(abs_path, project_root_abs):
71 raise ValueError(PROJECT_ROOT_SAFETY_MSG.format(abs_path))
73 # If it's a directory and exists, return it
74 if os.path.isdir(abs_path):
75 return abs_path
77 # For files, check if parent directory exists
78 parent_dir = os.path.dirname(abs_path)
79 if not os.path.exists(parent_dir):
80 raise FileNotFoundError(PARENT_DIR_MISSING_MSG.format(parent_dir))
82 return abs_path
85def _cleanup_temp_file(temp_fd: Optional[int], temp_path: Optional[str]) -> None:
86 """
87 Clean up temporary file handles and paths.
89 Silently ignores errors during cleanup.
91 Args:
92 temp_fd: File descriptor to close, or None
93 temp_path: Path to file to remove, or None
94 """
95 if temp_fd is not None:
96 try:
97 os.close(temp_fd)
98 except OSError:
99 pass
101 if temp_path and os.path.exists(temp_path):
102 try:
103 os.unlink(temp_path)
104 except OSError:
105 pass
108def save_phase_result(data: Dict[str, Any], target_path: str) -> None:
109 """
110 Atomically save phase result to JSON file.
112 Uses temporary file and atomic rename to ensure data integrity
113 even if write fails midway.
115 Args:
116 data: Dictionary to save
117 target_path: Full path where JSON should be saved
119 Raises:
120 IOError: If write or rename fails
121 OSError: If directory is not writable
122 """
123 target_dir = os.path.dirname(target_path)
124 os.makedirs(target_dir, exist_ok=True)
126 # Atomic write using temp file
127 temp_fd = None
128 temp_path = None
130 try:
131 # Create temp file in target directory for atomic rename
132 temp_fd, temp_path = tempfile.mkstemp(
133 dir=target_dir, prefix=".tmp_phase_", suffix=".json"
134 )
136 # Write JSON to temp file
137 with os.fdopen(temp_fd, "w", encoding="utf-8") as f:
138 json.dump(data, f, indent=2, ensure_ascii=False)
140 temp_fd = None # File handle is now closed
142 # Atomic rename
143 os.replace(temp_path, target_path)
145 except Exception as e:
146 _cleanup_temp_file(temp_fd, temp_path)
147 raise IOError(f"Failed to write {target_path}: {e}")
150def load_phase_result(source_path: str) -> Dict[str, Any]:
151 """
152 Load phase result from JSON file.
154 Args:
155 source_path: Full path to JSON file to load
157 Returns:
158 Dictionary containing phase result
160 Raises:
161 FileNotFoundError: If file doesn't exist
162 json.JSONDecodeError: If file is not valid JSON
163 """
164 if not os.path.exists(source_path):
165 raise FileNotFoundError(f"Phase result file not found: {source_path}")
167 with open(source_path, "r", encoding="utf-8") as f:
168 data = json.load(f)
170 return data
173def substitute_template_variables(text: str, context: Dict[str, str]) -> str:
174 """
175 Replace template variables in text with values from context.
177 Performs safe string substitution of {{VARIABLE}} placeholders.
179 Args:
180 text: Text containing {{VARIABLE}} placeholders
181 context: Dictionary mapping variable names to values
183 Returns:
184 Text with variables substituted
185 """
186 result = text
188 for key, value in context.items():
189 placeholder = f"{{{{{key}}}}}"
190 result = result.replace(placeholder, str(value))
192 return result
195# Regex pattern for detecting unsubstituted template variables
196# Matches {{VARIABLE}}, {{VAR_NAME}}, {{VAR1}}, etc.
197TEMPLATE_VAR_PATTERN = r"\{\{[A-Z_][A-Z0-9_]*\}\}"
200def validate_no_template_vars(text: str) -> None:
201 """
202 Validate that text contains no unsubstituted template variables.
204 Raises error if any {{VARIABLE}} patterns are found.
206 Args:
207 text: Text to validate
209 Raises:
210 ValueError: If unsubstituted variables are found
211 """
212 matches = re.findall(TEMPLATE_VAR_PATTERN, text)
214 if matches:
215 raise ValueError(f"Unsubstituted template variables found: {matches}")
218class ContextManager:
219 """
220 Manages context passing and state persistence for Commands layer.
222 Handles saving and loading phase results, managing state directory,
223 and providing convenient access to command state.
224 """
226 def __init__(self, project_root: str):
227 """
228 Initialize ContextManager.
230 Args:
231 project_root: Root directory of the project
232 """
233 self.project_root = project_root
234 self.state_dir = os.path.join(project_root, ".moai", "memory", "command-state")
235 os.makedirs(self.state_dir, exist_ok=True)
237 def save_phase_result(self, data: Dict[str, Any]) -> str:
238 """
239 Save phase result with timestamp.
241 Args:
242 data: Phase result data
244 Returns:
245 Path to saved file
246 """
247 # Generate filename with timestamp
248 timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
249 phase_name = data.get("phase", "unknown")
250 filename = f"{phase_name}-{timestamp}.json"
251 target_path = os.path.join(self.state_dir, filename)
253 save_phase_result(data, target_path)
254 return target_path
256 def load_latest_phase(self) -> Optional[Dict[str, Any]]:
257 """
258 Load the most recent phase result.
260 Returns:
261 Phase result dictionary or None if no phase files exist
262 """
263 # List all phase files
264 phase_files = sorted(
265 [f for f in os.listdir(self.state_dir) if f.endswith(".json")]
266 )
268 if not phase_files:
269 return None
271 # Load the latest (last in sorted order)
272 latest_file = phase_files[-1]
273 latest_path = os.path.join(self.state_dir, latest_file)
275 return load_phase_result(latest_path)
277 def get_state_dir(self) -> str:
278 """Get the command state directory path."""
279 return self.state_dir