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 max_retries = 3;
122 let mut last_error = None;
123
124 for attempt in 0..max_retries {
125 match r2
126 .download_component(tool.tool_name(), component, version)
127 .await
128 {
129 Ok(content) => {
130 let mut hasher = Sha256::new();
132 hasher.update(content.as_bytes());
133 let hash = format!("{:x}", hasher.finalize());
134
135 self.cache_component(tool, component, &content).await?;
137
138 tracing::info!(
139 "✅ Fetched component {}/{} from R2 (hash: {})",
140 tool.tool_name(),
141 component,
142 &hash[..8]
143 );
144
145 return Ok(content);
146 }
147 Err(e) => {
148 last_error = Some(e);
149 if attempt < max_retries - 1 {
150 tracing::warn!(
151 "⚠️ R2 fetch attempt {}/{} failed for {}/{}, retrying...",
152 attempt + 1,
153 max_retries,
154 tool.tool_name(),
155 component
156 );
157 tokio::time::sleep(std::time::Duration::from_millis(
159 100 * (1 << attempt),
160 ))
161 .await;
162 }
163 }
164 }
165 }
166
167 tracing::error!(
169 "❌ Failed to fetch {}/{} from R2 after {} attempts: {:?}",
170 tool.tool_name(),
171 component,
172 max_retries,
173 last_error
174 );
175 tracing::info!(
176 "📦 Using placeholder component for {}/{}",
177 tool.tool_name(),
178 component
179 );
180
181 let content = self.create_placeholder_component(tool, component);
182 self.cache_component(tool, component, &content).await?;
183 Ok(content)
184 } else {
185 let content = self.create_placeholder_component(tool, component);
187 self.cache_component(tool, component, &content).await?;
188 Ok(content)
189 }
190 }
191
192 async fn cache_component(
194 &mut self,
195 tool: &DxToolType,
196 component: &str,
197 content: &str,
198 ) -> Result<()> {
199 let key = self.cache_key(tool, component);
200
201 let tool_cache_dir = self.cache_dir.join(tool.tool_name());
203 fs::create_dir_all(&tool_cache_dir).await?;
204
205 let extension = match tool {
207 DxToolType::Ui => "tsx",
208 DxToolType::Icons => "tsx",
209 DxToolType::Fonts => "css",
210 DxToolType::Style => "css",
211 DxToolType::Auth => "ts",
212 _ => "ts",
213 };
214
215 let local_path = tool_cache_dir.join(format!("{}.{}", component, extension));
216
217 fs::write(&local_path, content).await?;
219
220 let mut hasher = Sha256::new();
222 hasher.update(content.as_bytes());
223 let hash = format!("{:x}", hasher.finalize());
224
225 let metadata = ComponentMetadata {
227 name: component.to_string(),
228 version: "latest".to_string(),
229 tool: tool.tool_name().to_string(),
230 hash,
231 size: content.len(),
232 dependencies: vec![],
233 exports: vec![component.to_string()],
234 };
235
236 let entry = CacheEntry {
237 metadata,
238 local_path,
239 cached_at: chrono::Utc::now(),
240 last_used: chrono::Utc::now(),
241 };
242
243 self.cache_index.insert(key, entry);
244 self.save_index()?;
245
246 Ok(())
247 }
248
249 fn create_placeholder_component(&self, tool: &DxToolType, component: &str) -> String {
251 match tool {
252 DxToolType::Ui => format!(
253 r#"// Auto-generated dx-ui component: {}
254import React from 'react';
255
256export interface {}Props {{
257 children?: React.ReactNode;
258 className?: string;
259}}
260
261export function {}({{ children, className }}: {}Props) {{
262 return (
263 <div className={{className}}>
264 {{children}}
265 </div>
266 );
267}}
268
269export default {};
270"#,
271 component, component, component, component, component
272 ),
273 DxToolType::Icons => format!(
274 r#"// Auto-generated dx-icons component: {}
275import React from 'react';
276
277export interface {}Props {{
278 size?: number;
279 color?: string;
280 className?: string;
281}}
282
283export function {}({{ size = 24, color = 'currentColor', className }}: {}Props) {{
284 return (
285 <svg width={{size}} height={{size}} fill={{color}} className={{className}}>
286 <path d="M12 2L2 22h20L12 2z" />
287 </svg>
288 );
289}}
290
291export default {};
292"#,
293 component, component, component, component, component
294 ),
295 DxToolType::Fonts => format!(
296 r#"/* Auto-generated dx-fonts: {} */
297@font-face {{
298 font-family: '{}';
299 src: url('https://fonts.dx.tools/{}/regular.woff2') format('woff2');
300 font-weight: 400;
301 font-style: normal;
302 font-display: swap;
303}}
304
305.font-{} {{
306 font-family: '{}', sans-serif;
307}}
308"#,
309 component, component, component, component, component
310 ),
311 _ => format!("// Placeholder for {} from {}", component, tool.tool_name()),
312 }
313 }
314
315 pub async fn inject_into_file(
317 &mut self,
318 file_path: &Path,
319 matches: &[PatternMatch],
320 ) -> Result<()> {
321 let mut content = fs::read_to_string(file_path).await?;
322
323 let mut by_tool: HashMap<DxToolType, Vec<&PatternMatch>> = HashMap::new();
325 for m in matches {
326 by_tool.entry(m.tool.clone()).or_default().push(m);
327 }
328
329 let mut imports = Vec::new();
331 for (tool, tool_matches) in &by_tool {
332 for m in tool_matches {
333 let component = &m.component_name;
334
335 self.fetch_component(tool, component, None).await?;
337
338 let import_path = format!(
340 ".dx/forge/component_cache/{}/{}",
341 tool.tool_name(),
342 component
343 );
344
345 imports.push(format!(
346 "import {{ {} }} from '{}';",
347 component, import_path
348 ));
349 }
350 }
351
352 if !imports.is_empty() {
354 imports.sort();
355 imports.dedup();
356
357 let import_block = imports.join("\n") + "\n";
358
359 if let Some(last_import) = content.rfind("import ") {
361 if let Some(newline) = content[last_import..].find('\n') {
362 let insert_pos = last_import + newline + 1;
363 content.insert_str(insert_pos, &import_block);
364 }
365 } else {
366 content.insert_str(0, &import_block);
367 }
368
369 fs::write(file_path, content).await?;
371 }
372
373 Ok(())
374 }
375
376 pub async fn cleanup_cache(&mut self, max_age_days: i64) -> Result<usize> {
378 let cutoff = chrono::Utc::now() - chrono::Duration::days(max_age_days);
379 let mut removed = 0;
380
381 let keys_to_remove: Vec<String> = self
382 .cache_index
383 .iter()
384 .filter(|(_, entry)| entry.last_used < cutoff)
385 .map(|(k, _)| k.clone())
386 .collect();
387
388 for key in keys_to_remove {
389 if let Some(entry) = self.cache_index.remove(&key) {
390 if entry.local_path.exists() {
391 fs::remove_file(&entry.local_path).await?;
392 removed += 1;
393 }
394 }
395 }
396
397 self.save_index()?;
398 Ok(removed)
399 }
400
401 fn save_index(&self) -> Result<()> {
403 let index_path = self.cache_dir.join("index.json");
404 let content = serde_json::to_string_pretty(&self.cache_index)?;
405 std::fs::write(index_path, content)?;
406 Ok(())
407 }
408
409 pub fn cache_stats(&self) -> CacheStats {
411 let total_size: usize = self
412 .cache_index
413 .values()
414 .map(|e| e.metadata.size)
415 .sum();
416
417 let by_tool: HashMap<String, usize> = self
418 .cache_index
419 .values()
420 .fold(HashMap::new(), |mut acc, entry| {
421 *acc.entry(entry.metadata.tool.clone()).or_insert(0) += 1;
422 acc
423 });
424
425 CacheStats {
426 total_components: self.cache_index.len(),
427 total_size_bytes: total_size,
428 components_by_tool: by_tool,
429 }
430 }
431}
432
433#[derive(Debug, Serialize, Deserialize)]
435pub struct CacheStats {
436 pub total_components: usize,
437 pub total_size_bytes: usize,
438 pub components_by_tool: HashMap<String, usize>,
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444 use tempfile::TempDir;
445
446 #[tokio::test]
447 async fn test_injection_manager_creation() {
448 let temp_dir = TempDir::new().unwrap();
449 let manager = InjectionManager::new(temp_dir.path());
450 assert!(manager.is_ok());
451 }
452
453 #[tokio::test]
454 async fn test_component_caching() {
455 let temp_dir = TempDir::new().unwrap();
456 let mut manager = InjectionManager::new(temp_dir.path()).unwrap();
457
458 let content = manager
459 .fetch_component(&DxToolType::Ui, "Button", None)
460 .await
461 .unwrap();
462
463 assert!(content.contains("Button"));
464 assert!(manager.is_cached(&DxToolType::Ui, "Button"));
465 }
466
467 #[tokio::test]
468 async fn test_cache_stats() {
469 let temp_dir = TempDir::new().unwrap();
470 let mut manager = InjectionManager::new(temp_dir.path()).unwrap();
471
472 manager
473 .fetch_component(&DxToolType::Ui, "Button", None)
474 .await
475 .unwrap();
476 manager
477 .fetch_component(&DxToolType::Icons, "Home", None)
478 .await
479 .unwrap();
480
481 let stats = manager.cache_stats();
482 assert_eq!(stats.total_components, 2);
483 }
484}