aurora-context 0.1.0

Session history, TODO finder, and code review
Documentation
use std::fs;
use std::path::PathBuf;

use aurora_core::{AuroraError, AuroraResult, Pipeline, Value};
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionEntry {
    pub timestamp: DateTime<Utc>,
    pub command: String,
    pub directory: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionStore {
    pub entries: Vec<SessionEntry>,
    path: PathBuf,
}

impl SessionStore {
    pub fn new() -> AuroraResult<Self> {
        let home = std::env::var("HOME")
            .map_err(|_| AuroraError::ContextError("HOME not set".to_string()))?;
        let dir = PathBuf::from(home).join(".cache/aurora");
        let path = dir.join("session.json");

        if path.exists() {
            let content =
                fs::read_to_string(&path).map_err(|e| AuroraError::Io(e))?;
            let entries: Vec<SessionEntry> =
                serde_json::from_str(&content).unwrap_or_default();
            Ok(Self { entries, path })
        } else {
            fs::create_dir_all(&dir).map_err(|e| AuroraError::Io(e))?;
            Ok(Self {
                entries: Vec::new(),
                path,
            })
        }
    }

    pub fn record(&mut self, cmd: &str) -> AuroraResult<()> {
        let cwd = std::env::current_dir()
            .map(|p| p.to_string_lossy().to_string())
            .unwrap_or_default();

        self.entries.push(SessionEntry {
            timestamp: Utc::now(),
            command: cmd.to_string(),
            directory: cwd,
        });

        let json =
            serde_json::to_string_pretty(&self.entries).map_err(|e| {
                AuroraError::ContextError(format!("serialize: {e}"))
            })?;
        fs::write(&self.path, &json).map_err(|e| AuroraError::Io(e))?;
        Ok(())
    }

    pub fn recent(&self, ago: &str) -> AuroraResult<Pipeline> {
        let ago = ago.trim();
        let duration = parse_duration(ago)?;
        let cutoff = Utc::now() - duration;

        let recent_entries: Vec<&SessionEntry> = self
            .entries
            .iter()
            .filter(|e| e.timestamp >= cutoff)
            .collect();

        if recent_entries.is_empty() {
            return Ok(Pipeline::new());
        }

        let headers = vec![
            "time".to_string(),
            "command".to_string(),
            "directory".to_string(),
        ];

        let mut rows: Vec<Vec<Value>> = Vec::new();
        for entry in &recent_entries {
            let time_str = entry.timestamp.format("%Y-%m-%d %H:%M:%S").to_string();
            rows.push(vec![
                Value::String(time_str),
                Value::String(entry.command.clone()),
                Value::String(entry.directory.clone()),
            ]);
        }

        Ok(Pipeline::table(headers, rows))
    }
}

fn parse_duration(s: &str) -> AuroraResult<Duration> {
    let parts: Vec<&str> = s.splitn(2, |c: char| c.is_whitespace()).collect();
    if parts.len() != 2 {
        return Err(AuroraError::ContextError(format!(
            "Invalid duration format: '{s}'. Expected e.g. '30 min'"
        )));
    }

    let amount: i64 = parts[0]
        .parse()
        .map_err(|_| AuroraError::ContextError(format!("Invalid number: '{}'", parts[0])))?;

    match parts[1] {
        "min" | "minute" | "minutes" => Ok(Duration::minutes(amount)),
        "hour" | "hours" => Ok(Duration::hours(amount)),
        "day" | "days" => Ok(Duration::days(amount)),
        "sec" | "second" | "seconds" => Ok(Duration::seconds(amount)),
        unit => Err(AuroraError::ContextError(format!(
            "Unknown time unit: '{unit}'. Use min, hours, days"
        ))),
    }
}