11use anyhow:: Result ;
2- use core_engine:: { ReviewAnalyzer , ReviewFinding , Severity } ;
2+ use core_engine:: { DevicePreference , ReviewAnalyzer , ReviewFinding , Severity } ;
33use indicatif:: { ProgressBar , ProgressStyle } ;
44use std:: {
55 collections:: HashSet ,
@@ -83,6 +83,16 @@ async fn main() -> Result<()> {
8383 Ok ( ( ) )
8484}
8585
86+ // ─── Device helpers ──────────────────────────────────────────────────────────
87+
88+ fn parse_device ( s : & str ) -> DevicePreference {
89+ match s. to_lowercase ( ) . as_str ( ) {
90+ "metal" => DevicePreference :: Metal ,
91+ "cpu" => DevicePreference :: Cpu ,
92+ _ => DevicePreference :: Auto ,
93+ }
94+ }
95+
8696// ─── Severity helpers ────────────────────────────────────────────────────────
8797
8898fn parse_severity ( s : & str ) -> Severity {
@@ -262,14 +272,15 @@ async fn run_static(
262272 // ── RAG context ───────────────────────────────────────────────────────────
263273 let index = Indexer :: load ( project_root) ;
264274 let mut context = String :: new ( ) ;
265- if let Some ( idx) = index {
266- if let Some ( rag_text) = rag:: get_rag_context ( diff, & idx) {
267- context = rag_text ;
268- }
275+ if let Some ( idx) = index
276+ && let Some ( rag_text) = rag:: get_rag_context ( diff, & idx)
277+ {
278+ context = rag_text ;
269279 }
270280
271281 // ── Build analyzer ────────────────────────────────────────────────────────
272- let mut analyzer = ReviewAnalyzer :: new ( & model_bytes, & tokenizer_bytes)
282+ let device_pref = parse_device ( & args. device ) ;
283+ let mut analyzer = ReviewAnalyzer :: new_with_device ( & model_bytes, & tokenizer_bytes, device_pref)
273284 . map_err ( |e| anyhow:: anyhow!( e. to_string( ) ) ) ?
274285 . with_languages ( langs)
275286 . with_debug ( args. debug ) ;
@@ -304,7 +315,7 @@ async fn run_static(
304315 } ) ;
305316
306317 let pb = spinner. clone ( ) ;
307- let ( all_findings , skipped) = analyzer
318+ let ( summary , skipped) = analyzer
308319 . analyze_diff_chunked_with_progress ( diff, & context, args. max_tokens , move |done, total| {
309320 * chunk_label. lock ( ) . unwrap ( ) = format ! ( "chunk {}/{}" , done, total) ;
310321 pb. set_message ( format ! ( "Analyzing chunk {}/{}..." , done, total) ) ;
@@ -323,39 +334,47 @@ async fn run_static(
323334 ) ;
324335 }
325336
326- // ── Filter to threshold ───────────────────────────────────────────────────
327- let findings: Vec < & ReviewFinding > = all_findings
337+ // ── Filter findings to threshold ──────────────────────────────────────────
338+ let findings: Vec < & ReviewFinding > = summary
339+ . findings
328340 . iter ( )
329341 . filter ( |f| meets_threshold ( & f. severity , & min_severity) )
330342 . collect ( ) ;
331343
332- if findings. is_empty ( ) {
333- if skipped > 0 {
334- eprintln ! ( " ? No parseable findings — try `--model 3b` for better output quality." ) ;
335- } else {
336- eprintln ! ( " ✓ No issues found." ) ;
337- }
338- eprintln ! ( ) ;
339- return Ok ( false ) ;
340- }
341-
342344 match args. format {
343345 cli:: OutputFormat :: Json => {
344- // Emit a clean JSON array — pipe-friendly for CI dashboards
345- let json = serde_json:: to_string_pretty ( & findings)
346- . map_err ( |e| anyhow:: anyhow!( e. to_string( ) ) ) ?;
346+ // Emit the full summary as JSON — pipe-friendly for CI dashboards
347+ let out = serde_json:: json!( {
348+ "findings" : findings,
349+ "positives" : summary. positives,
350+ "suggestions" : summary. suggestions,
351+ } ) ;
352+ let json =
353+ serde_json:: to_string_pretty ( & out) . map_err ( |e| anyhow:: anyhow!( e. to_string( ) ) ) ?;
347354 println ! ( "{}" , json) ;
348355 }
349356 cli:: OutputFormat :: Text => {
350357 println ! ( ) ;
351- for ( i, f) in findings. iter ( ) . enumerate ( ) {
352- print_finding ( f, i + 1 , findings. len ( ) ) ;
358+ if findings. is_empty ( ) {
359+ if skipped > 0 {
360+ eprintln ! (
361+ " ? No parseable findings — try `--model 3b` for better output quality."
362+ ) ;
363+ } else {
364+ use crossterm:: style:: Stylize ;
365+ eprintln ! ( " {} No issues found." , "✓" . green( ) . bold( ) ) ;
366+ }
367+ } else {
368+ for ( i, f) in findings. iter ( ) . enumerate ( ) {
369+ print_finding ( f, i + 1 , findings. len ( ) ) ;
370+ }
371+ print_summary ( findings. len ( ) , skipped) ;
353372 }
354- print_summary ( findings . len ( ) , skipped ) ;
373+ print_positives_and_suggestions ( & summary . positives , & summary . suggestions ) ;
355374 }
356375 }
357376
358- Ok ( true )
377+ Ok ( !findings . is_empty ( ) )
359378}
360379
361380// ─── Coloured finding renderer ────────────────────────────────────────────────
@@ -478,19 +497,45 @@ fn print_summary(count: usize, skipped: usize) {
478497 eprintln ! ( ) ;
479498}
480499
500+ fn print_positives_and_suggestions ( positives : & [ String ] , suggestions : & [ String ] ) {
501+ if positives. is_empty ( ) && suggestions. is_empty ( ) {
502+ return ;
503+ }
504+
505+ if !positives. is_empty ( ) {
506+ eprintln ! ( " {}" , "─" . repeat( 62 ) . dark_grey( ) ) ;
507+ eprintln ! ( " {} What looks good" , "✓" . green( ) . bold( ) ) ;
508+ for p in positives {
509+ eprintln ! ( " {} {}" , "·" . green( ) , p) ;
510+ }
511+ eprintln ! ( ) ;
512+ }
513+
514+ if !suggestions. is_empty ( ) {
515+ if positives. is_empty ( ) {
516+ eprintln ! ( " {}" , "─" . repeat( 62 ) . dark_grey( ) ) ;
517+ }
518+ eprintln ! ( " 💡 Suggestions" ) ;
519+ for s in suggestions {
520+ eprintln ! ( " {} {}" , "·" . dark_yellow( ) , s) ;
521+ }
522+ eprintln ! ( ) ;
523+ }
524+ }
525+
481526// ─── TUI runner ───────────────────────────────────────────────────────────────
482527
483528use crossterm:: {
484529 event:: { self , DisableMouseCapture , EnableMouseCapture , Event , KeyCode } ,
485530 execute,
486- terminal:: { disable_raw_mode , enable_raw_mode , EnterAlternateScreen , LeaveAlternateScreen } ,
531+ terminal:: { EnterAlternateScreen , LeaveAlternateScreen , disable_raw_mode , enable_raw_mode } ,
487532} ;
488533use ratatui:: {
534+ Frame , Terminal ,
489535 backend:: { Backend , CrosstermBackend } ,
490536 layout:: { Constraint , Direction , Layout } ,
491537 style:: { Color , Modifier , Style } ,
492538 widgets:: { Block , Borders , List , ListItem , ListState , Paragraph , Wrap } ,
493- Frame , Terminal ,
494539} ;
495540
496541struct App {
@@ -543,57 +588,60 @@ async fn run_tui(
543588 res
544589}
545590
546- async fn tui_loop < B : Backend > ( terminal : & mut Terminal < B > , app : Arc < Mutex < App > > ) -> Result < ( ) > {
591+ async fn tui_loop < B : Backend > ( terminal : & mut Terminal < B > , app : Arc < Mutex < App > > ) -> Result < ( ) >
592+ where
593+ B :: Error : Send + Sync + ' static ,
594+ {
547595 loop {
548596 {
549597 let mut app_lock = app. lock ( ) . await ;
550598 terminal. draw ( |f| ui ( f, & mut app_lock) ) ?;
551599 }
552600
553- if event:: poll ( Duration :: from_millis ( 100 ) ) ? {
554- if let Event :: Key ( key) = event:: read ( ) ? {
555- let mut app_lock = app . lock ( ) . await ;
556- match key . code {
557- KeyCode :: Char ( 'q' ) => return Ok ( ( ) ) ,
558- KeyCode :: Down | KeyCode :: Char ( 'j ' ) => {
559- let i = match app_lock . state . selected ( ) {
560- Some ( i ) if ! app_lock. findings . is_empty ( ) => {
561- ( i + 1 ) % app_lock. findings . len ( )
562- }
563- _ => 0 ,
564- } ;
565- app_lock . state . select ( Some ( i ) ) ;
566- }
567- KeyCode :: Up | KeyCode :: Char ( 'k' ) => {
568- let i = match app_lock . state . selected ( ) {
569- Some ( i ) if ! app_lock. findings . is_empty ( ) => {
570- if i == 0 {
571- app_lock . findings . len ( ) - 1
572- } else {
573- i - 1
574- }
601+ if event:: poll ( Duration :: from_millis ( 100 ) ) ?
602+ && let Event :: Key ( key) = event:: read ( ) ?
603+ {
604+ let mut app_lock = app . lock ( ) . await ;
605+ match key . code {
606+ KeyCode :: Char ( 'q ' ) => return Ok ( ( ) ) ,
607+ KeyCode :: Down | KeyCode :: Char ( 'j' ) => {
608+ let i = match app_lock. state . selected ( ) {
609+ Some ( i ) if ! app_lock. findings . is_empty ( ) => {
610+ ( i + 1 ) % app_lock . findings . len ( )
611+ }
612+ _ => 0 ,
613+ } ;
614+ app_lock . state . select ( Some ( i ) ) ;
615+ }
616+ KeyCode :: Up | KeyCode :: Char ( 'k' ) => {
617+ let i = match app_lock. state . selected ( ) {
618+ Some ( i ) if !app_lock . findings . is_empty ( ) => {
619+ if i == 0 {
620+ app_lock . findings . len ( ) - 1
621+ } else {
622+ i - 1
575623 }
576- _ => 0 ,
577- } ;
578- app_lock. state . select ( Some ( i) ) ;
579- }
580- KeyCode :: Char ( 'a' ) => {
581- if !app_lock. analyzing {
582- app_lock. analyzing = true ;
583- app_lock. status = "Analyzing..." . to_string ( ) ;
584- let app_clone = Arc :: clone ( & app) ;
585- tokio:: spawn ( async move {
586- let app_err = Arc :: clone ( & app_clone) ;
587- if let Err ( e) = background_analysis ( app_clone) . await {
588- let mut app = app_err. lock ( ) . await ;
589- app. status = format ! ( "Error: {}" , e) ;
590- app. analyzing = false ;
591- }
592- } ) ;
593624 }
625+ _ => 0 ,
626+ } ;
627+ app_lock. state . select ( Some ( i) ) ;
628+ }
629+ KeyCode :: Char ( 'a' ) => {
630+ if !app_lock. analyzing {
631+ app_lock. analyzing = true ;
632+ app_lock. status = "Analyzing..." . to_string ( ) ;
633+ let app_clone = Arc :: clone ( & app) ;
634+ tokio:: spawn ( async move {
635+ let app_err = Arc :: clone ( & app_clone) ;
636+ if let Err ( e) = background_analysis ( app_clone) . await {
637+ let mut app = app_err. lock ( ) . await ;
638+ app. status = format ! ( "Error: {}" , e) ;
639+ app. analyzing = false ;
640+ }
641+ } ) ;
594642 }
595- _ => { }
596643 }
644+ _ => { }
597645 }
598646 }
599647 }
@@ -619,28 +667,29 @@ async fn background_analysis(app: Arc<Mutex<App>>) -> Result<()> {
619667
620668 let index = Indexer :: load ( & project_root) ;
621669 let mut context = String :: new ( ) ;
622- if let Some ( idx) = index {
623- if let Some ( rag_text) = rag:: get_rag_context ( & diff, & idx) {
624- context = rag_text ;
625- }
670+ if let Some ( idx) = index
671+ && let Some ( rag_text) = rag:: get_rag_context ( & diff, & idx)
672+ {
673+ context = rag_text ;
626674 }
627675
628676 let langs = detect_languages ( & diff) ;
629- let mut analyzer = ReviewAnalyzer :: new ( & model_bytes, & tokenizer_bytes)
630- . map_err ( |e| anyhow:: anyhow!( e. to_string( ) ) ) ?
631- . with_languages ( langs) ;
677+ let mut analyzer =
678+ ReviewAnalyzer :: new_with_device ( & model_bytes, & tokenizer_bytes, DevicePreference :: Auto )
679+ . map_err ( |e| anyhow:: anyhow!( e. to_string( ) ) ) ?
680+ . with_languages ( langs) ;
632681
633682 if let Some ( req) = ticket {
634683 analyzer = analyzer. with_requirements ( req) ;
635684 }
636685
637- let findings = analyzer
686+ let summary = analyzer
638687 . analyze_diff_chunked ( & diff, & context, 1024 )
639688 . map_err ( |e| anyhow:: anyhow!( e. to_string( ) ) ) ?;
640689
641690 let mut app_lock = app. lock ( ) . await ;
642- let count = findings. len ( ) ;
643- app_lock. findings = findings;
691+ let count = summary . findings . len ( ) ;
692+ app_lock. findings = summary . findings ;
644693 app_lock. status = format ! (
645694 "Done — {} finding{}" ,
646695 count,
0 commit comments