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 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
16/// Generates a short unique run ID (7 characters, like git)
17pub 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(&timestamp.to_le_bytes());
24    let hex = hash.to_hex();
25    hex.as_str()[..7].to_string()
26}
27
28/// Get the state directory path
29///
30/// All paths land under a `.hack/` namespace with `rs/` reserved for this
31/// tool, leaving room for siblings (`.hack/ts/`, `.hack/shared/`, ...).
32///
33/// Priority order:
34/// 1. Environment variable HACK_STATE_DIR (treated as the `.hack/` base; `rs` is appended)
35/// 2. --local-state flag (uses ./.hack/rs)
36/// 3. Global default (system data directory under com.hack.hack/rs)
37pub 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
52/// Compute blake3 hash of a file
53pub 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/// File modification metadata
61#[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>, // AST nodes that were modified
67}
68
69/// Status of a run
70#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
71#[serde(rename_all = "lowercase")]
72pub enum RunStatus {
73    Applied,
74    Reverted,
75}
76
77/// Metadata about a single run
78#[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/// Index of all runs
90#[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    /// Load index, or reset state if incompatible format detected
116    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                // Delete the old state directory
122                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        // Atomic write using temp file
139        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
169/// Save backup nodes to JSON files
170pub 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    // Create a safe file prefix based on the file path
184    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    // Save each node as a separate JSON file
194    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
205/// Restore nodes from backup
206///
207/// This function restores AST nodes from backup by:
208/// 1. Parsing the current file into an AST
209/// 2. For each backup node, finding and replacing the corresponding node in the AST
210/// 3. Writing the restored content back to the file
211pub 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    // Whole-file backups (`comments apply`) restore byte-for-byte; re-emitting through the
219    // AST editor would drop the very comments being restored.
220    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    // Read current file content
230    let content = fs::read_to_string(file_path)
231        .with_context(|| format!("Failed to read file for revert: {}", file_path.display()))?;
232
233    // Parse into AST
234    let mut editor = RustEditor::new(&content)?;
235
236    // Separate struct-literal backups from others (they need special ordering)
237    let (mut struct_literal_backups, other_backups): (Vec<_>, Vec<_>) =
238        nodes.iter().partition(|b| b.node_type == "struct-literal");
239
240    // Sort struct-literal backups by counter in REVERSE order (process from end of file to
241    // beginning) This ensures byte offsets remain valid as we restore
242    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) // Reverse order
256    });
257
258    // Process struct-literal backups first (in reverse order)
259    for backup in &struct_literal_backups {
260        restore_struct_literal(&mut editor, backup)?;
261    }
262
263    // Then process other backups
264    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                // For match operations, we backup the whole function
277                restore_function(&mut editor, backup)?;
278            }
279            "ExprStruct" => {
280                // Struct literals are handled as part of the parent function
281                // Skip individual struct literal restoration
282            }
283            "struct-literal" => {
284                // Already handled above in the separate struct-literal processing
285                // This case should never be reached
286            }
287            "ItemUse" => {
288                // Use statements are simple, we can skip restoration
289                // since they should be handled by other means
290            }
291            _ => {
292                // For other node types, log a warning but don't fail
293                eprintln!(
294                    "Warning: Unsupported node type for revert: {}",
295                    backup.node_type
296                );
297            }
298        }
299    }
300
301    // Write back the restored content
302    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    // Parse the backup content
312    let backup_item: Item =
313        parse_str(&backup.original_content).context("Failed to parse backup struct content")?;
314
315    // Find the struct in the current AST by name
316    let struct_index = editor
317        .find_item_index("struct", &backup.identifier)
318        .with_context(|| format!("Struct '{}' not found for revert", backup.identifier))?;
319
320    // Replace with the backup using the editor's method
321    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    // Parse the backup content
330    let backup_item: Item =
331        parse_str(&backup.original_content).context("Failed to parse backup enum content")?;
332
333    // Find the enum in the current AST by name
334    let enum_index = editor
335        .find_item_index("enum", &backup.identifier)
336        .with_context(|| format!("Enum '{}' not found for revert", backup.identifier))?;
337
338    // Replace with the backup
339    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    // Parse the backup content
348    let backup_item: Item =
349        parse_str(&backup.original_content).context("Failed to parse backup impl content")?;
350
351    // Find impl block by matching on the self_ty
352    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    // Replace with the backup
362    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    // Parse the backup content
371    let backup_item: Item =
372        parse_str(&backup.original_content).context("Failed to parse backup function content")?;
373
374    // Find the function in the current AST by name
375    let fn_index = editor
376        .find_item_index("fn", &backup.identifier)
377        .with_context(|| format!("Function '{}' not found for revert", backup.identifier))?;
378
379    // Replace with the backup
380    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    // Extract the struct name and counter from the identifier (format: "StructName#counter" or
395    // "Enum::Variant#counter")
396    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    // Parse the backup content as an expression
406    let _backup_expr: ExprStruct = syn::parse_str(&backup.original_content)
407        .context("Failed to parse backup struct literal content")?;
408
409    // Find matching struct literal in the current file
410    struct LiteralFinder<'a> {
411        struct_name: &'a str,
412        current_literals: Vec<(usize, usize, String)>, // (start_byte, end_byte, content)
413        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            // Check if this matches our target struct name
419            let matches = if self.struct_name.contains("::") {
420                // Enum variant case
421                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                // Simple struct case
431                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    // Restore the specific occurrence identified by the counter
462    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    // Struct literal no longer exists, which is okay for revert
469    // (it might have been removed by the operation we're reverting)
470    Ok(())
471}
472
473/// Save run metadata
474pub 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    // Atomic write
480    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    // Update index
489    let mut index = RunsIndex::load(state_dir)?;
490    index.add_run(run.clone());
491    index.save(state_dir)?;
492
493    Ok(())
494}
495
496/// Load run metadata
497pub 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
510/// Revert a run
511pub fn revert_run(run_id: &str, force: bool, state_dir: &Path) -> Result<()> {
512    // Load run metadata
513    let run = load_run_metadata(run_id, state_dir)?;
514
515    // Check if already reverted
516    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    // Verify files haven't changed (unless --force)
525    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    // Restore from backups
548    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    // Mark run as reverted
555    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    // Update individual metadata file
563    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
572/// Display run history
573pub 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
615/// Clean old state data
616pub 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            // Remove backup directory
626            let backup_dir = state_dir.join(&run.run_id);
627            if backup_dir.exists() {
628                fs::remove_dir_all(&backup_dir)?;
629            }
630
631            // Remove metadata file
632            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    // Save updated index
644    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/// Get total size of state directory
659#[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); // Should be unique
689    }
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        // Same content should produce same hash
700        fs::write(&file_path, "hello world")?;
701        let hash2 = hash_file(&file_path)?;
702        assert_eq!(hash1, hash2);
703
704        // Different content should produce different hash
705        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        // Create a backup node
720        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        // Save backup nodes
733        let run_id = "abc1234";
734        save_backup_nodes(&file_path, &[node], run_id, &state_dir)?;
735
736        // Verify backup file exists
737        let backup_dir = state_dir.join(run_id);
738        assert!(backup_dir.exists());
739
740        // Verify we can read the backup
741        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
763        save_run_metadata(&run, &state_dir)?;
764
765        // Load and verify
766        let loaded = load_run_metadata("abc1234", &state_dir)?;
767        assert_eq!(loaded.run_id, "abc1234");
768        assert_eq!(loaded.operation, "AddStructField");
769
770        // Check index
771        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}