1use std::path::PathBuf;
25
26use serde::{Deserialize, Serialize};
27use thiserror::Error;
28
29use crate::facts::{
30 CallEdgeFact, CallFact, EntryPointFact, ImportFact, ImportResolution, ScanReport, SnapshotFact,
31 TypeFact,
32};
33use crate::graph::compute_graph_metrics;
34use crate::health::RaysenseConfig;
35use crate::scanner::{matching_plugin, module_name};
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
42#[serde(tag = "action", rename_all = "snake_case")]
43pub enum Action {
44 RemoveFile { file: String },
45 MoveFile { from: String, to: String },
46 AddEdge { from: String, to: String },
47 RemoveEdge { from: String, to: String },
48 BreakCycle { from: String, to: String },
49}
50
51#[derive(Debug, Error)]
52pub enum SimulateError {
53 #[error("file not found in scan: {0}")]
54 FileNotFound(String),
55 #[error("file already exists at destination: {0}")]
56 DestinationOccupied(String),
57 #[error("no matching local edge from {from} to {to}")]
58 EdgeNotFound { from: String, to: String },
59 #[error("matching local edge already exists from {from} to {to}")]
60 EdgeAlreadyExists { from: String, to: String },
61 #[error("edge from {from} to {to} does not participate in a cycle")]
62 EdgeNotInCycle { from: String, to: String },
63}
64
65pub fn remove_file(report: &ScanReport, file_path: &str) -> Result<ScanReport, SimulateError> {
70 let target_id = report
71 .files
72 .iter()
73 .position(|file| file.path.to_string_lossy() == file_path)
74 .ok_or_else(|| SimulateError::FileNotFound(file_path.to_string()))?;
75
76 let mut file_remap: Vec<Option<usize>> = vec![None; report.files.len()];
77 let mut new_files = Vec::with_capacity(report.files.len().saturating_sub(1));
78 for (old_id, file) in report.files.iter().enumerate() {
79 if old_id == target_id {
80 continue;
81 }
82 let new_id = new_files.len();
83 file_remap[old_id] = Some(new_id);
84 let mut new_file = file.clone();
85 new_file.file_id = new_id;
86 new_files.push(new_file);
87 }
88
89 let mut function_remap: Vec<Option<usize>> = vec![None; report.functions.len()];
90 let mut new_functions = Vec::new();
91 for (old_id, function) in report.functions.iter().enumerate() {
92 let Some(new_file_id) = file_remap[function.file_id] else {
93 continue;
94 };
95 let new_id = new_functions.len();
96 function_remap[old_id] = Some(new_id);
97 let mut new_function = function.clone();
98 new_function.function_id = new_id;
99 new_function.file_id = new_file_id;
100 new_functions.push(new_function);
101 }
102
103 let mut new_entry_points = Vec::new();
104 for entry in &report.entry_points {
105 let Some(new_file_id) = file_remap[entry.file_id] else {
106 continue;
107 };
108 new_entry_points.push(EntryPointFact {
109 entry_id: new_entry_points.len(),
110 file_id: new_file_id,
111 kind: entry.kind,
112 symbol: entry.symbol.clone(),
113 });
114 }
115
116 let mut new_imports = Vec::new();
117 for import in &report.imports {
118 let Some(new_from) = file_remap[import.from_file] else {
119 continue;
120 };
121 let new_resolved = match import.resolved_file {
122 Some(resolved) => match file_remap[resolved] {
123 Some(remapped) => Some(remapped),
124 None => continue,
125 },
126 None => None,
127 };
128 new_imports.push(ImportFact {
129 import_id: new_imports.len(),
130 from_file: new_from,
131 target: import.target.clone(),
132 kind: import.kind.clone(),
133 resolution: import.resolution,
134 resolved_file: new_resolved,
135 });
136 }
137
138 let mut call_remap: Vec<Option<usize>> = vec![None; report.calls.len()];
139 let mut new_calls = Vec::new();
140 for (old_id, call) in report.calls.iter().enumerate() {
141 let Some(new_file_id) = file_remap[call.file_id] else {
142 continue;
143 };
144 let new_caller = match call.caller_function {
145 Some(caller) => match function_remap[caller] {
146 Some(remapped) => Some(remapped),
147 None => continue,
148 },
149 None => None,
150 };
151 let new_id = new_calls.len();
152 call_remap[old_id] = Some(new_id);
153 new_calls.push(CallFact {
154 call_id: new_id,
155 file_id: new_file_id,
156 caller_function: new_caller,
157 target: call.target.clone(),
158 line: call.line,
159 });
160 }
161
162 let mut new_call_edges = Vec::new();
163 for edge in &report.call_edges {
164 let (Some(new_caller), Some(new_callee)) = (
165 function_remap[edge.caller_function],
166 function_remap[edge.callee_function],
167 ) else {
168 continue;
169 };
170 let Some(new_call_id) = call_remap.get(edge.call_id).copied().flatten() else {
171 continue;
172 };
173 new_call_edges.push(CallEdgeFact {
174 edge_id: new_call_edges.len(),
175 call_id: new_call_id,
176 caller_function: new_caller,
177 callee_function: new_callee,
178 });
179 }
180
181 let mut new_types = Vec::new();
182 for type_fact in &report.types {
183 let Some(new_file_id) = file_remap[type_fact.file_id] else {
184 continue;
185 };
186 new_types.push(TypeFact {
187 type_id: new_types.len(),
188 file_id: new_file_id,
189 name: type_fact.name.clone(),
190 is_abstract: type_fact.is_abstract,
191 line: type_fact.line,
192 bases: type_fact.bases.clone(),
193 });
194 }
195
196 let graph = compute_graph_metrics(&new_files, &new_imports);
197
198 Ok(ScanReport {
199 snapshot: SnapshotFact {
200 snapshot_id: format!("{}+remove_file:{}", report.snapshot.snapshot_id, file_path),
201 root: report.snapshot.root.clone(),
202 file_count: new_files.len(),
203 function_count: new_functions.len(),
204 import_count: new_imports.len(),
205 call_count: new_calls.len(),
206 },
207 files: new_files,
208 functions: new_functions,
209 entry_points: new_entry_points,
210 imports: new_imports,
211 calls: new_calls,
212 call_edges: new_call_edges,
213 types: new_types,
214 graph,
215 })
216}
217
218pub fn move_file(
223 report: &ScanReport,
224 config: &RaysenseConfig,
225 from_path: &str,
226 to_path: &str,
227) -> Result<ScanReport, SimulateError> {
228 let target_id = report
229 .files
230 .iter()
231 .position(|file| file.path.to_string_lossy() == from_path)
232 .ok_or_else(|| SimulateError::FileNotFound(from_path.to_string()))?;
233 if report
234 .files
235 .iter()
236 .any(|file| file.path.to_string_lossy() == to_path)
237 {
238 return Err(SimulateError::DestinationOccupied(to_path.to_string()));
239 }
240
241 let mut new_report = report.clone();
242 let new_path = PathBuf::from(to_path);
243 let language = new_report.files[target_id].language;
244 let plugin = matching_plugin(&new_path, config);
245 new_report.files[target_id].path = new_path.clone();
246 new_report.files[target_id].module = module_name(&new_path, language, plugin.as_ref());
247 new_report.snapshot.snapshot_id = format!(
248 "{}+move_file:{}->{}",
249 report.snapshot.snapshot_id, from_path, to_path
250 );
251 new_report.graph = compute_graph_metrics(&new_report.files, &new_report.imports);
252 Ok(new_report)
253}
254
255pub fn remove_edge(
258 report: &ScanReport,
259 from_path: &str,
260 to_path: &str,
261) -> Result<ScanReport, SimulateError> {
262 let from_id = file_id_for_path(report, from_path)?;
263 let to_id = file_id_for_path(report, to_path)?;
264
265 let mut after = report.clone();
266 let before_imports = after.imports.len();
267 after.imports.retain(|import| {
268 !(import.from_file == from_id
269 && import.resolved_file == Some(to_id)
270 && import.resolution == ImportResolution::Local)
271 });
272 if after.imports.len() == before_imports {
273 return Err(SimulateError::EdgeNotFound {
274 from: from_path.to_string(),
275 to: to_path.to_string(),
276 });
277 }
278 after.snapshot.import_count = after.imports.len();
279 after.graph = compute_graph_metrics(&after.files, &after.imports);
280 after.snapshot.snapshot_id = format!(
281 "{}+remove_edge:{}->{}",
282 report.snapshot.snapshot_id, from_path, to_path
283 );
284 Ok(after)
285}
286
287pub fn add_edge(
290 report: &ScanReport,
291 from_path: &str,
292 to_path: &str,
293) -> Result<ScanReport, SimulateError> {
294 let from_id = file_id_for_path(report, from_path)?;
295 let to_id = file_id_for_path(report, to_path)?;
296 if report.imports.iter().any(|import| {
297 import.from_file == from_id
298 && import.resolved_file == Some(to_id)
299 && import.resolution == ImportResolution::Local
300 }) {
301 return Err(SimulateError::EdgeAlreadyExists {
302 from: from_path.to_string(),
303 to: to_path.to_string(),
304 });
305 }
306
307 let mut after = report.clone();
308 after.imports.push(ImportFact {
309 import_id: after.imports.len(),
310 from_file: from_id,
311 target: to_path.to_string(),
312 kind: "what_if".to_string(),
313 resolution: ImportResolution::Local,
314 resolved_file: Some(to_id),
315 });
316 after.snapshot.import_count = after.imports.len();
317 after.graph = compute_graph_metrics(&after.files, &after.imports);
318 after.snapshot.snapshot_id = format!(
319 "{}+add_edge:{}->{}",
320 report.snapshot.snapshot_id, from_path, to_path
321 );
322 Ok(after)
323}
324
325pub fn break_cycle(
330 report: &ScanReport,
331 from_path: &str,
332 to_path: &str,
333) -> Result<ScanReport, SimulateError> {
334 let before_cycles = report.graph.cycle_count;
335 let after = remove_edge(report, from_path, to_path)?;
336 if after.graph.cycle_count >= before_cycles {
337 return Err(SimulateError::EdgeNotInCycle {
338 from: from_path.to_string(),
339 to: to_path.to_string(),
340 });
341 }
342 let mut after = after;
343 after.snapshot.snapshot_id = format!(
344 "{}+break_cycle:{}->{}",
345 report.snapshot.snapshot_id, from_path, to_path
346 );
347 Ok(after)
348}
349
350#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
352pub struct CycleBreakCandidate {
353 pub from: String,
354 pub to: String,
355 pub cycle_count_before: usize,
356 pub cycle_count_after: usize,
357 pub cycle_count_reduction: usize,
358}
359
360pub fn break_cycle_recommendations(
366 report: &ScanReport,
367 limit: usize,
368 max_candidates: usize,
369) -> Vec<CycleBreakCandidate> {
370 let baseline_cycles = report.graph.cycle_count;
371 if baseline_cycles == 0 {
372 return Vec::new();
373 }
374
375 let mut seen: std::collections::HashSet<(usize, usize)> = std::collections::HashSet::new();
376 let mut candidates = Vec::new();
377 let mut considered = 0usize;
378 for import in &report.imports {
379 if considered >= max_candidates {
380 break;
381 }
382 if import.resolution != ImportResolution::Local {
383 continue;
384 }
385 let Some(to_id) = import.resolved_file else {
386 continue;
387 };
388 if !seen.insert((import.from_file, to_id)) {
389 continue;
390 }
391 let Some(from_file) = report.files.get(import.from_file) else {
392 continue;
393 };
394 let Some(to_file) = report.files.get(to_id) else {
395 continue;
396 };
397 let from_path = from_file.path.to_string_lossy().into_owned();
398 let to_path = to_file.path.to_string_lossy().into_owned();
399 considered += 1;
400
401 let after_imports: Vec<ImportFact> = report
402 .imports
403 .iter()
404 .filter(|other| {
405 !(other.from_file == import.from_file
406 && other.resolved_file == Some(to_id)
407 && other.resolution == ImportResolution::Local)
408 })
409 .cloned()
410 .collect();
411 let after_graph = compute_graph_metrics(&report.files, &after_imports);
412 if after_graph.cycle_count < baseline_cycles {
413 candidates.push(CycleBreakCandidate {
414 from: from_path,
415 to: to_path,
416 cycle_count_before: baseline_cycles,
417 cycle_count_after: after_graph.cycle_count,
418 cycle_count_reduction: baseline_cycles - after_graph.cycle_count,
419 });
420 }
421 }
422
423 candidates.sort_by(|a, b| {
424 b.cycle_count_reduction
425 .cmp(&a.cycle_count_reduction)
426 .then_with(|| a.from.cmp(&b.from))
427 .then_with(|| a.to.cmp(&b.to))
428 });
429 candidates.truncate(limit);
430 candidates
431}
432
433pub fn simulate_sequence(
438 initial: &ScanReport,
439 config: &RaysenseConfig,
440 actions: &[Action],
441) -> Result<ScanReport, SequenceError> {
442 let mut current = initial.clone();
443 for (index, action) in actions.iter().enumerate() {
444 let result = match action {
445 Action::RemoveFile { file } => remove_file(¤t, file),
446 Action::MoveFile { from, to } => move_file(¤t, config, from, to),
447 Action::AddEdge { from, to } => add_edge(¤t, from, to),
448 Action::RemoveEdge { from, to } => remove_edge(¤t, from, to),
449 Action::BreakCycle { from, to } => break_cycle(¤t, from, to),
450 };
451 current = result.map_err(|source| SequenceError {
452 index,
453 action: action.clone(),
454 source,
455 })?;
456 }
457 Ok(current)
458}
459
460#[derive(Debug, Error)]
462#[error("action #{index} ({action:?}) failed: {source}")]
463pub struct SequenceError {
464 pub index: usize,
465 pub action: Action,
466 #[source]
467 pub source: SimulateError,
468}
469
470fn file_id_for_path(report: &ScanReport, path: &str) -> Result<usize, SimulateError> {
471 report
472 .files
473 .iter()
474 .position(|file| file.path.to_string_lossy() == path)
475 .ok_or_else(|| SimulateError::FileNotFound(path.to_string()))
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481 use crate::facts::{
482 CallEdgeFact, CallFact, EntryPointFact, EntryPointKind, FileFact, FunctionFact, ImportFact,
483 ImportResolution, Language,
484 };
485 use std::path::PathBuf;
486
487 fn file(file_id: usize, path: &str) -> FileFact {
488 FileFact {
489 file_id,
490 path: PathBuf::from(path),
491 language: Language::Rust,
492 language_name: "rust".to_string(),
493 module: path.trim_end_matches(".rs").to_string(),
494 lines: 100,
495 bytes: 100,
496 content_hash: String::new(),
497 comment_lines: 0,
498 }
499 }
500
501 fn function(function_id: usize, file_id: usize, name: &str) -> FunctionFact {
502 FunctionFact {
503 function_id,
504 file_id,
505 name: name.to_string(),
506 start_line: 1,
507 end_line: 10,
508 }
509 }
510
511 fn import(import_id: usize, from_file: usize, resolved: Option<usize>) -> ImportFact {
512 ImportFact {
513 import_id,
514 from_file,
515 target: String::new(),
516 kind: "use".to_string(),
517 resolution: ImportResolution::Local,
518 resolved_file: resolved,
519 }
520 }
521
522 fn report(
523 files: Vec<FileFact>,
524 functions: Vec<FunctionFact>,
525 imports: Vec<ImportFact>,
526 calls: Vec<CallFact>,
527 call_edges: Vec<CallEdgeFact>,
528 entry_points: Vec<EntryPointFact>,
529 ) -> ScanReport {
530 let graph = compute_graph_metrics(&files, &imports);
531 ScanReport {
532 snapshot: SnapshotFact {
533 snapshot_id: "before".to_string(),
534 root: PathBuf::from("."),
535 file_count: files.len(),
536 function_count: functions.len(),
537 import_count: imports.len(),
538 call_count: calls.len(),
539 },
540 files,
541 functions,
542 entry_points,
543 imports,
544 calls,
545 call_edges,
546 types: Vec::new(),
547 graph,
548 }
549 }
550
551 #[test]
552 fn remove_file_drops_file_and_dependent_facts() {
553 let files = vec![
556 file(0, "src/a.rs"),
557 file(1, "src/b.rs"),
558 file(2, "src/c.rs"),
559 ];
560 let functions = vec![
561 function(0, 0, "a_main"),
562 function(1, 1, "b_helper"),
563 function(2, 2, "c_util"),
564 ];
565 let imports = vec![import(0, 0, Some(1)), import(1, 1, Some(2))];
566 let calls = vec![CallFact {
567 call_id: 0,
568 file_id: 1,
569 caller_function: Some(1),
570 target: "c_util".to_string(),
571 line: 5,
572 }];
573 let call_edges = vec![CallEdgeFact {
574 edge_id: 0,
575 call_id: 0,
576 caller_function: 1,
577 callee_function: 2,
578 }];
579 let entry_points = vec![EntryPointFact {
580 entry_id: 0,
581 file_id: 1,
582 kind: EntryPointKind::Test,
583 symbol: "b_test".to_string(),
584 }];
585 let before = report(files, functions, imports, calls, call_edges, entry_points);
586
587 let after = remove_file(&before, "src/b.rs").unwrap();
588
589 assert_eq!(after.files.len(), 2);
590 assert_eq!(after.functions.len(), 2);
591 assert_eq!(after.imports.len(), 0);
592 assert_eq!(after.calls.len(), 0);
593 assert_eq!(after.call_edges.len(), 0);
594 assert_eq!(after.entry_points.len(), 0);
595
596 for (idx, file) in after.files.iter().enumerate() {
598 assert_eq!(file.file_id, idx);
599 }
600 for (idx, function) in after.functions.iter().enumerate() {
601 assert_eq!(function.function_id, idx);
602 }
603
604 let c_file_id = after
606 .files
607 .iter()
608 .find(|file| file.path == PathBuf::from("src/c.rs"))
609 .map(|file| file.file_id)
610 .expect("src/c.rs survives");
611 assert!(after
612 .functions
613 .iter()
614 .any(|function| function.file_id == c_file_id));
615
616 assert_eq!(after.snapshot.file_count, 2);
617 assert!(after.snapshot.snapshot_id.contains("+remove_file:src/b.rs"));
618 }
619
620 #[test]
621 fn remove_file_preserves_unrelated_edges() {
622 let files = vec![
623 file(0, "src/a.rs"),
624 file(1, "src/b.rs"),
625 file(2, "src/c.rs"),
626 file(3, "src/d.rs"),
627 ];
628 let imports = vec![import(0, 0, Some(1)), import(1, 2, Some(3))];
630 let before = report(
631 files,
632 Vec::new(),
633 imports,
634 Vec::new(),
635 Vec::new(),
636 Vec::new(),
637 );
638
639 let after = remove_file(&before, "src/b.rs").unwrap();
640
641 assert_eq!(after.files.len(), 3);
642 assert_eq!(after.imports.len(), 1);
643 let preserved = &after.imports[0];
644 let from = &after.files[preserved.from_file];
645 let to = preserved.resolved_file.map(|id| &after.files[id]);
646 assert_eq!(from.path, PathBuf::from("src/c.rs"));
647 assert_eq!(to.unwrap().path, PathBuf::from("src/d.rs"));
648 }
649
650 #[test]
651 fn move_file_updates_path_and_module() {
652 let files = vec![file(0, "src/foo.rs"), file(1, "src/bar.rs")];
653 let imports = vec![import(0, 0, Some(1))];
654 let before = report(
655 files,
656 Vec::new(),
657 imports,
658 Vec::new(),
659 Vec::new(),
660 Vec::new(),
661 );
662
663 let mut config = RaysenseConfig::default();
664 config.scan.module_roots = vec!["src".to_string()];
665
666 let after = move_file(&before, &config, "src/foo.rs", "lib/foo.rs").unwrap();
667
668 let moved = after
669 .files
670 .iter()
671 .find(|file| file.path == PathBuf::from("lib/foo.rs"))
672 .expect("destination present");
673 assert_eq!(moved.file_id, 0);
674 assert!(!moved.module.contains("src"));
675 assert_eq!(after.imports[0].from_file, 0);
676 assert_eq!(after.imports[0].resolved_file, Some(1));
677 assert!(after.snapshot.snapshot_id.contains("move_file"));
678 }
679
680 #[test]
681 fn move_file_rejects_destination_collision() {
682 let files = vec![file(0, "src/foo.rs"), file(1, "src/bar.rs")];
683 let before = report(
684 files,
685 Vec::new(),
686 Vec::new(),
687 Vec::new(),
688 Vec::new(),
689 Vec::new(),
690 );
691 let config = RaysenseConfig::default();
692 let err = move_file(&before, &config, "src/foo.rs", "src/bar.rs").unwrap_err();
693 assert!(matches!(err, SimulateError::DestinationOccupied(_)));
694 }
695
696 #[test]
697 fn move_file_returns_error_for_unknown_source() {
698 let before = report(
699 vec![file(0, "src/foo.rs")],
700 Vec::new(),
701 Vec::new(),
702 Vec::new(),
703 Vec::new(),
704 Vec::new(),
705 );
706 let config = RaysenseConfig::default();
707 let err = move_file(&before, &config, "src/missing.rs", "src/dest.rs").unwrap_err();
708 assert!(matches!(err, SimulateError::FileNotFound(_)));
709 }
710
711 #[test]
712 fn break_cycle_removes_edge_and_lowers_cycle_count() {
713 let files = vec![
715 file(0, "src/a.rs"),
716 file(1, "src/b.rs"),
717 file(2, "src/c.rs"),
718 ];
719 let imports = vec![
720 import(0, 0, Some(1)),
721 import(1, 1, Some(2)),
722 import(2, 2, Some(0)),
723 ];
724 let before = report(
725 files,
726 Vec::new(),
727 imports,
728 Vec::new(),
729 Vec::new(),
730 Vec::new(),
731 );
732 assert!(before.graph.cycle_count > 0);
733
734 let after = break_cycle(&before, "src/c.rs", "src/a.rs").unwrap();
735 assert!(after.graph.cycle_count < before.graph.cycle_count);
736 assert_eq!(after.imports.len(), 2);
737 assert!(after.snapshot.snapshot_id.contains("break_cycle"));
738 }
739
740 #[test]
741 fn break_cycle_rejects_edge_not_in_cycle() {
742 let files = vec![
744 file(0, "src/a.rs"),
745 file(1, "src/b.rs"),
746 file(2, "src/c.rs"),
747 file(3, "src/d.rs"),
748 ];
749 let imports = vec![
750 import(0, 0, Some(1)),
751 import(1, 2, Some(3)),
752 import(2, 3, Some(2)),
753 ];
754 let before = report(
755 files,
756 Vec::new(),
757 imports,
758 Vec::new(),
759 Vec::new(),
760 Vec::new(),
761 );
762 let err = break_cycle(&before, "src/a.rs", "src/b.rs").unwrap_err();
763 assert!(matches!(err, SimulateError::EdgeNotInCycle { .. }));
764 }
765
766 #[test]
767 fn remove_edge_drops_matching_local_import() {
768 let files = vec![file(0, "src/a.rs"), file(1, "src/b.rs")];
769 let imports = vec![import(0, 0, Some(1))];
770 let before = report(
771 files,
772 Vec::new(),
773 imports,
774 Vec::new(),
775 Vec::new(),
776 Vec::new(),
777 );
778 let after = remove_edge(&before, "src/a.rs", "src/b.rs").unwrap();
779 assert_eq!(after.imports.len(), 0);
780 assert!(after.snapshot.snapshot_id.contains("remove_edge"));
781 }
782
783 #[test]
784 fn add_edge_creates_local_import() {
785 let files = vec![file(0, "src/a.rs"), file(1, "src/b.rs")];
786 let before = report(
787 files,
788 Vec::new(),
789 Vec::new(),
790 Vec::new(),
791 Vec::new(),
792 Vec::new(),
793 );
794 let after = add_edge(&before, "src/a.rs", "src/b.rs").unwrap();
795 assert_eq!(after.imports.len(), 1);
796 let edge = &after.imports[0];
797 assert_eq!(edge.from_file, 0);
798 assert_eq!(edge.resolved_file, Some(1));
799 assert_eq!(edge.kind, "what_if");
800 assert!(matches!(
801 add_edge(&after, "src/a.rs", "src/b.rs").unwrap_err(),
802 SimulateError::EdgeAlreadyExists { .. }
803 ));
804 }
805
806 #[test]
807 fn break_cycle_recommendations_empty_when_acyclic() {
808 let files = vec![file(0, "src/a.rs"), file(1, "src/b.rs")];
809 let imports = vec![import(0, 0, Some(1))];
810 let before = report(
811 files,
812 Vec::new(),
813 imports,
814 Vec::new(),
815 Vec::new(),
816 Vec::new(),
817 );
818 assert_eq!(before.graph.cycle_count, 0);
819 let recs = break_cycle_recommendations(&before, 5, 100);
820 assert!(recs.is_empty());
821 }
822
823 #[test]
824 fn break_cycle_recommendations_ranks_edges_by_reduction() {
825 let files = vec![
828 file(0, "src/a.rs"),
829 file(1, "src/b.rs"),
830 file(2, "src/c.rs"),
831 ];
832 let imports = vec![
833 import(0, 0, Some(1)), import(1, 1, Some(0)), import(2, 1, Some(2)), import(3, 2, Some(1)), ];
838 let before = report(
839 files,
840 Vec::new(),
841 imports,
842 Vec::new(),
843 Vec::new(),
844 Vec::new(),
845 );
846 assert!(before.graph.cycle_count > 0);
847
848 let recs = break_cycle_recommendations(&before, 10, 100);
849 assert!(!recs.is_empty());
850 let top = &recs[0];
852 assert!(top.cycle_count_reduction >= 1);
853 assert_eq!(top.cycle_count_before, before.graph.cycle_count);
854 for window in recs.windows(2) {
856 assert!(window[0].cycle_count_reduction >= window[1].cycle_count_reduction);
857 }
858 }
859
860 #[test]
861 fn break_cycle_recommendations_respects_limit() {
862 let files = vec![
863 file(0, "src/a.rs"),
864 file(1, "src/b.rs"),
865 file(2, "src/c.rs"),
866 ];
867 let imports = vec![
868 import(0, 0, Some(1)),
869 import(1, 1, Some(2)),
870 import(2, 2, Some(0)),
871 ];
872 let before = report(
873 files,
874 Vec::new(),
875 imports,
876 Vec::new(),
877 Vec::new(),
878 Vec::new(),
879 );
880 let recs = break_cycle_recommendations(&before, 1, 100);
881 assert_eq!(recs.len(), 1);
882 }
883
884 #[test]
885 fn break_cycle_rejects_missing_edge() {
886 let files = vec![file(0, "src/a.rs"), file(1, "src/b.rs")];
887 let before = report(
888 files,
889 Vec::new(),
890 Vec::new(),
891 Vec::new(),
892 Vec::new(),
893 Vec::new(),
894 );
895 let err = break_cycle(&before, "src/a.rs", "src/b.rs").unwrap_err();
896 assert!(matches!(err, SimulateError::EdgeNotFound { .. }));
897 }
898
899 #[test]
900 fn remove_file_returns_error_for_unknown_path() {
901 let before = report(
902 vec![file(0, "src/a.rs")],
903 Vec::new(),
904 Vec::new(),
905 Vec::new(),
906 Vec::new(),
907 Vec::new(),
908 );
909 let err = remove_file(&before, "src/missing.rs").unwrap_err();
910 assert!(matches!(err, SimulateError::FileNotFound(_)));
911 }
912
913 #[test]
914 fn simulate_sequence_chains_actions_in_order() {
915 let files = vec![
916 file(0, "src/a.rs"),
917 file(1, "src/b.rs"),
918 file(2, "src/c.rs"),
919 ];
920 let imports = vec![import(0, 0, Some(1)), import(1, 1, Some(0))];
922 let before = report(
923 files,
924 Vec::new(),
925 imports,
926 Vec::new(),
927 Vec::new(),
928 Vec::new(),
929 );
930 assert!(before.graph.cycle_count >= 1, "setup must contain a cycle");
931
932 let actions = vec![
933 Action::AddEdge {
934 from: "src/a.rs".to_string(),
935 to: "src/c.rs".to_string(),
936 },
937 Action::BreakCycle {
938 from: "src/a.rs".to_string(),
939 to: "src/b.rs".to_string(),
940 },
941 ];
942 let after = simulate_sequence(&before, &RaysenseConfig::default(), &actions).unwrap();
943
944 assert_eq!(
945 after.graph.cycle_count, 0,
946 "the second action breaks the cycle"
947 );
948 assert_eq!(
949 after.imports.len(),
950 2,
951 "added edge survives, broken edge dropped",
952 );
953 assert!(after
954 .snapshot
955 .snapshot_id
956 .contains("+add_edge:src/a.rs->src/c.rs"));
957 assert!(after
958 .snapshot
959 .snapshot_id
960 .contains("+break_cycle:src/a.rs->src/b.rs"));
961 }
962
963 #[test]
964 fn simulate_sequence_reports_failing_step_index() {
965 let before = report(
966 vec![file(0, "src/a.rs")],
967 Vec::new(),
968 Vec::new(),
969 Vec::new(),
970 Vec::new(),
971 Vec::new(),
972 );
973 let actions = vec![Action::AddEdge {
974 from: "src/a.rs".to_string(),
975 to: "src/missing.rs".to_string(),
976 }];
977 let err = simulate_sequence(&before, &RaysenseConfig::default(), &actions).unwrap_err();
978 assert_eq!(err.index, 0);
979 assert!(matches!(err.source, SimulateError::FileNotFound(_)));
980 }
981}