use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionSnapshot {
pub id: String,
pub timestamp: DateTime<Utc>,
pub command: String,
pub output: String,
pub context: String, pub duration: Duration,
pub state_hash: String, }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecordingSession {
pub id: String,
pub start_time: DateTime<Utc>,
pub snapshots: VecDeque<SessionSnapshot>,
pub max_snapshots: usize,
pub is_recording: bool,
pub metadata: RecordingMetadata,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecordingMetadata {
pub project_path: PathBuf,
pub user: String,
pub description: String,
pub tags: Vec<String>,
}
pub struct RecordingManager {
sessions: Arc<Mutex<Vec<RecordingSession>>>,
current_session: Arc<Mutex<Option<String>>>,
storage_path: PathBuf,
}
impl RecordingManager {
pub fn new(storage_path: PathBuf) -> Result<Self> {
std::fs::create_dir_all(&storage_path)?;
Ok(Self {
sessions: Arc::new(Mutex::new(Vec::new())),
current_session: Arc::new(Mutex::new(None)),
storage_path,
})
}
pub fn start_recording(&self, description: &str) -> Result<String> {
let mut sessions_guard = self.sessions.lock().unwrap();
let session_id = uuid::Uuid::new_v4().to_string();
let session = RecordingSession {
id: session_id.clone(),
start_time: Utc::now(),
snapshots: VecDeque::new(),
max_snapshots: 1000, is_recording: true,
metadata: RecordingMetadata {
project_path: std::env::current_dir()?,
user: whoami::username(),
description: description.to_string(),
tags: vec!["automatic".to_string()],
},
};
sessions_guard.push(session);
*self.current_session.lock().unwrap() = Some(session_id.clone());
Ok(session_id)
}
pub fn stop_recording(&self) -> Result<()> {
let mut sessions_guard = self.sessions.lock().unwrap();
if let Some(ref session_id) = *self.current_session.lock().unwrap() {
for session in sessions_guard.iter_mut() {
if session.id == *session_id {
session.is_recording = false;
break;
}
}
}
*self.current_session.lock().unwrap() = None;
Ok(())
}
pub fn add_snapshot(&self, command: &str, output: &str, context: &str) -> Result<()> {
let session_id = {
if let Some(id) = self.current_session.lock().unwrap().as_ref() {
id.clone()
} else {
return Ok(()); }
};
let snapshot = SessionSnapshot {
id: uuid::Uuid::new_v4().to_string(),
timestamp: Utc::now(),
command: command.to_string(),
output: output.to_string(),
context: context.to_string(),
duration: Duration::from_millis(0), state_hash: format!("{:x}", blake3::hash(context.as_bytes())),
};
let mut sessions_guard = self.sessions.lock().unwrap();
for session in sessions_guard.iter_mut() {
if session.id == session_id {
session.snapshots.push_back(snapshot);
if session.snapshots.len() > session.max_snapshots {
session.snapshots.pop_front();
}
break;
}
}
Ok(())
}
pub fn load_recordings(&self) -> Result<Vec<RecordingSession>> {
let mut recordings = Vec::new();
let entries = std::fs::read_dir(&self.storage_path)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.is_file() && path.extension().map_or(false, |ext| ext == "json") {
if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(session) = serde_json::from_str::<RecordingSession>(&content) {
recordings.push(session);
}
}
}
}
Ok(recordings)
}
pub fn save_session(&self, session: &RecordingSession) -> Result<()> {
let filename = format!("recording_{}_{}.json",
session.id,
session.start_time.format("%Y%m%d_%H%M%S"));
let filepath = self.storage_path.join(filename);
let content = serde_json::to_string_pretty(session)?;
std::fs::write(filepath, content)?;
Ok(())
}
pub fn get_recorded_sessions(&self) -> Vec<RecordingSession> {
self.sessions.lock().unwrap().clone()
}
pub fn rewind_to_point(&self, session_id: &str, snapshot_id: &str) -> Result<SessionSnapshot> {
let sessions_guard = self.sessions.lock().unwrap();
for session in sessions_guard.iter() {
if session.id == session_id {
for snapshot in session.snapshots.iter() {
if snapshot.id == snapshot_id {
return Ok(snapshot.clone());
}
}
}
}
anyhow::bail!("Snapshot not found: {}", snapshot_id)
}
pub fn rewind_to_time(&self, session_id: &str, target_time: DateTime<Utc>) -> Result<SessionSnapshot> {
let sessions_guard = self.sessions.lock().unwrap();
for session in sessions_guard.iter() {
if session.id == session_id {
let mut closest_snapshot: Option<SessionSnapshot> = None;
let mut min_diff = Duration::MAX;
for snapshot in session.snapshots.iter() {
let diff = if snapshot.timestamp > target_time {
snapshot.timestamp - target_time
} else {
target_time - snapshot.timestamp
};
if diff < min_diff {
min_diff = diff;
closest_snapshot = Some(snapshot.clone());
}
}
if let Some(snapshot) = closest_snapshot {
return Ok(snapshot);
}
}
}
anyhow::bail!("No snapshot found for session: {}", session_id)
}
pub fn play_session(
&self,
session_id: &str,
callback: impl FnMut(&SessionSnapshot) -> Result<()>,
) -> Result<()> {
let sessions_guard = self.sessions.lock().unwrap();
for session in sessions_guard.iter() {
if session.id == session_id {
for snapshot in &session.snapshots {
callback(snapshot)?;
}
return Ok(());
}
}
anyhow::bail!("Session not found: {}", session_id)
}
pub fn get_timeline(&self, session_id: &str) -> Result<Vec<TimelineEntry>> {
let mut timeline = Vec::new();
let sessions_guard = self.sessions.lock().unwrap();
for session in sessions_guard.iter() {
if session.id == session_id {
for snapshot in &session.snapshots {
timeline.push(TimelineEntry {
timestamp: snapshot.timestamp,
event_type: match snapshot.command.split_whitespace().next().unwrap_or("") {
"/ask" => EventType::Question,
"/refactor" | "/fix" => EventType::CodeModification,
"/test" => EventType::Testing,
"/commit" | "/review" => EventType::Review,
_ => EventType::Command,
},
summary: format!("{}: {}", snapshot.command, snapshot.output.chars().take(50).collect::<String>()),
});
}
break;
}
}
Ok(timeline)
}
}
#[derive(Debug, Clone)]
pub struct TimelineEntry {
pub timestamp: DateTime<Utc>,
pub event_type: EventType,
pub summary: String,
}
#[derive(Debug, Clone)]
pub enum EventType {
Question,
CodeModification,
Testing,
Review,
Command,
}
pub struct RewindCapabilities {
pub recording_manager: RecordingManager,
}
impl RewindCapabilities {
pub fn new(storage_path: PathBuf) -> Result<Self> {
Ok(Self {
recording_manager: RecordingManager::new(storage_path)?,
})
}
pub fn start_collaborative_session(&self, description: &str, collaborators: &[String]) -> Result<String> {
let session_id = self.recording_manager.start_recording(description)?;
let mut sessions_guard = self.recording_manager.sessions.lock().unwrap();
if let Some(session) = sessions_guard.iter_mut().find(|s| s.id == session_id) {
session.metadata.tags.extend(collaborators.iter().map(|c| format!("collab:{}", c)));
}
Ok(session_id)
}
}
pub fn initialize_recording_system() -> Result<RewindCapabilities> {
let storage_path = dirs::data_dir()
.unwrap_or_else(|| std::env::current_dir().unwrap())
.join("kandil")
.join("recordings");
RewindCapabilities::new(storage_path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_recording_lifecycle() -> Result<()> {
let temp_dir = std::env::temp_dir().join("kandil_test_recording");
let manager = RecordingManager::new(temp_dir)?;
let session_id = manager.start_recording("Test recording")?;
manager.add_snapshot("ls -la", "file1.txt\nfile2.txt", "context1")?;
manager.add_snapshot("pwd", "/home/user/test", "context2")?;
manager.stop_recording()?;
let sessions = manager.get_recorded_sessions();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].snapshots.len(), 2);
Ok(())
}
}