1use anyhow::Result;
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use tokio::fs;
12
13use crate::patterns::{DxToolType, PatternMatch};
14use crate::storage::r2::{R2Config, R2Storage};
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ComponentMetadata {
19 pub name: String,
20 pub version: String,
21 pub tool: String,
22 pub hash: String,
23 pub size: usize,
24 pub dependencies: Vec<String>,
25 pub exports: Vec<String>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct CacheEntry {
31 pub metadata: ComponentMetadata,
32 pub local_path: PathBuf,
33 pub cached_at: chrono::DateTime<chrono::Utc>,
34 pub last_used: chrono::DateTime<chrono::Utc>,
35}
36
37pub struct InjectionManager {
39 cache_dir: PathBuf,
40 cache_index: HashMap<String, CacheEntry>,
41 r2_storage: Option<R2Storage>,
42}
43
44impl InjectionManager {
45 pub fn new(forge_dir: &Path) -> Result<Self> {
47 let cache_dir = forge_dir.join("component_cache");
48 std::fs::create_dir_all(&cache_dir)?;
49
50 let cache_index_path = cache_dir.join("index.json");
51 let cache_index = if cache_index_path.exists() {
52 let content = std::fs::read_to_string(&cache_index_path)?;
53 serde_json::from_str(&content).unwrap_or_default()
54 } else {
55 HashMap::new()
56 };
57
58 let r2_storage = R2Config::from_env()
60 .ok()
61 .and_then(|config| R2Storage::new(config).ok());
62
63 Ok(Self {
64 cache_dir,
65 cache_index,
66 r2_storage,
67 })
68 }
69
70 fn cache_key(&self, tool: &DxToolType, component: &str) -> String {
72 format!("{}/{}", tool.tool_name(), component)
73 }
74
75 pub fn is_cached(&self, tool: &DxToolType, component: &str) -> bool {
77 let key = self.cache_key(tool, component);
78 if let Some(entry) = self.cache_index.get(&key) {
79 entry.local_path.exists()
80 } else {
81 false
82 }
83 }
84
85 pub async fn get_cached(
87 &mut self,
88 tool: &DxToolType,
89 component: &str,
90 ) -> Result<Option<String>> {
91 let key = self.cache_key(tool, component);
92
93 if let Some(entry) = self.cache_index.get_mut(&key) {
94 entry.last_used = chrono::Utc::now();
96 let local_path = entry.local_path.clone();
97 self.save_index()?;
98
99 let content = fs::read_to_string(&local_path).await?;
100 Ok(Some(content))
101 } else {
102 Ok(None)
103 }
104 }
105
106 pub async fn fetch_component(
108 &mut self,
109 tool: &DxToolType,
110 component: &str,
111 version: Option<&str>,
112 ) -> Result<String> {
113 if let Some(cached) = self.get_cached(tool, component).await? {
115 return Ok(cached);
116 }
117
118 if let Some(_r2) = &self.r2_storage {
120 let _r2_key = format!(
121 "components/{}/{}/{}",
122 tool.tool_name(),
123 version.unwrap_or("latest"),
124 component
125 );
126
127 let content = self.create_placeholder_component(tool, component);
130
131 self.cache_component(tool, component, &content).await?;
133
134 Ok(content)
135 } else {
136 let content = self.create_placeholder_component(tool, component);
138 self.cache_component(tool, component, &content).await?;
139 Ok(content)
140 }
141 }
142
143 async fn cache_component(
145 &mut self,
146 tool: &DxToolType,
147 component: &str,
148 content: &str,
149 ) -> Result<()> {
150 let key = self.cache_key(tool, component);
151
152 let tool_cache_dir = self.cache_dir.join(tool.tool_name());
154 fs::create_dir_all(&tool_cache_dir).await?;
155
156 let extension = match tool {
158 DxToolType::Ui => "tsx",
159 DxToolType::Icons => "tsx",
160 DxToolType::Fonts => "css",
161 DxToolType::Style => "css",
162 DxToolType::Auth => "ts",
163 _ => "ts",
164 };
165
166 let local_path = tool_cache_dir.join(format!("{}.{}", component, extension));
167
168 fs::write(&local_path, content).await?;
170
171 let mut hasher = Sha256::new();
173 hasher.update(content.as_bytes());
174 let hash = format!("{:x}", hasher.finalize());
175
176 let metadata = ComponentMetadata {
178 name: component.to_string(),
179 version: "latest".to_string(),
180 tool: tool.tool_name().to_string(),
181 hash,
182 size: content.len(),
183 dependencies: vec![],
184 exports: vec![component.to_string()],
185 };
186
187 let entry = CacheEntry {
188 metadata,
189 local_path,
190 cached_at: chrono::Utc::now(),
191 last_used: chrono::Utc::now(),
192 };
193
194 self.cache_index.insert(key, entry);
195 self.save_index()?;
196
197 Ok(())
198 }
199
200 fn create_placeholder_component(&self, tool: &DxToolType, component: &str) -> String {
202 match tool {
203 DxToolType::Ui => format!(
204 r#"// Auto-generated dx-ui component: {}
205import React from 'react';
206
207export interface {}Props {{
208 children?: React.ReactNode;
209 className?: string;
210}}
211
212export function {}({{ children, className }}: {}Props) {{
213 return (
214 <div className={{className}}>
215 {{children}}
216 </div>
217 );
218}}
219
220export default {};
221"#,
222 component, component, component, component, component
223 ),
224 DxToolType::Icons => format!(
225 r#"// Auto-generated dx-icons component: {}
226import React from 'react';
227
228export interface {}Props {{
229 size?: number;
230 color?: string;
231 className?: string;
232}}
233
234export function {}({{ size = 24, color = 'currentColor', className }}: {}Props) {{
235 return (
236 <svg width={{size}} height={{size}} fill={{color}} className={{className}}>
237 <path d="M12 2L2 22h20L12 2z" />
238 </svg>
239 );
240}}
241
242export default {};
243"#,
244 component, component, component, component, component
245 ),
246 DxToolType::Fonts => format!(
247 r#"/* Auto-generated dx-fonts: {} */
248@font-face {{
249 font-family: '{}';
250 src: url('https://fonts.dx.tools/{}/regular.woff2') format('woff2');
251 font-weight: 400;
252 font-style: normal;
253 font-display: swap;
254}}
255
256.font-{} {{
257 font-family: '{}', sans-serif;
258}}
259"#,
260 component, component, component, component, component
261 ),
262 _ => format!("// Placeholder for {} from {}", component, tool.tool_name()),
263 }
264 }
265
266 pub async fn inject_into_file(
268 &mut self,
269 file_path: &Path,
270 matches: &[PatternMatch],
271 ) -> Result<()> {
272 let mut content = fs::read_to_string(file_path).await?;
273
274 let mut by_tool: HashMap<DxToolType, Vec<&PatternMatch>> = HashMap::new();
276 for m in matches {
277 by_tool.entry(m.tool.clone()).or_default().push(m);
278 }
279
280 let mut imports = Vec::new();
282 for (tool, tool_matches) in &by_tool {
283 for m in tool_matches {
284 let component = &m.component_name;
285
286 self.fetch_component(tool, component, None).await?;
288
289 let import_path = format!(
291 ".dx/forge/component_cache/{}/{}",
292 tool.tool_name(),
293 component
294 );
295
296 imports.push(format!(
297 "import {{ {} }} from '{}';",
298 component, import_path
299 ));
300 }
301 }
302
303 if !imports.is_empty() {
305 imports.sort();
306 imports.dedup();
307
308 let import_block = imports.join("\n") + "\n";
309
310 if let Some(last_import) = content.rfind("import ") {
312 if let Some(newline) = content[last_import..].find('\n') {
313 let insert_pos = last_import + newline + 1;
314 content.insert_str(insert_pos, &import_block);
315 }
316 } else {
317 content.insert_str(0, &import_block);
318 }
319
320 fs::write(file_path, content).await?;
322 }
323
324 Ok(())
325 }
326
327 pub async fn cleanup_cache(&mut self, max_age_days: i64) -> Result<usize> {
329 let cutoff = chrono::Utc::now() - chrono::Duration::days(max_age_days);
330 let mut removed = 0;
331
332 let keys_to_remove: Vec<String> = self
333 .cache_index
334 .iter()
335 .filter(|(_, entry)| entry.last_used < cutoff)
336 .map(|(k, _)| k.clone())
337 .collect();
338
339 for key in keys_to_remove {
340 if let Some(entry) = self.cache_index.remove(&key) {
341 if entry.local_path.exists() {
342 fs::remove_file(&entry.local_path).await?;
343 removed += 1;
344 }
345 }
346 }
347
348 self.save_index()?;
349 Ok(removed)
350 }
351
352 fn save_index(&self) -> Result<()> {
354 let index_path = self.cache_dir.join("index.json");
355 let content = serde_json::to_string_pretty(&self.cache_index)?;
356 std::fs::write(index_path, content)?;
357 Ok(())
358 }
359
360 pub fn cache_stats(&self) -> CacheStats {
362 let total_size: usize = self
363 .cache_index
364 .values()
365 .map(|e| e.metadata.size)
366 .sum();
367
368 let by_tool: HashMap<String, usize> = self
369 .cache_index
370 .values()
371 .fold(HashMap::new(), |mut acc, entry| {
372 *acc.entry(entry.metadata.tool.clone()).or_insert(0) += 1;
373 acc
374 });
375
376 CacheStats {
377 total_components: self.cache_index.len(),
378 total_size_bytes: total_size,
379 components_by_tool: by_tool,
380 }
381 }
382}
383
384#[derive(Debug, Serialize, Deserialize)]
386pub struct CacheStats {
387 pub total_components: usize,
388 pub total_size_bytes: usize,
389 pub components_by_tool: HashMap<String, usize>,
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395 use tempfile::TempDir;
396
397 #[tokio::test]
398 async fn test_injection_manager_creation() {
399 let temp_dir = TempDir::new().unwrap();
400 let manager = InjectionManager::new(temp_dir.path());
401 assert!(manager.is_ok());
402 }
403
404 #[tokio::test]
405 async fn test_component_caching() {
406 let temp_dir = TempDir::new().unwrap();
407 let mut manager = InjectionManager::new(temp_dir.path()).unwrap();
408
409 let content = manager
410 .fetch_component(&DxToolType::Ui, "Button", None)
411 .await
412 .unwrap();
413
414 assert!(content.contains("Button"));
415 assert!(manager.is_cached(&DxToolType::Ui, "Button"));
416 }
417
418 #[tokio::test]
419 async fn test_cache_stats() {
420 let temp_dir = TempDir::new().unwrap();
421 let mut manager = InjectionManager::new(temp_dir.path()).unwrap();
422
423 manager
424 .fetch_component(&DxToolType::Ui, "Button", None)
425 .await
426 .unwrap();
427 manager
428 .fetch_component(&DxToolType::Icons, "Home", None)
429 .await
430 .unwrap();
431
432 let stats = manager.cache_stats();
433 assert_eq!(stats.total_components, 2);
434 }
435}