t_trace 0.1.3

High-performance, command-line statistics tracker for your shell
Documentation
use crate::client::Client;
use crate::dto::CommandStats;

use anyhow::Result;
use comfy_table::{Attribute, Cell, Color, ContentArrangement, Table, presets::UTF8_FULL};
use std::time::Duration;

pub async fn handle_daemon_health_check() -> Result<()> {
    let mut client = Client::connect().await?;
    let response = client.send_health_check().await?;
    if response == "Daemon alive" {
        println!("Daemon is responsive.");
        Ok(())
    } else {
        anyhow::bail!("Daemon responded with an unexpected message: {}", response);
    }
}

pub async fn handle_daemon_command_begin(pid: u32, command: String) -> Result<()> {
    Client::connect()
        .await?
        .send_command_begin(pid, command)
        .await
}

pub async fn handle_daemon_command_end(pid: u32, exit_code: i32) -> Result<()> {
    if let Err(e) = Client::connect()
        .await?
        .send_command_end(pid, exit_code)
        .await
    {
        println!(
            "Failed to inform t_trace that the command was completed. {}",
            e
        );
    }
    Client::connect().await?.send_save_stats_command().await
}

pub async fn handle_daemon_stop() -> Result<()> {
    Client::connect().await?.send_stop().await?;
    Ok(())
}

pub async fn handle_stats(filter: Option<String>) -> Result<()> {
    let all_stats = Client::connect().await?.send_get_stats().await?;

    if all_stats.is_empty() {
        println!("No commands tracked yet. Run a few commands and try again!");
        return Ok(());
    }

    let mut filtered_stats = filter_stats(all_stats.into_iter().collect(), &filter);
    if filtered_stats.is_empty() {
        if let Some(filter) = filter {
            println!("No commands found matching the filter: \"{}\"", filter);
        }
        return Ok(());
    }

    filtered_stats.sort_by(|first, second| first.1.total_duration.cmp(&second.1.total_duration));

    println!("{}", build_stats_table(filtered_stats));

    Ok(())
}

fn filter_stats(
    stats: Vec<(String, CommandStats)>,
    filter: &Option<String>,
) -> Vec<(String, CommandStats)> {
    let Some(filter) = filter else {
        return stats;
    };

    let filter_lowercase = filter.to_lowercase();
    stats
        .into_iter()
        .filter(|(command, _stats)| command.to_lowercase().contains(&filter_lowercase))
        .collect()
}

fn build_stats_table(stats_to_display: Vec<(String, CommandStats)>) -> String {
    let mut table = Table::new();
    table.load_preset(UTF8_FULL);

    table
        .set_header(
            vec![
                "Command",
                "Success Count",
                "Fail Count",
                "Total Time",
                "Mean Time",
                "Last Time",
            ]
            .into_iter()
            .map(|h| Cell::new(h).add_attribute(Attribute::Bold)),
        )
        .set_content_arrangement(ContentArrangement::Dynamic);

    for (command, data) in stats_to_display {
        let mean_duration = if data.success_count > 0 || data.fail_count > 0 {
            data.total_duration.as_nanos() / (data.success_count as u128 + data.fail_count as u128)
        } else {
            0
        };

        table.add_row(vec![
            Cell::new(command).fg(Color::Yellow),
            Cell::new(data.success_count.to_string()).fg(Color::Green),
            Cell::new(data.fail_count.to_string()).fg(Color::Red),
            Cell::new(format!("{:.3?}", data.total_duration)),
            Cell::new(format!(
                "{:.3?}",
                Duration::from_nanos(mean_duration as u64)
            )),
            Cell::new(format!("{:.3?}", data.last_run_duration)),
        ]);
    }

    table.to_string()
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::time::Duration;

    fn dummy_command_stats(count: u64) -> CommandStats {
        CommandStats {
            total_duration: Duration::from_secs(1),
            last_run_duration: Duration::from_secs(1),
            success_count: count,
            fail_count: count,
        }
    }

    #[test]
    fn filter_stats_with_no_filter() {
        let stats = vec![
            ("git status".to_string(), dummy_command_stats(10)),
            ("ls -l".to_string(), dummy_command_stats(5)),
        ];
        let filtered = filter_stats(stats, &None);
        assert_eq!(filtered.len(), 2);
    }

    #[test]
    fn filter_stats_with_matching_filter() {
        let stats = vec![
            ("git status".to_string(), dummy_command_stats(10)),
            ("git push".to_string(), dummy_command_stats(8)),
            ("ls -l".to_string(), dummy_command_stats(5)),
        ];
        let filtered = filter_stats(stats, &Some("git".to_string()));
        assert_eq!(filtered.len(), 2);
        assert!(filtered.iter().all(|(cmd, _)| cmd.starts_with("git")));
    }

    #[test]
    fn filter_stats_is_case_insensitive() {
        let stats = vec![
            ("git status".to_string(), dummy_command_stats(10)),
            ("ls -l".to_string(), dummy_command_stats(5)),
        ];
        let filtered = filter_stats(stats, &Some("GIT".to_string()));
        assert_eq!(filtered.len(), 1);
        assert!(filtered.iter().all(|(cmd, _)| cmd.starts_with("git")));
    }

    #[test]
    fn filter_stats_with_no_matches() {
        let stats = vec![
            ("git status".to_string(), dummy_command_stats(10)),
            ("ls -l".to_string(), dummy_command_stats(5)),
        ];
        let filtered = filter_stats(stats, &Some("cargo".to_string()));
        assert!(filtered.is_empty());
    }
}