i-self 0.4.3

Personal developer-companion CLI: scans your repos, indexes code semantically, watches your activity, and moves AI-agent sessions between tools (Claude Code, Aider, Goose, OpenAI Codex CLI, Continue.dev, OpenCode).
#![allow(dead_code)]

use chrono::{DateTime, Utc, Duration as ChronoDuration};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use tokio::fs;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActivitySession {
    pub id: String,
    pub start_time: DateTime<Utc>,
    pub end_time: Option<DateTime<Utc>>,
    pub project: Option<String>,
    pub language: Option<String>,
    pub files_changed: usize,
    pub lines_added: usize,
    pub lines_removed: usize,
    pub commits: Vec<String>,
    pub idle_time_seconds: u64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActivitySummary {
    pub total_sessions: usize,
    pub total_coding_time_seconds: u64,
    pub total_idle_time_seconds: u64,
    pub projects_worked: Vec<String>,
    pub languages_used: HashMap<String, u64>,
    pub average_session_length_seconds: u64,
    pub most_productive_hour: Option<u32>,
    pub commit_count: usize,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkPattern {
    pub day_of_week: u8,
    pub hour: u8,
    pub activity_level: f64,
    pub typical_duration_minutes: u32,
}

pub struct ActivityTracker {
    pub sessions: Vec<ActivitySession>,
    data_dir: PathBuf,
}

impl ActivityTracker {
    pub fn new(data_dir: PathBuf) -> Self {
        Self {
            sessions: Vec::new(),
            data_dir,
        }
    }

    pub async fn load(&mut self) -> anyhow::Result<()> {
        let sessions_file = self.data_dir.join("sessions.json");
        if sessions_file.exists() {
            let content = fs::read_to_string(&sessions_file).await?;
            self.sessions = serde_json::from_str(&content).unwrap_or_default();
        }
        Ok(())
    }

    pub async fn save(&self) -> anyhow::Result<()> {
        let sessions_file = self.data_dir.join("sessions.json");
        let content = serde_json::to_string_pretty(&self.sessions)?;
        fs::write(sessions_file, content).await?;
        Ok(())
    }

    pub fn start_session(&mut self, project: Option<String>, language: Option<String>) -> &ActivitySession {
        let session = ActivitySession {
            id: uuid::Uuid::new_v4().to_string(),
            start_time: Utc::now(),
            end_time: None,
            project,
            language,
            files_changed: 0,
            lines_added: 0,
            lines_removed: 0,
            commits: Vec::new(),
            idle_time_seconds: 0,
        };
        self.sessions.push(session);
        self.sessions.last().unwrap()
    }

    pub fn end_session(&mut self, session_id: &str) -> Option<&ActivitySession> {
        if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) {
            session.end_time = Some(Utc::now());
            return Some(session);
        }
        None
    }

    pub fn record_file_change(&mut self, session_id: &str, lines_added: usize, lines_removed: usize) {
        if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) {
            session.files_changed += 1;
            session.lines_added += lines_added;
            session.lines_removed += lines_removed;
        }
    }

    pub fn record_commit(&mut self, session_id: &str, commit_hash: String) {
        if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) {
            session.commits.push(commit_hash);
        }
    }

    pub fn record_idle_time(&mut self, session_id: &str, seconds: u64) {
        if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) {
            session.idle_time_seconds += seconds;
        }
    }

    pub fn get_summary(&self, days: u32) -> ActivitySummary {
        let cutoff = Utc::now() - ChronoDuration::days(days as i64);
        let recent_sessions: Vec<&ActivitySession> = self.sessions
            .iter()
            .filter(|s| s.start_time > cutoff)
            .collect();

        let total_coding_time: u64 = recent_sessions
            .iter()
            .map(|s| {
                let end = s.end_time.unwrap_or(Utc::now());
                (end - s.start_time).num_seconds() as u64
            })
            .sum();

        let total_idle: u64 = recent_sessions.iter().map(|s| s.idle_time_seconds).sum();

        let mut projects: HashMap<String, u64> = HashMap::new();
        let mut languages: HashMap<String, u64> = HashMap::new();
        let mut hourly_activity: HashMap<u32, u64> = HashMap::new();

        for session in &recent_sessions {
            if let Some(ref project) = session.project {
                *projects.entry(project.clone()).or_insert(0) += 1;
            }
            if let Some(ref lang) = session.language {
                *languages.entry(lang.clone()).or_insert(0) += 1;
            }
            let naive = session.start_time.naive_utc();
            let hour = naive.format("%H").to_string().parse::<u32>().unwrap_or(0);
            *hourly_activity.entry(hour).or_insert(0) += 1;
        }

        let most_productive_hour = hourly_activity
            .iter()
            .max_by_key(|(_, &count)| count)
            .map(|(&hour, _)| hour);

        let avg_session = if !recent_sessions.is_empty() {
            total_coding_time / recent_sessions.len() as u64
        } else {
            0
        };

        ActivitySummary {
            total_sessions: recent_sessions.len(),
            total_coding_time_seconds: total_coding_time,
            total_idle_time_seconds: total_idle,
            projects_worked: projects.keys().cloned().collect(),
            languages_used: languages,
            average_session_length_seconds: avg_session,
            most_productive_hour,
            commit_count: recent_sessions.iter().map(|s| s.commits.len()).sum(),
        }
    }

    pub fn get_work_patterns(&self, days: u32) -> Vec<WorkPattern> {
        let cutoff = Utc::now() - ChronoDuration::days(days as i64);
        let recent_sessions: Vec<&ActivitySession> = self.sessions
            .iter()
            .filter(|s| s.start_time > cutoff)
            .collect();

        let mut patterns: HashMap<(u8, u8), (u64, u32)> = HashMap::new();

        for session in &recent_sessions {
            let naive = session.start_time.naive_utc();
            let day: u8 = naive.format("%w").to_string().parse().unwrap_or(0);
            let hour: u8 = naive.format("%H").to_string().parse().unwrap_or(0);
            let duration = session.end_time
                .map(|e| (e - session.start_time).num_minutes() as u32)
                .unwrap_or(0);

            let entry = patterns.entry((day, hour)).or_insert((0, 0));
            entry.0 += 1;
            entry.1 = entry.1.saturating_add(duration);
        }

        patterns.iter().map(|((day, hour), (count, duration))| {
            WorkPattern {
                day_of_week: *day,
                hour: *hour,
                activity_level: *count as f64,
                typical_duration_minutes: if *count > 0 { duration / (*count as u32) } else { 0 },
            }
        }).collect()
    }
}