Skip to main content

cargo_statum_graph/
heuristics.rs

1use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use module_path_extractor::{
6    module_path_from_file_with_root, module_path_to_file, module_root_from_file,
7};
8use quote::ToTokens;
9use statum_graph::{CodebaseDoc, CodebaseMachine, CodebaseState, CodebaseTransition};
10use syn::spanned::Spanned;
11use syn::visit::{self, Visit};
12use syn::{
13    AttrStyle, FnArg, ImplItem, ImplItemFn, Item, ItemEnum, ItemImpl, ItemMod, ItemStruct,
14    ReturnType, Type, TypePath, UseTree,
15};
16
17#[derive(Clone, Debug, Eq, PartialEq)]
18pub struct InspectPackageSource {
19    pub package_name: String,
20    pub manifest_dir: PathBuf,
21    pub lib_target_path: PathBuf,
22}
23
24#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
25pub enum HeuristicEvidenceKind {
26    Signature,
27    Body,
28}
29
30impl HeuristicEvidenceKind {
31    pub const fn display_label(self) -> &'static str {
32        match self {
33            Self::Signature => "type surface",
34            Self::Body => "body",
35        }
36    }
37}
38
39#[derive(Clone, Copy, Debug, Eq, PartialEq)]
40pub enum HeuristicStatusKind {
41    Available,
42    Partial,
43    Unavailable,
44}
45
46impl HeuristicStatusKind {
47    pub const fn display_label(self) -> &'static str {
48        match self {
49            Self::Available => "available",
50            Self::Partial => "partial",
51            Self::Unavailable => "unavailable",
52        }
53    }
54}
55
56#[derive(Clone, Debug, Eq, PartialEq)]
57pub struct HeuristicDiagnostic {
58    pub context: String,
59    pub message: String,
60}
61
62impl HeuristicDiagnostic {
63    pub fn display_label(&self) -> String {
64        if self.context.is_empty() {
65            self.message.clone()
66        } else {
67            format!("{}: {}", self.context, self.message)
68        }
69    }
70}
71
72#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
73pub enum HeuristicRelationSource {
74    State {
75        machine: usize,
76        state: usize,
77    },
78    Transition {
79        machine: usize,
80        transition: usize,
81    },
82    Method {
83        machine: usize,
84        state: usize,
85        method_name: String,
86    },
87}
88
89impl HeuristicRelationSource {
90    pub const fn machine(&self) -> usize {
91        match *self {
92            Self::State { machine, .. }
93            | Self::Transition { machine, .. }
94            | Self::Method { machine, .. } => machine,
95        }
96    }
97
98    pub const fn state(&self) -> Option<usize> {
99        match *self {
100            Self::State { state, .. } | Self::Method { state, .. } => Some(state),
101            Self::Transition { .. } => None,
102        }
103    }
104
105    pub const fn transition(&self) -> Option<usize> {
106        match *self {
107            Self::Transition { transition, .. } => Some(transition),
108            Self::State { .. } | Self::Method { .. } => None,
109        }
110    }
111
112    pub fn method_name(&self) -> Option<&str> {
113        match self {
114            Self::Method { method_name, .. } => Some(method_name),
115            Self::State { .. } | Self::Transition { .. } => None,
116        }
117    }
118
119    pub const fn kind_label(&self) -> &'static str {
120        match self {
121            Self::State { .. } => "state",
122            Self::Transition { .. } => "transition",
123            Self::Method { .. } => "method",
124        }
125    }
126}
127
128#[derive(Clone, Debug, Eq, PartialEq)]
129pub struct HeuristicRelation {
130    pub index: usize,
131    pub source: HeuristicRelationSource,
132    pub target_machine: usize,
133    pub evidence_kind: HeuristicEvidenceKind,
134    pub matched_path_text: String,
135    pub file_path: PathBuf,
136    pub line_number: usize,
137    pub snippet: Option<String>,
138}
139
140#[derive(Clone, Copy, Debug)]
141pub struct HeuristicRelationDetail<'a> {
142    pub relation: &'a HeuristicRelation,
143    pub source_machine: &'a CodebaseMachine,
144    pub source_state: Option<&'a CodebaseState>,
145    pub source_transition: Option<&'a CodebaseTransition>,
146    pub target_machine: &'a CodebaseMachine,
147}
148
149#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)]
150pub struct HeuristicRelationCount {
151    pub evidence_kind: HeuristicEvidenceKind,
152    pub count: usize,
153}
154
155impl HeuristicRelationCount {
156    pub fn display_label(&self) -> String {
157        if self.count == 1 {
158            self.evidence_kind.display_label().to_owned()
159        } else {
160            format!("{} x{}", self.evidence_kind.display_label(), self.count)
161        }
162    }
163}
164
165#[derive(Clone, Debug, Eq, PartialEq)]
166pub struct HeuristicMachineRelationGroup {
167    pub index: usize,
168    pub from_machine: usize,
169    pub to_machine: usize,
170    pub relation_indices: Vec<usize>,
171    pub counts: Vec<HeuristicRelationCount>,
172}
173
174impl HeuristicMachineRelationGroup {
175    pub fn display_label(&self) -> String {
176        let counts = self
177            .counts
178            .iter()
179            .map(HeuristicRelationCount::display_label)
180            .collect::<Vec<_>>()
181            .join(", ");
182        format!("heuristic refs: {counts}")
183    }
184}
185
186#[derive(Clone, Debug, Eq, PartialEq)]
187pub struct HeuristicOverlay {
188    status: HeuristicStatusKind,
189    diagnostics: Vec<HeuristicDiagnostic>,
190    relations: Vec<HeuristicRelation>,
191    machine_relation_groups: Vec<HeuristicMachineRelationGroup>,
192}
193
194impl HeuristicOverlay {
195    pub fn status(&self) -> HeuristicStatusKind {
196        self.status
197    }
198
199    pub fn diagnostics(&self) -> &[HeuristicDiagnostic] {
200        &self.diagnostics
201    }
202
203    pub fn relations(&self) -> &[HeuristicRelation] {
204        &self.relations
205    }
206
207    pub fn relation(&self, index: usize) -> Option<&HeuristicRelation> {
208        self.relations.get(index)
209    }
210
211    pub fn machine_relation_groups(&self) -> &[HeuristicMachineRelationGroup] {
212        &self.machine_relation_groups
213    }
214
215    pub fn outbound_relations_for_machine(
216        &self,
217        machine_index: usize,
218    ) -> impl Iterator<Item = &HeuristicRelation> + '_ {
219        self.relations
220            .iter()
221            .filter(move |relation| relation.source.machine() == machine_index)
222    }
223
224    pub fn inbound_relations_for_machine(
225        &self,
226        machine_index: usize,
227    ) -> impl Iterator<Item = &HeuristicRelation> + '_ {
228        self.relations
229            .iter()
230            .filter(move |relation| relation.target_machine == machine_index)
231    }
232
233    pub fn outbound_relations_for_transition(
234        &self,
235        machine_index: usize,
236        transition_index: usize,
237    ) -> impl Iterator<Item = &HeuristicRelation> + '_ {
238        self.relations.iter().filter(move |relation| {
239            relation.source.machine() == machine_index
240                && relation.source.transition() == Some(transition_index)
241        })
242    }
243
244    pub fn outbound_relations_for_state(
245        &self,
246        machine_index: usize,
247        state_index: usize,
248    ) -> impl Iterator<Item = &HeuristicRelation> + '_ {
249        self.relations.iter().filter(move |relation| {
250            relation.source.machine() == machine_index
251                && relation.source.state() == Some(state_index)
252        })
253    }
254
255    pub fn inbound_relations_for_transition(
256        &self,
257        _machine_index: usize,
258        _transition_index: usize,
259    ) -> impl Iterator<Item = &HeuristicRelation> + '_ {
260        self.relations.iter().filter(|_| false)
261    }
262
263    pub fn relation_detail<'a>(
264        &'a self,
265        doc: &'a CodebaseDoc,
266        index: usize,
267    ) -> Option<HeuristicRelationDetail<'a>> {
268        let relation = self.relation(index)?;
269        let source_machine = doc.machine(relation.source.machine())?;
270        let source_state = relation
271            .source
272            .state()
273            .and_then(|state_index| source_machine.state(state_index));
274        let source_transition = relation
275            .source
276            .transition()
277            .and_then(|transition_index| source_machine.transition(transition_index));
278        let target_machine = doc.machine(relation.target_machine)?;
279
280        Some(HeuristicRelationDetail {
281            relation,
282            source_machine,
283            source_state,
284            source_transition,
285            target_machine,
286        })
287    }
288
289    #[cfg(test)]
290    pub(crate) fn from_parts(
291        status: HeuristicStatusKind,
292        diagnostics: Vec<HeuristicDiagnostic>,
293        relations: Vec<HeuristicRelation>,
294    ) -> Self {
295        let machine_relation_groups = build_machine_relation_groups(&relations);
296        Self {
297            status,
298            diagnostics,
299            relations,
300            machine_relation_groups,
301        }
302    }
303}
304
305pub fn collect_heuristic_overlay(
306    doc: &CodebaseDoc,
307    packages: &[InspectPackageSource],
308) -> HeuristicOverlay {
309    let inventory = MachineInventory::new(doc);
310    let mut collector = OverlayCollector::new(doc, &inventory);
311    for package in packages {
312        collector.scan_package(package);
313    }
314    collector.finish()
315}
316
317type UseMap = BTreeMap<String, String>;
318
319struct LocalTypeRegistry<'a> {
320    structs: HashMap<String, &'a ItemStruct>,
321}
322
323impl<'a> LocalTypeRegistry<'a> {
324    fn new(items: &'a [Item]) -> Self {
325        let structs = items
326            .iter()
327            .filter_map(|item| match item {
328                Item::Struct(item_struct) if !has_cfg_attrs(&item_struct.attrs) => {
329                    Some((item_struct.ident.to_string(), item_struct))
330                }
331                _ => None,
332            })
333            .collect();
334        Self { structs }
335    }
336
337    fn single_segment_struct(&self, type_path: &TypePath) -> Option<&'a ItemStruct> {
338        if type_path.qself.is_some() || type_path.path.leading_colon.is_some() {
339            return None;
340        }
341        let mut segments = type_path.path.segments.iter();
342        let segment = segments.next()?;
343        if segments.next().is_some() {
344            return None;
345        }
346        self.structs.get(&segment.ident.to_string()).copied()
347    }
348}
349
350struct OverlayCollector<'a> {
351    doc: &'a CodebaseDoc,
352    inventory: &'a MachineInventory<'a>,
353    diagnostics: Vec<HeuristicDiagnostic>,
354    relations: BTreeMap<HeuristicRelationKey, HeuristicRelationCandidate>,
355    visited_modules: HashSet<(PathBuf, String)>,
356    scanned_files: usize,
357}
358
359impl<'a> OverlayCollector<'a> {
360    fn new(doc: &'a CodebaseDoc, inventory: &'a MachineInventory<'a>) -> Self {
361        Self {
362            doc,
363            inventory,
364            diagnostics: Vec::new(),
365            relations: BTreeMap::new(),
366            visited_modules: HashSet::new(),
367            scanned_files: 0,
368        }
369    }
370
371    fn scan_package(&mut self, package: &InspectPackageSource) {
372        let module_root = module_root_from_file(&package.lib_target_path.to_string_lossy());
373        let module_path = module_path_from_file_with_root(
374            &package.lib_target_path.to_string_lossy(),
375            &module_root,
376        );
377        self.scan_module_file(
378            package,
379            &module_root,
380            &package.lib_target_path,
381            &module_path,
382        );
383    }
384
385    fn scan_module_file(
386        &mut self,
387        package: &InspectPackageSource,
388        module_root: &Path,
389        file_path: &Path,
390        module_path: &str,
391    ) {
392        let normalized_file = normalize_absolute_path(file_path);
393        if !self
394            .visited_modules
395            .insert((normalized_file.clone(), module_path.to_owned()))
396        {
397            return;
398        }
399
400        let source = match fs::read_to_string(&normalized_file) {
401            Ok(source) => source,
402            Err(error) => {
403                self.push_diagnostic(
404                    format!("package {}", package.package_name),
405                    format!("failed to read `{}`: {error}", normalized_file.display()),
406                );
407                return;
408            }
409        };
410        let parsed = match syn::parse_file(&source) {
411            Ok(parsed) => parsed,
412            Err(error) => {
413                self.push_diagnostic(
414                    format!("file {}", normalized_file.display()),
415                    format!("failed to parse source: {error}"),
416                );
417                return;
418            }
419        };
420
421        self.scanned_files += 1;
422        self.scan_module_items(
423            package,
424            module_root,
425            &normalized_file,
426            &source,
427            module_path,
428            &parsed.items,
429        );
430    }
431
432    fn scan_module_items(
433        &mut self,
434        package: &InspectPackageSource,
435        module_root: &Path,
436        file_path: &Path,
437        source: &str,
438        module_path: &str,
439        items: &[Item],
440    ) {
441        let imports = build_use_map(items, module_path);
442        let local_types = LocalTypeRegistry::new(items);
443        for item in items {
444            match item {
445                Item::Impl(item_impl) => self.scan_impl(
446                    package,
447                    file_path,
448                    source,
449                    module_path,
450                    &imports,
451                    &local_types,
452                    item_impl,
453                ),
454                Item::Enum(item_enum) => self.scan_state_enum(
455                    file_path,
456                    source,
457                    module_path,
458                    &imports,
459                    &local_types,
460                    item_enum,
461                ),
462                Item::Mod(item_mod) => self.scan_child_module(
463                    package,
464                    module_root,
465                    file_path,
466                    source,
467                    module_path,
468                    item_mod,
469                ),
470                _ => {}
471            }
472        }
473    }
474
475    fn scan_child_module(
476        &mut self,
477        package: &InspectPackageSource,
478        module_root: &Path,
479        file_path: &Path,
480        source: &str,
481        module_path: &str,
482        item_mod: &ItemMod,
483    ) {
484        if has_cfg_attrs(&item_mod.attrs) {
485            return;
486        }
487
488        let child_module_path = join_module_path(module_path, &item_mod.ident.to_string());
489        if let Some((_, items)) = item_mod.content.as_ref() {
490            self.scan_module_items(
491                package,
492                module_root,
493                file_path,
494                source,
495                &child_module_path,
496                items,
497            );
498            return;
499        }
500
501        let child_file_path = match explicit_module_file_path(item_mod, file_path).or_else(|| {
502            module_path_to_file(
503                &child_module_path,
504                &file_path.to_string_lossy(),
505                module_root,
506            )
507        }) {
508            Some(child_file_path) => child_file_path,
509            None => {
510                self.push_diagnostic(
511                    format!("module {child_module_path}"),
512                    format!(
513                        "could not resolve source file from `{}`",
514                        file_path.display()
515                    ),
516                );
517                return;
518            }
519        };
520        self.scan_module_file(package, module_root, &child_file_path, &child_module_path);
521    }
522
523    #[allow(clippy::too_many_arguments)]
524    fn scan_impl(
525        &mut self,
526        package: &InspectPackageSource,
527        file_path: &Path,
528        source: &str,
529        module_path: &str,
530        imports: &UseMap,
531        local_types: &LocalTypeRegistry<'_>,
532        item_impl: &ItemImpl,
533    ) {
534        if has_transition_attr(&item_impl.attrs) {
535            self.scan_transition_impl(
536                package,
537                file_path,
538                source,
539                module_path,
540                imports,
541                local_types,
542                item_impl,
543            );
544        } else {
545            self.scan_method_impl(
546                file_path,
547                source,
548                module_path,
549                imports,
550                local_types,
551                item_impl,
552            );
553        }
554    }
555
556    fn scan_state_enum(
557        &mut self,
558        file_path: &Path,
559        source: &str,
560        module_path: &str,
561        imports: &UseMap,
562        local_types: &LocalTypeRegistry<'_>,
563        item_enum: &ItemEnum,
564    ) {
565        if !has_state_attr(&item_enum.attrs) || has_cfg_attrs(&item_enum.attrs) {
566            return;
567        }
568
569        let Some(machine) = self.inventory.resolve_machine_in_module(module_path) else {
570            return;
571        };
572
573        for variant in &item_enum.variants {
574            if has_cfg_attrs(&variant.attrs) {
575                continue;
576            }
577
578            let Some(source_state) = machine.state_named(&variant.ident.to_string()) else {
579                continue;
580            };
581            let relation_source = HeuristicRelationSource::State {
582                machine: machine.index,
583                state: source_state.index,
584            };
585            let context = format!("state {}::{}", machine.rust_type_path, variant.ident);
586            for field in &variant.fields {
587                if has_cfg_attrs(&field.attrs) {
588                    continue;
589                }
590                self.collect_type_surface_relations(
591                    file_path,
592                    source,
593                    module_path,
594                    imports,
595                    &context,
596                    &relation_source,
597                    &field.ty,
598                    local_types,
599                );
600            }
601        }
602    }
603
604    #[allow(clippy::too_many_arguments)]
605    fn scan_transition_impl(
606        &mut self,
607        _package: &InspectPackageSource,
608        file_path: &Path,
609        source: &str,
610        module_path: &str,
611        imports: &UseMap,
612        local_types: &LocalTypeRegistry<'_>,
613        item_impl: &ItemImpl,
614    ) {
615        if has_cfg_attrs(&item_impl.attrs) {
616            return;
617        }
618
619        let Some((source_machine_index, _source_state_index, source_state_name)) = self
620            .inventory
621            .resolve_state_impl(module_path, &item_impl.self_ty)
622        else {
623            return;
624        };
625        let source_machine = self
626            .doc
627            .machine(source_machine_index)
628            .expect("resolved heuristic source machine should exist");
629
630        for item in &item_impl.items {
631            let ImplItem::Fn(method) = item else {
632                continue;
633            };
634            if has_cfg_attrs(&method.attrs) {
635                continue;
636            }
637
638            let method_name = method.sig.ident.to_string();
639            let Some(source_transition) = source_machine.transitions.iter().find(|transition| {
640                transition.method_name == method_name
641                    && source_machine
642                        .state(transition.from)
643                        .is_some_and(|state| state.rust_name == source_state_name)
644            }) else {
645                continue;
646            };
647
648            let transition_context = format!(
649                "transition {}::{}",
650                source_machine.rust_type_path, method_name
651            );
652            let relation_source = HeuristicRelationSource::Transition {
653                machine: source_machine_index,
654                transition: source_transition.index,
655            };
656
657            self.collect_method_signature_relations(
658                file_path,
659                source,
660                module_path,
661                imports,
662                &transition_context,
663                &relation_source,
664                method,
665                local_types,
666            );
667
668            let body_evidence = collect_body_evidence(method);
669            for evidence in body_evidence {
670                self.try_record_evidence(
671                    file_path,
672                    source,
673                    module_path,
674                    imports,
675                    &transition_context,
676                    &relation_source,
677                    HeuristicEvidenceKind::Body,
678                    evidence,
679                );
680            }
681        }
682    }
683
684    fn scan_method_impl(
685        &mut self,
686        file_path: &Path,
687        source: &str,
688        module_path: &str,
689        imports: &UseMap,
690        local_types: &LocalTypeRegistry<'_>,
691        item_impl: &ItemImpl,
692    ) {
693        if has_cfg_attrs(&item_impl.attrs) {
694            return;
695        }
696
697        let Some((source_machine_index, source_state_index, _source_state_name)) = self
698            .inventory
699            .resolve_state_impl(module_path, &item_impl.self_ty)
700        else {
701            return;
702        };
703        let source_machine = self
704            .doc
705            .machine(source_machine_index)
706            .expect("resolved heuristic source machine should exist");
707
708        for item in &item_impl.items {
709            let ImplItem::Fn(method) = item else {
710                continue;
711            };
712            if has_cfg_attrs(&method.attrs) {
713                continue;
714            }
715
716            let method_name = method.sig.ident.to_string();
717            let method_context =
718                format!("method {}::{}", source_machine.rust_type_path, method_name);
719            let relation_source = HeuristicRelationSource::Method {
720                machine: source_machine_index,
721                state: source_state_index,
722                method_name,
723            };
724            self.collect_method_signature_relations(
725                file_path,
726                source,
727                module_path,
728                imports,
729                &method_context,
730                &relation_source,
731                method,
732                local_types,
733            );
734        }
735    }
736
737    #[allow(clippy::too_many_arguments)]
738    fn collect_method_signature_relations(
739        &mut self,
740        file_path: &Path,
741        source: &str,
742        module_path: &str,
743        imports: &UseMap,
744        context: &str,
745        relation_source: &HeuristicRelationSource,
746        method: &ImplItemFn,
747        local_types: &LocalTypeRegistry<'_>,
748    ) {
749        for evidence in collect_method_type_surface_evidence(method, local_types) {
750            self.try_record_evidence(
751                file_path,
752                source,
753                module_path,
754                imports,
755                context,
756                relation_source,
757                HeuristicEvidenceKind::Signature,
758                evidence,
759            );
760        }
761    }
762
763    #[allow(clippy::too_many_arguments)]
764    fn collect_type_surface_relations(
765        &mut self,
766        file_path: &Path,
767        source: &str,
768        module_path: &str,
769        imports: &UseMap,
770        context: &str,
771        relation_source: &HeuristicRelationSource,
772        ty: &Type,
773        local_types: &LocalTypeRegistry<'_>,
774    ) {
775        for evidence in collect_type_surface_evidence(ty, local_types) {
776            self.try_record_evidence(
777                file_path,
778                source,
779                module_path,
780                imports,
781                context,
782                relation_source,
783                HeuristicEvidenceKind::Signature,
784                evidence,
785            );
786        }
787    }
788
789    #[allow(clippy::too_many_arguments)]
790    fn try_record_evidence(
791        &mut self,
792        file_path: &Path,
793        source: &str,
794        module_path: &str,
795        imports: &UseMap,
796        transition_context: &str,
797        relation_source: &HeuristicRelationSource,
798        evidence_kind: HeuristicEvidenceKind,
799        evidence: PathEvidence,
800    ) {
801        let Some(resolved_path) = resolve_path_text(&evidence.path_text, module_path, imports)
802        else {
803            return;
804        };
805
806        match self.inventory.resolve_target_machine(&resolved_path) {
807            ResolveTargetMachine::Unique(target_machine_index) => {
808                if target_machine_index == relation_source.machine() {
809                    return;
810                }
811
812                let key = HeuristicRelationKey {
813                    source: relation_source.clone(),
814                    target_machine: target_machine_index,
815                    evidence_kind,
816                    file_path: file_path.to_path_buf(),
817                    line_number: evidence.line_number,
818                };
819                let candidate = HeuristicRelationCandidate {
820                    matched_path_text: evidence.path_text,
821                    snippet: source_line_snippet(source, evidence.line_number),
822                };
823                match self.relations.get_mut(&key) {
824                    Some(existing)
825                        if candidate.matched_path_text.len() > existing.matched_path_text.len() =>
826                    {
827                        *existing = candidate;
828                    }
829                    None => {
830                        self.relations.insert(key, candidate);
831                    }
832                    Some(_) => {}
833                }
834            }
835            ResolveTargetMachine::Ambiguous {
836                candidate,
837                machine_indices,
838            } => {
839                let machine_labels = machine_indices
840                    .into_iter()
841                    .filter_map(|index| self.doc.machine(index))
842                    .map(|machine| machine.rust_type_path.to_owned())
843                    .collect::<Vec<_>>()
844                    .join(", ");
845                self.push_diagnostic(
846                    transition_context.to_owned(),
847                    format!(
848                        "ambiguous heuristic target for `{}` via `{candidate}`; matches {machine_labels}",
849                        evidence.path_text
850                    ),
851                );
852            }
853            ResolveTargetMachine::NoCandidate => {}
854        }
855    }
856
857    fn push_diagnostic(&mut self, context: impl Into<String>, message: impl Into<String>) {
858        self.diagnostics.push(HeuristicDiagnostic {
859            context: context.into(),
860            message: message.into(),
861        });
862    }
863
864    fn finish(self) -> HeuristicOverlay {
865        let relations = self
866            .relations
867            .into_iter()
868            .enumerate()
869            .map(
870                |(
871                    index,
872                    (
873                        key,
874                        HeuristicRelationCandidate {
875                            matched_path_text,
876                            snippet,
877                        },
878                    ),
879                )| HeuristicRelation {
880                    index,
881                    source: key.source,
882                    target_machine: key.target_machine,
883                    evidence_kind: key.evidence_kind,
884                    matched_path_text,
885                    file_path: key.file_path,
886                    line_number: key.line_number,
887                    snippet,
888                },
889            )
890            .collect::<Vec<_>>();
891
892        let status = if self.scanned_files == 0 {
893            HeuristicStatusKind::Unavailable
894        } else if self.diagnostics.is_empty() {
895            HeuristicStatusKind::Available
896        } else {
897            HeuristicStatusKind::Partial
898        };
899
900        HeuristicOverlay {
901            status,
902            diagnostics: self.diagnostics,
903            machine_relation_groups: build_machine_relation_groups(&relations),
904            relations,
905        }
906    }
907}
908
909fn build_machine_relation_groups(
910    relations: &[HeuristicRelation],
911) -> Vec<HeuristicMachineRelationGroup> {
912    let mut groups = BTreeMap::<(usize, usize), Vec<usize>>::new();
913    for relation in relations {
914        groups
915            .entry((relation.source.machine(), relation.target_machine))
916            .or_default()
917            .push(relation.index);
918    }
919
920    groups
921        .into_iter()
922        .enumerate()
923        .map(|(index, ((from_machine, to_machine), relation_indices))| {
924            let mut counts = BTreeMap::<HeuristicEvidenceKind, usize>::new();
925            for relation_index in &relation_indices {
926                let relation = &relations[*relation_index];
927                *counts.entry(relation.evidence_kind).or_default() += 1;
928            }
929
930            HeuristicMachineRelationGroup {
931                index,
932                from_machine,
933                to_machine,
934                relation_indices,
935                counts: counts
936                    .into_iter()
937                    .map(|(evidence_kind, count)| HeuristicRelationCount {
938                        evidence_kind,
939                        count,
940                    })
941                    .collect(),
942            }
943        })
944        .collect()
945}
946
947struct MachineInventory<'a> {
948    doc: &'a CodebaseDoc,
949    by_module_path: HashMap<&'a str, Vec<usize>>,
950}
951
952impl<'a> MachineInventory<'a> {
953    fn new(doc: &'a CodebaseDoc) -> Self {
954        let mut by_module_path = HashMap::<&'a str, Vec<usize>>::new();
955        for machine in doc.machines() {
956            by_module_path
957                .entry(machine.module_path)
958                .or_default()
959                .push(machine.index);
960        }
961        Self {
962            doc,
963            by_module_path,
964        }
965    }
966
967    fn resolve_state_impl(
968        &self,
969        module_path: &str,
970        self_ty: &Type,
971    ) -> Option<(usize, usize, String)> {
972        let (machine_name, state_name) = parse_machine_self_ty(self_ty)?;
973        let module_path = strip_crate_prefix(module_path);
974        let candidates = self.by_module_path.get(module_path)?;
975        let machine = unique_machine(
976            candidates
977                .iter()
978                .copied()
979                .filter_map(|index| self.doc.machine(index))
980                .filter(|machine| rust_type_leaf(machine.rust_type_path) == machine_name),
981        )?;
982        let state = machine.state_named(&state_name)?;
983        Some((machine.index, state.index, state_name))
984    }
985
986    fn resolve_machine_in_module(&self, module_path: &str) -> Option<&'a CodebaseMachine> {
987        let module_path = strip_crate_prefix(module_path);
988        let candidates = self.by_module_path.get(module_path)?;
989        unique_machine(
990            candidates
991                .iter()
992                .copied()
993                .filter_map(|index| self.doc.machine(index)),
994        )
995    }
996
997    fn resolve_target_machine(&self, resolved_path: &str) -> ResolveTargetMachine {
998        for candidate in candidate_module_prefixes(resolved_path) {
999            let machine_indices = self.match_module_candidate(&candidate);
1000            match machine_indices.len() {
1001                0 => {}
1002                1 => return ResolveTargetMachine::Unique(machine_indices[0]),
1003                _ => {
1004                    return ResolveTargetMachine::Ambiguous {
1005                        candidate,
1006                        machine_indices,
1007                    };
1008                }
1009            }
1010        }
1011
1012        ResolveTargetMachine::NoCandidate
1013    }
1014
1015    fn match_module_candidate(&self, candidate: &str) -> Vec<usize> {
1016        self.doc
1017            .machines()
1018            .iter()
1019            .filter(|machine| {
1020                machine.module_path == candidate
1021                    || machine
1022                        .module_path
1023                        .strip_prefix(candidate)
1024                        .is_some_and(|rest| rest.starts_with("::"))
1025            })
1026            .map(|machine| machine.index)
1027            .collect()
1028    }
1029}
1030
1031enum ResolveTargetMachine {
1032    Unique(usize),
1033    Ambiguous {
1034        candidate: String,
1035        machine_indices: Vec<usize>,
1036    },
1037    NoCandidate,
1038}
1039
1040#[derive(Clone, Debug)]
1041struct PathEvidence {
1042    path_text: String,
1043    line_number: usize,
1044}
1045
1046#[derive(Clone, Debug)]
1047struct HeuristicRelationCandidate {
1048    matched_path_text: String,
1049    snippet: Option<String>,
1050}
1051
1052#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
1053struct HeuristicRelationKey {
1054    source: HeuristicRelationSource,
1055    target_machine: usize,
1056    evidence_kind: HeuristicEvidenceKind,
1057    file_path: PathBuf,
1058    line_number: usize,
1059}
1060
1061fn build_use_map(items: &[Item], current_module: &str) -> UseMap {
1062    let mut imports = UseMap::new();
1063    for item in items {
1064        let Item::Use(item_use) = item else {
1065            continue;
1066        };
1067        if has_cfg_attrs(&item_use.attrs) {
1068            continue;
1069        }
1070        collect_use_tree(current_module, &item_use.tree, &mut imports, &[]);
1071    }
1072    imports
1073}
1074
1075fn collect_use_tree(current_module: &str, tree: &UseTree, imports: &mut UseMap, prefix: &[String]) {
1076    match tree {
1077        UseTree::Path(use_path) => {
1078            let mut next_prefix = prefix.to_vec();
1079            next_prefix.push(use_path.ident.to_string());
1080            collect_use_tree(current_module, &use_path.tree, imports, &next_prefix);
1081        }
1082        UseTree::Name(use_name) => {
1083            if use_name.ident == "self" {
1084                let Some(alias) = prefix.last() else {
1085                    return;
1086                };
1087                if let Some(path) = resolve_use_path(current_module, prefix) {
1088                    imports.insert(alias.clone(), path);
1089                }
1090            } else {
1091                let mut full_path = prefix.to_vec();
1092                full_path.push(use_name.ident.to_string());
1093                if let Some(path) = resolve_use_path(current_module, &full_path) {
1094                    imports.insert(use_name.ident.to_string(), path);
1095                }
1096            }
1097        }
1098        UseTree::Rename(use_rename) => {
1099            let mut full_path = prefix.to_vec();
1100            full_path.push(use_rename.ident.to_string());
1101            if let Some(path) = resolve_use_path(current_module, &full_path) {
1102                imports.insert(use_rename.rename.to_string(), path);
1103            }
1104        }
1105        UseTree::Group(group) => {
1106            for item in &group.items {
1107                collect_use_tree(current_module, item, imports, prefix);
1108            }
1109        }
1110        UseTree::Glob(_) => {}
1111    }
1112}
1113
1114fn resolve_use_path(current_module: &str, segments: &[String]) -> Option<String> {
1115    resolve_path_segments(current_module, segments)
1116}
1117
1118fn resolve_path_text(path_text: &str, current_module: &str, imports: &UseMap) -> Option<String> {
1119    let parsed = syn::parse_str::<syn::Path>(path_text).ok()?;
1120    resolve_syn_path(&parsed, current_module, imports)
1121}
1122
1123fn resolve_syn_path(path: &syn::Path, current_module: &str, imports: &UseMap) -> Option<String> {
1124    let mut segments = path
1125        .segments
1126        .iter()
1127        .map(|segment| segment.ident.to_string())
1128        .collect::<Vec<_>>();
1129    if segments.is_empty() {
1130        return None;
1131    }
1132    if path.leading_colon.is_some() {
1133        return Some(segments.join("::"));
1134    }
1135
1136    if let Some(imported) = imports.get(&segments[0]) {
1137        let mut resolved = imported.split("::").map(str::to_owned).collect::<Vec<_>>();
1138        resolved.extend(segments.drain(1..));
1139        return Some(resolved.join("::"));
1140    }
1141
1142    resolve_path_segments(current_module, &segments)
1143}
1144
1145fn resolve_path_segments(current_module: &str, segments: &[String]) -> Option<String> {
1146    let first = segments.first()?;
1147    match first.as_str() {
1148        "crate" => Some(segments.join("::")),
1149        "self" => {
1150            let mut resolved = current_module
1151                .split("::")
1152                .map(str::to_owned)
1153                .collect::<Vec<_>>();
1154            resolved.extend(segments.iter().skip(1).cloned());
1155            Some(resolved.join("::"))
1156        }
1157        "super" => {
1158            let mut resolved = current_module
1159                .split("::")
1160                .map(str::to_owned)
1161                .collect::<Vec<_>>();
1162            let super_count = segments
1163                .iter()
1164                .take_while(|segment| segment.as_str() == "super")
1165                .count();
1166            for _ in 0..super_count {
1167                if resolved.len() <= 1 {
1168                    return None;
1169                }
1170                resolved.pop();
1171            }
1172            resolved.extend(segments.iter().skip(super_count).cloned());
1173            Some(resolved.join("::"))
1174        }
1175        _ => Some(segments.join("::")),
1176    }
1177}
1178
1179fn parse_machine_self_ty(self_ty: &Type) -> Option<(String, String)> {
1180    let Type::Path(type_path) = self_ty else {
1181        return None;
1182    };
1183    let segment = type_path.path.segments.last()?;
1184    let syn::PathArguments::AngleBracketed(arguments) = &segment.arguments else {
1185        return None;
1186    };
1187    let state = arguments.args.iter().find_map(|argument| {
1188        let syn::GenericArgument::Type(Type::Path(state_path)) = argument else {
1189            return None;
1190        };
1191        state_path
1192            .path
1193            .segments
1194            .last()
1195            .map(|segment| segment.ident.to_string())
1196    })?;
1197    Some((segment.ident.to_string(), state))
1198}
1199
1200fn rust_type_leaf(rust_type_path: &str) -> &str {
1201    rust_type_path.rsplit("::").next().unwrap_or(rust_type_path)
1202}
1203
1204fn unique_machine<'a>(
1205    mut candidates: impl Iterator<Item = &'a CodebaseMachine>,
1206) -> Option<&'a CodebaseMachine> {
1207    let first = candidates.next()?;
1208    if candidates.next().is_some() {
1209        None
1210    } else {
1211        Some(first)
1212    }
1213}
1214
1215fn candidate_module_prefixes(resolved_path: &str) -> Vec<String> {
1216    let normalized = resolved_path.trim_start_matches("::");
1217    let primary = normalized
1218        .split("::")
1219        .map(str::to_owned)
1220        .collect::<Vec<_>>();
1221    if primary.is_empty() {
1222        return Vec::new();
1223    }
1224
1225    let mut candidates = BTreeSet::new();
1226    push_prefix_candidates(&primary, &mut candidates);
1227    if primary.len() > 1 {
1228        push_prefix_candidates(&primary[1..], &mut candidates);
1229    }
1230
1231    candidates.into_iter().rev().collect()
1232}
1233
1234fn push_prefix_candidates(segments: &[String], candidates: &mut BTreeSet<String>) {
1235    for length in (1..=segments.len()).rev() {
1236        let candidate = segments[..length].join("::");
1237        if !candidate.is_empty() {
1238            candidates.insert(candidate);
1239        }
1240    }
1241}
1242
1243fn collect_method_type_surface_evidence(
1244    method: &ImplItemFn,
1245    local_types: &LocalTypeRegistry<'_>,
1246) -> Vec<PathEvidence> {
1247    let mut visitor = TypeSurfaceVisitor::new(local_types);
1248    for input in &method.sig.inputs {
1249        if let FnArg::Typed(input) = input {
1250            visitor.visit_pat_type(input);
1251        }
1252    }
1253    if let ReturnType::Type(_, ty) = &method.sig.output {
1254        visitor.visit_type(ty);
1255    }
1256    visitor.items
1257}
1258
1259fn collect_type_surface_evidence(
1260    ty: &Type,
1261    local_types: &LocalTypeRegistry<'_>,
1262) -> Vec<PathEvidence> {
1263    let mut visitor = TypeSurfaceVisitor::new(local_types);
1264    visitor.visit_type(ty);
1265    visitor.items
1266}
1267
1268fn collect_body_evidence(method: &ImplItemFn) -> Vec<PathEvidence> {
1269    let mut visitor = BodyPathVisitor { items: Vec::new() };
1270    visitor.visit_block(&method.block);
1271    visitor.items
1272}
1273
1274struct TypeSurfaceVisitor<'a> {
1275    items: Vec<PathEvidence>,
1276    local_types: &'a LocalTypeRegistry<'a>,
1277    visited_local_types: HashSet<String>,
1278}
1279
1280impl<'a> TypeSurfaceVisitor<'a> {
1281    fn new(local_types: &'a LocalTypeRegistry<'a>) -> Self {
1282        Self {
1283            items: Vec::new(),
1284            local_types,
1285            visited_local_types: HashSet::new(),
1286        }
1287    }
1288}
1289
1290impl<'ast> Visit<'ast> for TypeSurfaceVisitor<'_> {
1291    fn visit_type_path(&mut self, node: &'ast TypePath) {
1292        if node.qself.is_none() {
1293            self.items.push(PathEvidence {
1294                path_text: node.path.to_token_stream().to_string(),
1295                line_number: node.path.span().start().line,
1296            });
1297
1298            if let Some(item_struct) = self.local_types.single_segment_struct(node) {
1299                let struct_name = item_struct.ident.to_string();
1300                if self.visited_local_types.insert(struct_name) {
1301                    for field in &item_struct.fields {
1302                        if has_cfg_attrs(&field.attrs) {
1303                            continue;
1304                        }
1305                        self.visit_type(&field.ty);
1306                    }
1307                }
1308            }
1309        }
1310        visit::visit_type_path(self, node);
1311    }
1312}
1313
1314struct BodyPathVisitor {
1315    items: Vec<PathEvidence>,
1316}
1317
1318impl<'ast> Visit<'ast> for BodyPathVisitor {
1319    fn visit_expr_path(&mut self, node: &'ast syn::ExprPath) {
1320        if node.qself.is_none()
1321            && (node.path.leading_colon.is_some() || node.path.segments.len() > 1)
1322        {
1323            self.items.push(PathEvidence {
1324                path_text: node.path.to_token_stream().to_string(),
1325                line_number: node.path.span().start().line,
1326            });
1327        }
1328        visit::visit_expr_path(self, node);
1329    }
1330
1331    fn visit_type_path(&mut self, node: &'ast TypePath) {
1332        if node.qself.is_none() {
1333            self.items.push(PathEvidence {
1334                path_text: node.path.to_token_stream().to_string(),
1335                line_number: node.path.span().start().line,
1336            });
1337        }
1338        visit::visit_type_path(self, node);
1339    }
1340}
1341
1342fn source_line_snippet(source: &str, line_number: usize) -> Option<String> {
1343    if line_number == 0 {
1344        return None;
1345    }
1346    source
1347        .lines()
1348        .nth(line_number.saturating_sub(1))
1349        .map(str::trim)
1350        .filter(|line| !line.is_empty())
1351        .map(str::to_owned)
1352}
1353
1354fn has_state_attr(attrs: &[syn::Attribute]) -> bool {
1355    attrs.iter().any(|attribute| {
1356        matches!(attribute.style, AttrStyle::Outer) && attribute.path().is_ident("state")
1357    })
1358}
1359
1360fn has_transition_attr(attrs: &[syn::Attribute]) -> bool {
1361    attrs.iter().any(|attribute| {
1362        matches!(attribute.style, AttrStyle::Outer) && attribute.path().is_ident("transition")
1363    })
1364}
1365
1366fn has_cfg_attrs(attrs: &[syn::Attribute]) -> bool {
1367    attrs.iter().any(|attribute| {
1368        matches!(attribute.style, AttrStyle::Outer)
1369            && (attribute.path().is_ident("cfg") || attribute.path().is_ident("cfg_attr"))
1370    })
1371}
1372
1373fn explicit_module_file_path(item_mod: &ItemMod, file_path: &Path) -> Option<PathBuf> {
1374    let attr = item_mod.attrs.iter().find(|attribute| {
1375        matches!(attribute.style, AttrStyle::Outer) && attribute.path().is_ident("path")
1376    })?;
1377    let meta = attr.meta.require_name_value().ok()?;
1378    let syn::Expr::Lit(expr_lit) = &meta.value else {
1379        return None;
1380    };
1381    let syn::Lit::Str(path) = &expr_lit.lit else {
1382        return None;
1383    };
1384    Some(
1385        file_path
1386            .parent()
1387            .unwrap_or_else(|| Path::new("."))
1388            .join(path.value()),
1389    )
1390}
1391
1392fn join_module_path(parent: &str, child: &str) -> String {
1393    if parent == "crate" {
1394        format!("crate::{child}")
1395    } else {
1396        format!("{parent}::{child}")
1397    }
1398}
1399
1400fn strip_crate_prefix(path: &str) -> &str {
1401    path.strip_prefix("crate::").unwrap_or(path)
1402}
1403
1404fn normalize_absolute_path(path: &Path) -> PathBuf {
1405    if path.is_absolute() {
1406        path.to_path_buf()
1407    } else {
1408        std::env::current_dir()
1409            .expect("current directory should exist")
1410            .join(path)
1411    }
1412}
1413
1414#[cfg(test)]
1415mod tests {
1416    use super::*;
1417
1418    use std::fs;
1419
1420    use statum::{machine, state, transition};
1421    use tempfile::tempdir;
1422
1423    #[allow(dead_code)]
1424    mod heuristics_fixture {
1425        pub mod heuristic_task {
1426            use super::super::{machine, state, transition};
1427
1428            #[state]
1429            pub enum State {
1430                Idle,
1431                Running,
1432                Done,
1433            }
1434
1435            #[machine]
1436            pub struct Machine<State> {}
1437
1438            #[transition]
1439            impl Machine<Idle> {
1440                fn start(self) -> Machine<Running> {
1441                    self.transition()
1442                }
1443            }
1444
1445            #[transition]
1446            impl Machine<Running> {
1447                fn finish(self) -> Machine<Done> {
1448                    self.transition()
1449                }
1450            }
1451        }
1452
1453        pub mod heuristic_workflow {
1454            use super::super::{machine, state, transition};
1455            use super::heuristic_task;
1456
1457            #[state]
1458            pub enum State {
1459                Draft,
1460                InProgress,
1461                Done,
1462            }
1463
1464            #[machine]
1465            pub struct Machine<State> {}
1466
1467            #[transition]
1468            impl Machine<Draft> {
1469                fn start(
1470                    self,
1471                    task: heuristic_task::Machine<heuristic_task::Running>,
1472                ) -> Machine<InProgress> {
1473                    let _ = task;
1474                    self.transition()
1475                }
1476            }
1477
1478            #[transition]
1479            impl Machine<InProgress> {
1480                fn finish(self) -> Machine<Done> {
1481                    self.transition()
1482                }
1483            }
1484        }
1485    }
1486
1487    #[allow(dead_code)]
1488    mod ambiguous_fixture {
1489        pub mod workflow {
1490            use super::super::{machine, state, transition};
1491
1492            #[state]
1493            pub enum State {
1494                Draft,
1495                Done,
1496            }
1497
1498            #[machine]
1499            pub struct Machine<State> {}
1500
1501            #[transition]
1502            impl Machine<Draft> {
1503                fn start(self) -> Machine<Done> {
1504                    self.transition()
1505                }
1506            }
1507        }
1508
1509        pub mod flows {
1510            pub mod task {
1511                pub mod alpha {
1512                    use super::super::super::super::{machine, state};
1513
1514                    #[state]
1515                    pub enum State {
1516                        Ready,
1517                    }
1518
1519                    #[machine]
1520                    pub struct Machine<State> {}
1521                }
1522
1523                pub mod beta {
1524                    use super::super::super::super::{machine, state};
1525
1526                    #[state]
1527                    pub enum State {
1528                        Ready,
1529                    }
1530
1531                    #[machine]
1532                    pub struct Machine<State> {}
1533                }
1534            }
1535        }
1536    }
1537
1538    fn fixture_doc() -> CodebaseDoc {
1539        CodebaseDoc::linked().expect("linked codebase doc")
1540    }
1541
1542    fn write_package_sources(
1543        dir: &Path,
1544        lib_rs: &str,
1545        extra_files: &[(&str, &str)],
1546    ) -> InspectPackageSource {
1547        fs::create_dir_all(dir.join("src")).expect("fixture src dir");
1548        fs::write(dir.join("src/lib.rs"), lib_rs).expect("fixture lib.rs");
1549        for (relative, contents) in extra_files {
1550            let path = dir.join(relative);
1551            if let Some(parent) = path.parent() {
1552                fs::create_dir_all(parent).expect("fixture parent dir");
1553            }
1554            fs::write(path, contents).expect("fixture source file");
1555        }
1556
1557        InspectPackageSource {
1558            package_name: "fixture-app".to_owned(),
1559            manifest_dir: dir.to_path_buf(),
1560            lib_target_path: dir.join("src/lib.rs"),
1561        }
1562    }
1563
1564    #[test]
1565    fn collects_signature_and_body_relations_from_transition_sources() {
1566        let dir = tempdir().expect("fixture tempdir");
1567        let package = write_package_sources(
1568            dir.path(),
1569            "pub mod heuristics {\n    pub mod tests {\n        pub mod heuristics_fixture;\n    }\n}\n",
1570            &[
1571                (
1572                    "src/heuristics/tests/heuristics_fixture/mod.rs",
1573                    "pub mod heuristic_task;\npub mod heuristic_workflow;\n",
1574                ),
1575                (
1576                    "src/heuristics/tests/heuristics_fixture/heuristic_task.rs",
1577                    "pub struct Receipt;\n\
1578                     pub struct Idle;\n\
1579                     pub struct Running;\n\
1580                     pub struct Done;\n\
1581                     pub struct Machine<State>(std::marker::PhantomData<State>);\n",
1582                ),
1583                (
1584                    "src/heuristics/tests/heuristics_fixture/heuristic_workflow.rs",
1585                    "use super::heuristic_task;\n\
1586                     use statum::transition;\n\
1587                     pub struct Draft;\n\
1588                     pub struct InProgress;\n\
1589                     pub struct Done;\n\
1590                     pub struct Machine<State>(std::marker::PhantomData<State>);\n\
1591                     #[transition]\n\
1592                     impl Machine<Draft> {\n\
1593                         fn start(self, task: heuristic_task::Machine<heuristic_task::Running>) -> Machine<InProgress> {\n\
1594                             let _receipt = heuristic_task::Receipt;\n\
1595                             let _builder = heuristic_task::Machine::<heuristic_task::Running>;\n\
1596                             self\n\
1597                         }\n\
1598                     }\n",
1599                ),
1600            ],
1601        );
1602
1603        let overlay = collect_heuristic_overlay(&fixture_doc(), &[package]);
1604
1605        assert_eq!(overlay.status(), HeuristicStatusKind::Available);
1606        assert_eq!(overlay.relations().len(), 3);
1607        assert_eq!(
1608            overlay
1609                .relations()
1610                .iter()
1611                .map(|relation| relation.evidence_kind)
1612                .collect::<Vec<_>>(),
1613            vec![
1614                HeuristicEvidenceKind::Signature,
1615                HeuristicEvidenceKind::Body,
1616                HeuristicEvidenceKind::Body
1617            ]
1618        );
1619    }
1620
1621    #[test]
1622    fn ambiguous_module_affinity_fails_closed_with_diagnostic() {
1623        let dir = tempdir().expect("fixture tempdir");
1624        let package = write_package_sources(
1625            dir.path(),
1626            "pub mod heuristics {\n    pub mod tests {\n        pub mod ambiguous_fixture;\n    }\n}\n",
1627            &[
1628                (
1629                    "src/heuristics/tests/ambiguous_fixture/mod.rs",
1630                    "pub mod workflow;\npub mod flows;\n",
1631                ),
1632                (
1633                    "src/heuristics/tests/ambiguous_fixture/workflow.rs",
1634                    "use statum::transition;\n\
1635                     pub struct Draft;\n\
1636                     pub struct Done;\n\
1637                     pub struct Machine<State>(std::marker::PhantomData<State>);\n\
1638                     #[transition]\n\
1639                     impl Machine<Draft> {\n\
1640                         fn start(self) -> Machine<Done> {\n\
1641                             let _receipt = crate::heuristics::tests::ambiguous_fixture::flows::task::Receipt;\n\
1642                             self\n\
1643                         }\n\
1644                     }\n",
1645                ),
1646                (
1647                    "src/heuristics/tests/ambiguous_fixture/flows/mod.rs",
1648                    "pub mod task;\n",
1649                ),
1650                (
1651                    "src/heuristics/tests/ambiguous_fixture/flows/task/mod.rs",
1652                    "pub mod alpha;\npub mod beta;\npub struct Receipt;\n",
1653                ),
1654                (
1655                    "src/heuristics/tests/ambiguous_fixture/flows/task/alpha.rs",
1656                    "pub struct Ready;\npub struct Machine<State>(std::marker::PhantomData<State>);\n",
1657                ),
1658                (
1659                    "src/heuristics/tests/ambiguous_fixture/flows/task/beta.rs",
1660                    "pub struct Ready;\npub struct Machine<State>(std::marker::PhantomData<State>);\n",
1661                ),
1662            ],
1663        );
1664
1665        let overlay = collect_heuristic_overlay(&fixture_doc(), &[package]);
1666
1667        assert_eq!(overlay.status(), HeuristicStatusKind::Partial);
1668        assert!(overlay.relations().is_empty());
1669        assert!(overlay
1670            .diagnostics()
1671            .iter()
1672            .any(|diagnostic| diagnostic.message.contains("ambiguous heuristic target")));
1673    }
1674
1675    #[test]
1676    fn cfg_decorated_transition_methods_are_skipped() {
1677        let dir = tempdir().expect("fixture tempdir");
1678        let package = write_package_sources(
1679            dir.path(),
1680            "pub mod heuristics {\n    pub mod tests {\n        pub mod heuristics_fixture;\n    }\n}\n",
1681            &[
1682                (
1683                    "src/heuristics/tests/heuristics_fixture/mod.rs",
1684                    "pub mod heuristic_task;\npub mod heuristic_workflow;\n",
1685                ),
1686                (
1687                    "src/heuristics/tests/heuristics_fixture/heuristic_task.rs",
1688                    "pub struct Running;\n\
1689                     pub struct Machine<State>(std::marker::PhantomData<State>);\n",
1690                ),
1691                (
1692                    "src/heuristics/tests/heuristics_fixture/heuristic_workflow.rs",
1693                    "use super::heuristic_task;\n\
1694                     use statum::transition;\n\
1695                     pub struct Draft;\n\
1696                     pub struct Done;\n\
1697                     pub struct Machine<State>(std::marker::PhantomData<State>);\n\
1698                     #[transition]\n\
1699                     impl Machine<Draft> {\n\
1700                         #[cfg(any())]\n\
1701                         fn start(self, task: heuristic_task::Machine<heuristic_task::Running>) -> Machine<Done> {\n\
1702                             let _ = task;\n\
1703                             self\n\
1704                         }\n\
1705                     }\n",
1706                ),
1707            ],
1708        );
1709
1710        let overlay = collect_heuristic_overlay(&fixture_doc(), &[package]);
1711
1712        assert_eq!(overlay.status(), HeuristicStatusKind::Available);
1713        assert!(overlay.relations().is_empty());
1714    }
1715
1716    #[test]
1717    fn body_variable_uses_do_not_count_as_explicit_body_paths() {
1718        let dir = tempdir().expect("fixture tempdir");
1719        let package = write_package_sources(
1720            dir.path(),
1721            "pub mod heuristics {\n    pub mod tests {\n        pub mod heuristics_fixture;\n    }\n}\n",
1722            &[
1723                (
1724                    "src/heuristics/tests/heuristics_fixture/mod.rs",
1725                    "pub mod heuristic_task;\npub mod heuristic_workflow;\n",
1726                ),
1727                (
1728                    "src/heuristics/tests/heuristics_fixture/heuristic_task.rs",
1729                    "pub struct Running;\n\
1730                     pub struct Machine<State>(std::marker::PhantomData<State>);\n",
1731                ),
1732                (
1733                    "src/heuristics/tests/heuristics_fixture/heuristic_workflow.rs",
1734                    "use super::heuristic_task;\n\
1735                     use statum::transition;\n\
1736                     pub struct Draft;\n\
1737                     pub struct Done;\n\
1738                     pub struct Machine<State>(std::marker::PhantomData<State>);\n\
1739                     #[transition]\n\
1740                     impl Machine<Draft> {\n\
1741                         fn start(self, task: heuristic_task::Machine<heuristic_task::Running>) -> Machine<Done> {\n\
1742                             let _ = task;\n\
1743                             self\n\
1744                         }\n\
1745                     }\n",
1746                ),
1747            ],
1748        );
1749
1750        let overlay = collect_heuristic_overlay(&fixture_doc(), &[package]);
1751
1752        assert_eq!(overlay.status(), HeuristicStatusKind::Available);
1753        assert_eq!(overlay.relations().len(), 1);
1754        assert_eq!(
1755            overlay.relations()[0].evidence_kind,
1756            HeuristicEvidenceKind::Signature
1757        );
1758    }
1759
1760    #[test]
1761    fn path_attribute_modules_are_scanned_without_unavailable_diagnostics() {
1762        let dir = tempdir().expect("fixture tempdir");
1763        let package = write_package_sources(
1764            dir.path(),
1765            "#[path = \"support/fault.rs\"]\n\
1766             pub mod fault;\n\
1767             pub mod heuristics {\n    pub mod tests {\n        pub mod heuristics_fixture;\n    }\n}\n",
1768            &[
1769                ("src/support/fault.rs", "pub struct Error;\n"),
1770                (
1771                    "src/heuristics/tests/heuristics_fixture/mod.rs",
1772                    "pub mod heuristic_task;\npub mod heuristic_workflow;\n",
1773                ),
1774                (
1775                    "src/heuristics/tests/heuristics_fixture/heuristic_task.rs",
1776                    "pub struct Running;\n\
1777                     pub struct Machine<State>(std::marker::PhantomData<State>);\n",
1778                ),
1779                (
1780                    "src/heuristics/tests/heuristics_fixture/heuristic_workflow.rs",
1781                    "use super::heuristic_task;\n\
1782                     pub struct Draft;\n\
1783                     pub struct Machine<State>(std::marker::PhantomData<State>);\n\
1784                     impl Machine<Draft> {\n\
1785                         fn await_task(self, _task: heuristic_task::Machine<heuristic_task::Running>) -> Self {\n\
1786                             self\n\
1787                         }\n\
1788                     }\n",
1789                ),
1790            ],
1791        );
1792
1793        let overlay = collect_heuristic_overlay(&fixture_doc(), &[package]);
1794
1795        assert_eq!(overlay.status(), HeuristicStatusKind::Available);
1796        assert_eq!(overlay.diagnostics(), &[]);
1797    }
1798
1799    #[test]
1800    fn collects_relations_from_non_transition_method_signatures() {
1801        let dir = tempdir().expect("fixture tempdir");
1802        let package = write_package_sources(
1803            dir.path(),
1804            "pub mod heuristics {\n    pub mod tests {\n        pub mod heuristics_fixture;\n    }\n}\n",
1805            &[
1806                (
1807                    "src/heuristics/tests/heuristics_fixture/mod.rs",
1808                    "pub mod heuristic_task;\npub mod heuristic_workflow;\n",
1809                ),
1810                (
1811                    "src/heuristics/tests/heuristics_fixture/heuristic_task.rs",
1812                    "pub struct Running;\n\
1813                     pub struct Machine<State>(std::marker::PhantomData<State>);\n",
1814                ),
1815                (
1816                    "src/heuristics/tests/heuristics_fixture/heuristic_workflow.rs",
1817                    "use super::heuristic_task;\n\
1818                     pub struct Draft;\n\
1819                     pub struct Done;\n\
1820                     pub struct Machine<State>(std::marker::PhantomData<State>);\n\
1821                     impl Machine<Draft> {\n\
1822                         fn await_task(self, _task: heuristic_task::Machine<heuristic_task::Running>) -> Machine<Done> {\n\
1823                             self\n\
1824                         }\n\
1825                     }\n",
1826                ),
1827            ],
1828        );
1829
1830        let overlay = collect_heuristic_overlay(&fixture_doc(), &[package]);
1831
1832        assert_eq!(overlay.status(), HeuristicStatusKind::Available);
1833        assert_eq!(overlay.relations().len(), 1);
1834        assert_eq!(
1835            overlay.relations()[0].evidence_kind,
1836            HeuristicEvidenceKind::Signature
1837        );
1838        assert!(matches!(
1839            overlay.relations()[0].source,
1840            HeuristicRelationSource::Method {
1841                state: 0,
1842                ref method_name,
1843                ..
1844            } if method_name == "await_task"
1845        ));
1846    }
1847
1848    #[test]
1849    fn collects_relations_from_state_payload_struct_recursion() {
1850        let dir = tempdir().expect("fixture tempdir");
1851        let package = write_package_sources(
1852            dir.path(),
1853            "pub mod heuristics {\n    pub mod tests {\n        pub mod heuristics_fixture;\n    }\n}\n",
1854            &[
1855                (
1856                    "src/heuristics/tests/heuristics_fixture/mod.rs",
1857                    "pub mod heuristic_task;\npub mod heuristic_workflow;\n",
1858                ),
1859                (
1860                    "src/heuristics/tests/heuristics_fixture/heuristic_task.rs",
1861                    "pub struct Running;\n\
1862                     pub struct Machine<State>(std::marker::PhantomData<State>);\n",
1863                ),
1864                (
1865                    "src/heuristics/tests/heuristics_fixture/heuristic_workflow.rs",
1866                    "use super::heuristic_task;\n\
1867                     use statum::{machine, state};\n\
1868                     pub struct ReadyData {\n\
1869                         handoff: heuristic_task::Machine<heuristic_task::Running>,\n\
1870                     }\n\
1871                     #[state]\n\
1872                     pub enum State {\n\
1873                         Draft,\n\
1874                         InProgress(ReadyData),\n\
1875                         Done,\n\
1876                     }\n\
1877                     #[machine]\n\
1878                     pub struct Machine<State> {}\n",
1879                ),
1880            ],
1881        );
1882
1883        let overlay = collect_heuristic_overlay(&fixture_doc(), &[package]);
1884
1885        assert_eq!(overlay.status(), HeuristicStatusKind::Available);
1886        assert_eq!(overlay.relations().len(), 1);
1887        assert!(matches!(
1888            overlay.relations()[0].source,
1889            HeuristicRelationSource::State { state: 1, .. }
1890        ));
1891    }
1892}