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            let _r2_key = format!(
121                "components/{}/{}/{}",
122                tool.tool_name(),
123                version.unwrap_or("latest"),
124                component
125            );
126
127            // TODO: Implement actual R2 fetch
128            // For now, return a placeholder
129            let content = self.create_placeholder_component(tool, component);
130
131            // Cache it
132            self.cache_component(tool, component, &content).await?;
133
134            Ok(content)
135        } else {
136            // No R2 storage configured, return placeholder
137            let content = self.create_placeholder_component(tool, component);
138            self.cache_component(tool, component, &content).await?;
139            Ok(content)
140        }
141    }
142
143    /// Cache a component locally
144    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        // Create tool-specific cache directory
153        let tool_cache_dir = self.cache_dir.join(tool.tool_name());
154        fs::create_dir_all(&tool_cache_dir).await?;
155
156        // Determine file extension based on tool type
157        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        // Write content
169        fs::write(&local_path, content).await?;
170
171        // Compute hash
172        let mut hasher = Sha256::new();
173        hasher.update(content.as_bytes());
174        let hash = format!("{:x}", hasher.finalize());
175
176        // Create cache entry
177        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    /// Create placeholder component for development
201    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    /// Inject component into file
267    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        // Group matches by tool
275        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        // Generate imports
281        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                // Fetch component (will cache if needed)
287                self.fetch_component(tool, component, None).await?;
288
289                // Generate import statement
290                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        // Insert imports at the top (after existing imports if any)
304        if !imports.is_empty() {
305            imports.sort();
306            imports.dedup();
307
308            let import_block = imports.join("\n") + "\n";
309
310            // Find insertion point (after existing imports or at start)
311            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            // Write back to file
321            fs::write(file_path, content).await?;
322        }
323
324        Ok(())
325    }
326
327    /// Clear old cache entries (LRU eviction)
328    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    /// Save cache index to disk
353    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    /// Get cache statistics
361    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/// Cache statistics
385#[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}