Coverage for src / moai_adk / core / template / merger.py: 15.91%

88 statements  

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

1"""Template file merger (SPEC-INIT-003 v0.3.0). 

2 

3Intelligently merges existing user files with new templates. 

4""" 

5 

6from __future__ import annotations 

7 

8import json 

9import shutil 

10from pathlib import Path 

11from typing import Any 

12 

13 

14class TemplateMerger: 

15 """Encapsulate template merging logic.""" 

16 

17 PROJECT_INFO_HEADERS = ( 

18 "## Project Information", 

19 "## Project Information", # Keep for backward compatibility 

20 "## Project Info", 

21 ) 

22 

23 def __init__(self, target_path: Path) -> None: 

24 """Initialize the merger. 

25 

26 Args: 

27 target_path: Project path (absolute). 

28 """ 

29 self.target_path = target_path.resolve() 

30 

31 def merge_claude_md(self, template_path: Path, existing_path: Path) -> None: 

32 """Smart merge for CLAUDE.md. 

33 

34 Rules: 

35 - Use the latest template structure/content. 

36 - Preserve the existing "## Project Information" section. 

37 

38 Args: 

39 template_path: Template CLAUDE.md. 

40 existing_path: Existing CLAUDE.md. 

41 """ 

42 # Extract the existing project information section 

43 existing_content = existing_path.read_text(encoding="utf-8") 

44 project_info_start, _ = self._find_project_info_section(existing_content) 

45 project_info = "" 

46 if project_info_start != -1: 

47 # Extract until EOF 

48 project_info = existing_content[project_info_start:] 

49 

50 # Load template content 

51 template_content = template_path.read_text(encoding="utf-8") 

52 

53 # Merge when project info exists 

54 if project_info: 

55 # Remove the project info section from the template 

56 template_project_start, _ = self._find_project_info_section( 

57 template_content 

58 ) 

59 if template_project_start != -1: 

60 template_content = template_content[:template_project_start].rstrip() 

61 

62 # Merge template content with the preserved section 

63 merged_content = f"{template_content}\n\n{project_info}" 

64 existing_path.write_text(merged_content, encoding="utf-8") 

65 else: 

66 # No project info; copy the template as-is 

67 shutil.copy2(template_path, existing_path) 

68 

69 def _find_project_info_section(self, content: str) -> tuple[int, str | None]: 

70 """Find the project information header in the given content.""" 

71 for header in self.PROJECT_INFO_HEADERS: 

72 index = content.find(header) 

73 if index != -1: 

74 return index, header 

75 return -1, None 

76 

77 def merge_gitignore(self, template_path: Path, existing_path: Path) -> None: 

78 """.gitignore merge. 

79 

80 Rules: 

81 - Keep existing entries. 

82 - Add new entries from the template. 

83 - Remove duplicates. 

84 

85 Args: 

86 template_path: Template .gitignore file. 

87 existing_path: Existing .gitignore file. 

88 """ 

89 template_lines = set(template_path.read_text(encoding="utf-8").splitlines()) 

90 existing_lines = existing_path.read_text(encoding="utf-8").splitlines() 

91 

92 # Merge while removing duplicates 

93 merged_lines = existing_lines + [ 

94 line for line in template_lines if line not in existing_lines 

95 ] 

96 

97 existing_path.write_text("\n".join(merged_lines) + "\n", encoding="utf-8") 

98 

99 def merge_config(self, detected_language: str | None = None) -> dict[str, str]: 

100 """Smart merge for config.json. 

101 

102 Rules: 

103 - Prefer existing settings. 

104 - Use detected language plus defaults for new projects. 

105 

106 Args: 

107 detected_language: Detected language. 

108 

109 Returns: 

110 Merged configuration dictionary. 

111 """ 

112 config_path = self.target_path / ".moai" / "config" / "config.json" 

113 

114 # Load existing config if present 

115 existing_config: dict[str, Any] = {} 

116 if config_path.exists(): 

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

118 existing_config = json.load(f) 

119 

120 # Build new config while preferring existing values 

121 new_config: dict[str, str] = { 

122 "projectName": existing_config.get("projectName", self.target_path.name), 

123 "mode": existing_config.get("mode", "personal"), 

124 "locale": existing_config.get("locale", "ko"), 

125 "language": existing_config.get("language", detected_language or "generic"), 

126 } 

127 

128 return new_config 

129 

130 def merge_settings_json( 

131 self, template_path: Path, existing_path: Path, backup_path: Path | None = None 

132 ) -> None: 

