Skip to main content

cargo_mate/captain/
tide.rs

1use anyhow::{Context, Result};
2use chrono::{DateTime, Timelike, Utc};
3use colored::Colorize;
4use crossterm::{
5    event::{self, Event, KeyCode},
6    terminal::{
7        disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
8    },
9    ExecutableCommand,
10};
11use ratatui::{
12    backend::CrosstermBackend, layout::{Alignment, Constraint, Direction, Layout, Rect},
13    style::{Color, Modifier, Style},
14    symbols,
15    widgets::{
16        Axis, BarChart, Block, Borders, Chart, Dataset, Gauge, List, ListItem, Paragraph,
17        Sparkline, Tabs,
18    },
19    Frame, Terminal,
20};
21use serde::{Deserialize, Serialize};
22use std::collections::HashMap;
23use std::fs;
24use std::io;
25use std::path::PathBuf;
26use std::process::Command;
27use crate::license;
28#[derive(Debug, Serialize, Deserialize, Clone)]
29pub struct BuildMetrics {
30    pub timestamp: DateTime<Utc>,
31    pub command: String,
32    pub duration_seconds: f64,
33    pub success: bool,
34    pub error_count: usize,
35    pub warning_count: usize,
36    pub incremental: bool,
37    pub profile: String,
38    pub features: Vec<String>,
39    pub dependencies_compiled: usize,
40    pub crate_units_compiled: usize,
41    pub memory_peak_mb: Option<f64>,
42    pub cpu_usage_percent: Option<f64>,
43}
44#[derive(Debug, Serialize, Deserialize)]
45pub struct DependencyMetrics {
46    pub name: String,
47    pub version: String,
48    pub compile_time_seconds: f64,
49    pub size_bytes: u64,
50    pub features: Vec<String>,
51}
52#[derive(Debug, Serialize, Deserialize)]
53pub struct TideData {
54    pub builds: Vec<BuildMetrics>,
55    pub dependencies: HashMap<String, DependencyMetrics>,
56    pub daily_summary: HashMap<String, DailySummary>,
57}
58#[derive(Debug, Serialize, Deserialize, Clone)]
59pub struct DailySummary {
60    pub date: String,
61    pub total_builds: usize,
62    pub successful_builds: usize,
63    pub failed_builds: usize,
64    pub total_time_seconds: f64,
65    pub avg_build_time: f64,
66    pub total_errors: usize,
67    pub total_warnings: usize,
68}
69pub struct TideCharts {
70    data: TideData,
71    data_file: PathBuf,
72    selected_tab: usize,
73}
74impl TideCharts {
75    pub fn new() -> Result<Self> {
76        let data_file = dirs::home_dir()
77            .context("Could not find home directory")?
78            .join(".shipwreck")
79            .join("tide_data.json");
80        let data = if data_file.exists() {
81            let content = fs::read_to_string(&data_file)?;
82            serde_json::from_str(&content).unwrap_or_default()
83        } else {
84            TideData::default()
85        };
86        Ok(Self {
87            data,
88            data_file,
89            selected_tab: 0,
90        })
91    }
92    pub fn record_build(&mut self, metrics: BuildMetrics) -> Result<()> {
93        self.data.builds.push(metrics.clone());
94        let date = metrics.timestamp.date_naive().to_string();
95        let summary = self
96            .data
97            .daily_summary
98            .entry(date.clone())
99            .or_insert(DailySummary {
100                date,
101                total_builds: 0,
102                successful_builds: 0,
103                failed_builds: 0,
104                total_time_seconds: 0.0,
105                avg_build_time: 0.0,
106                total_errors: 0,
107                total_warnings: 0,
108            });
109        summary.total_builds += 1;
110        if metrics.success {
111            summary.successful_builds += 1;
112        } else {
113            summary.failed_builds += 1;
114        }
115        summary.total_time_seconds += metrics.duration_seconds;
116        summary.avg_build_time = summary.total_time_seconds
117            / summary.total_builds as f64;
118        summary.total_errors += metrics.error_count;
119        summary.total_warnings += metrics.warning_count;
120        if self.data.builds.len() > 10000 {
121            self.data.builds = self
122                .data
123                .builds[self.data.builds.len() - 10000..]
124                .to_vec();
125        }
126        self.save()?;
127        Ok(())
128    }
129    pub fn analyze_dependencies(&mut self) -> Result<()> {
130        println!("🔍 Analyzing dependency compile times...");
131        let output = Command::new("cargo").args(&["build", "--timings"]).output()?;
132        if output.status.success() {
133            println!(
134                "✅ Timing data collected. Check target/cargo-timings/ for detailed report."
135            );
136        }
137        let metadata = cargo_metadata::MetadataCommand::new().exec()?;
138        for package in metadata.packages {
139            if package.source.is_some() {
140                let dep_metrics = DependencyMetrics {
141                    name: package.name.clone(),
142                    version: package.version.to_string(),
143                    compile_time_seconds: 0.0,
144                    size_bytes: 0,
145                    features: package.features.keys().cloned().collect(),
146                };
147                self.data.dependencies.insert(package.name, dep_metrics);
148            }
149        }
150        self.save()?;
151        Ok(())
152    }
153    pub fn show_interactive(&mut self) -> Result<()> {
154        enable_raw_mode()?;
155        let mut stdout = io::stdout();
156        stdout.execute(EnterAlternateScreen)?;
157        let backend = CrosstermBackend::new(stdout);
158        let mut terminal = Terminal::new(backend)?;
159        let res = self.run_interactive(&mut terminal);
160        disable_raw_mode()?;
161        terminal.backend_mut().execute(LeaveAlternateScreen)?;
162        terminal.show_cursor()?;
163        res
164    }
165    fn run_interactive<B: ratatui::backend::Backend>(
166        &mut self,
167        terminal: &mut Terminal<B>,
168    ) -> Result<()> {
169        loop {
170            terminal.draw(|f| self.ui(f))?;
171            if event::poll(std::time::Duration::from_millis(100))? {
172                if let Event::Key(key) = event::read()? {
173                    match key.code {
174                        KeyCode::Char('q') | KeyCode::Esc => break,
175                        KeyCode::Tab => {
176                            self.selected_tab = (self.selected_tab + 1) % 4;
177                        }
178                        KeyCode::BackTab => {
179                            self.selected_tab = if self.selected_tab > 0 {
180                                self.selected_tab - 1
181                            } else {
182                                3
183                            };
184                        }
185                        _ => {}
186                    }
187                }
188            }
189        }
190        Ok(())
191    }
192    fn ui(&self, frame: &mut Frame) {
193        let chunks = Layout::default()
194            .direction(Direction::Vertical)
195            .constraints([
196                Constraint::Length(3),
197                Constraint::Min(0),
198                Constraint::Length(3),
199            ])
200            .split(frame.size());
201        let titles = vec!["Overview", "Performance", "Dependencies", "Trends"];
202        let tabs = Tabs::new(titles)
203            .block(Block::default().borders(Borders::ALL).title("🌊 Tide Charts"))
204            .highlight_style(
205                Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
206            )
207            .select(self.selected_tab);
208        frame.render_widget(tabs, chunks[0]);
209        match self.selected_tab {
210            0 => self.render_overview(frame, chunks[1]),
211            1 => self.render_performance(frame, chunks[1]),
212            2 => self.render_dependencies(frame, chunks[1]),
213            3 => self.render_trends(frame, chunks[1]),
214            _ => {}
215        }
216        let help = Paragraph::new("Press Tab to switch views | q to quit")
217            .style(Style::default().fg(Color::DarkGray))
218            .alignment(Alignment::Center)
219            .block(Block::default().borders(Borders::ALL));
220        frame.render_widget(help, chunks[2]);
221    }
222    fn render_overview(&self, frame: &mut Frame, area: Rect) {
223        let chunks = Layout::default()
224            .direction(Direction::Vertical)
225            .constraints([
226                Constraint::Length(8),
227                Constraint::Length(8),
228                Constraint::Min(5),
229            ])
230            .split(area);
231        let recent_builds = self.data.builds.iter().rev().take(50).collect::<Vec<_>>();
232        let success_rate = if !recent_builds.is_empty() {
233            let successful = recent_builds.iter().filter(|b| b.success).count();
234            (successful as f64 / recent_builds.len() as f64) * 100.0
235        } else {
236            0.0
237        };
238        let stats = vec![
239            format!("Total Builds: {}", self.data.builds.len()),
240            format!("Success Rate: {:.1}%", success_rate),
241            format!("Avg Build Time: {:.2}s", self.get_avg_build_time()),
242            format!("Dependencies: {}", self.data.dependencies.len()),
243        ];
244        let stats_widget = Paragraph::new(stats.join("\n"))
245            .block(Block::default().borders(Borders::ALL).title("📊 Statistics"))
246            .style(Style::default().fg(Color::White));
247        frame.render_widget(stats_widget, chunks[0]);
248        let gauge = Gauge::default()
249            .block(
250                Block::default().borders(Borders::ALL).title("🎯 Build Success Rate"),
251            )
252            .gauge_style(Style::default().fg(Color::Green).bg(Color::Black))
253            .percent(success_rate as u16)
254            .label(format!("{:.1}%", success_rate));
255        frame.render_widget(gauge, chunks[1]);
256        let sparkline_data: Vec<u64> = recent_builds
257            .iter()
258            .map(|b| (b.duration_seconds * 10.0) as u64)
259            .collect();
260        let sparkline = Sparkline::default()
261            .block(
262                Block::default().borders(Borders::ALL).title("⏱️ Recent Build Times"),
263            )
264            .data(&sparkline_data)
265            .style(Style::default().fg(Color::Cyan));
266        frame.render_widget(sparkline, chunks[2]);
267    }
268    fn render_performance(&self, frame: &mut Frame, area: Rect) {
269        let chunks = Layout::default()
270            .direction(Direction::Horizontal)
271            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
272            .split(area);
273        let recent: Vec<_> = self.data.builds.iter().rev().take(10).collect();
274        let data: Vec<(f64, f64)> = recent
275            .iter()
276            .enumerate()
277            .map(|(i, b)| (i as f64, b.duration_seconds))
278            .collect();
279        if !data.is_empty() {
280            let datasets = vec![
281                Dataset::default().name("Build Time").marker(symbols::Marker::Dot)
282                .style(Style::default().fg(Color::Cyan))
283                .graph_type(ratatui::widgets::GraphType::Line).data(& data)
284            ];
285            let max_time = data.iter().map(|(_, t)| *t).fold(0.0, f64::max);
286            let max_x = (data.len() - 1) as f64;
287            let chart = Chart::new(datasets)
288                .block(
289                    Block::default()
290                        .borders(Borders::ALL)
291                        .title("📈 Build Performance"),
292                )
293                .x_axis(
294                    Axis::default()
295                        .title("Recent Builds")
296                        .style(Style::default().fg(Color::Gray))
297                        .bounds([0.0, max_x]),
298                )
299                .y_axis(
300                    Axis::default()
301                        .title("Time (s)")
302                        .style(Style::default().fg(Color::Gray))
303                        .bounds([0.0, max_time * 1.2]),
304                );
305            frame.render_widget(chart, chunks[0]);
306        } else {
307            let placeholder = Paragraph::new(
308                    "No build data yet. Run some cargo commands!",
309                )
310                .block(
311                    Block::default()
312                        .borders(Borders::ALL)
313                        .title("📈 Build Performance"),
314                )
315                .style(Style::default().fg(Color::Gray));
316            frame.render_widget(placeholder, chunks[0]);
317        }
318        let mut daily_summaries: Vec<_> = self.data.daily_summary.values().collect();
319        daily_summaries.sort_by(|a, b| b.date.cmp(&a.date));
320        if !daily_summaries.is_empty() {
321            let formatted_dates: Vec<String> = daily_summaries
322                .iter()
323                .take(7)
324                .map(|s| {
325                    if s.date.starts_with("2025-") {
326                        s.date[5..].to_string()
327                    } else {
328                        s.date.clone()
329                    }
330                })
331                .collect();
332            let daily_data: Vec<(&str, u64)> = formatted_dates
333                .iter()
334                .zip(daily_summaries.iter().take(7))
335                .map(|(date_str, summary)| (
336                    date_str.as_str(),
337                    summary.total_builds as u64,
338                ))
339                .collect();
340            let max_builds = daily_data.iter().map(|(_, v)| *v).max().unwrap_or(1);
341            let bar_chart = BarChart::default()
342                .block(Block::default().borders(Borders::ALL).title("📅 Daily Builds"))
343                .data(&daily_data)
344                .bar_width(6)
345                .bar_gap(1)
346                .max(max_builds)
347                .style(Style::default().fg(Color::Yellow))
348                .value_style(Style::default().fg(Color::Black).bg(Color::Yellow));
349            frame.render_widget(bar_chart, chunks[1]);
350        } else {
351            let placeholder = Paragraph::new("No daily build data yet.")
352                .block(Block::default().borders(Borders::ALL).title("📅 Daily Builds"))
353                .style(Style::default().fg(Color::Gray));
354            frame.render_widget(placeholder, chunks[1]);
355        }
356    }
357    fn render_dependencies(&self, frame: &mut Frame, area: Rect) {
358        let mut deps: Vec<(&String, &DependencyMetrics)> = self
359            .data
360            .dependencies
361            .iter()
362            .collect();
363        deps.sort_by(|a, b| {
364            b.1.compile_time_seconds.partial_cmp(&a.1.compile_time_seconds).unwrap()
365        });
366        let items: Vec<ListItem> = deps
367            .iter()
368            .take(20)
369            .map(|(name, metrics)| {
370                let content = format!(
371                    "{} v{} - {:.2}s", name, metrics.version, metrics
372                    .compile_time_seconds
373                );
374                ListItem::new(content)
375            })
376            .collect();
377        let list = List::new(items)
378            .block(
379                Block::default()
380                    .borders(Borders::ALL)
381                    .title("📦 Dependency Compile Times"),
382            )
383            .highlight_style(Style::default().add_modifier(Modifier::BOLD))
384            .highlight_symbol(">> ");
385        frame.render_widget(list, area);
386    }
387    fn render_trends(&self, frame: &mut Frame, area: Rect) {
388        let chunks = Layout::default()
389            .direction(Direction::Vertical)
390            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
391            .split(area);
392        let trend_text = self.analyze_trends();
393        let trends = Paragraph::new(trend_text)
394            .block(
395                Block::default()
396                    .borders(Borders::ALL)
397                    .title("📊 Build Trends Analysis"),
398            )
399            .style(Style::default().fg(Color::White))
400            .wrap(ratatui::widgets::Wrap {
401                trim: true,
402            });
403        frame.render_widget(trends, chunks[0]);
404        let recommendations = self.get_recommendations();
405        let rec_items: Vec<ListItem> = recommendations
406            .iter()
407            .map(|r| ListItem::new(r.as_str()))
408            .collect();
409        let rec_list = List::new(rec_items)
410            .block(Block::default().borders(Borders::ALL).title("💡 Recommendations"))
411            .style(Style::default().fg(Color::Green));
412        frame.render_widget(rec_list, chunks[1]);
413    }
414    fn analyze_trends(&self) -> String {
415        if self.data.builds.is_empty() {
416            return "No build data available yet.".to_string();
417        }
418        let recent = &self.data.builds[self.data.builds.len().saturating_sub(100)..];
419        let old = &self.data.builds[..self.data.builds.len().saturating_sub(100)];
420        let recent_avg = if !recent.is_empty() {
421            recent.iter().map(|b| b.duration_seconds).sum::<f64>() / recent.len() as f64
422        } else {
423            0.0
424        };
425        let old_avg = if !old.is_empty() {
426            old.iter().map(|b| b.duration_seconds).sum::<f64>() / old.len() as f64
427        } else {
428            recent_avg
429        };
430        let improvement = ((old_avg - recent_avg) / old_avg * 100.0).abs();
431        let trend = if recent_avg < old_avg {
432            format!("✅ Build times improved by {:.1}%", improvement)
433        } else if recent_avg > old_avg {
434            format!("⚠️ Build times increased by {:.1}%", improvement)
435        } else {
436            "→ Build times stable".to_string()
437        };
438        let error_trend = self.analyze_error_trend();
439        let peak_times = self.find_peak_build_times();
440        format!("{}\n{}\n{}", trend, error_trend, peak_times)
441    }
442    fn analyze_error_trend(&self) -> String {
443        let recent = &self.data.builds[self.data.builds.len().saturating_sub(50)..];
444        let total_errors: usize = recent.iter().map(|b| b.error_count).sum();
445        let total_warnings: usize = recent.iter().map(|b| b.warning_count).sum();
446        format!("Recent: {} errors, {} warnings", total_errors, total_warnings)
447    }
448    fn find_peak_build_times(&self) -> String {
449        if self.data.builds.is_empty() {
450            return String::new();
451        }
452        let mut hour_builds: HashMap<u32, Vec<f64>> = HashMap::new();
453        for build in &self.data.builds {
454            let hour = build.timestamp.hour();
455            hour_builds.entry(hour).or_default().push(build.duration_seconds);
456        }
457        let mut peak_hour = 0;
458        let mut peak_avg = 0.0;
459        for (hour, times) in &hour_builds {
460            let avg = times.iter().sum::<f64>() / times.len() as f64;
461            if avg > peak_avg {
462                peak_avg = avg;
463                peak_hour = *hour;
464            }
465        }
466        format!("Peak build time: {}:00 (avg {:.2}s)", peak_hour, peak_avg)
467    }
468    fn get_recommendations(&self) -> Vec<String> {
469        let mut recommendations = Vec::new();
470        if self.get_avg_build_time() > 60.0 {
471            recommendations
472                .push("Consider using cargo-nextest for faster test runs".to_string());
473            recommendations
474                .push("Enable incremental compilation in Cargo.toml".to_string());
475        }
476        let recent_failures = self
477            .data
478            .builds
479            .iter()
480            .rev()
481            .take(10)
482            .filter(|b| !b.success)
483            .count();
484        if recent_failures > 3 {
485            recommendations
486                .push(
487                    "High failure rate detected - consider running clippy".to_string(),
488                );
489            recommendations
490                .push("Set up pre-commit hooks to catch issues earlier".to_string());
491        }
492        if self.data.dependencies.len() > 100 {
493            recommendations
494                .push("Large dependency count - audit with cargo-outdated".to_string());
495            recommendations
496                .push(
497                    "Consider using cargo-machete to find unused dependencies"
498                        .to_string(),
499                );
500        }
501        let incremental_builds = self
502            .data
503            .builds
504            .iter()
505            .filter(|b| b.incremental)
506            .count();
507        if incremental_builds < self.data.builds.len() / 2 {
508            recommendations
509                .push("Enable incremental compilation for faster rebuilds".to_string());
510        }
511        recommendations
512    }
513    fn get_avg_build_time(&self) -> f64 {
514        if self.data.builds.is_empty() {
515            0.0
516        } else {
517            let total: f64 = self.data.builds.iter().map(|b| b.duration_seconds).sum();
518            total / self.data.builds.len() as f64
519        }
520    }
521    fn save(&self) -> Result<()> {
522        let json = serde_json::to_string_pretty(&self.data)?;
523        fs::write(&self.data_file, json)?;
524        Ok(())
525    }
526    pub fn export_csv(&self, path: &PathBuf) -> Result<()> {
527        let mut csv = String::new();
528        csv.push_str("timestamp,command,duration,success,errors,warnings\n");
529        for build in &self.data.builds {
530            csv.push_str(
531                &format!(
532                    "{},{},{},{},{},{}\n", build.timestamp.to_rfc3339(), build.command,
533                    build.duration_seconds, build.success, build.error_count, build
534                    .warning_count
535                ),
536            );
537        }
538        fs::write(path, csv)?;
539        println!("✅ Build metrics exported to {}", path.display());
540        Ok(())
541    }
542}
543impl Default for TideData {
544    fn default() -> Self {
545        Self {
546            builds: Vec::new(),
547            dependencies: HashMap::new(),
548            daily_summary: HashMap::new(),
549        }
550    }
551}
552pub fn check_sailor_tracker(command: &str) -> Result<bool> {
553    println!("⛵ Sailor tracking command '{}' - checking the winds!", command.cyan());
554    let license_manager = license::LicenseManager::new()?;
555    match license_manager.enforce_license(command) {
556        Ok(_) => {
557            println!(
558                "✅ Sailor reports: Command '{}' has fair winds!", command.green()
559            );
560            println!("   ⛵ All sails set - ready to catch the wind!");
561            Ok(true)
562        }
563        Err(e) => {
564            if e.to_string().contains("limit") {
565                println!("⚠️  Sailor warning: Command wind exceeded!");
566                println!("   ⛵ Change course to: https://cargo.do/checkout");
567                println!("   ⛵ Upgrade for unlimited sailing commands");
568            } else if e.to_string().contains("License not found") {
569                println!("❌ Sailor emergency: No navigation charts found!");
570                println!("   ⛵ Get charts with 'cm register <key>'");
571            } else {
572                println!(
573                    "❌ Sailor distress: Command tracking failed: {}", e.to_string()
574                    .red()
575                );
576                println!("   ⛵ Man overboard - secure the ship!");
577            }
578            Ok(false)
579        }
580    }
581}