Skip to main content

forgekit_core/edit/
mod.rs

1//! Edit module - Span-safe code editing
2//!
3//! This module provides span-safe refactoring operations by delegating to
4//! `splice::forge` convenience functions when the `splice` feature is enabled.
5//! Symbol discovery uses `llmgrep::forge::search_symbols` for file resolution,
6//! then `splice::forge::patch_symbol_in_file` for each file.
7
8mod identifiers;
9mod undo;
10
11pub use undo::{PendingUndo, UndoResult};
12
13use crate::error::{ForgeError, Result};
14use std::path::{Path, PathBuf};
15
16use identifiers::language_from_extension;
17use undo::UndoableOp;
18
19use identifiers::identifier_spans;
20
21/// Result of an edit operation.
22#[derive(Debug, Clone)]
23pub struct EditResult {
24    pub success: bool,
25    pub changed_files: Vec<PathBuf>,
26    pub error: Option<String>,
27}
28
29impl EditResult {
30    pub fn success(files: Vec<PathBuf>) -> Self {
31        Self {
32            success: true,
33            changed_files: files,
34            error: None,
35        }
36    }
37
38    pub fn failure(error: String) -> Self {
39        Self {
40            success: false,
41            changed_files: Vec::new(),
42            error: Some(error),
43        }
44    }
45}
46
47/// Edit module for span-safe refactoring.
48pub struct EditModule {
49    store: std::sync::Arc<crate::storage::UnifiedGraphStore>,
50    undo_stack: parking_lot::Mutex<Vec<PendingUndo>>,
51    undo_capacity: usize,
52}
53
54impl EditModule {
55    pub fn new(store: std::sync::Arc<crate::storage::UnifiedGraphStore>) -> Self {
56        Self {
57            store,
58            undo_stack: parking_lot::Mutex::new(Vec::new()),
59            undo_capacity: 100,
60        }
61    }
62
63    fn validate_relative_path(&self, path: &Path) -> Result<PathBuf> {
64        if path.is_absolute() {
65            return Err(ForgeError::PathNotAllowed(path.to_path_buf()));
66        }
67        let resolved = self.store.codebase_path.join(path);
68        let canonical_base = self
69            .store
70            .codebase_path
71            .canonicalize()
72            .unwrap_or_else(|_| self.store.codebase_path.clone());
73        if let Some(parent) = resolved.parent() {
74            if let Ok(canonical_parent) = parent.canonicalize() {
75                if !canonical_parent.starts_with(&canonical_base) {
76                    return Err(ForgeError::PathNotAllowed(path.to_path_buf()));
77                }
78            }
79        }
80        Ok(resolved)
81    }
82
83    pub async fn create_file(&self, path: &Path, content: &str) -> Result<EditResult> {
84        let resolved = self.validate_relative_path(path)?;
85        if resolved.exists() {
86            return Err(ForgeError::FileAlreadyExists(resolved));
87        }
88        if let Some(parent) = resolved.parent() {
89            tokio::fs::create_dir_all(parent).await?;
90        }
91        tokio::fs::write(&resolved, content).await?;
92        self.push_undo(UndoableOp::CreateFile {
93            path: path.to_path_buf(),
94        });
95        Ok(EditResult::success(vec![path.to_path_buf()]))
96    }
97
98    pub async fn create_directory(&self, path: &Path) -> Result<EditResult> {
99        let resolved = self.validate_relative_path(path)?;
100        if resolved.exists() {
101            return Err(ForgeError::FileAlreadyExists(resolved));
102        }
103        tokio::fs::create_dir_all(&resolved).await?;
104        self.push_undo(UndoableOp::CreateDirectory {
105            path: path.to_path_buf(),
106        });
107        Ok(EditResult::success(vec![path.to_path_buf()]))
108    }
109
110    pub async fn write_file(&self, path: &Path, content: &str) -> Result<EditResult> {
111        let resolved = self.validate_relative_path(path)?;
112        let previous = if resolved.exists() {
113            tokio::fs::read_to_string(&resolved).await.ok()
114        } else {
115            None
116        };
117        if let Some(parent) = resolved.parent() {
118            tokio::fs::create_dir_all(parent).await?;
119        }
120        tokio::fs::write(&resolved, content).await?;
121        self.push_undo(UndoableOp::WriteFile {
122            path: path.to_path_buf(),
123            previous,
124        });
125        Ok(EditResult::success(vec![path.to_path_buf()]))
126    }
127
128    pub async fn apply(&mut self, op: EditOperation) -> Result<()> {
129        match op {
130            EditOperation::Replace {
131                file_path,
132                start,
133                end,
134                new_content,
135            } => {
136                splice::patch::replace_span(&file_path, start, end, &new_content).map_err(|e| {
137                    ForgeError::DatabaseError(format!("Splice replace failed: {}", e))
138                })?;
139                Ok(())
140            }
141        }
142    }
143
144    pub async fn patch_symbol(&self, symbol: &str, replacement: &str) -> Result<EditResult> {
145        let db_path = self.store.db_path.clone();
146        if !db_path.exists() {
147            return Err(ForgeError::DatabaseError(
148                "graph DB not found; run forge.graph().index() first".to_string(),
149            ));
150        }
151        self.patch_symbol_via_db(symbol, replacement, &db_path)
152            .await
153    }
154
155    async fn patch_symbol_via_db(
156        &self,
157        symbol: &str,
158        replacement: &str,
159        db_path: &Path,
160    ) -> Result<EditResult> {
161        let matches = llmgrep::forge::search_symbols(symbol, db_path, 50)
162            .map_err(|e| ForgeError::DatabaseError(format!("Symbol search failed: {}", e)))?;
163
164        let files: std::collections::HashSet<PathBuf> = matches
165            .iter()
166            .map(|m| PathBuf::from(&m.span.file_path))
167            .collect();
168
169        let mut changed_files = Vec::new();
170        for file_path in files {
171            let full_path = self.store.codebase_path.join(&file_path);
172            match splice::forge::patch_symbol_in_file(&full_path, symbol, replacement, db_path) {
173                Ok(_) => {
174                    changed_files.push(file_path);
175                }
176                Err(e) => {
177                    tracing::warn!("Failed to patch {} in file: {}", symbol, e);
178                }
179            }
180        }
181
182        if changed_files.is_empty() {
183            return Err(ForgeError::SymbolNotFound(format!(
184                "Symbol '{}' not found",
185                symbol
186            )));
187        }
188
189        Ok(EditResult::success(changed_files))
190    }
191
192    pub async fn rename_symbol(&self, old_name: &str, new_name: &str) -> Result<EditResult> {
193        let db_path = self.store.db_path.clone();
194        if !db_path.exists() {
195            return Err(ForgeError::DatabaseError(
196                "graph DB not found; run forge.graph().index() first".to_string(),
197            ));
198        }
199        self.rename_symbol_via_db(old_name, new_name, &db_path)
200            .await
201    }
202
203    async fn rename_symbol_via_db(
204        &self,
205        old_name: &str,
206        new_name: &str,
207        db_path: &Path,
208    ) -> Result<EditResult> {
209        let mut graph = magellan::CodeGraph::open(db_path)
210            .map_err(|e| ForgeError::DatabaseError(format!("Failed to open graph: {}", e)))?;
211
212        let mut affected_files: std::collections::HashSet<std::path::PathBuf> =
213            std::collections::HashSet::new();
214
215        if let Ok(defs) = graph.search_symbols_by_name(old_name) {
216            for sym in &defs {
217                affected_files.insert(std::path::PathBuf::from(&sym.file_path));
218            }
219        }
220
221        let file_nodes = graph
222            .all_file_nodes()
223            .map_err(|e| ForgeError::DatabaseError(format!("Failed to get file nodes: {}", e)))?;
224
225        for file_path in file_nodes.keys() {
226            if let Ok(call_facts) = graph.callers_of_symbol(file_path, old_name) {
227                if !call_facts.is_empty() {
228                    affected_files.insert(std::path::PathBuf::from(file_path));
229                }
230            }
231            if let Ok(Some(symbol_id)) = graph.symbol_id_by_name(file_path, old_name) {
232                if let Ok(refs) = graph.references_to_symbol(symbol_id) {
233                    for r in refs {
234                        affected_files.insert(r.file_path.clone());
235                    }
236                }
237            }
238        }
239
240        if affected_files.is_empty() {
241            return Err(ForgeError::SymbolNotFound(format!(
242                "Symbol '{}' not found",
243                old_name
244            )));
245        }
246
247        let mut all_refs: Vec<magellan::references::ReferenceFact> = Vec::new();
248        for rel_path in &affected_files {
249            let full_path = self.store.codebase_path.join(rel_path);
250            if let Ok(content) = std::fs::read(&full_path) {
251                let lang = language_from_extension(rel_path);
252                for (start, end) in identifier_spans(&content, old_name, lang) {
253                    all_refs.push(magellan::references::ReferenceFact {
254                        file_path: rel_path.clone(),
255                        referenced_symbol: old_name.to_string(),
256                        byte_start: start,
257                        byte_end: end,
258                        start_line: 0,
259                        start_col: 0,
260                        end_line: 0,
261                        end_col: 0,
262                    });
263                }
264            }
265        }
266
267        if all_refs.is_empty() {
268            return Err(ForgeError::SymbolNotFound(format!(
269                "Symbol '{}' not found",
270                old_name
271            )));
272        }
273
274        let mut changed_files = Vec::new();
275        let by_file = splice::graph::rename::group_references_by_file(&all_refs);
276        for (file_path, refs) in by_file {
277            let full_path = self.store.codebase_path.join(&file_path);
278            match splice::graph::rename::apply_replacements_in_file(
279                &full_path, old_name, new_name, &refs,
280            ) {
281                Ok(count) if count > 0 => {
282                    changed_files.push(file_path);
283                }
284                Ok(_) => {}
285                Err(e) => {
286                    tracing::warn!("Failed to rename in {}: {}", file_path.display(), e);
287                }
288            }
289        }
290
291        if changed_files.is_empty() {
292            return Err(ForgeError::SymbolNotFound(format!(
293                "Symbol '{}' references found but no files changed",
294                old_name
295            )));
296        }
297
298        Ok(EditResult::success(changed_files))
299    }
300
301    pub async fn delete_symbol(&self, file_path: &Path, symbol: &str) -> Result<EditResult> {
302        let db_path = self.store.db_path.clone();
303        if !db_path.exists() {
304            return Err(ForgeError::DatabaseError(
305                "graph DB not found; run forge.graph().index() first".to_string(),
306            ));
307        }
308        self.delete_symbol_via_db(file_path, symbol, &db_path).await
309    }
310
311    async fn delete_symbol_via_db(
312        &self,
313        file_path: &Path,
314        symbol: &str,
315        db_path: &Path,
316    ) -> Result<EditResult> {
317        let full_path = self.store.codebase_path.join(file_path);
318        match splice::forge::delete_symbol_from_file(&full_path, symbol, db_path) {
319            Ok(result) => Ok(EditResult::success(vec![result.file])),
320            Err(e) => Err(ForgeError::DatabaseError(format!("Delete failed: {}", e))),
321        }
322    }
323
324    pub async fn resolve_span(
325        &self,
326        file_path: &Path,
327        symbol: &str,
328    ) -> Result<splice::forge::SymbolSpan> {
329        let db_path = self.store.db_path.clone();
330        if !db_path.exists() {
331            return Err(ForgeError::DatabaseError(
332                "graph DB not found; run forge.graph().index() first".to_string(),
333            ));
334        }
335        splice::forge::resolve_symbol_span(
336            &self.store.codebase_path.join(file_path),
337            symbol,
338            &db_path,
339        )
340        .map_err(|e| ForgeError::DatabaseError(format!("Span resolution failed: {}", e)))
341    }
342}
343
344/// An edit operation.
345pub enum EditOperation {
346    Replace {
347        file_path: PathBuf,
348        start: usize,
349        end: usize,
350        new_content: String,
351    },
352}
353
354#[cfg(test)]
355fn find_symbol_span(content: &str, symbol: &str) -> Option<(usize, usize)> {
356    let patterns = [
357        format!("fn {}", symbol),
358        format!("pub fn {}", symbol),
359        format!("pub(crate) fn {}", symbol),
360        format!("async fn {}", symbol),
361        format!("pub async fn {}", symbol),
362        format!("struct {}", symbol),
363        format!("pub struct {}", symbol),
364        format!("enum {}", symbol),
365        format!("pub enum {}", symbol),
366        format!("trait {}", symbol),
367        format!("pub trait {}", symbol),
368        format!("impl {}", symbol),
369        format!("mod {}", symbol),
370        format!("pub mod {}", symbol),
371        format!("const {}", symbol),
372        format!("static {}", symbol),
373        format!("type {}", symbol),
374    ];
375
376    for pattern in &patterns {
377        if let Some(pos) = content.find(pattern.as_str()) {
378            let end = find_definition_end(content, pos);
379            return Some((pos, end));
380        }
381    }
382
383    None
384}
385
386#[cfg(test)]
387fn find_definition_end(content: &str, start: usize) -> usize {
388    let rest = &content[start..];
389
390    if let Some(brace_pos) = rest.find('{') {
391        let mut depth = 0u32;
392        for (i, b) in rest[brace_pos..].bytes().enumerate() {
393            match b {
394                b'{' => depth += 1,
395                b'}' => {
396                    depth -= 1;
397                    if depth == 0 {
398                        return start + brace_pos + i + 1;
399                    }
400                }
401                _ => {}
402            }
403        }
404    }
405
406    if let Some(semi_pos) = rest.find(';') {
407        return start + semi_pos + 1;
408    }
409
410    content.len()
411}
412
413#[cfg(test)]
414fn simple_word_replace(content: &str, old: &str, new: &str) -> String {
415    let mut result = String::new();
416    let mut last_end = 0;
417
418    for (i, _) in content.match_indices(old) {
419        let before = if i > 0 {
420            content.as_bytes().get(i - 1).copied()
421        } else {
422            None
423        };
424        let after = content.as_bytes().get(i + old.len()).copied();
425
426        let is_word = |c: u8| c.is_ascii_alphanumeric() || c == b'_';
427        let word_before = before.map(is_word).unwrap_or(false);
428        let word_after = after.map(is_word).unwrap_or(false);
429
430        if !word_before && !word_after {
431            result.push_str(&content[last_end..i]);
432            result.push_str(new);
433            last_end = i + old.len();
434        }
435    }
436
437    result.push_str(&content[last_end..]);
438    result
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444
445    #[test]
446    fn test_edit_module_creation() {
447        use crate::storage::UnifiedGraphStore;
448        let _store: Option<UnifiedGraphStore> = None;
449    }
450
451    #[test]
452    fn test_edit_operation_replace() {
453        let _op = EditOperation::Replace {
454            file_path: PathBuf::from("test.rs"),
455            start: 10,
456            end: 20,
457            new_content: String::from("test"),
458        };
459    }
460
461    #[test]
462    fn test_edit_result_success() {
463        let result = EditResult::success(vec![PathBuf::from("foo.rs")]);
464        assert!(result.success);
465        assert_eq!(result.changed_files.len(), 1);
466        assert!(result.error.is_none());
467    }
468
469    #[test]
470    fn test_edit_result_failure() {
471        let result = EditResult::failure("something went wrong".to_string());
472        assert!(!result.success);
473        assert!(result.changed_files.is_empty());
474        assert!(result.error.is_some());
475    }
476
477    #[test]
478    fn test_find_symbol_span_function() {
479        let code = "fn hello() { println!(\"Hello\"); }\n";
480        let span = find_symbol_span(code, "hello").unwrap();
481        assert!(code[span.0..span.1].starts_with("fn hello"));
482        assert!(code[span.0..span.1].ends_with("}"));
483    }
484
485    #[test]
486    fn test_find_symbol_span_struct() {
487        let code = "pub struct Foo { x: i32 }\n";
488        let span = find_symbol_span(code, "Foo").unwrap();
489        assert!(code[span.0..span.1].contains("struct Foo"));
490    }
491
492    #[test]
493    fn test_find_symbol_span_not_found() {
494        let code = "fn bar() {}\n";
495        assert!(find_symbol_span(code, "baz").is_none());
496    }
497
498    #[test]
499    fn test_simple_word_replace() {
500        let code = "fn old_name() {}\nfn caller() { old_name(); }";
501        let result = simple_word_replace(code, "old_name", "new_name");
502        assert!(result.contains("fn new_name()"));
503        assert!(result.contains("new_name();"));
504        assert!(!result.contains("old_name"));
505    }
506
507    #[test]
508    fn test_simple_word_replace_respects_boundaries() {
509        let code = "fn get_name() {}\nfn name() {}";
510        let result = simple_word_replace(code, "name", "title");
511        assert!(result.contains("get_name"));
512        assert!(result.contains("fn title()"));
513    }
514
515    #[tokio::test]
516    async fn test_create_file_new() {
517        let temp = tempfile::tempdir().unwrap();
518        let store = std::sync::Arc::new(
519            crate::storage::UnifiedGraphStore::open_with_path(
520                temp.path(),
521                temp.path().join("test.db"),
522                crate::storage::BackendKind::default(),
523            )
524            .await
525            .unwrap(),
526        );
527        let edit = EditModule::new(store);
528
529        let result = edit
530            .create_file(Path::new("src/lib.rs"), "pub fn hello() {}")
531            .await
532            .unwrap();
533        assert!(result.success);
534
535        let content = tokio::fs::read_to_string(temp.path().join("src/lib.rs"))
536            .await
537            .unwrap();
538        assert_eq!(content, "pub fn hello() {}");
539    }
540
541    #[tokio::test]
542    async fn test_create_file_rejects_absolute() {
543        let temp = tempfile::tempdir().unwrap();
544        let store = std::sync::Arc::new(
545            crate::storage::UnifiedGraphStore::open_with_path(
546                temp.path(),
547                temp.path().join("test.db"),
548                crate::storage::BackendKind::default(),
549            )
550            .await
551            .unwrap(),
552        );
553        let edit = EditModule::new(store);
554
555        let result = edit.create_file(Path::new("/tmp/evil.rs"), "bad").await;
556        assert!(result.is_err());
557        let err = result.unwrap_err();
558        assert!(matches!(err, ForgeError::PathNotAllowed(_)));
559    }
560
561    #[tokio::test]
562    async fn test_create_file_rejects_existing() {
563        let temp = tempfile::tempdir().unwrap();
564        let existing = temp.path().join("exists.rs");
565        tokio::fs::write(&existing, "old").await.unwrap();
566
567        let store = std::sync::Arc::new(
568            crate::storage::UnifiedGraphStore::open_with_path(
569                temp.path(),
570                temp.path().join("test.db"),
571                crate::storage::BackendKind::default(),
572            )
573            .await
574            .unwrap(),
575        );
576        let edit = EditModule::new(store);
577
578        let result = edit.create_file(Path::new("exists.rs"), "new").await;
579        assert!(result.is_err());
580        assert!(matches!(
581            result.unwrap_err(),
582            ForgeError::FileAlreadyExists(_)
583        ));
584    }
585
586    #[tokio::test]
587    async fn test_create_file_nested_dirs() {
588        let temp = tempfile::tempdir().unwrap();
589        let store = std::sync::Arc::new(
590            crate::storage::UnifiedGraphStore::open_with_path(
591                temp.path(),
592                temp.path().join("test.db"),
593                crate::storage::BackendKind::default(),
594            )
595            .await
596            .unwrap(),
597        );
598        let edit = EditModule::new(store);
599
600        let result = edit
601            .create_file(Path::new("a/b/c/deep.rs"), "content")
602            .await
603            .unwrap();
604        assert!(result.success);
605        assert!(temp.path().join("a/b/c/deep.rs").exists());
606    }
607
608    #[tokio::test]
609    async fn test_create_directory_new() {
610        let temp = tempfile::tempdir().unwrap();
611        let store = std::sync::Arc::new(
612            crate::storage::UnifiedGraphStore::open_with_path(
613                temp.path(),
614                temp.path().join("test.db"),
615                crate::storage::BackendKind::default(),
616            )
617            .await
618            .unwrap(),
619        );
620        let edit = EditModule::new(store);
621
622        let result = edit
623            .create_directory(Path::new("src/models"))
624            .await
625            .unwrap();
626        assert!(result.success);
627        assert!(temp.path().join("src/models").is_dir());
628    }
629
630    #[tokio::test]
631    async fn test_write_file_overwrites() {
632        let temp = tempfile::tempdir().unwrap();
633        let file = temp.path().join("existing.rs");
634        tokio::fs::write(&file, "old content").await.unwrap();
635
636        let store = std::sync::Arc::new(
637            crate::storage::UnifiedGraphStore::open_with_path(
638                temp.path(),
639                temp.path().join("test.db"),
640                crate::storage::BackendKind::default(),
641            )
642            .await
643            .unwrap(),
644        );
645        let edit = EditModule::new(store);
646
647        let result = edit
648            .write_file(Path::new("existing.rs"), "new content")
649            .await
650            .unwrap();
651        assert!(result.success);
652
653        let content = tokio::fs::read_to_string(&file).await.unwrap();
654        assert_eq!(content, "new content");
655    }
656
657    #[tokio::test]
658    async fn test_write_file_creates_parent_dirs() {
659        let temp = tempfile::tempdir().unwrap();
660        let store = std::sync::Arc::new(
661            crate::storage::UnifiedGraphStore::open_with_path(
662                temp.path(),
663                temp.path().join("test.db"),
664                crate::storage::BackendKind::default(),
665            )
666            .await
667            .unwrap(),
668        );
669        let edit = EditModule::new(store);
670
671        let result = edit
672            .write_file(Path::new("deep/nested/file.rs"), "content")
673            .await
674            .unwrap();
675        assert!(result.success);
676        assert!(temp.path().join("deep/nested/file.rs").exists());
677    }
678
679    #[test]
680    fn test_identifier_spans_rust() {
681        use crate::types::Language;
682        let code = b"fn foo() { self.foo(); foo(); crate::foo(); }";
683        let spans = identifier_spans(code, "foo", Language::Rust);
684        assert!(spans.len() >= 3, "should find foo, self.foo, crate::foo");
685    }
686
687    #[test]
688    fn test_identifier_spans_python() {
689        use crate::types::Language;
690        let code = b"self.value\ncls.value\nvalue\n";
691        let spans = identifier_spans(code, "value", Language::Python);
692        assert!(spans.len() >= 3, "should find value, self.value, cls.value");
693    }
694
695    #[test]
696    fn test_identifier_spans_java() {
697        use crate::types::Language;
698        let code = b"this.name\nname\n";
699        let spans = identifier_spans(code, "name", Language::Java);
700        assert!(spans.len() >= 2, "should find name and this.name");
701    }
702
703    #[test]
704    fn test_identifier_spans_respects_boundaries() {
705        use crate::types::Language;
706        let code = b"get_name\nname\nnames\n";
707        let spans = identifier_spans(code, "name", Language::Rust);
708        assert_eq!(spans.len(), 1, "should not match get_name or names");
709    }
710
711    #[test]
712    fn test_language_from_extension() {
713        assert!(matches!(
714            language_from_extension(Path::new("foo.rs")),
715            crate::types::Language::Rust
716        ));
717        assert!(matches!(
718            language_from_extension(Path::new("bar.py")),
719            crate::types::Language::Python
720        ));
721        assert!(matches!(
722            language_from_extension(Path::new("baz.java")),
723            crate::types::Language::Java
724        ));
725        assert!(matches!(
726            language_from_extension(Path::new("a.ts")),
727            crate::types::Language::TypeScript
728        ));
729    }
730
731    #[tokio::test]
732    async fn test_undo_create_file() {
733        let temp = tempfile::tempdir().unwrap();
734        let store = std::sync::Arc::new(
735            crate::storage::UnifiedGraphStore::open_with_path(
736                temp.path(),
737                temp.path().join("test.db"),
738                crate::storage::BackendKind::default(),
739            )
740            .await
741            .unwrap(),
742        );
743        let edit = EditModule::new(store);
744
745        edit.create_file(Path::new("new.rs"), "fn main() {}")
746            .await
747            .unwrap();
748        assert!(temp.path().join("new.rs").exists());
749        assert!(edit.can_undo());
750        assert_eq!(edit.undo_depth(), 1);
751
752        let result = edit.undo().await.unwrap();
753        assert!(matches!(result, UndoResult::Undone { .. }));
754        assert!(!temp.path().join("new.rs").exists());
755        assert!(!edit.can_undo());
756    }
757
758    #[tokio::test]
759    async fn test_undo_write_file_restores_previous() {
760        let temp = tempfile::tempdir().unwrap();
761        let file = temp.path().join("existing.rs");
762        tokio::fs::write(&file, "old content").await.unwrap();
763
764        let store = std::sync::Arc::new(
765            crate::storage::UnifiedGraphStore::open_with_path(
766                temp.path(),
767                temp.path().join("test.db"),
768                crate::storage::BackendKind::default(),
769            )
770            .await
771            .unwrap(),
772        );
773        let edit = EditModule::new(store);
774
775        edit.write_file(Path::new("existing.rs"), "new content")
776            .await
777            .unwrap();
778        assert_eq!(
779            tokio::fs::read_to_string(&file).await.unwrap(),
780            "new content"
781        );
782
783        edit.undo().await.unwrap();
784        assert_eq!(
785            tokio::fs::read_to_string(&file).await.unwrap(),
786            "old content"
787        );
788    }
789
790    #[tokio::test]
791    async fn test_undo_write_file_removes_new() {
792        let temp = tempfile::tempdir().unwrap();
793        let store = std::sync::Arc::new(
794            crate::storage::UnifiedGraphStore::open_with_path(
795                temp.path(),
796                temp.path().join("test.db"),
797                crate::storage::BackendKind::default(),
798            )
799            .await
800            .unwrap(),
801        );
802        let edit = EditModule::new(store);
803
804        edit.write_file(Path::new("brand_new.rs"), "content")
805            .await
806            .unwrap();
807        assert!(temp.path().join("brand_new.rs").exists());
808
809        edit.undo().await.unwrap();
810        assert!(!temp.path().join("brand_new.rs").exists());
811    }
812
813    #[tokio::test]
814    async fn test_undo_create_directory() {
815        let temp = tempfile::tempdir().unwrap();
816        let store = std::sync::Arc::new(
817            crate::storage::UnifiedGraphStore::open_with_path(
818                temp.path(),
819                temp.path().join("test.db"),
820                crate::storage::BackendKind::default(),
821            )
822            .await
823            .unwrap(),
824        );
825        let edit = EditModule::new(store);
826
827        edit.create_directory(Path::new("my_dir")).await.unwrap();
828        assert!(temp.path().join("my_dir").is_dir());
829
830        edit.undo().await.unwrap();
831        assert!(!temp.path().join("my_dir").exists());
832    }
833
834    #[tokio::test]
835    async fn test_undo_empty_stack() {
836        let temp = tempfile::tempdir().unwrap();
837        let store = std::sync::Arc::new(
838            crate::storage::UnifiedGraphStore::open_with_path(
839                temp.path(),
840                temp.path().join("test.db"),
841                crate::storage::BackendKind::default(),
842            )
843            .await
844            .unwrap(),
845        );
846        let edit = EditModule::new(store);
847
848        let result = edit.undo().await.unwrap();
849        assert!(matches!(result, UndoResult::Empty));
850    }
851
852    #[tokio::test]
853    async fn test_undo_depth_and_clear() {
854        let temp = tempfile::tempdir().unwrap();
855        let store = std::sync::Arc::new(
856            crate::storage::UnifiedGraphStore::open_with_path(
857                temp.path(),
858                temp.path().join("test.db"),
859                crate::storage::BackendKind::default(),
860            )
861            .await
862            .unwrap(),
863        );
864        let edit = EditModule::new(store);
865
866        edit.create_file(Path::new("a.rs"), "a").await.unwrap();
867        edit.create_file(Path::new("b.rs"), "b").await.unwrap();
868        edit.create_file(Path::new("c.rs"), "c").await.unwrap();
869        assert_eq!(edit.undo_depth(), 3);
870
871        edit.clear_undo_stack();
872        assert_eq!(edit.undo_depth(), 0);
873        assert!(!edit.can_undo());
874    }
875}