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