Skip to main content

seshat_cli/report/
conventions.rs

1//! Conventions Detected and Next Steps sections of the scan report.
2//!
3//! Displays aggregated convention findings with confidence tiers, trend
4//! indicators, and actionable next steps for the user.
5
6use owo_colors::OwoColorize;
7use seshat_core::Trend;
8use seshat_detectors::AggregatedConvention;
9
10use crate::format::{self, ConfidenceTier, Verbosity, styled_tier_bullet};
11use crate::report::ReportData;
12
13/// Maximum number of conventions shown in default (non-verbose) mode.
14const DEFAULT_TOP_N: usize = 10;
15
16/// Print the Conventions Detected section.
17///
18/// ```text
19/// ── Conventions Detected (42) ────────────────────────────────
20///   ● High (12)  ◐ Medium (18)  ○ Low (12)
21///
22///   ● snake_case function naming        ↑  98%  (naming)
23///   ● thiserror for error types         ─  92%  (error_handling)
24///   ◐ ESM module system                 ↑  75%  (export_patterns)
25///   ...
26/// ```
27pub fn print_conventions(data: &ReportData, verbosity: Verbosity, color: bool) {
28    let conventions = &data.conventions;
29    if conventions.is_empty() {
30        return;
31    }
32
33    // Sort by confidence descending, then alphabetically by description
34    // for stable ordering within the same tier.
35    let mut sorted: Vec<&AggregatedConvention> = conventions.iter().collect();
36    sorted.sort_by(|a, b| {
37        b.confidence
38            .partial_cmp(&a.confidence)
39            .unwrap_or(std::cmp::Ordering::Equal)
40            .then_with(|| a.description.cmp(&b.description))
41    });
42
43    // Section header with count.
44    let header_title = format!("Conventions Detected ({})", conventions.len());
45    eprintln!("{}", format::format_section_header(&header_title, color),);
46
47    // Tier summary line.
48    let (high, medium, low) = count_tiers(conventions);
49    print_tier_summary(high, medium, low, color);
50
51    eprintln!();
52
53    // Top findings list.
54    let limit = if verbosity.show_verbose() {
55        sorted.len()
56    } else {
57        sorted.len().min(DEFAULT_TOP_N)
58    };
59
60    // Compute the column width for descriptions so that % and detector
61    // align vertically. Cap at 60 to avoid excessively wide lines.
62    let max_desc_width = sorted
63        .iter()
64        .take(limit)
65        .map(|c| c.description.len())
66        .max()
67        .unwrap_or(40)
68        .clamp(30, 60);
69
70    for conv in sorted.iter().take(limit) {
71        print_convention_line(conv, color, max_desc_width);
72    }
73
74    // Show "... and N more" hint in default mode when truncated.
75    let remaining = sorted.len().saturating_sub(limit);
76    if remaining > 0 {
77        if color {
78            eprintln!(
79                "  {} and {} more — use --verbose to see all",
80                "...".dimmed(),
81                remaining,
82            );
83        } else {
84            eprintln!("  ... and {remaining} more — use --verbose to see all");
85        }
86    }
87
88    eprintln!();
89}
90
91/// Print the Next Steps section.
92///
93/// ```text
94/// ── Next Steps ───────────────────────────────────────────────
95///   Run `seshat review` to validate detected conventions
96///   Run `seshat serve` to start MCP server
97///   Run `seshat init` to generate MCP config
98/// ```
99pub fn print_next_steps(color: bool) {
100    eprintln!("{}", format::format_section_header("Next Steps", color));
101
102    let steps = [
103        "Run `seshat review` to validate detected conventions",
104        "Run `seshat serve` to start MCP server",
105        "Run `seshat init` to generate MCP config",
106    ];
107
108    for step in &steps {
109        if color {
110            eprintln!("  {}", step.dimmed());
111        } else {
112            eprintln!("  {step}");
113        }
114    }
115
116    eprintln!();
117}
118
119/// Count conventions in each confidence tier.
120fn count_tiers(conventions: &[AggregatedConvention]) -> (usize, usize, usize) {
121    let mut high = 0;
122    let mut medium = 0;
123    let mut low = 0;
124
125    for conv in conventions {
126        match ConfidenceTier::from_confidence(conv.confidence * 100.0) {
127            ConfidenceTier::High => high += 1,
128            ConfidenceTier::Medium => medium += 1,
129            ConfidenceTier::Low => low += 1,
130        }
131    }
132
133    (high, medium, low)
134}
135
136/// Print the tier summary line with Unicode bullets.
137fn print_tier_summary(high: usize, medium: usize, low: usize, color: bool) {
138    let parts: Vec<String> = [
139        (high, "High", ConfidenceTier::High),
140        (medium, "Medium", ConfidenceTier::Medium),
141        (low, "Low", ConfidenceTier::Low),
142    ]
143    .iter()
144    .filter(|(count, _, _)| *count > 0)
145    .map(|(count, label, tier)| format::format_tier_bullet(label, *count, *tier, color))
146    .collect();
147
148    if !parts.is_empty() {
149        eprintln!("  {}", parts.join("  "));
150    }
151}
152
153/// Format a trend indicator as a single character.
154///
155/// - `↑` Rising
156/// - `─` Stable
157/// - `↓` Declining
158/// - ` ` Unknown (space)
159fn trend_indicator(trend: Trend) -> &'static str {
160    match trend {
161        Trend::Rising => "\u{2191}",    // ↑
162        Trend::Stable => "\u{2500}",    // ─
163        Trend::Declining => "\u{2193}", // ↓
164        Trend::Unknown => " ",
165    }
166}
167
168/// Print a single convention finding line.
169///
170/// Format: `  ● description                ↑  98%  (detector_name)`
171///
172/// `desc_width` controls the column width for the description field
173/// so that the percentage and detector name align vertically.
174fn print_convention_line(conv: &AggregatedConvention, color: bool, desc_width: usize) {
175    let tier = ConfidenceTier::from_confidence(conv.confidence * 100.0);
176    let bullet = styled_tier_bullet(tier, color);
177
178    let pct = (conv.confidence * 100.0).round() as u32;
179    let trend = trend_indicator(conv.trend);
180    let detector = &conv.detector_name;
181
182    // Truncate description if it exceeds the column width.
183    // Use char-aware truncation to avoid slicing into multi-byte UTF-8
184    // characters (e.g., '→' is 3 bytes).
185    let desc = if conv.description.len() > desc_width {
186        let mut end = desc_width.saturating_sub(3);
187        while end > 0 && !conv.description.is_char_boundary(end) {
188            end -= 1;
189        }
190        format!("{}...", &conv.description[..end])
191    } else {
192        conv.description.clone()
193    };
194
195    if color {
196        eprintln!(
197            "  {bullet} {desc:<width$} {trend} {pct:>3}%  ({})",
198            detector.dimmed(),
199            width = desc_width,
200        );
201    } else {
202        eprintln!(
203            "  {bullet} {desc:<width$} {trend} {pct:>3}%  ({detector})",
204            width = desc_width,
205        );
206    }
207}
208
209// ══════════════════════════════════════════════════════════════════════
210// Tests
211// ══════════════════════════════════════════════════════════════════════
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use seshat_core::{KnowledgeNature, KnowledgeWeight};
217
218    fn make_convention(
219        description: &str,
220        detector_name: &str,
221        confidence: f64,
222        trend: Trend,
223    ) -> AggregatedConvention {
224        AggregatedConvention {
225            detector_name: detector_name.to_owned(),
226            description: description.to_owned(),
227            nature: KnowledgeNature::Convention,
228            adoption_count: (confidence * 10.0) as u32,
229            total_count: 10,
230            confidence,
231            weight: KnowledgeWeight::Strong,
232            evidence: vec![],
233            trend,
234        }
235    }
236
237    fn make_report_data_with_conventions(conventions: Vec<AggregatedConvention>) -> ReportData {
238        ReportData {
239            language_breakdown: vec![],
240            total_files: 10,
241            total_dependencies: 0,
242            dependency_breakdown: vec![],
243            conventions,
244            files_discovered: 10,
245            files_parsed: 10,
246            nodes_persisted: 0,
247            edges_persisted: 0,
248            manifests_analyzed: 0,
249            docs_ingested: 0,
250            db_path: std::path::PathBuf::from("/tmp/test.db"),
251            db_size: 12_400_000,
252            elapsed: std::time::Duration::from_secs(2),
253            excluded_submodules: vec![],
254            submodules_excluded_by_flag: false,
255        }
256    }
257
258    // ── count_tiers ──────────────────────────────────────────────────
259
260    #[test]
261    fn test_count_tiers_empty() {
262        let (high, medium, low) = count_tiers(&[]);
263        assert_eq!((high, medium, low), (0, 0, 0));
264    }
265
266    #[test]
267    fn test_count_tiers_mixed() {
268        let conventions = vec![
269            make_convention("a", "d1", 0.95, Trend::Rising), // high (95%)
270            make_convention("b", "d2", 0.70, Trend::Stable), // medium (70%)
271            make_convention("c", "d3", 0.30, Trend::Unknown), // low (30%)
272            make_convention("d", "d4", 0.90, Trend::Declining), // high (90%)
273        ];
274        let (high, medium, low) = count_tiers(&conventions);
275        assert_eq!(high, 2);
276        assert_eq!(medium, 1);
277        assert_eq!(low, 1);
278    }
279
280    #[test]
281    fn test_count_tiers_boundary_85_percent() {
282        // 85% is medium, not high (ConfidenceTier::from_confidence uses > 85)
283        let conventions = vec![make_convention("a", "d1", 0.85, Trend::Stable)];
284        let (high, medium, _low) = count_tiers(&conventions);
285        assert_eq!(high, 0);
286        assert_eq!(medium, 1);
287    }
288
289    #[test]
290    fn test_count_tiers_boundary_50_percent() {
291        // 50% is medium (ConfidenceTier::from_confidence uses >= 50)
292        let conventions = vec![make_convention("a", "d1", 0.50, Trend::Stable)];
293        let (_high, medium, low) = count_tiers(&conventions);
294        assert_eq!(medium, 1);
295        assert_eq!(low, 0);
296    }
297
298    // ── trend_indicator ──────────────────────────────────────────────
299
300    #[test]
301    fn test_trend_indicator_rising() {
302        assert_eq!(trend_indicator(Trend::Rising), "\u{2191}"); // ↑
303    }
304
305    #[test]
306    fn test_trend_indicator_stable() {
307        assert_eq!(trend_indicator(Trend::Stable), "\u{2500}"); // ─
308    }
309
310    #[test]
311    fn test_trend_indicator_declining() {
312        assert_eq!(trend_indicator(Trend::Declining), "\u{2193}"); // ↓
313    }
314
315    #[test]
316    fn test_trend_indicator_unknown() {
317        assert_eq!(trend_indicator(Trend::Unknown), " ");
318    }
319
320    // ── print_conventions ────────────────────────────────────────────
321
322    #[test]
323    fn test_print_conventions_empty_does_not_panic() {
324        let data = make_report_data_with_conventions(vec![]);
325        // Should early-return without printing anything.
326        print_conventions(&data, Verbosity::Default, false);
327    }
328
329    #[test]
330    fn test_print_conventions_default_mode_does_not_panic() {
331        let conventions = (0..15)
332            .map(|i| {
333                make_convention(
334                    &format!("convention_{i}"),
335                    "detector",
336                    0.95 - (i as f64 * 0.05),
337                    Trend::Stable,
338                )
339            })
340            .collect();
341        let data = make_report_data_with_conventions(conventions);
342        // Default mode: should show at most DEFAULT_TOP_N (10) and "... and N more".
343        print_conventions(&data, Verbosity::Default, false);
344    }
345
346    #[test]
347    fn test_print_conventions_verbose_shows_all() {
348        let conventions = (0..15)
349            .map(|i| {
350                make_convention(
351                    &format!("convention_{i}"),
352                    "detector",
353                    0.95 - (i as f64 * 0.05),
354                    Trend::Rising,
355                )
356            })
357            .collect();
358        let data = make_report_data_with_conventions(conventions);
359        // Verbose mode should show all 15 without truncation.
360        print_conventions(&data, Verbosity::Verbose, false);
361    }
362
363    #[test]
364    fn test_print_conventions_with_color_does_not_panic() {
365        let conventions = vec![
366            make_convention("snake_case naming", "naming", 0.98, Trend::Rising),
367            make_convention("thiserror usage", "error_handling", 0.72, Trend::Stable),
368            make_convention(
369                "test file placement",
370                "test_patterns",
371                0.30,
372                Trend::Declining,
373            ),
374        ];
375        let data = make_report_data_with_conventions(conventions);
376        print_conventions(&data, Verbosity::Default, true);
377    }
378
379    #[test]
380    fn test_print_conventions_quiet_mode() {
381        let conventions = vec![make_convention("a", "d", 0.90, Trend::Stable)];
382        let data = make_report_data_with_conventions(conventions);
383        // Quiet mode — findings hidden, but print_conventions is only called
384        // when show_findings() is true, so this just verifies no panic.
385        print_conventions(&data, Verbosity::Quiet, false);
386    }
387
388    // ── print_next_steps ─────────────────────────────────────────────
389
390    #[test]
391    fn test_print_next_steps_no_color() {
392        print_next_steps(false);
393    }
394
395    #[test]
396    fn test_print_next_steps_with_color() {
397        print_next_steps(true);
398    }
399}