1use anyhow::{Context, Result, bail};
5use chrono::{DateTime, Utc, Duration};
6use directories::ProjectDirs;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::fs;
10use std::io::Write;
11use std::path::{Path, PathBuf};
12
13use crate::operations::BackupNode;
14
15pub fn generate_run_id() -> String {
17 use std::time::{SystemTime, UNIX_EPOCH};
18 let timestamp = SystemTime::now()
19 .duration_since(UNIX_EPOCH)
20 .unwrap()
21 .as_nanos();
22 let hash = blake3::hash(×tamp.to_le_bytes());
23 let hex = hash.to_hex();
24 hex.as_str()[..7].to_string()
25}
26
27pub fn get_state_dir(local: bool) -> Result<PathBuf> {
37 if let Ok(base) = std::env::var("HACK_STATE_DIR") {
38 return Ok(PathBuf::from(base).join("rs"));
39 }
40
41 if local {
42 let current_dir = std::env::current_dir()?;
43 Ok(current_dir.join(".hack").join("rs"))
44 } else {
45 let proj_dirs = ProjectDirs::from("com", "hack", "hack")
46 .context("Could not determine project directories")?;
47 Ok(proj_dirs.data_dir().join("rs"))
48 }
49}
50
51pub fn hash_file(path: &Path) -> Result<String> {
53 let content = fs::read(path)
54 .with_context(|| format!("Failed to read file for hashing: {}", path.display()))?;
55 let hash = blake3::hash(&content);
56 Ok(hash.to_hex().to_string())
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct FileModification {
62 pub path: PathBuf,
63 pub hash_before: String,
64 pub hash_after: String,
65 pub backup_nodes: Vec<BackupNode>, }
67
68#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
70#[serde(rename_all = "lowercase")]
71pub enum RunStatus {
72 Applied,
73 Reverted,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct RunMetadata {
79 pub run_id: String,
80 pub timestamp: DateTime<Utc>,
81 pub command: String,
82 pub operation: String,
83 pub files_modified: Vec<FileModification>,
84 pub status: RunStatus,
85 pub can_revert: bool,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize, Default)]
90pub struct RunsIndex {
91 pub runs: HashMap<String, RunMetadata>,
92}
93
94impl RunsIndex {
95 pub fn load(state_dir: &Path) -> Result<Self> {
96 let index_path = state_dir.join("runs.json");
97 if !index_path.exists() {
98 return Ok(Self::default());
99 }
100
101 let content = fs::read_to_string(&index_path)
102 .context("Failed to read runs index")?;
103
104 let index: RunsIndex = serde_json::from_str(&content)
105 .map_err(|e| {
106 if e.to_string().contains("missing field") {
107 eprintln!("⚠️ Incompatible state format detected from previous hack version.");
108 eprintln!(" The state directory will be reset.");
109 eprintln!(" Location: {}", state_dir.display());
110 }
111 anyhow::anyhow!("Failed to parse runs index: {}", e)
112 })?;
113 Ok(index)
114 }
115
116 pub fn load_or_reset(state_dir: &Path) -> Result<Self> {
118 match Self::load(state_dir) {
119 Ok(index) => Ok(index),
120 Err(e) if e.to_string().contains("missing field") => {
121 eprintln!("🔄 Resetting incompatible state format...");
122 if state_dir.exists() {
124 fs::remove_dir_all(state_dir)
125 .context("Failed to remove old state directory")?;
126 }
127 eprintln!("✓ State directory cleared");
128 Ok(Self::default())
129 }
130 Err(e) => Err(e),
131 }
132 }
133
134 pub fn save(&self, state_dir: &Path) -> Result<()> {
135 fs::create_dir_all(state_dir)?;
136 let index_path = state_dir.join("runs.json");
137 let content = serde_json::to_string_pretty(self)?;
138
139 let temp_path = state_dir.join("runs.json.tmp");
141 let mut file = fs::File::create(&temp_path)?;
142 file.write_all(content.as_bytes())?;
143 file.sync_all()?;
144 drop(file);
145
146 fs::rename(temp_path, index_path)?;
147 Ok(())
148 }
149
150 pub fn add_run(&mut self, run: RunMetadata) {
151 self.runs.insert(run.run_id.clone(), run);
152 }
153
154 #[allow(dead_code)]
155 pub fn get_run(&self, run_id: &str) -> Option<&RunMetadata> {
156 self.runs.get(run_id)
157 }
158
159 pub fn get_run_mut(&mut self, run_id: &str) -> Option<&mut RunMetadata> {
160 self.runs.get_mut(run_id)
161 }
162
163 pub fn get_sorted_runs(&self) -> Vec<&RunMetadata> {
164 let mut runs: Vec<_> = self.runs.values().collect();
165 runs.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
166 runs
167 }
168}
169
170pub fn save_backup_nodes(
172 file_path: &Path,
173 nodes: &[BackupNode],
174 run_id: &str,
175 state_dir: &Path,
176) -> Result<()> {
177 if nodes.is_empty() {
178 return Ok(());
179 }
180
181 let backup_dir = state_dir.join(run_id);
182 fs::create_dir_all(&backup_dir)?;
183
184 let safe_name = file_path
186 .components()
187 .filter_map(|c| match c {
188 std::path::Component::Normal(s) => Some(s.to_string_lossy().to_string()),
189 _ => None,
190 })
191 .collect::<Vec<_>>()
192 .join("_");
193
194 for (idx, node) in nodes.iter().enumerate() {
196 let node_filename = format!("{}__node_{}.json", safe_name, idx);
197 let node_path = backup_dir.join(&node_filename);
198
199 let json = serde_json::to_string_pretty(node)?;
200 fs::write(&node_path, json)?;
201 }
202
203 Ok(())
204}
205
206pub fn restore_from_nodes(
213 file_path: &Path,
214 nodes: &[BackupNode],
215 _state_dir: &Path,
216) -> Result<()> {
217 use crate::editor::RustEditor;
218
219 if nodes.is_empty() {
220 return Ok(());
221 }
222
223 let content = fs::read_to_string(file_path)
225 .with_context(|| format!("Failed to read file for revert: {}", file_path.display()))?;
226
227 let mut editor = RustEditor::new(&content)?;
229
230 let (mut struct_literal_backups, other_backups): (Vec<_>, Vec<_>) = nodes.iter()
232 .partition(|b| b.node_type == "struct-literal");
233
234 struct_literal_backups.sort_by(|a, b| {
237 let counter_a = a.identifier.split('#').nth(1).and_then(|s| s.parse::<usize>().ok()).unwrap_or(0);
238 let counter_b = b.identifier.split('#').nth(1).and_then(|s| s.parse::<usize>().ok()).unwrap_or(0);
239 counter_b.cmp(&counter_a) });
241
242 for backup in &struct_literal_backups {
244 restore_struct_literal(&mut editor, backup)?;
245 }
246
247 for backup in other_backups {
249 match backup.node_type.as_str() {
250 "ItemStruct" | "struct" => {
251 restore_struct(&mut editor, backup)?;
252 }
253 "ItemEnum" | "enum" => {
254 restore_enum(&mut editor, backup)?;
255 }
256 "ItemImpl" => {
257 restore_impl(&mut editor, backup)?;
258 }
259 "ItemFn" | "function" => {
260 restore_function(&mut editor, backup)?;
262 }
263 "ExprStruct" => {
264 }
267 "struct-literal" => {
268 }
271 "ItemUse" => {
272 }
275 _ => {
276 eprintln!("Warning: Unsupported node type for revert: {}", backup.node_type);
278 }
279 }
280 }
281
282 fs::write(file_path, editor.to_string())
284 .with_context(|| format!("Failed to write restored file: {}", file_path.display()))?;
285
286 Ok(())
287}
288
289fn restore_struct(editor: &mut crate::editor::RustEditor, backup: &BackupNode) -> Result<()> {
290 use syn::{parse_str, Item};
291
292 let backup_item: Item = parse_str(&backup.original_content)
294 .context("Failed to parse backup struct content")?;
295
296 let struct_index = editor.find_item_index("struct", &backup.identifier)
298 .with_context(|| format!("Struct '{}' not found for revert", backup.identifier))?;
299
300 editor.replace_item_at_index(struct_index, backup_item)?;
302
303 Ok(())
304}
305
306fn restore_enum(editor: &mut crate::editor::RustEditor, backup: &BackupNode) -> Result<()> {
307 use syn::{parse_str, Item};
308
309 let backup_item: Item = parse_str(&backup.original_content)
311 .context("Failed to parse backup enum content")?;
312
313 let enum_index = editor.find_item_index("enum", &backup.identifier)
315 .with_context(|| format!("Enum '{}' not found for revert", backup.identifier))?;
316
317 editor.replace_item_at_index(enum_index, backup_item)?;
319
320 Ok(())
321}
322
323fn restore_impl(editor: &mut crate::editor::RustEditor, backup: &BackupNode) -> Result<()> {
324 use syn::{parse_str, Item};
325
326 let backup_item: Item = parse_str(&backup.original_content)
328 .context("Failed to parse backup impl content")?;
329
330 let impl_index = editor.find_item_index("impl", &backup.identifier)
332 .with_context(|| format!("Impl block for '{}' not found for revert", backup.identifier))?;
333
334 editor.replace_item_at_index(impl_index, backup_item)?;
336
337 Ok(())
338}
339
340fn restore_function(editor: &mut crate::editor::RustEditor, backup: &BackupNode) -> Result<()> {
341 use syn::{parse_str, Item};
342
343 let backup_item: Item = parse_str(&backup.original_content)
345 .context("Failed to parse backup function content")?;
346
347 let fn_index = editor.find_item_index("fn", &backup.identifier)
349 .with_context(|| format!("Function '{}' not found for revert", backup.identifier))?;
350
351 editor.replace_item_at_index(fn_index, backup_item)?;
353
354 Ok(())
355}
356
357fn restore_struct_literal(editor: &mut crate::editor::RustEditor, backup: &BackupNode) -> Result<()> {
358 use syn::{visit::Visit, ExprStruct, spanned::Spanned};
359 use quote::ToTokens;
360
361 let parts: Vec<&str> = backup.identifier.split('#').collect();
363 if parts.len() != 2 {
364 anyhow::bail!("Invalid struct literal identifier: {}", backup.identifier);
365 }
366 let struct_name = parts[0];
367 let target_counter: usize = parts[1].parse()
368 .context("Invalid counter in struct literal identifier")?;
369
370 let _backup_expr: ExprStruct = syn::parse_str(&backup.original_content)
372 .context("Failed to parse backup struct literal content")?;
373
374 struct LiteralFinder<'a> {
376 struct_name: &'a str,
377 current_literals: Vec<(usize, usize, String)>, editor: &'a crate::editor::RustEditor,
379 }
380
381 impl<'ast, 'a> Visit<'ast> for LiteralFinder<'a> {
382 fn visit_expr_struct(&mut self, node: &'ast ExprStruct) {
383 let matches = if self.struct_name.contains("::") {
385 let path_str = node.path.segments.iter()
387 .map(|seg| seg.ident.to_string())
388 .collect::<Vec<_>>()
389 .join("::");
390 path_str == self.struct_name
391 } else {
392 node.path.segments.len() == 1
394 && node.path.segments.last()
395 .map(|seg| seg.ident.to_string())
396 .as_ref() == Some(&self.struct_name.to_string())
397 };
398
399 if matches {
400 let start = self.editor.span_to_byte_offset(node.span().start());
401 let end = self.editor.span_to_byte_offset(node.span().end());
402 let content = node.to_token_stream().to_string();
403 self.current_literals.push((start, end, content));
404 }
405
406 syn::visit::visit_expr_struct(self, node);
407 }
408 }
409
410 let mut finder = LiteralFinder {
411 struct_name,
412 current_literals: Vec::new(),
413 editor,
414 };
415
416 let syntax_tree = editor.get_syntax_tree();
417 finder.visit_file(syntax_tree);
418
419 if target_counter < finder.current_literals.len() {
421 let (start, end, _) = finder.current_literals[target_counter];
422 let backup_content = backup.original_content.trim();
423 editor.replace_range(start, end, backup_content)?;
424 Ok(())
425 } else {
426 Ok(())
429 }
430}
431
432pub fn save_run_metadata(run: &RunMetadata, state_dir: &Path) -> Result<()> {
434 fs::create_dir_all(state_dir)?;
435 let metadata_path = state_dir.join(format!("{}.json", run.run_id));
436 let content = serde_json::to_string_pretty(run)?;
437
438 let temp_path = state_dir.join(format!("{}.json.tmp", run.run_id));
440 let mut file = fs::File::create(&temp_path)?;
441 file.write_all(content.as_bytes())?;
442 file.sync_all()?;
443 drop(file);
444
445 fs::rename(temp_path, metadata_path)?;
446
447 let mut index = RunsIndex::load(state_dir)?;
449 index.add_run(run.clone());
450 index.save(state_dir)?;
451
452 Ok(())
453}
454
455pub fn load_run_metadata(run_id: &str, state_dir: &Path) -> Result<RunMetadata> {
457 let metadata_path = state_dir.join(format!("{}.json", run_id));
458
459 if !metadata_path.exists() {
460 bail!("Run {} not found", run_id);
461 }
462
463 let content = fs::read_to_string(&metadata_path)
464 .context("Failed to read run metadata")?;
465 let metadata: RunMetadata = serde_json::from_str(&content)
466 .context("Failed to parse run metadata")?;
467 Ok(metadata)
468}
469
470pub fn revert_run(run_id: &str, force: bool, state_dir: &Path) -> Result<()> {
472 let run = load_run_metadata(run_id, state_dir)?;
474
475 if run.status == RunStatus::Reverted {
477 bail!("Run {} has already been reverted", run_id);
478 }
479
480 if !run.can_revert {
481 bail!("Run {} cannot be reverted", run_id);
482 }
483
484 if !force {
486 for file in &run.files_modified {
487 if !file.path.exists() {
488 bail!("File {} no longer exists (use --force to ignore)", file.path.display());
489 }
490
491 let current_hash = hash_file(&file.path)?;
492 if current_hash != file.hash_after {
493 bail!(
494 "File {} has changed since run {} (use --force to ignore)\nExpected hash: {}\nCurrent hash: {}",
495 file.path.display(),
496 run_id,
497 file.hash_after,
498 current_hash
499 );
500 }
501 }
502 }
503
504 println!("Reverting {} file(s)...", run.files_modified.len());
506 for file in &run.files_modified {
507 restore_from_nodes(&file.path, &file.backup_nodes, state_dir)?;
508 println!(" ✓ Restored: {}", file.path.display());
509 }
510
511 let mut index = RunsIndex::load_or_reset(state_dir)?;
513 if let Some(run_meta) = index.get_run_mut(run_id) {
514 run_meta.status = RunStatus::Reverted;
515 run_meta.can_revert = false;
516 }
517 index.save(state_dir)?;
518
519 let mut run = run;
521 run.status = RunStatus::Reverted;
522 run.can_revert = false;
523 save_run_metadata(&run, state_dir)?;
524
525 println!("✓ Run {} reverted successfully", run_id);
526 Ok(())
527}
528
529pub fn show_history(limit: usize, state_dir: &Path) -> Result<()> {
531 let index = RunsIndex::load_or_reset(state_dir)?;
532 let runs = index.get_sorted_runs();
533
534 if runs.is_empty() {
535 println!("No runs found");
536 return Ok(());
537 }
538
539 println!("Recent runs (showing up to {}):\n", limit);
540
541 for run in runs.iter().take(limit) {
542 let status_str = match run.status {
543 RunStatus::Applied => if run.can_revert { "[can revert]" } else { "[applied]" },
544 RunStatus::Reverted => "[reverted]",
545 };
546
547 let files_str = if run.files_modified.len() == 1 {
548 "1 file".to_string()
549 } else {
550 format!("{} files", run.files_modified.len())
551 };
552
553 println!(
554 "{} {} {:20} {:10} {}",
555 run.run_id,
556 run.timestamp.format("%Y-%m-%d %H:%M"),
557 truncate_str(&run.operation, 20),
558 files_str,
559 status_str
560 );
561 }
562
563 Ok(())
564}
565
566pub fn clean_old_state(keep_days: u32, state_dir: &Path) -> Result<()> {
568 let index = RunsIndex::load_or_reset(state_dir)?;
569 let cutoff = Utc::now() - Duration::days(keep_days as i64);
570
571 let mut cleaned = 0;
572 let mut new_index = RunsIndex::default();
573
574 for run in index.runs.values() {
575 if run.timestamp < cutoff {
576 let backup_dir = state_dir.join(&run.run_id);
578 if backup_dir.exists() {
579 fs::remove_dir_all(&backup_dir)?;
580 }
581
582 let metadata_path = state_dir.join(format!("{}.json", run.run_id));
584 if metadata_path.exists() {
585 fs::remove_file(&metadata_path)?;
586 }
587
588 cleaned += 1;
589 } else {
590 new_index.add_run(run.clone());
591 }
592 }
593
594 new_index.save(state_dir)?;
596
597 println!("✓ Cleaned {} old run(s)", cleaned);
598 Ok(())
599}
600
601fn truncate_str(s: &str, max_len: usize) -> String {
602 if s.len() <= max_len {
603 s.to_string()
604 } else {
605 format!("{}...", &s[..max_len-3])
606 }
607}
608
609#[allow(dead_code)]
611pub fn get_state_size(state_dir: &Path) -> Result<u64> {
612 if !state_dir.exists() {
613 return Ok(0);
614 }
615
616 let mut total_size = 0u64;
617 for entry in walkdir::WalkDir::new(state_dir) {
618 let entry = entry?;
619 if entry.file_type().is_file() {
620 total_size += entry.metadata()?.len();
621 }
622 }
623 Ok(total_size)
624}
625
626#[cfg(test)]
627mod tests {
628 use super::*;
629 use tempfile::TempDir;
630
631 #[test]
632 fn test_generate_run_id() {
633 let id1 = generate_run_id();
634 let id2 = generate_run_id();
635
636 assert_eq!(id1.len(), 7);
637 assert_eq!(id2.len(), 7);
638 assert_ne!(id1, id2); }
640
641 #[test]
642 fn test_hash_file() -> Result<()> {
643 let temp_dir = TempDir::new()?;
644 let file_path = temp_dir.path().join("test.txt");
645
646 fs::write(&file_path, "hello world")?;
647 let hash1 = hash_file(&file_path)?;
648
649 fs::write(&file_path, "hello world")?;
651 let hash2 = hash_file(&file_path)?;
652 assert_eq!(hash1, hash2);
653
654 fs::write(&file_path, "goodbye world")?;
656 let hash3 = hash_file(&file_path)?;
657 assert_ne!(hash1, hash3);
658
659 Ok(())
660 }
661
662 #[test]
663 fn test_backup_nodes() -> Result<()> {
664 use crate::operations::{BackupNode, NodeLocation};
665 let temp_dir = TempDir::new()?;
666 let state_dir = temp_dir.path().join("state");
667 let file_path = temp_dir.path().join("test.rs");
668
669 let node = BackupNode {
671 node_type: "ItemStruct".to_string(),
672 identifier: "User".to_string(),
673 original_content: "pub struct User { id: u64 }".to_string(),
674 location: NodeLocation {
675 line: 1,
676 column: 0,
677 end_line: 1,
678 end_column: 27,
679 },
680 };
681
682 let run_id = "abc1234";
684 save_backup_nodes(&file_path, &[node.clone()], run_id, &state_dir)?;
685
686 let backup_dir = state_dir.join(run_id);
688 assert!(backup_dir.exists());
689
690 let node_files: Vec<_> = fs::read_dir(&backup_dir)?.collect();
692 assert_eq!(node_files.len(), 1);
693
694 Ok(())
695 }
696
697 #[test]
698 fn test_runs_index() -> Result<()> {
699 let temp_dir = TempDir::new()?;
700 let state_dir = temp_dir.path().join("state");
701
702 let run = RunMetadata {
703 run_id: "abc1234".to_string(),
704 timestamp: Utc::now(),
705 command: "add-struct-field".to_string(),
706 operation: "AddStructField".to_string(),
707 files_modified: vec![],
708 status: RunStatus::Applied,
709 can_revert: true,
710 };
711
712 save_run_metadata(&run, &state_dir)?;
714
715 let loaded = load_run_metadata("abc1234", &state_dir)?;
717 assert_eq!(loaded.run_id, "abc1234");
718 assert_eq!(loaded.operation, "AddStructField");
719
720 let index = RunsIndex::load(&state_dir)?;
722 assert_eq!(index.runs.len(), 1);
723 assert!(index.get_run("abc1234").is_some());
724
725 Ok(())
726 }
727}