Skip to main content

raysense/
simulate.rs

1/*
2 *   Copyright (c) 2025-2026 Anton Kundenko <singaraiona@gmail.com>
3 *   All rights reserved.
4 *
5 *   Permission is hereby granted, free of charge, to any person obtaining a copy
6 *   of this software and associated documentation files (the "Software"), to deal
7 *   in the Software without restriction, including without limitation the rights
8 *   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 *   copies of the Software, and to permit persons to whom the Software is
10 *   furnished to do so, subject to the following conditions:
11 *
12 *   The above copyright notice and this permission notice shall be included in all
13 *   copies or substantial portions of the Software.
14 *
15 *   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 *   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 *   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 *   AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 *   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 *   OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 *   SOFTWARE.
22 */
23
24use 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/// One step in a what-if simulation chain. Each variant maps to a single-action
38/// helper in this module — `simulate_sequence` applies them in order. The
39/// JSON shape matches the MCP `raysense_what_if` tool: the discriminator key
40/// is `action`, and the field names align with the per-action MCP arguments.
41#[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
65/// Produce a `ScanReport` representing the codebase as if `file_path` did not
66/// exist. All facts referencing the file (and any functions defined in it) are
67/// dropped, and remaining file/function/call ids are renumbered so downstream
68/// consumers that index by id continue to work.
69pub 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
218/// Produce a `ScanReport` representing the codebase as if `from_path` had been
219/// moved to `to_path`. The file keeps its `file_id` (and so all imports/calls
220/// referencing it stay valid); only `path`, `module`, and the
221/// graph-derived metrics that depend on path/module change.
222pub 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
255/// Remove the local import edge from `from_path` to `to_path`. Returns
256/// `EdgeNotFound` if no such local edge exists.
257pub 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
287/// Add a local import edge from `from_path` to `to_path`. Returns
288/// `EdgeAlreadyExists` if the same local edge is already present.
289pub 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
325/// Remove the local import edge from `from_path` to `to_path` and confirm the
326/// reduction lowers the report's cycle count. Returns `EdgeNotFound` if the
327/// edge does not exist, or `EdgeNotInCycle` if removal does not break a cycle
328/// (i.e., the edge is not load-bearing for any cycle).
329pub 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/// One candidate edge whose removal would reduce the report's cycle count.
351#[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
360/// Rank candidate local edges by how much each one's removal reduces the
361/// report's cycle count. Considers up to `max_candidates` distinct local edges
362/// (capped to avoid quadratic cost on large graphs); returns at most `limit`
363/// recommendations sorted by reduction (descending), then path. Returns an
364/// empty list when the report has no cycles.
365pub 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
433/// Apply a sequence of `Action`s in order, threading the mutated `ScanReport`
434/// through each step. Returns the first `SimulateError` encountered, indexed
435/// by action position so callers know which step failed. Filesystem state is
436/// never touched — every mutation is in-memory.
437pub 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(&current, file),
446            Action::MoveFile { from, to } => move_file(&current, config, from, to),
447            Action::AddEdge { from, to } => add_edge(&current, from, to),
448            Action::RemoveEdge { from, to } => remove_edge(&current, from, to),
449            Action::BreakCycle { from, to } => break_cycle(&current, 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/// Annotates a `SimulateError` with which step in a chain failed.
461#[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        // Three files: a -> b -> c. Remove b: a's import to b is dropped,
554        // c's incoming import from b is dropped.
555        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        // ids must be sequential after renumbering.
597        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        // surviving function for src/c.rs must point at the new file_id.
605        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        // a -> b, c -> d. Removing b leaves c -> d intact.
629        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        // Three-file cycle: a -> b -> c -> a. Remove c -> a to break it.
714        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        // a -> b, plus a separate cycle c -> d -> c.
743        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        // Two cycles share file b: a -> b -> a, and c -> b -> c via b -> c, c -> b.
826        // Removing edges in or out of b should reduce cycle count.
827        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)), // a -> b
834            import(1, 1, Some(0)), // b -> a (cycle 1)
835            import(2, 1, Some(2)), // b -> c
836            import(3, 2, Some(1)), // c -> b (cycle 2)
837        ];
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        // Highest-reduction recommendation must come first.
851        let top = &recs[0];
852        assert!(top.cycle_count_reduction >= 1);
853        assert_eq!(top.cycle_count_before, before.graph.cycle_count);
854        // Reductions must be monotonically non-increasing across the list.
855        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        // a <-> b cycle, plus c isolated.
921        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}