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}