Skip to main content

mockforge_intelligence/ai_studio/
artifact_freezer.rs

1//! Artifact freezer for converting AI outputs to deterministic formats
2//!
3//! This module provides functionality to freeze AI-generated artifacts into
4//! deterministic YAML/JSON files for version control and reproducible testing.
5
6use chrono::Utc;
7use mockforge_foundation::Result;
8// Data types re-exported from foundation.
9pub use mockforge_foundation::ai_studio_types::{FreezeMetadata, FreezeRequest, FrozenArtifact};
10use serde_json::Value;
11use sha2::{Digest, Sha256};
12use std::path::{Path, PathBuf};
13use tokio::fs;
14
15/// Artifact freezer for deterministic output
16pub struct ArtifactFreezer {
17    /// Base directory for frozen artifacts
18    base_dir: PathBuf,
19}
20
21impl ArtifactFreezer {
22    /// Create a new artifact freezer with default directory
23    pub fn new() -> Self {
24        Self {
25            base_dir: PathBuf::from(".mockforge/frozen"),
26        }
27    }
28
29    /// Create a new artifact freezer with custom base directory
30    pub fn with_base_dir<P: AsRef<Path>>(base_dir: P) -> Self {
31        Self {
32            base_dir: base_dir.as_ref().to_path_buf(),
33        }
34    }
35
36    /// Get the base directory for frozen artifacts
37    pub fn base_dir(&self) -> &Path {
38        &self.base_dir
39    }
40
41    /// Freeze an AI-generated artifact to deterministic format
42    ///
43    /// This method converts AI-generated content (mocks, personas, scenarios, etc.)
44    /// into deterministic YAML/JSON files that can be version controlled and used
45    /// for reproducible testing.
46    pub async fn freeze(&self, request: &FreezeRequest) -> Result<FrozenArtifact> {
47        // Determine output path
48        let path = if let Some(custom_path) = &request.path {
49            PathBuf::from(custom_path)
50        } else {
51            // Generate default path based on artifact type and timestamp
52            let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
53            let extension = if request.format == "yaml" || request.format == "yml" {
54                "yaml"
55            } else {
56                "json"
57            };
58            self.base_dir
59                .join(format!("{}_{}.{}", request.artifact_type, timestamp, extension))
60        };
61
62        // Ensure parent directory exists
63        if let Some(parent) = path.parent() {
64            fs::create_dir_all(parent).await.map_err(|e| {
65                mockforge_foundation::Error::io_with_context(
66                    "create frozen artifacts directory",
67                    e.to_string(),
68                )
69            })?;
70        }
71
72        // Calculate output hash if metadata tracking is enabled
73        let output_hash = if request.metadata.is_some() {
74            let content_str = serde_json::to_string(&request.content)?;
75            let mut hasher = Sha256::new();
76            hasher.update(content_str.as_bytes());
77            Some(format!("{:x}", hasher.finalize()))
78        } else {
79            None
80        };
81
82        // Add metadata to the artifact
83        let mut frozen_content = request.content.clone();
84        if let Some(obj) = frozen_content.as_object_mut() {
85            let mut metadata_json = serde_json::json!({
86                "frozen_at": Utc::now().to_rfc3339(),
87                "artifact_type": request.artifact_type,
88                "source": "ai_generated",
89                "format": request.format,
90            });
91
92            // Add detailed metadata if provided
93            if let Some(ref metadata) = request.metadata {
94                if let Some(ref provider) = metadata.llm_provider {
95                    metadata_json["llm_provider"] = Value::String(provider.clone());
96                }
97                if let Some(ref model) = metadata.llm_model {
98                    metadata_json["llm_model"] = Value::String(model.clone());
99                }
100                if let Some(ref version) = metadata.llm_version {
101                    metadata_json["llm_version"] = Value::String(version.clone());
102                }
103                if let Some(ref prompt_hash) = metadata.prompt_hash {
104                    metadata_json["prompt_hash"] = Value::String(prompt_hash.clone());
105                }
106                if let Some(ref output_hash) = output_hash {
107                    metadata_json["output_hash"] = Value::String(output_hash.clone());
108                }
109                if let Some(ref prompt) = metadata.original_prompt {
110                    metadata_json["original_prompt"] = Value::String(prompt.clone());
111                }
112            }
113
114            obj.insert("_frozen_metadata".to_string(), metadata_json);
115        }
116
117        // Serialize to the requested format
118        let content_str = if request.format == "yaml" || request.format == "yml" {
119            serde_yaml::to_string(&frozen_content).map_err(|e| {
120                mockforge_foundation::Error::internal(format!("Failed to serialize to YAML: {}", e))
121            })?
122        } else {
123            serde_json::to_string_pretty(&frozen_content).map_err(|e| {
124                mockforge_foundation::Error::internal(format!("Failed to serialize to JSON: {}", e))
125            })?
126        };
127
128        // Write to file
129        fs::write(&path, content_str).await.map_err(|e| {
130            mockforge_foundation::Error::io_with_context("write frozen artifact", e.to_string())
131        })?;
132
133        Ok(FrozenArtifact {
134            artifact_type: request.artifact_type.clone(),
135            content: frozen_content,
136            format: request.format.clone(),
137            path: path.to_string_lossy().to_string(),
138            metadata: request.metadata.clone(),
139            output_hash,
140        })
141    }
142
143    /// Auto-freeze an artifact if auto-freeze is enabled
144    ///
145    /// This method checks the deterministic mode config and automatically freezes
146    /// the artifact if auto-freeze is enabled.
147    pub async fn auto_freeze_if_enabled(
148        &self,
149        request: &FreezeRequest,
150        deterministic_config: &crate::ai_studio::config::DeterministicModeConfig,
151    ) -> Result<Option<FrozenArtifact>> {
152        if deterministic_config.enabled && deterministic_config.is_auto_freeze_enabled() {
153            Ok(Some(self.freeze(request).await?))
154        } else {
155            Ok(None)
156        }
157    }
158
159    /// Verify the integrity of a frozen artifact
160    ///
161    /// Checks that the output hash matches the current content.
162    pub async fn verify_frozen_artifact(&self, artifact: &FrozenArtifact) -> Result<bool> {
163        // Calculate current hash
164        let content_str = serde_json::to_string(&artifact.content)?;
165        let mut hasher = Sha256::new();
166        hasher.update(content_str.as_bytes());
167        let current_hash = format!("{:x}", hasher.finalize());
168
169        // Compare with stored hash
170        if let Some(ref stored_hash) = artifact.output_hash {
171            Ok(current_hash == *stored_hash)
172        } else {
173            // No hash stored, assume valid
174            Ok(true)
175        }
176    }
177
178    /// Freeze multiple artifacts at once
179    pub async fn freeze_batch(&self, requests: &[FreezeRequest]) -> Result<Vec<FrozenArtifact>> {
180        let mut results = Vec::new();
181        for request in requests {
182            results.push(self.freeze(request).await?);
183        }
184        Ok(results)
185    }
186
187    /// Load a frozen artifact by type and identifier
188    ///
189    /// In deterministic mode, this method searches for frozen artifacts matching
190    /// the given type and identifier (e.g., description hash, persona ID).
191    pub async fn load_frozen(
192        &self,
193        artifact_type: &str,
194        identifier: Option<&str>,
195    ) -> Result<Option<FrozenArtifact>> {
196        // Build search pattern
197        let _search_pattern = if let Some(id) = identifier {
198            format!("{}_*_{}", artifact_type, id)
199        } else {
200            format!("{}_*", artifact_type)
201        };
202
203        // Search for matching files
204        let mut entries = fs::read_dir(&self.base_dir).await.map_err(|e| {
205            mockforge_foundation::Error::io_with_context(
206                "read frozen artifacts directory",
207                e.to_string(),
208            )
209        })?;
210
211        let mut latest_match: Option<FrozenArtifact> = None;
212        let mut latest_time = chrono::DateTime::<Utc>::MIN_UTC;
213
214        while let Some(entry) = entries.next_entry().await.map_err(|e| {
215            mockforge_foundation::Error::io_with_context("read directory entry", e.to_string())
216        })? {
217            let path = entry.path();
218            if path.is_file() {
219                let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
220
221                // Check if file matches pattern
222                let matches = if let Some(id) = identifier {
223                    file_name.contains(artifact_type) && file_name.contains(id)
224                } else {
225                    file_name.starts_with(&format!("{}_", artifact_type))
226                };
227
228                if matches {
229                    // Try to load the file
230                    let content = fs::read_to_string(&path).await.map_err(|e| {
231                        mockforge_foundation::Error::io_with_context(
232                            "read frozen artifact",
233                            e.to_string(),
234                        )
235                    })?;
236
237                    let content_value: Value = if path.extension().and_then(|e| e.to_str())
238                        == Some("yaml")
239                        || path.extension().and_then(|e| e.to_str()) == Some("yml")
240                    {
241                        serde_yaml::from_str(&content).map_err(|e| {
242                            mockforge_foundation::Error::internal(format!(
243                                "Failed to parse YAML: {}",
244                                e
245                            ))
246                        })?
247                    } else {
248                        serde_json::from_str(&content).map_err(|e| {
249                            mockforge_foundation::Error::internal(format!(
250                                "Failed to parse JSON: {}",
251                                e
252                            ))
253                        })?
254                    };
255
256                    // Extract frozen_at timestamp if available
257                    let frozen_time = content_value
258                        .get("_frozen_metadata")
259                        .and_then(|m| m.get("frozen_at"))
260                        .and_then(|t| t.as_str())
261                        .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
262                        .map(|dt| dt.with_timezone(&Utc))
263                        .unwrap_or_else(|| {
264                            // Fallback: use file metadata if available, otherwise use current time
265                            // Note: We can't use await in unwrap_or_else, so we'll use current time as fallback
266                            // The file metadata would need to be retrieved before this point if needed
267                            Utc::now()
268                        });
269
270                    // Keep the latest match
271                    if frozen_time > latest_time {
272                        latest_time = frozen_time;
273                        latest_match = Some(FrozenArtifact {
274                            artifact_type: artifact_type.to_string(),
275                            content: content_value,
276                            format: if path.extension().and_then(|e| e.to_str()) == Some("yaml")
277                                || path.extension().and_then(|e| e.to_str()) == Some("yml")
278                            {
279                                "yaml".to_string()
280                            } else {
281                                "json".to_string()
282                            },
283                            path: path.to_string_lossy().to_string(),
284                            metadata: None,
285                            output_hash: None,
286                        });
287                    }
288                }
289            }
290        }
291
292        Ok(latest_match)
293    }
294}
295
296impl Default for ArtifactFreezer {
297    fn default() -> Self {
298        Self::new()
299    }
300}