Coverage for src / moai_adk / core / command_helpers.py: 20.55%

73 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-11-20 20:52 +0900

1""" 

2Command Helper Utilities 

3 

4Provides helper functions for commands to interact with ContextManager 

5and perform common operations like context extraction and validation. 

6""" 

7 

8import json 

9import os 

10from datetime import datetime, timezone 

11from typing import Any, Dict, List, Optional 

12 

13# Conditional import of ContextManager 

14try: 

15 from moai_adk.core.context_manager import ( 

16 ContextManager, 

17 validate_and_convert_path, 

18 validate_no_template_vars, 

19 ) 

20 

21 CONTEXT_MANAGER_AVAILABLE = True 

22except ImportError: 

23 CONTEXT_MANAGER_AVAILABLE = False 

24 

25 

26def extract_project_metadata(project_root: str) -> Dict[str, Any]: 

27 """ 

28 Extract project metadata from config.json. 

29 

30 Args: 

31 project_root: Root directory of the project 

32 

33 Returns: 

34 Dictionary containing project metadata 

35 

36 Raises: 

37 FileNotFoundError: If config.json doesn't exist 

38 json.JSONDecodeError: If config.json is invalid 

39 """ 

40 config_path = os.path.join(project_root, ".moai", "config", "config.json") 

41 

42 if not os.path.exists(config_path): 

43 raise FileNotFoundError(f"Config file not found: {config_path}") 

44 

45 with open(config_path, "r", encoding="utf-8") as f: 

46 config = json.load(f) 

47 

48 # Extract key metadata 

49 metadata = { 

50 "project_name": config.get("project", {}).get("name", "Unknown"), 

51 "mode": config.get("project", {}).get("mode", "personal"), 

52 "owner": config.get("project", {}).get("owner", "@user"), 

53 "language": config.get("language", {}).get("conversation_language", "en"), 

54 "tech_stack": [], # To be detected separately 

55 } 

56 

57 return metadata 

58 

59 

60def detect_tech_stack(project_root: str) -> List[str]: 

61 """ 

62 Detect primary tech stack from project structure. 

63 

64 Checks for common project indicator files. 

65 

66 Args: 

67 project_root: Root directory of the project 

68 

69 Returns: 

70 List of detected languages/frameworks 

71 """ 

72 indicators = { 

73 "pyproject.toml": "python", 

74 "package.json": "javascript", 

75 "go.mod": "go", 

76 "Cargo.toml": "rust", 

77 "pom.xml": "java", 

78 "Gemfile": "ruby", 

79 } 

80 

81 tech_stack = [] 

82 

83 for indicator_file, language in indicators.items(): 

84 if os.path.exists(os.path.join(project_root, indicator_file)): 

85 tech_stack.append(language) 

86 

87 # Default to python if nothing detected 

88 if not tech_stack: 

89 tech_stack.append("python") 

90 

91 return tech_stack 

92 

93 

94def build_phase_result( 

95 phase_name: str, 

96 status: str, 

97 outputs: Dict[str, Any], 

98 files_created: List[str], 

99 next_phase: Optional[str] = None, 

100) -> Dict[str, Any]: 

101 """ 

102 Build standardized phase result dictionary. 

103 

104 Args: 

105 phase_name: Name of the phase (e.g., "0-project") 

106 status: Phase status (completed/error/interrupted) 

107 outputs: Dictionary of phase outputs 

108 files_created: List of created files (absolute paths) 

109 next_phase: Optional next phase name 

110 

111 Returns: 

112 Standardized phase result dictionary 

113 """ 

114 phase_result = { 

115 "phase": phase_name, 

116 "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), 

117 "status": status, 

118 "outputs": outputs, 

119 "files_created": files_created, 

120 } 

121 

122 if next_phase: 

123 phase_result["next_phase"] = next_phase 

124 

125 return phase_result 

126 

127 

128def validate_phase_files(relative_paths: List[str], project_root: str) -> List[str]: 

129 """ 

130 Validate and convert relative file paths to absolute paths. 

131 

132 Handles errors gracefully by logging warnings and skipping invalid paths. 

133 

134 Args: 

135 relative_paths: List of relative file paths 

136 project_root: Project root directory 

137 

138 Returns: 

139 List of validated absolute paths 

140 """ 

