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}