mockforge_intelligence/ai_studio/
artifact_freezer.rs1use chrono::Utc;
7use mockforge_foundation::Result;
8pub 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
15pub struct ArtifactFreezer {
17 base_dir: PathBuf,
19}
20
21impl ArtifactFreezer {
22 pub fn new() -> Self {
24 Self {
25 base_dir: PathBuf::from(".mockforge/frozen"),
26 }
27 }
28
29 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 pub fn base_dir(&self) -> &Path {
38 &self.base_dir
39 }
40
41 pub async fn freeze(&self, request: &FreezeRequest) -> Result<FrozenArtifact> {
47 let path = if let Some(custom_path) = &request.path {
49 PathBuf::from(custom_path)
50 } else {
51 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 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 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 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 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 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 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 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 pub async fn verify_frozen_artifact(&self, artifact: &FrozenArtifact) -> Result<bool> {
163 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 if let Some(ref stored_hash) = artifact.output_hash {
171 Ok(current_hash == *stored_hash)
172 } else {
173 Ok(true)
175 }
176 }
177
178 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 pub async fn load_frozen(
192 &self,
193 artifact_type: &str,
194 identifier: Option<&str>,
195 ) -> Result<Option<FrozenArtifact>> {
196 let _search_pattern = if let Some(id) = identifier {
198 format!("{}_*_{}", artifact_type, id)
199 } else {
200 format!("{}_*", artifact_type)
201 };
202
203 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 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 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 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 Utc::now()
268 });
269
270 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}