dx_forge/
injection.rs

1//! Component Injection System
2//!
3//! Fetches components from R2 storage, caches them locally,
4//! and injects them into user files with proper imports.
5
6use 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/// Component metadata
17#[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/// Component cache entry
29#[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
37/// Component injection manager
38pub struct InjectionManager {
39    cache_dir: PathBuf,
40    cache_index: HashMap<String, CacheEntry>,
41    r2_storage: Option<R2Storage>,
42}
43
44impl InjectionManager {
45    /// Create a new injection manager
46    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        // Try to initialize R2 storage (optional)
59        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    /// Get component cache key
71    fn cache_key(&self, tool: &DxToolType, component: &str) -> String {
72        format!("{}/{}", tool.tool_name(), component)
73    }
74
75    /// Check if component is cached
76    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    /// Get component from cache
86    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            // Update last used time
95            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    /// Fetch component from R2 and cache it
107    pub async fn fetch_component(
108        &mut self,
109        tool: &DxToolType,
110        component: &str,
111        version: Option<&str>,
112    ) -> Result<String> {
113        // Check cache first
114        if let Some(cached) = self.get_cached(tool, component).await? {
115            return Ok(cached);
116        }
117
118        // Fetch from R2 if available
119        if let Some(r2) = &self.r2_storage {
120            // Try to fetch from R2 with retry logic
121            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                        // Verify content hash
131                        let mut hasher = Sha256::new();
132                        hasher.update(content.as_bytes());
133                        let hash = format!("{:x}", hasher.finalize());
134
135                        // Cache it with verified hash
136                        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                            // Exponential backoff: 100ms, 200ms, 400ms
158                            tokio::time::sleep(std::time::Duration::from_millis(
159                                100 * (1 << attempt),
160                            ))
161                            .await;
162                        }
163                    }
164                }
165            }
166
167            // All retries failed, fall back to placeholder
168            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            // No R2 storage configured, return placeholder
186            let content = self.create_placeholder_component(tool, component);
187            self.cache_component(tool, component, &content).await?;
188            Ok(content)
189        }
190    }
191
192    /// Cache a component locally
193    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        // Create tool-specific cache directory
202        let tool_cache_dir = self.cache_dir.join(tool.tool_name());
203        fs::create_dir_all(&tool_cache_dir).await?;
204
205        // Determine file extension based on tool type
206        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        // Write content
218        fs::write(&local_path, content).await?;
219
220        // Compute hash
221        let mut hasher = Sha256::new();
222        hasher.update(content.as_bytes());
223        let hash = format!("{:x}", hasher.finalize());
224
225        // Create cache entry
226        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    /// Create placeholder component for development
250    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    /// Inject component into file
316    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        // Group matches by tool
324        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        // Generate imports
330        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                // Fetch component (will cache if needed)
336                self.fetch_component(tool, component, None).await?;
337
338                // Generate import statement
339                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        // Insert imports at the top (after existing imports if any)
353        if !imports.is_empty() {
354            imports.sort();
355            imports.dedup();
356
357            let import_block = imports.join("\n") + "\n";
358
359            // Find insertion point (after existing imports or at start)
360            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            // Write back to file
370            fs::write(file_path, content).await?;
371        }
372
373        Ok(())
374    }
375
376    /// Clear old cache entries (LRU eviction)
377    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    /// Save cache index to disk
402    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    /// Get cache statistics
410    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/// Cache statistics
434#[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}