rs_hack/
state.rs

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
12/// Generates a short unique run ID (7 characters, like git)
13pub 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(&timestamp.to_le_bytes());
20    let hex = hash.to_hex();
21    hex.as_str()[..7].to_string()
22}
23
24/// Get the state directory path
25///
26/// Priority order:
27/// 1. Environment variable RS_HACK_STATE_DIR (highest priority)
28/// 2. --local-state flag (uses ./.rs-hack)
29/// 3. Global default (uses system data directory)
30pub fn get_state_dir(local: bool) -> Result<PathBuf> {
31    // Priority 1: Check environment variable
32    if let Ok(custom_dir) = std::env::var("RS_HACK_STATE_DIR") {
33        return Ok(PathBuf::from(custom_dir));
34    }
35
36    // Priority 2: Local state flag
37    if local {
38        // Use project-local .rs-hack directory
39        let current_dir = std::env::current_dir()?;
40        Ok(current_dir.join(".rs-hack"))
41    } else {
42        // Priority 3: Use user's home directory (default)
43        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
49/// Compute blake3 hash of a file
50pub 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/// File modification metadata
58#[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>, // AST nodes that were modified
64}
65
66/// Status of a run
67#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
68#[serde(rename_all = "lowercase")]
69pub enum RunStatus {
70    Applied,
71    Reverted,
72}
73
74/// Metadata about a single run
75#[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/// Index of all runs
87#[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    /// Load index, or reset state if incompatible format detected
115    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                // Delete the old state directory
121                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        // Atomic write using temp file
138        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
168/// Save backup nodes to JSON files
169pub 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    // Create a safe file prefix based on the file path
183    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    // Save each node as a separate JSON file
193    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
204/// Restore nodes from backup
205///
206/// This function restores AST nodes from backup by:
207/// 1. Parsing the current file into an AST
208/// 2. For each backup node, finding and replacing the corresponding node in the AST
209/// 3. Writing the restored content back to the file
210pub 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    // Read current file content
222    let content = fs::read_to_string(file_path)
223        .with_context(|| format!("Failed to read file for revert: {}", file_path.display()))?;
224
225    // Parse into AST
226    let mut editor = RustEditor::new(&content)?;
227
228    // Separate struct-literal backups from others (they need special ordering)
229    let (mut struct_literal_backups, other_backups): (Vec<_>, Vec<_>) = nodes.iter()
230        .partition(|b| b.node_type == "struct-literal");
231
232    // Sort struct-literal backups by counter in REVERSE order (process from end of file to beginning)
233    // This ensures byte offsets remain valid as we restore
234    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) // Reverse order
238    });
239
240    // Process struct-literal backups first (in reverse order)
241    for backup in &struct_literal_backups {
242        restore_struct_literal(&mut editor, backup)?;
243    }
244
245    // Then process other backups
246    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                // For match operations, we backup the whole function
259                restore_function(&mut editor, backup)?;
260            }
261            "ExprStruct" => {
262                // Struct literals are handled as part of the parent function
263                // Skip individual struct literal restoration
264            }
265            "struct-literal" => {
266                // Already handled above in the separate struct-literal processing
267                // This case should never be reached
268            }
269            "ItemUse" => {
270                // Use statements are simple, we can skip restoration
271                // since they should be handled by other means
272            }
273            _ => {
274                // For other node types, log a warning but don't fail
275                eprintln!("Warning: Unsupported node type for revert: {}", backup.node_type);
276            }
277        }
278    }
279
280    // Write back the restored content
281    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    // Parse the backup content
291    let backup_item: Item = parse_str(&backup.original_content)
292        .context("Failed to parse backup struct content")?;
293
294    // Find the struct in the current AST by name
295    let struct_index = editor.find_item_index("struct", &backup.identifier)
296        .with_context(|| format!("Struct '{}' not found for revert", backup.identifier))?;
297
298    // Replace with the backup using the editor's method
299    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    // Parse the backup content
308    let backup_item: Item = parse_str(&backup.original_content)
309        .context("Failed to parse backup enum content")?;
310
311    // Find the enum in the current AST by name
312    let enum_index = editor.find_item_index("enum", &backup.identifier)
313        .with_context(|| format!("Enum '{}' not found for revert", backup.identifier))?;
314
315    // Replace with the backup
316    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    // Parse the backup content
325    let backup_item: Item = parse_str(&backup.original_content)
326        .context("Failed to parse backup impl content")?;
327
328    // Find impl block by matching on the self_ty
329    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    // Replace with the backup
333    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    // Parse the backup content
342    let backup_item: Item = parse_str(&backup.original_content)
343        .context("Failed to parse backup function content")?;
344
345    // Find the function in the current AST by name
346    let fn_index = editor.find_item_index("fn", &backup.identifier)
347        .with_context(|| format!("Function '{}' not found for revert", backup.identifier))?;
348
349    // Replace with the backup
350    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    // Extract the struct name and counter from the identifier (format: "StructName#counter" or "Enum::Variant#counter")
360    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    // Parse the backup content as an expression
369    let _backup_expr: ExprStruct = syn::parse_str(&backup.original_content)
370        .context("Failed to parse backup struct literal content")?;
371
372    // Find matching struct literal in the current file
373    struct LiteralFinder<'a> {
374        struct_name: &'a str,
375        current_literals: Vec<(usize, usize, String)>, // (start_byte, end_byte, content)
376        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            // Check if this matches our target struct name
382            let matches = if self.struct_name.contains("::") {
383                // Enum variant case
384                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                // Simple struct case
391                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    // Restore the specific occurrence identified by the counter
418    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        // Struct literal no longer exists, which is okay for revert
425        // (it might have been removed by the operation we're reverting)
426        Ok(())
427    }
428}
429
430/// Save run metadata
431pub 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    // Atomic write
437    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    // Update index
446    let mut index = RunsIndex::load(state_dir)?;
447    index.add_run(run.clone());
448    index.save(state_dir)?;
449
450    Ok(())
451}
452
453/// Load run metadata
454pub 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
468/// Revert a run
469pub fn revert_run(run_id: &str, force: bool, state_dir: &Path) -> Result<()> {
470    // Load run metadata
471    let run = load_run_metadata(run_id, state_dir)?;
472
473    // Check if already reverted
474    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    // Verify files haven't changed (unless --force)
483    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    // Restore from backups
503    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    // Mark run as reverted
510    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    // Update individual metadata file
518    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
527/// Display run history
528pub 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
564/// Clean old state data
565pub 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            // Remove backup directory
575            let backup_dir = state_dir.join(&run.run_id);
576            if backup_dir.exists() {
577                fs::remove_dir_all(&backup_dir)?;
578            }
579
580            // Remove metadata file
581            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    // Save updated index
593    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/// Get total size of state directory
608#[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); // Should be unique
637    }
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        // Same content should produce same hash
648        fs::write(&file_path, "hello world")?;
649        let hash2 = hash_file(&file_path)?;
650        assert_eq!(hash1, hash2);
651
652        // Different content should produce different hash
653        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        // Create a backup node
668        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        // Save backup nodes
681        let run_id = "abc1234";
682        save_backup_nodes(&file_path, &[node.clone()], run_id, &state_dir)?;
683
684        // Verify backup file exists
685        let backup_dir = state_dir.join(run_id);
686        assert!(backup_dir.exists());
687
688        // Verify we can read the backup
689        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
711        save_run_metadata(&run, &state_dir)?;
712
713        // Load and verify
714        let loaded = load_run_metadata("abc1234", &state_dir)?;
715        assert_eq!(loaded.run_id, "abc1234");
716        assert_eq!(loaded.operation, "AddStructField");
717
718        // Check index
719        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}