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