Skip to main content

advisor_core/
lib.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt;
3use std::path::{Path, PathBuf};
4
5use serde::Serialize;
6
7#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
8#[serde(rename_all = "kebab-case")]
9pub enum Confidence {
10    High,
11    Medium,
12    Low,
13}
14
15impl Confidence {
16    pub fn as_str(&self) -> &'static str {
17        match self {
18            Self::High => "high",
19            Self::Medium => "medium",
20            Self::Low => "low",
21        }
22    }
23
24    fn rank_score(&self) -> i32 {
25        match self {
26            Self::High => 90,
27            Self::Medium => 75,
28            Self::Low => 60,
29        }
30    }
31}
32
33#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
34pub struct Tradeoff {
35    pub area: String,
36    pub detail: String,
37}
38
39#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
40pub struct TrustNote {
41    pub label: String,
42    pub detail: String,
43}
44
45#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
46pub struct Receipt {
47    pub source: String,
48    pub summary: String,
49    pub detail: String,
50}
51
52#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize)]
53pub struct EvidenceBundle {
54    pub receipts: Vec<Receipt>,
55    pub trust_notes: Vec<TrustNote>,
56}
57
58#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
59#[serde(rename_all = "kebab-case")]
60pub enum RecommendationArchetype {
61    BestDefault,
62    LeanOption,
63    PowerOption,
64    Specialist,
65}
66
67impl RecommendationArchetype {
68    pub fn label(&self) -> &'static str {
69        match self {
70            Self::BestDefault => "best default",
71            Self::LeanOption => "lean option",
72            Self::PowerOption => "power option",
73            Self::Specialist => "specialist option",
74        }
75    }
76
77    fn rank_bonus(&self) -> i32 {
78        match self {
79            Self::BestDefault => 6,
80            Self::LeanOption => 3,
81            Self::PowerOption => 2,
82            Self::Specialist => 0,
83        }
84    }
85}
86
87#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
88#[serde(rename_all = "kebab-case")]
89pub enum GoalFitStrength {
90    Strong,
91    Good,
92    Weak,
93}
94
95impl GoalFitStrength {
96    fn score_delta(&self) -> i32 {
97        match self {
98            Self::Strong => 24,
99            Self::Good => 12,
100            Self::Weak => -8,
101        }
102    }
103
104    fn summary(&self) -> &'static str {
105        match self {
106            Self::Strong => "strong fit",
107            Self::Good => "good fit",
108            Self::Weak => "weaker fit",
109        }
110    }
111}
112
113#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
114pub struct GoalFit {
115    pub goal: String,
116    pub strength: GoalFitStrength,
117    pub detail: String,
118}
119
120#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
121pub struct CatalogEntry {
122    pub crate_name: String,
123    pub intent: String,
124    pub summary: String,
125    pub rationale: Vec<String>,
126    pub goal_fits: Vec<GoalFit>,
127    pub tradeoffs: Vec<Tradeoff>,
128    pub trust_notes: Vec<TrustNote>,
129    pub confidence: Confidence,
130    pub archetype: RecommendationArchetype,
131}
132
133#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
134pub struct Recommendation {
135    pub crate_name: String,
136    pub intent: String,
137    pub summary: String,
138    pub confidence: Confidence,
139    pub archetype: RecommendationArchetype,
140    pub rationale: Vec<String>,
141    pub fit_notes: Vec<String>,
142    pub tradeoffs: Vec<Tradeoff>,
143    pub trust_notes: Vec<TrustNote>,
144    pub receipts: Vec<Receipt>,
145    pub score: i32,
146}
147
148#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
149pub struct BestFitSection {
150    pub label: String,
151    pub summary: String,
152    pub recommendation: Recommendation,
153}
154
155#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
156pub struct RecommendReport {
157    pub requested_intent: String,
158    pub intent: String,
159    pub requested_goal: Option<String>,
160    pub goal: Option<String>,
161    pub summary: String,
162    pub recommendation: Recommendation,
163    pub confidence: Confidence,
164    pub tradeoffs: Vec<Tradeoff>,
165    pub alternatives: Vec<Recommendation>,
166    pub best_fit_sections: Vec<BestFitSection>,
167    pub trust_notes: Vec<TrustNote>,
168    pub receipts: Vec<Receipt>,
169}
170
171#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
172pub struct CompareReport {
173    pub requested_intent: Option<String>,
174    pub intent: Option<String>,
175    pub requested_crates: Vec<String>,
176    pub summary: String,
177    pub recommendation: Recommendation,
178    pub confidence: Confidence,
179    pub tradeoffs: Vec<Tradeoff>,
180    pub alternatives: Vec<Recommendation>,
181    pub trust_notes: Vec<TrustNote>,
182    pub receipts: Vec<Receipt>,
183}
184
185#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
186pub struct ExplainReport {
187    pub requested_intent: Option<String>,
188    pub intent: String,
189    pub summary: String,
190    pub recommendation: Recommendation,
191    pub confidence: Confidence,
192    pub tradeoffs: Vec<Tradeoff>,
193    pub trust_notes: Vec<TrustNote>,
194    pub receipts: Vec<Receipt>,
195}
196
197#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
198#[serde(rename_all = "kebab-case")]
199pub enum FindingSeverity {
200    Info,
201    Warning,
202}
203
204impl FindingSeverity {
205    pub fn as_str(&self) -> &'static str {
206        match self {
207            Self::Info => "info",
208            Self::Warning => "warning",
209        }
210    }
211}
212
213#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
214pub struct ReviewFinding {
215    pub severity: FindingSeverity,
216    pub title: String,
217    pub detail: String,
218}
219
220#[derive(Clone, Debug, PartialEq, Eq)]
221pub struct LoadedManifest {
222    pub manifest_path: PathBuf,
223    pub package_name: Option<String>,
224    pub is_root: bool,
225}
226
227#[derive(Clone, Debug, PartialEq, Eq)]
228pub enum ReviewDependencyKind {
229    Normal,
230    Dev,
231    Build,
232}
233
234impl ReviewDependencyKind {
235    pub fn label(&self) -> &'static str {
236        match self {
237            Self::Normal => "normal",
238            Self::Dev => "dev",
239            Self::Build => "build",
240        }
241    }
242}
243
244#[derive(Clone, Debug, PartialEq, Eq)]
245pub struct ManifestDependency {
246    pub manifest_path: PathBuf,
247    pub package_name: Option<String>,
248    pub dependency_name: String,
249    pub declared_name: String,
250    pub kind: ReviewDependencyKind,
251    pub target: Option<String>,
252}
253
254#[derive(Clone, Debug, PartialEq, Eq)]
255pub struct ReviewInputs {
256    pub manifest_path: PathBuf,
257    pub manifest_contents: Option<String>,
258    pub lockfile_path: PathBuf,
259    pub lockfile_contents: Option<String>,
260    pub manifests: Vec<LoadedManifest>,
261    pub dependencies: Vec<ManifestDependency>,
262    pub evidence: EvidenceBundle,
263}
264
265#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
266pub struct ReviewedManifest {
267    pub manifest_path: PathBuf,
268    pub package_name: Option<String>,
269    pub is_root: bool,
270    pub dependency_count: usize,
271}
272
273#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
274pub struct LockfileDuplicateVersion {
275    pub crate_name: String,
276    pub versions: Vec<String>,
277}
278
279#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
280pub struct LockfileSummary {
281    pub package_count: usize,
282    pub duplicate_versions: Vec<LockfileDuplicateVersion>,
283}
284
285#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
286pub struct ReviewReport {
287    pub summary: String,
288    pub manifest_path: PathBuf,
289    pub lockfile_path: PathBuf,
290    pub manifests: Vec<ReviewedManifest>,
291    pub dependencies: Vec<String>,
292    pub lockfile_summary: Option<LockfileSummary>,
293    pub findings: Vec<ReviewFinding>,
294    pub recommendation: Option<Recommendation>,
295    pub confidence: Option<Confidence>,
296    pub tradeoffs: Vec<Tradeoff>,
297    pub follow_up_recommendations: Vec<Recommendation>,
298    pub trust_notes: Vec<TrustNote>,
299    pub receipts: Vec<Receipt>,
300}
301
302#[derive(Clone, Debug, PartialEq, Eq)]
303pub enum AdvisorError {
304    Usage(String),
305    UnsupportedIntent {
306        requested: String,
307        supported: Vec<String>,
308    },
309}
310
311impl fmt::Display for AdvisorError {
312    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
313        match self {
314            Self::Usage(message) => write!(f, "{message}"),
315            Self::UnsupportedIntent {
316                requested,
317                supported,
318            } => write!(
319                f,
320                "unsupported intent '{requested}'. Supported intents: {}",
321                supported.join(", ")
322            ),
323        }
324    }
325}
326
327impl std::error::Error for AdvisorError {}
328
329#[derive(Clone, Debug, PartialEq, Eq)]
330struct GoalContext {
331    requested: String,
332    canonical: Option<String>,
333}
334
335pub fn supported_intents(catalog: &[CatalogEntry]) -> Vec<String> {
336    let mut seen = BTreeSet::new();
337    catalog
338        .iter()
339        .filter_map(|entry| {
340            if seen.insert(entry.intent.clone()) {
341                Some(entry.intent.clone())
342            } else {
343                None
344            }
345        })
346        .collect()
347}
348
349pub fn recommend(
350    catalog: &[CatalogEntry],
351    intent: &str,
352    goal: Option<&str>,
353    evidence: &EvidenceBundle,
354) -> Result<RecommendReport, AdvisorError> {
355    let supported = supported_intents(catalog);
356    let resolved_intent =
357        resolve_intent(catalog, intent).ok_or_else(|| AdvisorError::UnsupportedIntent {
358            requested: intent.to_string(),
359            supported,
360        })?;
361    let goal_context = goal.map(goal_context);
362    let mut matches: Vec<&CatalogEntry> = catalog
363        .iter()
364        .filter(|entry| normalize_key(&entry.intent) == normalize_key(&resolved_intent))
365        .collect();
366
367    matches.sort_by(|left, right| left.crate_name.cmp(&right.crate_name));
368    let mut ranked = build_ranked_recommendations(matches, goal_context.as_ref(), evidence);
369    ranked.sort_by(recommendation_sort);
370
371    let recommendation = ranked.remove(0);
372    let candidate_count = ranked.len() + 1;
373    let summary = build_recommend_summary(
374        &recommendation,
375        intent,
376        &resolved_intent,
377        goal_context.as_ref(),
378    );
379    let best_fit_sections = build_best_fit_sections(&recommendation, &ranked);
380
381    Ok(RecommendReport {
382        requested_intent: intent.to_string(),
383        intent: resolved_intent.clone(),
384        requested_goal: goal.map(ToOwned::to_owned),
385        goal: goal_context.as_ref().and_then(|goal| goal.canonical.clone()),
386        summary,
387        confidence: recommendation.confidence.clone(),
388        tradeoffs: recommendation.tradeoffs.clone(),
389        recommendation: recommendation.clone(),
390        alternatives: ranked,
391        best_fit_sections,
392        trust_notes: merge_trust_notes([
393            recommendation.trust_notes.clone(),
394            build_resolution_trust_notes(goal_context.as_ref()),
395            evidence.trust_notes.clone(),
396        ]),
397        receipts: merge_receipts([
398            recommendation.receipts.clone(),
399            build_resolution_receipts(intent, &resolved_intent, goal_context.as_ref()),
400            evidence.receipts.clone(),
401            vec![Receipt {
402                source: "catalog".to_string(),
403                summary: format!(
404                    "Matched {} curated candidates for intent '{}'.",
405                    candidate_count,
406                    resolved_intent
407                ),
408                detail:
409                    "Phase 2 still ranks only checked-in catalog entries and explicit command inputs."
410                        .to_string(),
411            }],
412        ]),
413    })
414}
415
416pub fn compare(
417    catalog: &[CatalogEntry],
418    requested_crates: &[String],
419    intent: Option<&str>,
420    evidence: &EvidenceBundle,
421) -> Result<CompareReport, AdvisorError> {
422    let resolved_intent = intent
423        .map(|requested| {
424            ensure_supported_intent(catalog, requested).map(|value| (requested.to_string(), value))
425        })
426        .transpose()?;
427
428    let mut ranked: Vec<Recommendation> = requested_crates
429        .iter()
430        .map(|crate_name| {
431            explain_candidate(
432                catalog,
433                crate_name,
434                resolved_intent
435                    .as_ref()
436                    .map(|(_, resolved)| resolved.as_str()),
437                evidence,
438            )
439        })
440        .collect();
441    ranked.sort_by(recommendation_sort);
442
443    let recommendation = ranked.remove(0);
444    let summary = match resolved_intent.as_ref() {
445        Some((requested, resolved)) if requested != resolved => format!(
446            "{} is the best current fit for {} (requested as '{}') among {}.",
447            recommendation.crate_name,
448            resolved,
449            requested,
450            requested_crates.join(", ")
451        ),
452        Some((_, resolved)) => format!(
453            "{} is the best current fit for {} among {}.",
454            recommendation.crate_name,
455            resolved,
456            requested_crates.join(", ")
457        ),
458        None => format!(
459            "{} has the strongest fit in the phase-2 catalog among {}.",
460            recommendation.crate_name,
461            requested_crates.join(", ")
462        ),
463    };
464
465    Ok(CompareReport {
466        requested_intent: resolved_intent.as_ref().map(|(requested, _)| requested.clone()),
467        intent: resolved_intent.as_ref().map(|(_, resolved)| resolved.clone()),
468        requested_crates: requested_crates.to_vec(),
469        summary,
470        confidence: recommendation.confidence.clone(),
471        tradeoffs: recommendation.tradeoffs.clone(),
472        recommendation: recommendation.clone(),
473        alternatives: ranked,
474        trust_notes: merge_trust_notes([
475            recommendation.trust_notes.clone(),
476            evidence.trust_notes.clone(),
477        ]),
478        receipts: merge_receipts([
479            recommendation.receipts.clone(),
480            resolved_intent
481                .as_ref()
482                .filter(|(requested, resolved)| requested != resolved)
483                .map(|(requested, resolved)| {
484                    vec![Receipt {
485                        source: "intent normalization".to_string(),
486                        summary: format!(
487                            "Normalized requested intent '{}' to '{}'.",
488                            requested, resolved
489                        ),
490                        detail:
491                            "Intent aliases are deterministic and map to the checked-in canonical taxonomy."
492                                .to_string(),
493                    }]
494                })
495                .unwrap_or_default(),
496            evidence.receipts.clone(),
497            vec![Receipt {
498                source: "catalog".to_string(),
499                summary: format!(
500                    "Compared {} requested crates against the local catalog.",
501                    requested_crates.len()
502                ),
503                detail: "No live registry or documentation sources were queried during compare."
504                    .to_string(),
505            }],
506        ]),
507    })
508}
509
510pub fn explain(
511    catalog: &[CatalogEntry],
512    crate_name: &str,
513    intent: Option<&str>,
514    evidence: &EvidenceBundle,
515) -> Result<ExplainReport, AdvisorError> {
516    let resolved_intent = intent
517        .map(|requested| {
518            ensure_supported_intent(catalog, requested).map(|value| (requested.to_string(), value))
519        })
520        .transpose()?;
521
522    let recommendation = explain_candidate(
523        catalog,
524        crate_name,
525        resolved_intent
526            .as_ref()
527            .map(|(_, resolved)| resolved.as_str()),
528        evidence,
529    );
530    let summary = match resolved_intent.as_ref() {
531        Some((requested, resolved)) if requested != resolved => format!(
532            "{} is evaluated against {} (requested as '{}') with {} confidence.",
533            recommendation.crate_name,
534            resolved,
535            requested,
536            recommendation.confidence.as_str()
537        ),
538        Some((_, resolved)) => format!(
539            "{} is evaluated against '{}' with {} confidence.",
540            recommendation.crate_name,
541            resolved,
542            recommendation.confidence.as_str()
543        ),
544        None => format!(
545            "{} is evaluated from the phase-2 curated catalog with {} confidence.",
546            recommendation.crate_name,
547            recommendation.confidence.as_str()
548        ),
549    };
550
551    Ok(ExplainReport {
552        requested_intent: resolved_intent.as_ref().map(|(requested, _)| requested.clone()),
553        intent: recommendation.intent.clone(),
554        summary,
555        confidence: recommendation.confidence.clone(),
556        tradeoffs: recommendation.tradeoffs.clone(),
557        recommendation: recommendation.clone(),
558        trust_notes: merge_trust_notes([
559            recommendation.trust_notes.clone(),
560            evidence.trust_notes.clone(),
561        ]),
562        receipts: merge_receipts([
563            recommendation.receipts.clone(),
564            resolved_intent
565                .as_ref()
566                .filter(|(requested, resolved)| requested != resolved)
567                .map(|(requested, resolved)| {
568                    vec![Receipt {
569                        source: "intent normalization".to_string(),
570                        summary: format!(
571                            "Normalized requested intent '{}' to '{}'.",
572                            requested, resolved
573                        ),
574                        detail:
575                            "Intent aliases are deterministic and map to the checked-in canonical taxonomy."
576                                .to_string(),
577                    }]
578                })
579                .unwrap_or_default(),
580            evidence.receipts.clone(),
581            vec![Receipt {
582                source: "catalog".to_string(),
583                summary: format!("Explained '{}' from the local curated catalog.", crate_name),
584                detail: "Phase 2 explain still does not pull fresh registry or security metadata."
585                    .to_string(),
586            }],
587        ]),
588    })
589}
590
591pub fn review(catalog: &[CatalogEntry], inputs: &ReviewInputs) -> ReviewReport {
592    let reviewed_manifests = build_reviewed_manifests(inputs);
593    let dependencies = dedup_strings(
594        inputs
595            .dependencies
596            .iter()
597            .map(|dependency| dependency.dependency_name.clone())
598            .collect(),
599    );
600    let lockfile_packages = inputs
601        .lockfile_contents
602        .as_deref()
603        .map(parse_lockfile_package_entries)
604        .unwrap_or_default();
605    let lockfile_summary = inputs.lockfile_contents.as_deref().map(summarize_lockfile);
606
607    let mut findings = Vec::new();
608    if inputs.manifest_contents.is_none() {
609        findings.push(ReviewFinding {
610            severity: FindingSeverity::Warning,
611            title: "Manifest missing".to_string(),
612            detail: format!(
613                "No manifest was loaded from '{}', so review could not inspect dependencies.",
614                inputs.manifest_path.display()
615            ),
616        });
617    }
618
619    if inputs.dependencies.is_empty() && inputs.manifest_contents.is_some() {
620        findings.push(ReviewFinding {
621            severity: FindingSeverity::Info,
622            title: "No dependencies found".to_string(),
623            detail:
624                "The local review did not detect direct dependencies in normal, dev, build, or target-specific dependency sections."
625                    .to_string(),
626        });
627    }
628
629    if inputs.lockfile_contents.is_none() {
630        findings.push(ReviewFinding {
631            severity: FindingSeverity::Info,
632            title: "Lockfile missing".to_string(),
633            detail: format!(
634                "No lockfile was loaded from '{}'; version-level review receipts are limited.",
635                inputs.lockfile_path.display()
636            ),
637        });
638    } else if lockfile_packages.is_empty() {
639        findings.push(ReviewFinding {
640            severity: FindingSeverity::Info,
641            title: "Lockfile parsed with no packages".to_string(),
642            detail: "The local lockfile did not yield package entries for version summarization."
643                .to_string(),
644        });
645    }
646
647    let overlap_findings = detect_overlap_findings(catalog, &inputs.dependencies);
648    let mut overlap_receipts = Vec::new();
649    let mut follow_up_recommendations = Vec::new();
650    let mut seen_recommendations = BTreeSet::new();
651    for overlap in overlap_findings {
652        findings.push(overlap.finding);
653        overlap_receipts.push(overlap.receipt);
654        if let Ok(report) = recommend(catalog, &overlap.intent, None, &EvidenceBundle::default()) {
655            let recommendation = report.recommendation;
656            if seen_recommendations.insert(recommendation.crate_name.clone()) {
657                follow_up_recommendations.push(recommendation);
658            }
659        }
660    }
661
662    if let Some(summary) = lockfile_summary.as_ref() {
663        let direct_dependency_names: BTreeSet<_> = dependencies.iter().cloned().collect();
664        let direct_duplicates: Vec<_> = summary
665            .duplicate_versions
666            .iter()
667            .filter(|duplicate| direct_dependency_names.contains(&duplicate.crate_name))
668            .cloned()
669            .collect();
670
671        if !direct_duplicates.is_empty() {
672            findings.push(ReviewFinding {
673                severity: FindingSeverity::Warning,
674                title: "Direct dependency version spread".to_string(),
675                detail: format!(
676                    "The lockfile carries multiple resolved versions for direct dependencies: {}.",
677                    format_duplicate_versions(&direct_duplicates)
678                ),
679            });
680        } else if !summary.duplicate_versions.is_empty() {
681            findings.push(ReviewFinding {
682                severity: FindingSeverity::Info,
683                title: "Lockfile version spread".to_string(),
684                detail: format!(
685                    "The lockfile shows multiple resolved versions for transitive crates: {}.",
686                    format_duplicate_versions(&summary.duplicate_versions)
687                ),
688            });
689        }
690    }
691
692    let mut receipts = inputs.evidence.receipts.clone();
693    if !reviewed_manifests.is_empty() {
694        receipts.push(Receipt {
695            source: "review manifests".to_string(),
696            summary: format!(
697                "Reviewed {} manifest(s) and {} direct dependency declarations.",
698                reviewed_manifests.len(),
699                inputs.dependencies.len()
700            ),
701            detail: format!(
702                "Manifests: {}",
703                reviewed_manifests
704                    .iter()
705                    .map(format_manifest_receipt)
706                    .collect::<Vec<_>>()
707                    .join("; ")
708            ),
709        });
710    }
711
712    if let Some(summary) = lockfile_summary.as_ref() {
713        receipts.push(Receipt {
714            source: "review lockfile".to_string(),
715            summary: format!(
716                "Parsed {} package entries from '{}'.",
717                summary.package_count,
718                inputs.lockfile_path.display()
719            ),
720            detail: if summary.duplicate_versions.is_empty() {
721                "No duplicate crate versions were observed in the local lockfile.".to_string()
722            } else {
723                format!(
724                    "Duplicate versions observed for: {}.",
725                    format_duplicate_versions(&summary.duplicate_versions)
726                )
727            },
728        });
729    }
730    receipts.extend(overlap_receipts);
731
732    if findings.is_empty() {
733        findings.push(ReviewFinding {
734            severity: FindingSeverity::Info,
735            title: "No consolidation hotspots".to_string(),
736            detail:
737                "The local review did not detect overlapping dependency families or lockfile version spread that needs follow-up."
738                    .to_string(),
739        });
740    }
741
742    let summary = if let Some(finding) = findings
743        .iter()
744        .find(|finding| finding.severity == FindingSeverity::Warning)
745    {
746        format!("Local review flagged a warning: {}.", finding.title)
747    } else {
748        format!(
749            "Local review scanned {} direct dependencies across {} manifest(s) without warning-level consolidation issues.",
750            dependencies.len(),
751            reviewed_manifests.len()
752        )
753    };
754
755    let recommendation = follow_up_recommendations.first().cloned();
756    let confidence = recommendation
757        .as_ref()
758        .map(|value| value.confidence.clone());
759    let tradeoffs = recommendation
760        .as_ref()
761        .map(|value| value.tradeoffs.clone())
762        .unwrap_or_default();
763
764    ReviewReport {
765        summary,
766        manifest_path: inputs.manifest_path.clone(),
767        lockfile_path: inputs.lockfile_path.clone(),
768        manifests: reviewed_manifests,
769        dependencies,
770        lockfile_summary,
771        findings,
772        recommendation,
773        confidence,
774        tradeoffs,
775        follow_up_recommendations,
776        trust_notes: merge_trust_notes([
777            inputs.evidence.trust_notes.clone(),
778            vec![TrustNote {
779                label: "phase-3 local review".to_string(),
780                detail:
781                    "Review uses local manifests, local cargo metadata, and local lockfile contents only; no live registry, docs, benchmark, or security sources were consulted."
782                        .to_string(),
783            }],
784        ]),
785        receipts,
786    }
787}
788
789pub fn parse_manifest_dependency_entries(
790    contents: &str,
791    manifest_path: &Path,
792    package_name: Option<&str>,
793) -> Vec<ManifestDependency> {
794    let mut dependencies = Vec::new();
795    let mut current_section = None;
796
797    for raw_line in contents.lines() {
798        let line = raw_line.trim();
799        if line.is_empty() || line.starts_with('#') {
800            continue;
801        }
802
803        if line.starts_with('[') && line.ends_with(']') {
804            current_section = parse_dependency_section(line);
805            if let Some(section) = current_section.as_ref() {
806                if let Some(dependency_name) = section.dependency_name.as_deref() {
807                    dependencies.push(ManifestDependency {
808                        manifest_path: manifest_path.to_path_buf(),
809                        package_name: package_name.map(ToOwned::to_owned),
810                        dependency_name: dependency_name.to_string(),
811                        declared_name: dependency_name.to_string(),
812                        kind: section.kind.clone(),
813                        target: section.target.clone(),
814                    });
815                }
816            }
817            continue;
818        }
819
820        let Some(section) = current_section.as_ref() else {
821            continue;
822        };
823        if section.dependency_name.is_some() {
824            continue;
825        }
826
827        let Some((raw_key, raw_value)) = line.split_once('=') else {
828            continue;
829        };
830        let key = raw_key.trim();
831        let declared_name = key.strip_suffix(".workspace").unwrap_or(key).trim();
832        if !is_valid_dependency_key(declared_name) {
833            continue;
834        }
835
836        dependencies.push(ManifestDependency {
837            manifest_path: manifest_path.to_path_buf(),
838            package_name: package_name.map(ToOwned::to_owned),
839            dependency_name: inline_package_name(raw_value.trim())
840                .unwrap_or_else(|| declared_name.to_string()),
841            declared_name: declared_name.to_string(),
842            kind: section.kind.clone(),
843            target: section.target.clone(),
844        });
845    }
846
847    dedup_manifest_dependencies(dependencies)
848}
849
850pub fn parse_manifest_dependencies(contents: &str) -> Vec<String> {
851    dedup_strings(
852        parse_manifest_dependency_entries(contents, Path::new("Cargo.toml"), None)
853            .into_iter()
854            .map(|dependency| dependency.dependency_name)
855            .collect(),
856    )
857}
858
859pub fn parse_lockfile_packages(contents: &str) -> Vec<String> {
860    dedup_strings(
861        parse_lockfile_package_entries(contents)
862            .into_iter()
863            .map(|package| package.name)
864            .collect(),
865    )
866}
867
868#[derive(Clone, Debug, PartialEq, Eq)]
869struct DependencySection {
870    kind: ReviewDependencyKind,
871    target: Option<String>,
872    dependency_name: Option<String>,
873}
874
875#[derive(Clone, Debug, PartialEq, Eq)]
876struct LockfilePackage {
877    name: String,
878    version: String,
879}
880
881#[derive(Clone, Debug, PartialEq, Eq)]
882struct OverlapFinding {
883    intent: String,
884    finding: ReviewFinding,
885    receipt: Receipt,
886}
887
888fn build_reviewed_manifests(inputs: &ReviewInputs) -> Vec<ReviewedManifest> {
889    let mut dependency_counts = BTreeMap::new();
890    for dependency in &inputs.dependencies {
891        *dependency_counts
892            .entry(dependency.manifest_path.clone())
893            .or_insert(0usize) += 1;
894    }
895
896    let mut seen = BTreeSet::new();
897    let mut manifests = Vec::new();
898    for manifest in &inputs.manifests {
899        if seen.insert(manifest.manifest_path.clone()) {
900            manifests.push(ReviewedManifest {
901                manifest_path: manifest.manifest_path.clone(),
902                package_name: manifest.package_name.clone(),
903                is_root: manifest.is_root,
904                dependency_count: dependency_counts
905                    .get(&manifest.manifest_path)
906                    .copied()
907                    .unwrap_or_default(),
908            });
909        }
910    }
911
912    if manifests.is_empty() && inputs.manifest_contents.is_some() {
913        manifests.push(ReviewedManifest {
914            manifest_path: inputs.manifest_path.clone(),
915            package_name: None,
916            is_root: true,
917            dependency_count: inputs.dependencies.len(),
918        });
919    }
920
921    manifests.sort_by(|left, right| {
922        right
923            .is_root
924            .cmp(&left.is_root)
925            .then_with(|| left.manifest_path.cmp(&right.manifest_path))
926    });
927    manifests
928}
929
930fn format_manifest_receipt(manifest: &ReviewedManifest) -> String {
931    let role = if manifest.is_root {
932        "review root"
933    } else {
934        manifest
935            .package_name
936            .as_deref()
937            .unwrap_or("workspace member")
938    };
939    format!(
940        "{} at '{}' ({} direct dependencies)",
941        role,
942        manifest.manifest_path.display(),
943        manifest.dependency_count
944    )
945}
946
947fn parse_dependency_section(header: &str) -> Option<DependencySection> {
948    let section = header.trim_matches(&['[', ']'][..]);
949    let (kind, target, dependency_name) = if section == "dependencies" {
950        (ReviewDependencyKind::Normal, None, None)
951    } else if section == "dev-dependencies" {
952        (ReviewDependencyKind::Dev, None, None)
953    } else if section == "build-dependencies" {
954        (ReviewDependencyKind::Build, None, None)
955    } else if let Some(dependency_name) = section.strip_prefix("dependencies.") {
956        (
957            ReviewDependencyKind::Normal,
958            None,
959            Some(dependency_name.to_string()),
960        )
961    } else if let Some(dependency_name) = section.strip_prefix("dev-dependencies.") {
962        (
963            ReviewDependencyKind::Dev,
964            None,
965            Some(dependency_name.to_string()),
966        )
967    } else if let Some(dependency_name) = section.strip_prefix("build-dependencies.") {
968        (
969            ReviewDependencyKind::Build,
970            None,
971            Some(dependency_name.to_string()),
972        )
973    } else if let Some(rest) = section.strip_prefix("target.") {
974        if let Some((target, suffix)) = rest.rsplit_once(".dependencies.") {
975            (
976                ReviewDependencyKind::Normal,
977                Some(target.to_string()),
978                Some(suffix.to_string()),
979            )
980        } else if let Some((target, suffix)) = rest.rsplit_once(".dev-dependencies.") {
981            (
982                ReviewDependencyKind::Dev,
983                Some(target.to_string()),
984                Some(suffix.to_string()),
985            )
986        } else if let Some((target, suffix)) = rest.rsplit_once(".build-dependencies.") {
987            (
988                ReviewDependencyKind::Build,
989                Some(target.to_string()),
990                Some(suffix.to_string()),
991            )
992        } else if let Some(target) = rest.strip_suffix(".dependencies") {
993            (ReviewDependencyKind::Normal, Some(target.to_string()), None)
994        } else if let Some(target) = rest.strip_suffix(".dev-dependencies") {
995            (ReviewDependencyKind::Dev, Some(target.to_string()), None)
996        } else if let Some(target) = rest.strip_suffix(".build-dependencies") {
997            (ReviewDependencyKind::Build, Some(target.to_string()), None)
998        } else {
999            return None;
1000        }
1001    } else {
1002        return None;
1003    };
1004
1005    Some(DependencySection {
1006        kind,
1007        target,
1008        dependency_name,
1009    })
1010}
1011
1012fn is_valid_dependency_key(value: &str) -> bool {
1013    !value.is_empty()
1014        && value.chars().all(|character| {
1015            character.is_ascii_alphanumeric() || character == '-' || character == '_'
1016        })
1017}
1018
1019fn inline_package_name(value: &str) -> Option<String> {
1020    let (_, tail) = value.split_once("package")?;
1021    let (_, tail) = tail.split_once('"')?;
1022    let (package_name, _) = tail.split_once('"')?;
1023    if is_valid_dependency_key(package_name) {
1024        Some(package_name.to_string())
1025    } else {
1026        None
1027    }
1028}
1029
1030fn dedup_manifest_dependencies(dependencies: Vec<ManifestDependency>) -> Vec<ManifestDependency> {
1031    let mut seen = BTreeSet::new();
1032    let mut deduped = Vec::new();
1033    for dependency in dependencies {
1034        let key = (
1035            dependency.manifest_path.clone(),
1036            dependency.package_name.clone(),
1037            dependency.dependency_name.clone(),
1038            dependency.declared_name.clone(),
1039            dependency.kind.label().to_string(),
1040            dependency.target.clone(),
1041        );
1042        if seen.insert(key) {
1043            deduped.push(dependency);
1044        }
1045    }
1046    deduped
1047}
1048
1049fn parse_lockfile_package_entries(contents: &str) -> Vec<LockfilePackage> {
1050    let mut packages = Vec::new();
1051    let mut in_package = false;
1052    let mut current_name = None;
1053    let mut current_version = None;
1054
1055    for raw_line in contents.lines() {
1056        let line = raw_line.trim();
1057        if line == "[[package]]" {
1058            if in_package {
1059                if let (Some(name), Some(version)) = (current_name.take(), current_version.take()) {
1060                    packages.push(LockfilePackage { name, version });
1061                }
1062            }
1063            in_package = true;
1064            current_name = None;
1065            current_version = None;
1066            continue;
1067        }
1068
1069        if !in_package {
1070            continue;
1071        }
1072
1073        if let Some(value) = line.strip_prefix("name = \"") {
1074            if let Some(value) = value.strip_suffix('"') {
1075                current_name = Some(value.to_string());
1076            }
1077            continue;
1078        }
1079
1080        if let Some(value) = line.strip_prefix("version = \"") {
1081            if let Some(value) = value.strip_suffix('"') {
1082                current_version = Some(value.to_string());
1083            }
1084        }
1085    }
1086
1087    if let (Some(name), Some(version)) = (current_name, current_version) {
1088        packages.push(LockfilePackage { name, version });
1089    }
1090
1091    packages
1092}
1093
1094fn summarize_lockfile(contents: &str) -> LockfileSummary {
1095    let packages = parse_lockfile_package_entries(contents);
1096    let mut versions_by_crate: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
1097    for package in &packages {
1098        versions_by_crate
1099            .entry(package.name.clone())
1100            .or_default()
1101            .insert(package.version.clone());
1102    }
1103
1104    let duplicate_versions = versions_by_crate
1105        .into_iter()
1106        .filter_map(|(crate_name, versions)| {
1107            if versions.len() > 1 {
1108                Some(LockfileDuplicateVersion {
1109                    crate_name,
1110                    versions: versions.into_iter().collect(),
1111                })
1112            } else {
1113                None
1114            }
1115        })
1116        .collect();
1117
1118    LockfileSummary {
1119        package_count: packages.len(),
1120        duplicate_versions,
1121    }
1122}
1123
1124fn format_duplicate_versions(duplicates: &[LockfileDuplicateVersion]) -> String {
1125    duplicates
1126        .iter()
1127        .map(|duplicate| {
1128            format!(
1129                "{} ({})",
1130                duplicate.crate_name,
1131                duplicate.versions.join(", ")
1132            )
1133        })
1134        .collect::<Vec<_>>()
1135        .join("; ")
1136}
1137
1138fn detect_overlap_findings(
1139    catalog: &[CatalogEntry],
1140    dependencies: &[ManifestDependency],
1141) -> Vec<OverlapFinding> {
1142    let mut by_intent: BTreeMap<String, BTreeMap<String, Vec<&ManifestDependency>>> =
1143        BTreeMap::new();
1144
1145    for dependency in dependencies {
1146        if let Some(entry) = catalog.iter().find(|entry| {
1147            normalize_key(&entry.crate_name) == normalize_key(&dependency.dependency_name)
1148        }) {
1149            by_intent
1150                .entry(entry.intent.clone())
1151                .or_default()
1152                .entry(entry.crate_name.clone())
1153                .or_default()
1154                .push(dependency);
1155        }
1156    }
1157
1158    let mut overlaps = Vec::new();
1159    for (intent, crates) in by_intent {
1160        if !should_flag_overlap(&intent, &crates) {
1161            continue;
1162        }
1163
1164        let crate_names: Vec<_> = crates.keys().cloned().collect();
1165        let severity = overlap_severity(&crates);
1166        let migration_target = default_migration_target(catalog, &intent);
1167        let detail = format!(
1168            "{} Consolidate toward {}.",
1169            format_overlap_detail(&intent, &crates),
1170            migration_target
1171                .as_deref()
1172                .map(|target| migration_guidance(&intent, target))
1173                .unwrap_or_else(|| "one primary stack for this decision area".to_string())
1174        );
1175
1176        overlaps.push(OverlapFinding {
1177            intent: intent.clone(),
1178            finding: ReviewFinding {
1179                severity,
1180                title: format!("Overlapping stack: {intent}"),
1181                detail: detail.clone(),
1182            },
1183            receipt: Receipt {
1184                source: "review overlap".to_string(),
1185                summary: format!(
1186                    "Detected overlapping crates for '{}': {}.",
1187                    intent,
1188                    crate_names.join(", ")
1189                ),
1190                detail,
1191            },
1192        });
1193    }
1194
1195    overlaps
1196}
1197
1198fn should_flag_overlap(intent: &str, crates: &BTreeMap<String, Vec<&ManifestDependency>>) -> bool {
1199    if crates.len() <= 1 {
1200        return false;
1201    }
1202
1203    match intent {
1204        "error-handling" | "testing" => false,
1205        "logging-tracing" => {
1206            let crate_names: BTreeSet<_> = crates.keys().map(String::as_str).collect();
1207            crate_names != BTreeSet::from(["env_logger", "log"])
1208        }
1209        _ => true,
1210    }
1211}
1212
1213fn overlap_severity(crates: &BTreeMap<String, Vec<&ManifestDependency>>) -> FindingSeverity {
1214    if crates
1215        .values()
1216        .flatten()
1217        .any(|dependency| dependency.kind != ReviewDependencyKind::Dev)
1218    {
1219        FindingSeverity::Warning
1220    } else {
1221        FindingSeverity::Info
1222    }
1223}
1224
1225fn default_migration_target(catalog: &[CatalogEntry], intent: &str) -> Option<String> {
1226    catalog
1227        .iter()
1228        .filter(|entry| normalize_key(&entry.intent) == normalize_key(intent))
1229        .max_by(|left, right| {
1230            left.confidence
1231                .rank_score()
1232                .cmp(&right.confidence.rank_score())
1233                .then_with(|| {
1234                    left.archetype
1235                        .rank_bonus()
1236                        .cmp(&right.archetype.rank_bonus())
1237                })
1238        })
1239        .map(|entry| entry.crate_name.clone())
1240}
1241
1242fn migration_guidance(intent: &str, target: &str) -> String {
1243    match intent {
1244        "cli-parsing" => format!(
1245            "{target} so help text, completions, and parser behavior stay on one CLI surface"
1246        ),
1247        "config" => format!(
1248            "{target} so configuration layering and overrides follow one model across the project"
1249        ),
1250        "logging-tracing" => format!(
1251            "{target} as the primary telemetry stack unless a library intentionally keeps a facade-only `log` surface"
1252        ),
1253        "http-client" => {
1254            format!("{target} to avoid duplicating TLS, retry, middleware, and client ergonomics")
1255        }
1256        "http-server" => format!(
1257            "{target} so server/runtime integration, middleware, and handler style stay consistent"
1258        ),
1259        "serialization" => format!(
1260            "{target} as the main serialization boundary to reduce conversion and trait-surface churn"
1261        ),
1262        "async-runtime" => format!(
1263            "{target} so executors, timing primitives, and ecosystem integrations do not split"
1264        ),
1265        "database-access" => {
1266            format!("{target} so the query model and runtime/database integration stay on one path")
1267        }
1268        _ => "one primary stack for this decision area".to_string(),
1269    }
1270}
1271
1272fn format_overlap_detail(
1273    intent: &str,
1274    crates: &BTreeMap<String, Vec<&ManifestDependency>>,
1275) -> String {
1276    format!(
1277        "The review found multiple crates for '{}': {}.",
1278        intent,
1279        crates
1280            .iter()
1281            .map(|(crate_name, entries)| format!(
1282                "{crate_name} [{}]",
1283                format_dependency_locations(entries)
1284            ))
1285            .collect::<Vec<_>>()
1286            .join("; ")
1287    )
1288}
1289
1290fn format_dependency_locations(entries: &[&ManifestDependency]) -> String {
1291    let mut locations = Vec::new();
1292    let mut seen = BTreeSet::new();
1293    for entry in entries {
1294        let label = format!(
1295            "{}{}{}",
1296            entry.manifest_path.display(),
1297            match entry.package_name.as_deref() {
1298                Some(package_name) => format!(" ({package_name})"),
1299                None => String::new(),
1300            },
1301            match entry.target.as_deref() {
1302                Some(target) => format!(" [{} target {}]", entry.kind.label(), target),
1303                None => format!(" [{}]", entry.kind.label()),
1304            }
1305        );
1306        if seen.insert(label.clone()) {
1307            locations.push(label);
1308        }
1309    }
1310    locations.join(", ")
1311}
1312
1313fn build_recommend_summary(
1314    recommendation: &Recommendation,
1315    requested_intent: &str,
1316    resolved_intent: &str,
1317    goal: Option<&GoalContext>,
1318) -> String {
1319    let intent_clause = if requested_intent == resolved_intent {
1320        resolved_intent.to_string()
1321    } else {
1322        format!("{resolved_intent} (requested as '{requested_intent}')")
1323    };
1324
1325    match goal {
1326        Some(goal) => match goal.canonical.as_deref() {
1327            Some(canonical) if canonical != goal.requested => format!(
1328                "{} is the current best {} for {} when '{}' resolves to '{}'.",
1329                recommendation.crate_name,
1330                recommendation.archetype.label(),
1331                intent_clause,
1332                goal.requested,
1333                canonical
1334            ),
1335            Some(canonical) => format!(
1336                "{} is the current best {} for {} when the goal is '{}'.",
1337                recommendation.crate_name,
1338                recommendation.archetype.label(),
1339                intent_clause,
1340                canonical
1341            ),
1342            None => format!(
1343                "{} is the current best {} for {} using the raw goal '{}'.",
1344                recommendation.crate_name,
1345                recommendation.archetype.label(),
1346                intent_clause,
1347                goal.requested
1348            ),
1349        },
1350        None => format!(
1351            "{} is the current best {} for {} in the curated phase-2 catalog.",
1352            recommendation.crate_name,
1353            recommendation.archetype.label(),
1354            intent_clause
1355        ),
1356    }
1357}
1358
1359fn build_best_fit_sections(
1360    recommendation: &Recommendation,
1361    alternatives: &[Recommendation],
1362) -> Vec<BestFitSection> {
1363    let mut candidates = vec![recommendation.clone()];
1364    candidates.extend(alternatives.iter().cloned());
1365
1366    let views = [
1367        RecommendationArchetype::BestDefault,
1368        RecommendationArchetype::LeanOption,
1369        RecommendationArchetype::PowerOption,
1370    ];
1371
1372    let mut sections = Vec::new();
1373    for archetype in views {
1374        if let Some(best) = candidates
1375            .iter()
1376            .filter(|candidate| candidate.archetype == archetype)
1377            .max_by(|left, right| {
1378                left.score
1379                    .cmp(&right.score)
1380                    .then_with(|| right.confidence.cmp(&left.confidence))
1381                    .then_with(|| right.crate_name.cmp(&left.crate_name))
1382            })
1383            .cloned()
1384        {
1385            sections.push(BestFitSection {
1386                label: archetype.label().to_string(),
1387                summary: format!(
1388                    "{} currently leads the {} view.",
1389                    best.crate_name,
1390                    archetype.label()
1391                ),
1392                recommendation: best,
1393            });
1394        }
1395    }
1396    sections
1397}
1398
1399fn ensure_supported_intent(catalog: &[CatalogEntry], intent: &str) -> Result<String, AdvisorError> {
1400    let supported = supported_intents(catalog);
1401    resolve_intent(catalog, intent).ok_or_else(|| AdvisorError::UnsupportedIntent {
1402        requested: intent.to_string(),
1403        supported,
1404    })
1405}
1406
1407fn resolve_intent(catalog: &[CatalogEntry], intent: &str) -> Option<String> {
1408    let normalized = normalize_key(intent);
1409    let supported = supported_intents(catalog);
1410    supported
1411        .iter()
1412        .find(|supported_intent| normalize_key(supported_intent) == normalized)
1413        .cloned()
1414        .or_else(|| {
1415            intent_aliases().iter().find_map(|(alias, canonical)| {
1416                if normalized == *alias {
1417                    supported
1418                        .iter()
1419                        .find(|supported_intent| normalize_key(supported_intent) == *canonical)
1420                        .cloned()
1421                } else {
1422                    None
1423                }
1424            })
1425        })
1426}
1427
1428fn explain_candidate(
1429    catalog: &[CatalogEntry],
1430    crate_name: &str,
1431    requested_intent: Option<&str>,
1432    evidence: &EvidenceBundle,
1433) -> Recommendation {
1434    if let Some(entry) = catalog.iter().find(|entry| {
1435        normalize_key(&entry.crate_name) == normalize_key(crate_name)
1436            && requested_intent
1437                .map(|intent| normalize_key(&entry.intent) == normalize_key(intent))
1438                .unwrap_or(true)
1439    }) {
1440        score_entry(entry, None, evidence)
1441    } else if let Some(entry) = catalog
1442        .iter()
1443        .find(|entry| normalize_key(&entry.crate_name) == normalize_key(crate_name))
1444    {
1445        let mut recommendation = score_entry(entry, None, evidence);
1446        if let Some(intent) = requested_intent {
1447            recommendation.confidence = Confidence::Low;
1448            recommendation.summary = format!(
1449                "{} is cataloged for {}, not {}.",
1450                entry.crate_name, entry.intent, intent
1451            );
1452            recommendation.fit_notes.push(format!(
1453                "Requested intent '{}' does not match the curated '{}' entry.",
1454                intent, entry.intent
1455            ));
1456            recommendation.tradeoffs.push(Tradeoff {
1457                area: "intent mismatch".to_string(),
1458                detail: format!(
1459                    "The crate is curated under '{}' instead of the requested '{}'.",
1460                    entry.intent, intent
1461                ),
1462            });
1463            recommendation.trust_notes.push(TrustNote {
1464                label: "curated mismatch".to_string(),
1465                detail: "The catalog found the crate, but not under the requested intent."
1466                    .to_string(),
1467            });
1468            recommendation.score -= 25;
1469        }
1470        recommendation
1471    } else {
1472        Recommendation {
1473            crate_name: crate_name.to_string(),
1474            intent: requested_intent.unwrap_or("uncurated").to_string(),
1475            summary: format!(
1476                "{} is not in the phase-2 curated catalog, so explain is limited.",
1477                crate_name
1478            ),
1479            confidence: Confidence::Low,
1480            archetype: RecommendationArchetype::Specialist,
1481            rationale: vec![
1482                "The crate name was provided explicitly.".to_string(),
1483                "No checked-in catalog entry matched it.".to_string(),
1484            ],
1485            fit_notes: vec!["No curated fit profile was available for this crate.".to_string()],
1486            tradeoffs: vec![Tradeoff {
1487                area: "coverage".to_string(),
1488                detail: "Phase 2 still does not perform live registry, docs, or security lookups."
1489                    .to_string(),
1490            }],
1491            trust_notes: merge_trust_notes([
1492                vec![TrustNote {
1493                    label: "catalog gap".to_string(),
1494                    detail:
1495                        "This result is a bounded fallback because the crate is outside the local curated catalog."
1496                            .to_string(),
1497                }],
1498                evidence.trust_notes.clone(),
1499            ]),
1500            receipts: merge_receipts([
1501                vec![Receipt {
1502                    source: "catalog".to_string(),
1503                    summary: format!("No curated entry matched '{}'.", crate_name),
1504                    detail: "The explain fallback was generated without live external evidence."
1505                        .to_string(),
1506                }],
1507                evidence.receipts.clone(),
1508            ]),
1509            score: 30,
1510        }
1511    }
1512}
1513
1514fn build_ranked_recommendations(
1515    entries: Vec<&CatalogEntry>,
1516    goal: Option<&GoalContext>,
1517    evidence: &EvidenceBundle,
1518) -> Vec<Recommendation> {
1519    entries
1520        .into_iter()
1521        .map(|entry| score_entry(entry, goal, evidence))
1522        .collect()
1523}
1524
1525fn score_entry(
1526    entry: &CatalogEntry,
1527    goal: Option<&GoalContext>,
1528    evidence: &EvidenceBundle,
1529) -> Recommendation {
1530    let mut score = entry.confidence.rank_score() + entry.archetype.rank_bonus();
1531    let mut rationale = entry.rationale.clone();
1532    let mut fit_notes = vec![format!(
1533        "Curated as the {} for {}.",
1534        entry.archetype.label(),
1535        entry.intent
1536    )];
1537
1538    if let Some(goal) = goal {
1539        match goal.canonical.as_deref() {
1540            Some(canonical) => {
1541                if let Some(goal_fit) = entry
1542                    .goal_fits
1543                    .iter()
1544                    .find(|goal_fit| normalize_key(&goal_fit.goal) == normalize_key(canonical))
1545                {
1546                    score += goal_fit.strength.score_delta();
1547                    fit_notes.push(format!(
1548                        "Goal '{}' normalized to '{}' and {} for {}.",
1549                        goal.requested,
1550                        canonical,
1551                        goal_fit.strength.summary(),
1552                        entry.crate_name
1553                    ));
1554                    fit_notes.push(goal_fit.detail.clone());
1555                    rationale.push(format!(
1556                        "The '{}' goal semantics materially influence {} toward this choice.",
1557                        canonical, entry.crate_name
1558                    ));
1559                } else {
1560                    fit_notes.push(format!(
1561                        "Goal '{}' normalized to '{}', but this entry has no explicit curated boost for it.",
1562                        goal.requested, canonical
1563                    ));
1564                }
1565            }
1566            None => {
1567                fit_notes.push(format!(
1568                    "Goal '{}' was kept as free text because it did not map to the curated phase-2 goal vocabulary.",
1569                    goal.requested
1570                ));
1571            }
1572        }
1573    }
1574
1575    Recommendation {
1576        crate_name: entry.crate_name.clone(),
1577        intent: entry.intent.clone(),
1578        summary: entry.summary.clone(),
1579        confidence: entry.confidence.clone(),
1580        archetype: entry.archetype.clone(),
1581        rationale,
1582        fit_notes,
1583        tradeoffs: entry.tradeoffs.clone(),
1584        trust_notes: merge_trust_notes([entry.trust_notes.clone(), evidence.trust_notes.clone()]),
1585        receipts: merge_receipts([
1586            vec![Receipt {
1587                source: "catalog".to_string(),
1588                summary: format!(
1589                    "Used the curated '{}' entry for '{}'.",
1590                    entry.intent, entry.crate_name
1591                ),
1592                detail: entry.summary.clone(),
1593            }],
1594            evidence.receipts.clone(),
1595        ]),
1596        score,
1597    }
1598}
1599
1600fn goal_context(value: &str) -> GoalContext {
1601    GoalContext {
1602        requested: value.to_string(),
1603        canonical: resolve_goal(value),
1604    }
1605}
1606
1607fn resolve_goal(value: &str) -> Option<String> {
1608    let normalized = normalize_key(value);
1609    goal_aliases().iter().find_map(|(alias, canonical)| {
1610        if normalized == *alias {
1611            Some((*canonical).to_string())
1612        } else {
1613            None
1614        }
1615    })
1616}
1617
1618fn build_resolution_receipts(
1619    requested_intent: &str,
1620    resolved_intent: &str,
1621    goal: Option<&GoalContext>,
1622) -> Vec<Receipt> {
1623    let mut receipts = Vec::new();
1624    if requested_intent != resolved_intent {
1625        receipts.push(Receipt {
1626            source: "intent normalization".to_string(),
1627            summary: format!(
1628                "Normalized requested intent '{}' to '{}'.",
1629                requested_intent, resolved_intent
1630            ),
1631            detail:
1632                "Intent aliases are deterministic and map to the checked-in canonical taxonomy."
1633                    .to_string(),
1634        });
1635    }
1636
1637    if let Some(goal) = goal {
1638        match goal.canonical.as_deref() {
1639            Some(canonical) if canonical != goal.requested => receipts.push(Receipt {
1640                source: "goal normalization".to_string(),
1641                summary: format!(
1642                    "Normalized requested goal '{}' to '{}'.",
1643                    goal.requested, canonical
1644                ),
1645                detail:
1646                    "Goal normalization uses a small checked-in phase-2 vocabulary instead of live ecosystem signals."
1647                        .to_string(),
1648            }),
1649            Some(canonical) => receipts.push(Receipt {
1650                source: "goal normalization".to_string(),
1651                summary: format!("Goal '{}' matched the curated goal vocabulary.", canonical),
1652                detail:
1653                    "Scoring used the canonical goal semantics defined in the checked-in catalog."
1654                        .to_string(),
1655            }),
1656            None => receipts.push(Receipt {
1657                source: "goal normalization".to_string(),
1658                summary: format!(
1659                    "Goal '{}' did not map to the curated goal vocabulary.",
1660                    goal.requested
1661                ),
1662                detail:
1663                    "No semantic score boost was invented beyond the deterministic checked-in catalog."
1664                        .to_string(),
1665            }),
1666        }
1667    }
1668
1669    receipts
1670}
1671
1672fn build_resolution_trust_notes(goal: Option<&GoalContext>) -> Vec<TrustNote> {
1673    match goal {
1674        Some(goal) if goal.canonical.is_none() => vec![TrustNote {
1675            label: "goal boundary".to_string(),
1676            detail:
1677                "The goal text stayed as free text because it did not map to the curated phase-2 goal vocabulary."
1678                    .to_string(),
1679        }],
1680        _ => Vec::new(),
1681    }
1682}
1683
1684fn recommendation_sort(left: &Recommendation, right: &Recommendation) -> std::cmp::Ordering {
1685    right
1686        .score
1687        .cmp(&left.score)
1688        .then_with(|| left.confidence.cmp(&right.confidence))
1689        .then_with(|| left.crate_name.cmp(&right.crate_name))
1690}
1691
1692fn normalize_key(value: &str) -> String {
1693    let mut normalized = String::new();
1694    let mut previous_dash = false;
1695    for character in value.chars().flat_map(|character| character.to_lowercase()) {
1696        if character.is_ascii_alphanumeric() {
1697            normalized.push(character);
1698            previous_dash = false;
1699        } else if !previous_dash {
1700            normalized.push('-');
1701            previous_dash = true;
1702        }
1703    }
1704    normalized.trim_matches('-').to_string()
1705}
1706
1707fn intent_aliases() -> &'static [(&'static str, &'static str)] {
1708    &[
1709        ("cli", "cli-parsing"),
1710        ("cli-parser", "cli-parsing"),
1711        ("args", "cli-parsing"),
1712        ("argument-parsing", "cli-parsing"),
1713        ("configuration", "config"),
1714        ("settings", "config"),
1715        ("telemetry", "logging-tracing"),
1716        ("logging", "logging-tracing"),
1717        ("tracing", "logging-tracing"),
1718        ("http", "http-client"),
1719        ("http-client", "http-client"),
1720        ("api-client", "http-client"),
1721        ("rest-client", "http-client"),
1722        ("http-api", "http-server"),
1723        ("rest-api", "http-server"),
1724        ("web-api", "http-server"),
1725        ("web-server", "http-server"),
1726        ("serde", "serialization"),
1727        ("json", "serialization"),
1728        ("encoding", "serialization"),
1729        ("async", "async-runtime"),
1730        ("runtime", "async-runtime"),
1731        ("executor", "async-runtime"),
1732        ("errors", "error-handling"),
1733        ("error", "error-handling"),
1734        ("diagnostics", "error-handling"),
1735        ("tests", "testing"),
1736        ("snapshots", "testing"),
1737        ("db", "database-access"),
1738        ("database", "database-access"),
1739        ("orm", "database-access"),
1740        ("sql", "database-access"),
1741    ]
1742}
1743
1744fn goal_aliases() -> &'static [(&'static str, &'static str)] {
1745    &[
1746        ("default", "boring-default"),
1747        ("boring", "boring-default"),
1748        ("safe-default", "boring-default"),
1749        ("conventional", "boring-default"),
1750        ("minimal", "minimal-footprint"),
1751        ("small", "minimal-footprint"),
1752        ("small-binary", "minimal-footprint"),
1753        ("lightweight", "minimal-footprint"),
1754        ("lean", "minimal-footprint"),
1755        ("footprint", "minimal-footprint"),
1756        ("control", "maximum-control"),
1757        ("power", "maximum-control"),
1758        ("custom", "maximum-control"),
1759        ("low-level", "maximum-control"),
1760        ("maximum-control", "maximum-control"),
1761        ("ship", "fastest-to-ship"),
1762        ("quick", "fastest-to-ship"),
1763        ("quickly", "fastest-to-ship"),
1764        ("ergonomic", "fastest-to-ship"),
1765        ("productive", "fastest-to-ship"),
1766        ("derive", "fastest-to-ship"),
1767        ("blocking", "blocking"),
1768        ("sync", "blocking"),
1769        ("synchronous", "blocking"),
1770        ("async", "async"),
1771        ("non-blocking", "async"),
1772        ("tokio", "async"),
1773        ("typed", "typed-surfaces"),
1774        ("typed-api", "typed-surfaces"),
1775        ("explicit", "typed-surfaces"),
1776        ("library", "typed-surfaces"),
1777        ("diagnostics", "rich-diagnostics"),
1778        ("reports", "rich-diagnostics"),
1779        ("observability", "rich-diagnostics"),
1780        ("property", "property-coverage"),
1781        ("properties", "property-coverage"),
1782        ("randomized", "property-coverage"),
1783        ("layered", "layered-config"),
1784        ("providers", "layered-config"),
1785        ("merge", "layered-config"),
1786        ("env-and-files", "layered-config"),
1787    ]
1788}
1789
1790fn merge_trust_notes<const N: usize>(groups: [Vec<TrustNote>; N]) -> Vec<TrustNote> {
1791    let mut seen = BTreeSet::new();
1792    let mut merged = Vec::new();
1793    for group in groups {
1794        for note in group {
1795            let key = format!("{}::{}", note.label, note.detail);
1796            if seen.insert(key) {
1797                merged.push(note);
1798            }
1799        }
1800    }
1801    merged
1802}
1803
1804fn merge_receipts<const N: usize>(groups: [Vec<Receipt>; N]) -> Vec<Receipt> {
1805    let mut seen = BTreeSet::new();
1806    let mut merged = Vec::new();
1807    for group in groups {
1808        for receipt in group {
1809            let key = format!(
1810                "{}::{}::{}",
1811                receipt.source, receipt.summary, receipt.detail
1812            );
1813            if seen.insert(key) {
1814                merged.push(receipt);
1815            }
1816        }
1817    }
1818    merged
1819}
1820
1821fn dedup_strings(values: Vec<String>) -> Vec<String> {
1822    let mut seen = BTreeSet::new();
1823    values
1824        .into_iter()
1825        .filter(|value| seen.insert(value.clone()))
1826        .collect()
1827}
1828
1829#[cfg(test)]
1830mod tests {
1831    use super::*;
1832
1833    fn sample_catalog() -> Vec<CatalogEntry> {
1834        vec![
1835            CatalogEntry {
1836                crate_name: "clap".to_string(),
1837                intent: "cli-parsing".to_string(),
1838                summary: "Default pick for rich CLIs.".to_string(),
1839                rationale: vec!["Strong derive support.".to_string()],
1840                goal_fits: vec![
1841                    GoalFit {
1842                        goal: "fastest-to-ship".to_string(),
1843                        strength: GoalFitStrength::Strong,
1844                        detail: "Derive-first ergonomics keep full-featured CLI work fast."
1845                            .to_string(),
1846                    },
1847                    GoalFit {
1848                        goal: "boring-default".to_string(),
1849                        strength: GoalFitStrength::Strong,
1850                        detail: "It is the lowest-risk default for most application CLIs."
1851                            .to_string(),
1852                    },
1853                ],
1854                tradeoffs: vec![Tradeoff {
1855                    area: "compile time".to_string(),
1856                    detail: "Feature-rich setup increases compile cost.".to_string(),
1857                }],
1858                trust_notes: vec![TrustNote {
1859                    label: "catalog".to_string(),
1860                    detail: "Curated locally.".to_string(),
1861                }],
1862                confidence: Confidence::High,
1863                archetype: RecommendationArchetype::BestDefault,
1864            },
1865            CatalogEntry {
1866                crate_name: "argh".to_string(),
1867                intent: "cli-parsing".to_string(),
1868                summary: "Small and direct for simple CLIs.".to_string(),
1869                rationale: vec!["Good for minimal argument surfaces.".to_string()],
1870                goal_fits: vec![GoalFit {
1871                    goal: "minimal-footprint".to_string(),
1872                    strength: GoalFitStrength::Strong,
1873                    detail: "It keeps parser surface and dependency weight low.".to_string(),
1874                }],
1875                tradeoffs: vec![Tradeoff {
1876                    area: "breadth".to_string(),
1877                    detail: "Smaller feature surface than clap.".to_string(),
1878                }],
1879                trust_notes: vec![],
1880                confidence: Confidence::Medium,
1881                archetype: RecommendationArchetype::LeanOption,
1882            },
1883            CatalogEntry {
1884                crate_name: "bpaf".to_string(),
1885                intent: "cli-parsing".to_string(),
1886                summary: "Combinator-first for precise parser control.".to_string(),
1887                rationale: vec!["Good for bespoke argument flows.".to_string()],
1888                goal_fits: vec![GoalFit {
1889                    goal: "maximum-control".to_string(),
1890                    strength: GoalFitStrength::Strong,
1891                    detail: "The composable parser model keeps more behavior explicit.".to_string(),
1892                }],
1893                tradeoffs: vec![Tradeoff {
1894                    area: "learning curve".to_string(),
1895                    detail: "Less familiar than derive-heavy CLIs.".to_string(),
1896                }],
1897                trust_notes: vec![],
1898                confidence: Confidence::Medium,
1899                archetype: RecommendationArchetype::PowerOption,
1900            },
1901        ]
1902    }
1903
1904    #[test]
1905    fn recommend_normalizes_intent_aliases() {
1906        let report = recommend(&sample_catalog(), "cli", None, &EvidenceBundle::default())
1907            .expect("recommend should succeed");
1908
1909        assert_eq!(report.intent, "cli-parsing");
1910        assert_eq!(report.recommendation.crate_name, "clap");
1911        assert!(
1912            report
1913                .receipts
1914                .iter()
1915                .any(|receipt| receipt.source == "intent normalization")
1916        );
1917    }
1918
1919    #[test]
1920    fn recommend_uses_canonical_goal_semantics() {
1921        let report = recommend(
1922            &sample_catalog(),
1923            "cli parsing",
1924            Some("small binary"),
1925            &EvidenceBundle::default(),
1926        )
1927        .expect("recommend should succeed");
1928
1929        assert_eq!(report.goal.as_deref(), Some("minimal-footprint"));
1930        assert_eq!(report.recommendation.crate_name, "argh");
1931        assert!(
1932            report
1933                .recommendation
1934                .fit_notes
1935                .iter()
1936                .any(|line| line.contains("minimal-footprint"))
1937        );
1938    }
1939
1940    #[test]
1941    fn recommend_exposes_best_fit_sections() {
1942        let report = recommend(
1943            &sample_catalog(),
1944            "cli-parsing",
1945            None,
1946            &EvidenceBundle::default(),
1947        )
1948        .expect("recommend should succeed");
1949
1950        assert_eq!(report.best_fit_sections.len(), 3);
1951        assert_eq!(report.best_fit_sections[1].label, "lean option");
1952        assert_eq!(
1953            report.best_fit_sections[2].recommendation.crate_name,
1954            "bpaf"
1955        );
1956    }
1957
1958    #[test]
1959    fn parse_manifest_dependencies_reads_dependency_sections() {
1960        let manifest = r#"
1961            [package]
1962            name = "demo"
1963
1964            [dependencies]
1965            clap = "4"
1966            serde = { version = "1", features = ["derive"] }
1967
1968            [dev-dependencies]
1969            insta = "1"
1970        "#;
1971
1972        assert_eq!(
1973            parse_manifest_dependencies(manifest),
1974            vec!["clap".to_string(), "serde".to_string(), "insta".to_string()]
1975        );
1976    }
1977
1978    #[test]
1979    fn parse_manifest_dependency_entries_captures_targets_and_renames() {
1980        let manifest = r#"
1981            [package]
1982            name = "demo"
1983
1984            [dependencies]
1985            http = { package = "reqwest", version = "0.12" }
1986
1987            [target.'cfg(unix)'.build-dependencies]
1988            cc = "1"
1989
1990            [dev-dependencies.insta]
1991            version = "1"
1992        "#;
1993
1994        let dependencies =
1995            parse_manifest_dependency_entries(manifest, Path::new("Cargo.toml"), Some("demo"));
1996
1997        assert!(dependencies.iter().any(|dependency| {
1998            dependency.dependency_name == "reqwest"
1999                && dependency.declared_name == "http"
2000                && dependency.kind == ReviewDependencyKind::Normal
2001        }));
2002        assert!(dependencies.iter().any(|dependency| {
2003            dependency.dependency_name == "cc"
2004                && dependency.kind == ReviewDependencyKind::Build
2005                && dependency.target.as_deref() == Some("'cfg(unix)'")
2006        }));
2007        assert!(dependencies.iter().any(|dependency| {
2008            dependency.dependency_name == "insta" && dependency.kind == ReviewDependencyKind::Dev
2009        }));
2010    }
2011
2012    #[test]
2013    fn parse_lockfile_packages_reads_package_names() {
2014        let lockfile = r#"
2015            [[package]]
2016            name = "clap"
2017            version = "4.0.0"
2018
2019            [[package]]
2020            name = "serde"
2021            version = "1.0.0"
2022        "#;
2023
2024        assert_eq!(
2025            parse_lockfile_packages(lockfile),
2026            vec!["clap".to_string(), "serde".to_string()]
2027        );
2028    }
2029
2030    #[test]
2031    fn review_flags_direct_dependency_version_spread() {
2032        let report = review(
2033            &sample_catalog(),
2034            &ReviewInputs {
2035                manifest_path: PathBuf::from("Cargo.toml"),
2036                manifest_contents: Some("[package]\nname = \"demo\"\n".to_string()),
2037                lockfile_path: PathBuf::from("Cargo.lock"),
2038                lockfile_contents: Some(
2039                    r#"
2040                        version = 3
2041
2042                        [[package]]
2043                        name = "reqwest"
2044                        version = "0.11.27"
2045
2046                        [[package]]
2047                        name = "reqwest"
2048                        version = "0.12.15"
2049                    "#
2050                    .to_string(),
2051                ),
2052                manifests: vec![LoadedManifest {
2053                    manifest_path: PathBuf::from("Cargo.toml"),
2054                    package_name: Some("demo".to_string()),
2055                    is_root: true,
2056                }],
2057                dependencies: vec![ManifestDependency {
2058                    manifest_path: PathBuf::from("Cargo.toml"),
2059                    package_name: Some("demo".to_string()),
2060                    dependency_name: "reqwest".to_string(),
2061                    declared_name: "reqwest".to_string(),
2062                    kind: ReviewDependencyKind::Normal,
2063                    target: None,
2064                }],
2065                evidence: EvidenceBundle::default(),
2066            },
2067        );
2068
2069        assert_eq!(report.findings[0].title, "Direct dependency version spread");
2070        assert_eq!(
2071            report.lockfile_summary.as_ref().unwrap().duplicate_versions[0].crate_name,
2072            "reqwest"
2073        );
2074    }
2075}