Skip to main content

rs_hack/
state.rs

1//! State management: tracks run history with unique IDs, stores
2//! backup nodes for revert, and manages the .hack/rs state directory.
3
4use 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
15/// Generates a short unique run ID (7 characters, like git)
16pub 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(&timestamp.to_le_bytes());
23    let hex = hash.to_hex();
24    hex.as_str()[..7].to_string()
25}
26
27/// Get the state directory path
28///
29/// All paths land under a `.hack/` namespace with `rs/` reserved for this
30/// tool, leaving room for siblings (`.hack/ts/`, `.hack/shared/`, ...).
31///
32/// Priority order:
33/// 1. Environment variable HACK_STATE_DIR (treated as the `.hack/` base; `rs` is appended)
34/// 2. --local-state flag (uses ./.hack/rs)
35/// 3. Global default (system data directory under com.hack.hack/rs)
36pub 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
51/// Compute blake3 hash of a file
52pub 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/// File modification metadata
60#[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>, // AST nodes that were modified
66}
67
68/// Status of a run
69#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
70#[serde(rename_all = "lowercase")]
71pub enum RunStatus {
72    Applied,
73    Reverted,
74}
75
76/// Metadata about a single run
77#[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/// Index of all runs
89#[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    /// Load index, or reset state if incompatible format detected
117    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                // Delete the old state directory
123                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        // Atomic write using temp file
140        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
170/// Save backup nodes to JSON files
171pub 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    // Create a safe file prefix based on the file path
185    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    // Save each node as a separate JSON file
195    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
206/// Restore nodes from backup
207///
208/// This function restores AST nodes from backup by:
209/// 1. Parsing the current file into an AST
210/// 2. For each backup node, finding and replacing the corresponding node in the AST
211/// 3. Writing the restored content back to the file
212pub 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    // Read current file content
224    let content = fs::read_to_string(file_path)
225        .with_context(|| format!("Failed to read file for revert: {}", file_path.display()))?;
226
227    // Parse into AST
228    let mut editor = RustEditor::new(&content)?;
229
230    // Separate struct-literal backups from others (they need special ordering)
231    let (mut struct_literal_backups, other_backups): (Vec<_>, Vec<_>) = nodes.iter()
232        .partition(|b| b.node_type == "struct-literal");
233
234    // Sort struct-literal backups by counter in REVERSE order (process from end of file to beginning)
235    // This ensures byte offsets remain valid as we restore
236    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) // Reverse order
240    });
241
242    // Process struct-literal backups first (in reverse order)
243    for backup in &struct_literal_backups {
244        restore_struct_literal(&mut editor, backup)?;
245    }
246
247    // Then process other backups
248    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                // For match operations, we backup the whole function
261                restore_function(&mut editor, backup)?;
262            }
263            "ExprStruct" => {
264                // Struct literals are handled as part of the parent function
265                // Skip individual struct literal restoration
266            }
267            "struct-literal" => {
268                // Already handled above in the separate struct-literal processing
269                // This case should never be reached
270            }
271            "ItemUse" => {
272                // Use statements are simple, we can skip restoration
273                // since they should be handled by other means
274            }
275            _ => {
276                // For other node types, log a warning but don't fail
277                eprintln!("Warning: Unsupported node type for revert: {}", backup.node_type);
278            }
279        }
280    }
281
282    // Write back the restored content
283    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    // Parse the backup content
293    let backup_item: Item = parse_str(&backup.original_content)
294        .context("Failed to parse backup struct content")?;
295
296    // Find the struct in the current AST by name
297    let struct_index = editor.find_item_index("struct", &backup.identifier)
298        .with_context(|| format!("Struct '{}' not found for revert", backup.identifier))?;
299
300    // Replace with the backup using the editor's method
301    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    // Parse the backup content
310    let backup_item: Item = parse_str(&backup.original_content)
311        .context("Failed to parse backup enum content")?;
312
313    // Find the enum in the current AST by name
314    let enum_index = editor.find_item_index("enum", &backup.identifier)
315        .with_context(|| format!("Enum '{}' not found for revert", backup.identifier))?;
316
317    // Replace with the backup
318    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    // Parse the backup content
327    let backup_item: Item = parse_str(&backup.original_content)
328        .context("Failed to parse backup impl content")?;
329
330    // Find impl block by matching on the self_ty
331    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    // Replace with the backup
335    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    // Parse the backup content
344    let backup_item: Item = parse_str(&backup.original_content)
345        .context("Failed to parse backup function content")?;
346
347    // Find the function in the current AST by name
348    let fn_index = editor.find_item_index("fn", &backup.identifier)
349        .with_context(|| format!("Function '{}' not found for revert", backup.identifier))?;
350
351    // Replace with the backup
352    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    // Extract the struct name and counter from the identifier (format: "StructName#counter" or "Enum::Variant#counter")
362    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    // Parse the backup content as an expression
371    let _backup_expr: ExprStruct = syn::parse_str(&backup.original_content)
372        .context("Failed to parse backup struct literal content")?;
373
374    // Find matching struct literal in the current file
375    struct LiteralFinder<'a> {
376        struct_name: &'a str,
377        current_literals: Vec<(usize, usize, String)>, // (start_byte, end_byte, content)
378        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            // Check if this matches our target struct name
384            let matches = if self.struct_name.contains("::") {
385                // Enum variant case
386                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                // Simple struct case
393                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    // Restore the specific occurrence identified by the counter
420    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        // Struct literal no longer exists, which is okay for revert
427        // (it might have been removed by the operation we're reverting)
428        Ok(())
429    }
430}
431
432/// Save run metadata
433pub 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    // Atomic write
439    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    // Update index
448    let mut index = RunsIndex::load(state_dir)?;
449    index.add_run(run.clone());
450    index.save(state_dir)?;
451
452    Ok(())
453}
454
455/// Load run metadata
456pub 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
470/// Revert a run
471pub fn revert_run(run_id: &str, force: bool, state_dir: &Path) -> Result<()> {
472    // Load run metadata
473    let run = load_run_metadata(run_id, state_dir)?;
474
475    // Check if already reverted
476    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    // Verify files haven't changed (unless --force)
485    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    // Restore from backups
505    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    // Mark run as reverted
512    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    // Update individual metadata file
520    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
529/// Display run history
530pub 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
566/// Clean old state data
567pub 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            // Remove backup directory
577            let backup_dir = state_dir.join(&run.run_id);
578            if backup_dir.exists() {
579                fs::remove_dir_all(&backup_dir)?;
580            }
581
582            // Remove metadata file
583            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    // Save updated index
595    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/// Get total size of state directory
610#[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); // Should be unique
639    }
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        // Same content should produce same hash
650        fs::write(&file_path, "hello world")?;
651        let hash2 = hash_file(&file_path)?;
652        assert_eq!(hash1, hash2);
653
654        // Different content should produce different hash
655        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        // Create a backup node
670        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        // Save backup nodes
683        let run_id = "abc1234";
684        save_backup_nodes(&file_path, &[node.clone()], run_id, &state_dir)?;
685
686        // Verify backup file exists
687        let backup_dir = state_dir.join(run_id);
688        assert!(backup_dir.exists());
689
690        // Verify we can read the backup
691        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
713        save_run_metadata(&run, &state_dir)?;
714
715        // Load and verify
716        let loaded = load_run_metadata("abc1234", &state_dir)?;
717        assert_eq!(loaded.run_id, "abc1234");
718        assert_eq!(loaded.operation, "AddStructField");
719
720        // Check index
721        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}