1mod 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#[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
47pub 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
344pub 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}