141 if not CONTEXT_MANAGER_AVAILABLE: 

142 # Fallback: simple absolute path conversion 

143 return [os.path.abspath(os.path.join(project_root, p)) for p in relative_paths] 

144 

145 absolute_paths = [] 

146 

147 for rel_path in relative_paths: 

148 try: 

149 abs_path = validate_and_convert_path(rel_path, project_root) 

150 absolute_paths.append(abs_path) 

151 except (ValueError, FileNotFoundError) as e: 

152 # Log warning but continue processing 

153 print(f"Warning: Could not validate path '{rel_path}': {e}") 

154 

155 return absolute_paths 

156 

157 

158def _prepare_phase_data( 

159 phase_name: str, 

160 status: str, 

161 outputs: Dict[str, Any], 

162 absolute_paths: List[str], 

163 next_phase: Optional[str], 

164) -> Dict[str, Any]: 

165 """ 

166 Prepare phase data for saving. 

167 

168 Args: 

169 phase_name: Name of the phase 

170 status: Phase status 

171 outputs: Phase outputs 

172 absolute_paths: List of absolute file paths 

173 next_phase: Optional next phase 

174 

175 Returns: 

176 Phase data dictionary ready for saving 

177 """ 

178 phase_data = build_phase_result( 

179 phase_name=phase_name, 

180 status=status, 

181 outputs=outputs, 

182 files_created=absolute_paths, 

183 next_phase=next_phase, 

184 ) 

185 

186 # Validate no unsubstituted template variables 

187 phase_json = json.dumps(phase_data) 

188 validate_no_template_vars(phase_json) 

189 

190 return phase_data 

191 

192 

193def _validate_and_save(context_mgr: Any, phase_data: Dict[str, Any]) -> str: 

194 """ 

195 Validate and save phase data. 

196 

197 Args: 

198 context_mgr: ContextManager instance 

199 phase_data: Phase data to save 

200 

201 Returns: 

202 Path to saved file 

203 """ 

204 saved_path = context_mgr.save_phase_result(phase_data) 

205 print(f"✓ Phase context saved: {os.path.basename(saved_path)}") 

206 return saved_path 

207 

208 

209def save_command_context( 

210 phase_name: str, 

211 project_root: str, 

212 outputs: Dict[str, Any], 

213 files_created: List[str], 

214 next_phase: Optional[str] = None, 

215 status: str = "completed", 

216) -> Optional[str]: 

217 """ 

218 Save command phase context using ContextManager. 

219 

220 This is a convenience wrapper for commands to save phase results 

221 with proper error handling. 

222 

223 Args: 

224 phase_name: Name of the phase (e.g., "0-project") 

225 project_root: Project root directory 

226 outputs: Phase-specific outputs 

227 files_created: List of relative file paths 

228 next_phase: Optional next phase recommendation 

229 status: Phase status (default: "completed") 

230 

231 Returns: 

232 Path to saved file, or None if save failed 

233 """ 

234 if not CONTEXT_MANAGER_AVAILABLE: 

235 print("Warning: ContextManager not available. Phase context not saved.") 

236 return None 

237 

238 try: 

239 context_mgr = ContextManager(project_root) 

240 absolute_paths = validate_phase_files(files_created, project_root) 

241 phase_data = _prepare_phase_data( 

242 phase_name, status, outputs, absolute_paths, next_phase 

243 ) 

244 return _validate_and_save(context_mgr, phase_data) 

245 

246 except Exception as e: 

247 print(f"Warning: Failed to save phase context: {e}") 

248 print("Command execution continues normally.") 

249 return None 

250 

251 

252def load_previous_phase(project_root: str) -> Optional[Dict[str, Any]]: 

253 """ 

254 Load the most recent phase result. 

255 

256 Args: 

257 project_root: Project root directory 

258 

259 Returns: 

260 Phase result dictionary, or None if unavailable 

261 """ 

262 if not CONTEXT_MANAGER_AVAILABLE: 

263 return None 

264 

265 try: 

266 context_mgr = ContextManager(project_root) 

267 return context_mgr.load_latest_phase() 

268 except Exception as e: 

269 print(f"Warning: Could not load previous phase: {e}") 

270 return None