1use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::fs;
12use std::path::Path;
13
14use crate::constraints::AttemptStatus;
15use crate::error::Result;
16
17fn default_attempts_schema() -> String {
18 "https://acp-protocol.dev/schemas/v1/attempts.schema.json".to_string()
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct AttemptTracker {
24 #[serde(rename = "$schema", default = "default_attempts_schema")]
26 pub schema: String,
27
28 pub version: String,
30
31 pub updated_at: DateTime<Utc>,
33
34 pub attempts: HashMap<String, TrackedAttempt>,
36
37 pub checkpoints: HashMap<String, TrackedCheckpoint>,
39
40 pub history: Vec<AttemptHistoryEntry>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct TrackedAttempt {
46 pub id: String,
47 pub for_issue: Option<String>,
48 pub description: Option<String>,
49 pub status: AttemptStatus,
50 pub created_at: DateTime<Utc>,
51 pub updated_at: DateTime<Utc>,
52
53 pub files: Vec<AttemptFile>,
55
56 pub revert_if: Vec<String>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct AttemptFile {
62 pub path: String,
63 pub original_hash: String,
64 pub original_content: Option<String>,
65 pub modified_hash: String,
66 pub lines_changed: Option<[usize; 2]>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct TrackedCheckpoint {
71 pub name: String,
72 pub created_at: DateTime<Utc>,
73 pub description: Option<String>,
74
75 #[serde(skip_serializing_if = "Option::is_none")]
77 pub git_commit: Option<String>,
78
79 pub files: HashMap<String, FileState>,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct FileState {
85 pub hash: String,
86 pub content: Option<String>, }
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct AttemptHistoryEntry {
91 pub id: String,
92 pub status: AttemptStatus,
93 pub started_at: DateTime<Utc>,
94 pub ended_at: DateTime<Utc>,
95 pub for_issue: Option<String>,
96 pub files_modified: usize,
97 pub outcome: Option<String>,
98}
99
100impl AttemptTracker {
101 const FILE_NAME: &'static str = ".acp/acp.attempts.json";
102 const MAX_STORED_CONTENT_SIZE: usize = 100_000; pub fn load_or_create() -> Self {
106 Self::load().unwrap_or_else(|_| Self {
107 schema: default_attempts_schema(),
108 version: crate::VERSION.to_string(),
109 updated_at: Utc::now(),
110 attempts: HashMap::new(),
111 checkpoints: HashMap::new(),
112 history: Vec::new(),
113 })
114 }
115
116 pub fn load() -> Result<Self> {
118 let content = fs::read_to_string(Self::FILE_NAME)?;
119 Ok(serde_json::from_str(&content)?)
120 }
121
122 pub fn save(&self) -> Result<()> {
124 let content = serde_json::to_string_pretty(self)?;
125 fs::write(Self::FILE_NAME, content)?;
126 Ok(())
127 }
128
129 pub fn start_attempt(
131 &mut self,
132 id: &str,
133 for_issue: Option<&str>,
134 description: Option<&str>,
135 ) -> &mut TrackedAttempt {
136 let attempt = TrackedAttempt {
137 id: id.to_string(),
138 for_issue: for_issue.map(String::from),
139 description: description.map(String::from),
140 status: AttemptStatus::Active,
141 created_at: Utc::now(),
142 updated_at: Utc::now(),
143 files: Vec::new(),
144 revert_if: Vec::new(),
145 };
146
147 self.attempts.insert(id.to_string(), attempt);
148 self.updated_at = Utc::now();
149 self.attempts.get_mut(id).unwrap()
150 }
151
152 pub fn record_modification(
154 &mut self,
155 attempt_id: &str,
156 file_path: &str,
157 original_content: &str,
158 new_content: &str,
159 ) -> Result<()> {
160 let attempt = self.attempts.get_mut(attempt_id).ok_or_else(|| {
161 crate::error::AcpError::Other(format!("Attempt not found: {}", attempt_id))
162 })?;
163
164 let original_hash = format!("{:x}", md5::compute(original_content));
165 let modified_hash = format!("{:x}", md5::compute(new_content));
166
167 let stored_content = if original_content.len() <= Self::MAX_STORED_CONTENT_SIZE {
169 Some(original_content.to_string())
170 } else {
171 None
172 };
173
174 attempt.files.push(AttemptFile {
175 path: file_path.to_string(),
176 original_hash,
177 original_content: stored_content,
178 modified_hash,
179 lines_changed: None,
180 });
181
182 attempt.updated_at = Utc::now();
183 self.updated_at = Utc::now();
184 Ok(())
185 }
186
187 pub fn fail_attempt(&mut self, id: &str, reason: Option<&str>) -> Result<()> {
189 if let Some(attempt) = self.attempts.get_mut(id) {
190 attempt.status = AttemptStatus::Failed;
191 attempt.updated_at = Utc::now();
192
193 self.history.push(AttemptHistoryEntry {
195 id: attempt.id.clone(),
196 status: AttemptStatus::Failed,
197 started_at: attempt.created_at,
198 ended_at: Utc::now(),
199 for_issue: attempt.for_issue.clone(),
200 files_modified: attempt.files.len(),
201 outcome: reason.map(String::from),
202 });
203 }
204 self.updated_at = Utc::now();
205 Ok(())
206 }
207
208 pub fn verify_attempt(&mut self, id: &str) -> Result<()> {
210 if let Some(attempt) = self.attempts.get_mut(id) {
211 attempt.status = AttemptStatus::Verified;
212 attempt.updated_at = Utc::now();
213
214 self.history.push(AttemptHistoryEntry {
216 id: attempt.id.clone(),
217 status: AttemptStatus::Verified,
218 started_at: attempt.created_at,
219 ended_at: Utc::now(),
220 for_issue: attempt.for_issue.clone(),
221 files_modified: attempt.files.len(),
222 outcome: Some("Verified and kept".to_string()),
223 });
224
225 self.attempts.remove(id);
227 }
228 self.updated_at = Utc::now();
229 Ok(())
230 }
231
232 pub fn revert_attempt(&mut self, id: &str) -> Result<Vec<RevertAction>> {
234 let attempt = self
235 .attempts
236 .get(id)
237 .ok_or_else(|| crate::error::AcpError::Other(format!("Attempt not found: {}", id)))?
238 .clone();
239
240 let mut actions = Vec::new();
241
242 for file in &attempt.files {
243 if let Some(original) = &file.original_content {
244 fs::write(&file.path, original)?;
246 actions.push(RevertAction {
247 file: file.path.clone(),
248 action: "restored".to_string(),
249 from_hash: file.modified_hash.clone(),
250 to_hash: file.original_hash.clone(),
251 });
252 } else {
253 actions.push(RevertAction {
255 file: file.path.clone(),
256 action: "manual-revert-needed".to_string(),
257 from_hash: file.modified_hash.clone(),
258 to_hash: file.original_hash.clone(),
259 });
260 }
261 }
262
263 self.history.push(AttemptHistoryEntry {
265 id: attempt.id.clone(),
266 status: AttemptStatus::Reverted,
267 started_at: attempt.created_at,
268 ended_at: Utc::now(),
269 for_issue: attempt.for_issue.clone(),
270 files_modified: attempt.files.len(),
271 outcome: Some("Reverted".to_string()),
272 });
273
274 self.attempts.remove(id);
276 self.updated_at = Utc::now();
277 self.save()?;
278
279 Ok(actions)
280 }
281
282 pub fn create_checkpoint(
284 &mut self,
285 name: &str,
286 files: &[&str],
287 description: Option<&str>,
288 ) -> Result<()> {
289 let mut file_states = HashMap::new();
290 let mut file_data: Vec<(String, String, String)> = Vec::new(); for file_path in files {
293 if Path::new(file_path).exists() {
294 let content = fs::read_to_string(file_path)?;
295 let hash = format!("{:x}", md5::compute(&content));
296
297 let stored_content = if content.len() <= Self::MAX_STORED_CONTENT_SIZE {
298 Some(content.clone())
299 } else {
300 None
301 };
302
303 file_data.push((file_path.to_string(), content, hash.clone()));
304 file_states.insert(
305 file_path.to_string(),
306 FileState {
307 hash,
308 content: stored_content,
309 },
310 );
311 }
312 }
313
314 let git_commit = std::process::Command::new("git")
316 .args(["rev-parse", "HEAD"])
317 .output()
318 .ok()
319 .filter(|o| o.status.success())
320 .and_then(|o| String::from_utf8(o.stdout).ok())
321 .map(|s| s.trim().to_string())
322 .filter(|s| s.len() == 40);
323
324 self.checkpoints.insert(
325 name.to_string(),
326 TrackedCheckpoint {
327 name: name.to_string(),
328 created_at: Utc::now(),
329 description: description.map(String::from),
330 git_commit,
331 files: file_states,
332 },
333 );
334
335 if let Some(attempt) = self
337 .attempts
338 .values_mut()
339 .filter(|a| a.status == AttemptStatus::Active)
340 .max_by_key(|a| a.created_at)
341 {
342 for (path, content, hash) in file_data {
343 if !attempt.files.iter().any(|f| f.path == path) {
345 let stored_content = if content.len() <= Self::MAX_STORED_CONTENT_SIZE {
346 Some(content)
347 } else {
348 None
349 };
350 attempt.files.push(AttemptFile {
351 path,
352 original_hash: hash.clone(),
353 original_content: stored_content,
354 modified_hash: hash, lines_changed: None,
356 });
357 attempt.updated_at = Utc::now();
358 }
359 }
360 }
361
362 self.updated_at = Utc::now();
363 self.save()?;
364 Ok(())
365 }
366
367 pub fn restore_checkpoint(&mut self, name: &str) -> Result<Vec<RevertAction>> {
369 let checkpoint = self
370 .checkpoints
371 .get(name)
372 .ok_or_else(|| {
373 crate::error::AcpError::Other(format!("Checkpoint not found: {}", name))
374 })?
375 .clone();
376
377 let mut actions = Vec::new();
378
379 for (path, state) in &checkpoint.files {
380 if let Some(content) = &state.content {
381 fs::write(path, content)?;
382 actions.push(RevertAction {
383 file: path.clone(),
384 action: "restored".to_string(),
385 from_hash: "current".to_string(),
386 to_hash: state.hash.clone(),
387 });
388 } else {
389 actions.push(RevertAction {
390 file: path.clone(),
391 action: "manual-restore-needed".to_string(),
392 from_hash: "current".to_string(),
393 to_hash: state.hash.clone(),
394 });
395 }
396 }
397
398 self.updated_at = Utc::now();
399 Ok(actions)
400 }
401
402 pub fn active_attempts(&self) -> Vec<&TrackedAttempt> {
404 self.attempts
405 .values()
406 .filter(|a| a.status == AttemptStatus::Active || a.status == AttemptStatus::Testing)
407 .collect()
408 }
409
410 pub fn failed_attempts(&self) -> Vec<&TrackedAttempt> {
412 self.attempts
413 .values()
414 .filter(|a| a.status == AttemptStatus::Failed)
415 .collect()
416 }
417
418 pub fn cleanup_failed(&mut self) -> Result<Vec<RevertAction>> {
420 let failed_ids: Vec<_> = self
421 .failed_attempts()
422 .iter()
423 .map(|a| a.id.clone())
424 .collect();
425
426 let mut all_actions = Vec::new();
427 for id in failed_ids {
428 let actions = self.revert_attempt(&id)?;
429 all_actions.extend(actions);
430 }
431
432 Ok(all_actions)
433 }
434}
435
436#[derive(Debug, Clone, Serialize, Deserialize)]
437pub struct RevertAction {
438 pub file: String,
439 pub action: String,
440 pub from_hash: String,
441 pub to_hash: String,
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447
448 #[test]
449 fn test_start_attempt() {
450 let mut tracker = AttemptTracker::load_or_create();
451 tracker.start_attempt("test-001", Some("bug#123"), Some("Testing fix"));
452
453 assert!(tracker.attempts.contains_key("test-001"));
454 assert_eq!(
455 tracker.attempts["test-001"].for_issue,
456 Some("bug#123".to_string())
457 );
458 }
459}