133 """Smart merge for .claude/settings.json. 

134 

135 Rules: 

136 - env: shallow merge (user variables preserved) 

137 - permissions.allow: array merge (deduplicated) 

138 - permissions.deny: template priority (security) 

139 - hooks: template priority 

140 

141 Args: 

142 template_path: Template settings.json. 

143 existing_path: Existing settings.json. 

144 backup_path: Backup settings.json (optional, for user settings extraction). 

145 """ 

146 # Load template 

147 template_data = json.loads(template_path.read_text(encoding="utf-8")) 

148 

149 # Load backup or existing for user settings 

150 user_data: dict[str, Any] = {} 

151 if backup_path and backup_path.exists(): 

152 user_data = json.loads(backup_path.read_text(encoding="utf-8")) 

153 elif existing_path.exists(): 

154 user_data = json.loads(existing_path.read_text(encoding="utf-8")) 

155 

156 # Merge env (shallow merge, user variables preserved) 

157 merged_env = {**template_data.get("env", {}), **user_data.get("env", {})} 

158 

159 # Merge permissions.allow (deduplicated array merge) 

160 template_allow = set(template_data.get("permissions", {}).get("allow", [])) 

161 user_allow = set(user_data.get("permissions", {}).get("allow", [])) 

162 merged_allow = sorted(template_allow | user_allow) 

163 

164 # permissions.deny: template priority (security) 

165 merged_deny = template_data.get("permissions", {}).get("deny", []) 

166 

167 # permissions.ask: template priority + user additions 

168 template_ask = set(template_data.get("permissions", {}).get("ask", [])) 

169 user_ask = set(user_data.get("permissions", {}).get("ask", [])) 

170 merged_ask = sorted(template_ask | user_ask) 

171 

172 # Start with full template (include all fields from template) 

173 merged = template_data.copy() 

174 

175 # Override with merged values 

176 merged["env"] = merged_env 

177 merged["permissions"] = { 

178 "defaultMode": template_data.get("permissions", {}).get( 

179 "defaultMode", "default" 

180 ), 

181 "allow": merged_allow, 

182 "ask": merged_ask, 

183 "deny": merged_deny, 

184 } 

185 

186 # Preserve user customizations for specific fields (if exist in backup/existing) 

187 preserve_fields = ["outputStyle", "spinnerTipsEnabled"] 

188 for field in preserve_fields: 

189 if field in user_data: 

190 merged[field] = user_data[field] 

191 

192 existing_path.write_text( 

193 json.dumps(merged, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" 

194 ) 

195 

196 def merge_github_workflows(self, template_dir: Path, existing_dir: Path) -> None: 

197 """Smart merge for .github/workflows/ directory. 

198 

199 Rules: 

200 - Preserve existing user workflows (never delete) 

201 - Add/update only MoAI-ADK managed workflows (moai-*.yml) 

202 - Copy other template directories (ISSUE_TEMPLATE/, PULL_REQUEST_TEMPLATE.md) 

203 

204 Args: 

205 template_dir: Template .github directory. 

206 existing_dir: Existing .github directory. 

207 """ 

208 import shutil 

209 

210 # Ensure workflows directory exists 

211 workflows_dir = existing_dir / "workflows" 

212 workflows_dir.mkdir(exist_ok=True) 

213 

214 # Track existing user workflows for preservation 

215 user_workflows = set() 

216 if workflows_dir.exists(): 

217 for workflow_file in workflows_dir.glob("*.yml"): 

218 user_workflows.add(workflow_file.name) 

219 

220 # Copy template contents with smart merge for workflows 

221 for item in template_dir.rglob("*"): 

222 if item.is_file(): 

223 rel_path = item.relative_to(template_dir) 

224 dst_item = existing_dir / rel_path 

225 

226 # Handle workflow files specially 

227 if rel_path.parent.name == "workflows" and rel_path.name.endswith( 

228 ".yml" 

229 ): 

230 # Only update MoAI-ADK managed workflows (moai-*.yml) 

231 if rel_path.name.startswith("moai-"): 

232 dst_item.parent.mkdir(parents=True, exist_ok=True) 

233 shutil.copy2(item, dst_item) 

234 # Skip non-moai workflows to preserve user custom workflows 

235 continue 

236 

237 # Copy non-workflow files normally 

238 dst_item.parent.mkdir(parents=True, exist_ok=True) 

239 shutil.copy2(item, dst_item) 

240 

241 elif item.is_dir(): 

242 # Create directories as needed 

243 rel_path = item.relative_to(template_dir) 

244 dst_item = existing_dir / rel_path 

245 dst_item.mkdir(parents=True, exist_ok=True)