Skip to main content

cargo_statum_graph/
suggestions.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt::Write as _;
3
4use statum_graph::{
5    CodebaseDoc, CodebaseMachine, CodebaseRelation, CodebaseRelationBasis, CodebaseRelationCount,
6};
7
8use crate::heuristics::{HeuristicOverlay, HeuristicRelationCount};
9
10#[derive(Clone, Copy, Debug, Eq, PartialEq)]
11pub enum CompositionSuggestionSeverity {
12    Warning,
13    Suggestion,
14}
15
16impl CompositionSuggestionSeverity {
17    pub const fn display_label(self) -> &'static str {
18        match self {
19            Self::Warning => "warning",
20            Self::Suggestion => "suggestion",
21        }
22    }
23}
24
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26pub enum CompositionSuggestionKind {
27    MissingCompositionRole,
28    HeuristicCompositionCandidate,
29}
30
31impl CompositionSuggestionKind {
32    pub const fn display_label(self) -> &'static str {
33        match self {
34            Self::MissingCompositionRole => "missing composition role",
35            Self::HeuristicCompositionCandidate => "heuristic composition candidate",
36        }
37    }
38}
39
40#[derive(Clone, Debug, Eq, PartialEq)]
41pub struct CompositionSuggestion {
42    pub index: usize,
43    pub severity: CompositionSuggestionSeverity,
44    pub kind: CompositionSuggestionKind,
45    pub source_machine: usize,
46    pub target_machine: usize,
47    pub exact_relation_indices: Vec<usize>,
48    pub heuristic_relation_indices: Vec<usize>,
49    pub exact_counts: Vec<CodebaseRelationCount>,
50    pub heuristic_counts: Vec<HeuristicRelationCount>,
51}
52
53impl CompositionSuggestion {
54    pub fn source_machine<'a>(&self, doc: &'a CodebaseDoc) -> Option<&'a CodebaseMachine> {
55        doc.machine(self.source_machine)
56    }
57
58    pub fn target_machine<'a>(&self, doc: &'a CodebaseDoc) -> Option<&'a CodebaseMachine> {
59        doc.machine(self.target_machine)
60    }
61
62    pub fn summary_label(&self, doc: &CodebaseDoc) -> String {
63        let source = self
64            .source_machine(doc)
65            .map(render_machine_label)
66            .unwrap_or_else(|| "<missing machine>".to_owned());
67        let target = self
68            .target_machine(doc)
69            .map(render_machine_label)
70            .unwrap_or_else(|| "<missing machine>".to_owned());
71        format!("{source} -> {target}")
72    }
73
74    pub fn counts_label(&self) -> String {
75        match self.severity {
76            CompositionSuggestionSeverity::Warning => self
77                .exact_counts
78                .iter()
79                .map(CodebaseRelationCount::display_label)
80                .collect::<Vec<_>>()
81                .join(", "),
82            CompositionSuggestionSeverity::Suggestion => self
83                .heuristic_counts
84                .iter()
85                .map(HeuristicRelationCount::display_label)
86                .collect::<Vec<_>>()
87                .join(", "),
88        }
89    }
90
91    pub const fn help_text(&self) -> &'static str {
92        match self.kind {
93            CompositionSuggestionKind::MissingCompositionRole => {
94                "consider `#[machine(role = composition)]` on the source machine"
95            }
96            CompositionSuggestionKind::HeuristicCompositionCandidate => {
97                "if this coupling is real workflow orchestration, model it in typed composition state/transition surfaces or promote a detached handoff into the exact lane"
98            }
99        }
100    }
101
102    pub const fn why_text(&self) -> &'static str {
103        match self.kind {
104            CompositionSuggestionKind::MissingCompositionRole => {
105                "protocol machine already exposes typed cross-machine orchestration"
106            }
107            CompositionSuggestionKind::HeuristicCompositionCandidate => {
108                "cross-machine coupling is still only visible through the heuristic lane"
109            }
110        }
111    }
112}
113
114#[derive(Clone, Debug, Default, Eq, PartialEq)]
115pub struct CompositionSuggestionOverlay {
116    suggestions: Vec<CompositionSuggestion>,
117}
118
119impl CompositionSuggestionOverlay {
120    pub fn suggestions(&self) -> &[CompositionSuggestion] {
121        &self.suggestions
122    }
123
124    pub fn is_empty(&self) -> bool {
125        self.suggestions.is_empty()
126    }
127
128    pub fn machine_suggestions(
129        &self,
130        machine_index: usize,
131    ) -> impl Iterator<Item = &CompositionSuggestion> + '_ {
132        self.suggestions
133            .iter()
134            .filter(move |suggestion| suggestion.source_machine == machine_index)
135    }
136
137    pub fn warning_count(&self) -> usize {
138        self.suggestions
139            .iter()
140            .filter(|suggestion| suggestion.severity == CompositionSuggestionSeverity::Warning)
141            .count()
142    }
143
144    pub fn suggestion_count(&self) -> usize {
145        self.suggestions
146            .iter()
147            .filter(|suggestion| suggestion.severity == CompositionSuggestionSeverity::Suggestion)
148            .count()
149    }
150
151    #[cfg(test)]
152    pub(crate) fn from_suggestions(suggestions: Vec<CompositionSuggestion>) -> Self {
153        Self { suggestions }
154    }
155}
156
157pub fn collect_composition_suggestions(
158    doc: &CodebaseDoc,
159    heuristic: &HeuristicOverlay,
160) -> CompositionSuggestionOverlay {
161    let mut suggestions = Vec::new();
162    let mut exact_pairs = BTreeSet::new();
163
164    for group in doc.machine_relation_groups() {
165        if group.from_machine == group.to_machine {
166            continue;
167        }
168        let Some(source_machine) = doc.machine(group.from_machine) else {
169            continue;
170        };
171        if source_machine.role.is_composition() {
172            continue;
173        }
174
175        let mut candidate_relations = Vec::new();
176        let mut counts =
177            BTreeMap::<(statum_graph::CodebaseRelationKind, CodebaseRelationBasis), usize>::new();
178        for relation_index in &group.relation_indices {
179            let Some(relation) = doc.relation(*relation_index) else {
180                continue;
181            };
182            if !is_high_confidence_typed_orchestration(relation) {
183                continue;
184            }
185            candidate_relations.push(*relation_index);
186            *counts.entry((relation.kind, relation.basis)).or_default() += 1;
187        }
188
189        if candidate_relations.is_empty() {
190            continue;
191        }
192
193        exact_pairs.insert((group.from_machine, group.to_machine));
194        suggestions.push(CompositionSuggestion {
195            index: suggestions.len(),
196            severity: CompositionSuggestionSeverity::Warning,
197            kind: CompositionSuggestionKind::MissingCompositionRole,
198            source_machine: group.from_machine,
199            target_machine: group.to_machine,
200            exact_relation_indices: candidate_relations,
201            heuristic_relation_indices: Vec::new(),
202            exact_counts: counts
203                .into_iter()
204                .map(|((kind, basis), count)| CodebaseRelationCount { kind, basis, count })
205                .collect(),
206            heuristic_counts: Vec::new(),
207        });
208    }
209
210    let mut legacy_link_counts = BTreeMap::<(usize, usize), usize>::new();
211    for link in doc.links() {
212        *legacy_link_counts
213            .entry((link.from_machine, link.to_machine))
214            .or_default() += 1;
215    }
216
217    for ((from_machine, to_machine), count) in legacy_link_counts {
218        if from_machine == to_machine || exact_pairs.contains(&(from_machine, to_machine)) {
219            continue;
220        }
221        let Some(source_machine) = doc.machine(from_machine) else {
222            continue;
223        };
224        if source_machine.role.is_composition() {
225            continue;
226        }
227
228        exact_pairs.insert((from_machine, to_machine));
229        suggestions.push(CompositionSuggestion {
230            index: suggestions.len(),
231            severity: CompositionSuggestionSeverity::Warning,
232            kind: CompositionSuggestionKind::MissingCompositionRole,
233            source_machine: from_machine,
234            target_machine: to_machine,
235            exact_relation_indices: Vec::new(),
236            heuristic_relation_indices: Vec::new(),
237            exact_counts: vec![CodebaseRelationCount {
238                kind: statum_graph::CodebaseRelationKind::StatePayload,
239                basis: CodebaseRelationBasis::DirectTypeSyntax,
240                count,
241            }],
242            heuristic_counts: Vec::new(),
243        });
244    }
245
246    for group in heuristic.machine_relation_groups() {
247        if group.from_machine == group.to_machine {
248            continue;
249        }
250        if exact_pairs.contains(&(group.from_machine, group.to_machine)) {
251            continue;
252        }
253        let Some(source_machine) = doc.machine(group.from_machine) else {
254            continue;
255        };
256        if source_machine.role.is_composition() {
257            continue;
258        }
259        if doc.machine_relation_groups().iter().any(|exact| {
260            exact.from_machine == group.from_machine && exact.to_machine == group.to_machine
261        }) {
262            continue;
263        }
264
265        suggestions.push(CompositionSuggestion {
266            index: suggestions.len(),
267            severity: CompositionSuggestionSeverity::Suggestion,
268            kind: CompositionSuggestionKind::HeuristicCompositionCandidate,
269            source_machine: group.from_machine,
270            target_machine: group.to_machine,
271            exact_relation_indices: Vec::new(),
272            heuristic_relation_indices: group.relation_indices.clone(),
273            exact_counts: Vec::new(),
274            heuristic_counts: group.counts.clone(),
275        });
276    }
277
278    CompositionSuggestionOverlay { suggestions }
279}
280
281pub fn render_composition_suggestions(doc: &CodebaseDoc, heuristic: &HeuristicOverlay) -> String {
282    let overlay = collect_composition_suggestions(doc, heuristic);
283    let mut output = String::new();
284    let _ = writeln!(
285        output,
286        "composition diagnostics: {} warning, {} suggestion",
287        overlay.warning_count(),
288        overlay.suggestion_count()
289    );
290    let _ = writeln!(
291        output,
292        "heuristics: {} ({})",
293        heuristic.status().display_label(),
294        heuristic.diagnostics().len()
295    );
296    for diagnostic in heuristic.diagnostics().iter().take(3) {
297        let _ = writeln!(
298            output,
299            "heuristic diagnostic: {}",
300            diagnostic.display_label()
301        );
302    }
303
304    if overlay.is_empty() {
305        let _ = writeln!(output, "no composition diagnostics");
306        return output;
307    }
308
309    for suggestion in overlay.suggestions() {
310        let _ = writeln!(output);
311        let _ = writeln!(
312            output,
313            "{}: {}",
314            suggestion.severity.display_label(),
315            suggestion.summary_label(doc)
316        );
317        let _ = writeln!(output, "kind: {}", suggestion.kind.display_label());
318        let _ = writeln!(output, "why: {}", suggestion.why_text());
319        let _ = writeln!(output, "evidence: {}", suggestion.counts_label());
320        let _ = writeln!(output, "help: {}", suggestion.help_text());
321    }
322
323    output
324}
325
326fn is_high_confidence_typed_orchestration(relation: &CodebaseRelation) -> bool {
327    matches!(
328        relation.basis,
329        CodebaseRelationBasis::DirectTypeSyntax
330            | CodebaseRelationBasis::AttestedTypeSyntax
331            | CodebaseRelationBasis::ViaDeclaration
332    )
333}
334
335fn render_machine_label(machine: &CodebaseMachine) -> String {
336    machine.label.unwrap_or(machine.rust_type_path).to_owned()
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use crate::heuristics::{
343        HeuristicEvidenceKind, HeuristicRelation, HeuristicRelationSource, HeuristicStatusKind,
344    };
345
346    mod suggestion_task {
347        use statum::{machine, state};
348
349        #[state]
350        pub enum State {
351            Running,
352        }
353
354        #[machine]
355        pub struct Machine<State> {}
356    }
357
358    mod suggestion_workflow {
359        use super::suggestion_task as task;
360        use statum::{machine, state, transition};
361
362        #[state]
363        pub enum State {
364            Draft,
365            InProgress(task::Machine<task::Running>),
366        }
367
368        #[machine]
369        pub struct Machine<State> {}
370
371        #[allow(dead_code)]
372        #[transition]
373        impl Machine<Draft> {
374            fn start(self, task: task::Machine<task::Running>) -> Machine<InProgress> {
375                self.transition_with(task)
376            }
377        }
378    }
379
380    fn fixture_doc() -> CodebaseDoc {
381        CodebaseDoc::linked().expect("linked doc")
382    }
383
384    #[test]
385    fn exact_protocol_machine_with_typed_child_machine_gets_warning() {
386        let doc = fixture_doc();
387        let overlay = collect_composition_suggestions(
388            &doc,
389            &HeuristicOverlay::from_parts(HeuristicStatusKind::Available, Vec::new(), Vec::new()),
390        );
391
392        assert!(overlay.warning_count() >= 1);
393        let suggestion = overlay
394            .suggestions()
395            .iter()
396            .find(|suggestion| {
397                suggestion.severity == CompositionSuggestionSeverity::Warning
398                    && suggestion.kind == CompositionSuggestionKind::MissingCompositionRole
399            })
400            .expect("composition warning");
401        assert_eq!(
402            suggestion.kind,
403            CompositionSuggestionKind::MissingCompositionRole
404        );
405        assert!(!suggestion.exact_counts.is_empty());
406    }
407
408    #[test]
409    fn heuristic_only_protocol_machine_gets_suggestion() {
410        let doc = fixture_doc();
411        let task = doc
412            .machines()
413            .iter()
414            .find(|machine| machine.rust_type_path.ends_with("suggestion_task::Machine"))
415            .expect("task");
416        let workflow = doc
417            .machines()
418            .iter()
419            .find(|machine| {
420                machine
421                    .rust_type_path
422                    .ends_with("suggestion_workflow::Machine")
423            })
424            .expect("workflow");
425
426        let overlay = collect_composition_suggestions(
427            &doc,
428            &HeuristicOverlay::from_parts(
429                HeuristicStatusKind::Available,
430                Vec::new(),
431                vec![HeuristicRelation {
432                    index: 0,
433                    source: HeuristicRelationSource::Transition {
434                        machine: task.index,
435                        transition: 0,
436                    },
437                    target_machine: workflow.index,
438                    evidence_kind: HeuristicEvidenceKind::Signature,
439                    matched_path_text:
440                        "suggestion_workflow::Machine<suggestion_workflow::InProgress>".to_owned(),
441                    file_path: "/tmp/task.rs".into(),
442                    line_number: 10,
443                    snippet: None,
444                }],
445            ),
446        );
447
448        assert!(overlay.warning_count() >= 1);
449        assert!(overlay.suggestion_count() >= 1);
450        let suggestion = overlay
451            .suggestions()
452            .iter()
453            .find(|suggestion| suggestion.severity == CompositionSuggestionSeverity::Suggestion)
454            .expect("heuristic suggestion");
455        assert_eq!(
456            suggestion.kind,
457            CompositionSuggestionKind::HeuristicCompositionCandidate
458        );
459    }
460
461    #[test]
462    fn rendered_report_includes_heuristic_status() {
463        let doc = fixture_doc();
464        let report = render_composition_suggestions(
465            &doc,
466            &HeuristicOverlay::from_parts(
467                HeuristicStatusKind::Partial,
468                vec![crate::heuristics::HeuristicDiagnostic {
469                    context: "package fixture".to_owned(),
470                    message: "failed to parse one module".to_owned(),
471                }],
472                Vec::new(),
473            ),
474        );
475
476        assert!(report.contains("heuristics: partial (1)"));
477        assert!(report.contains("heuristic diagnostic:"));
478    }
479}