use crate::db::{ContextItem, Database, SessionContext};
use anyhow::Result;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextSummary {
pub id: String,
pub name: String,
pub project_name: String,
pub description: Option<String>,
pub created_at: String,
pub updated_at: String,
pub item_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FullContext {
pub context: SessionContext,
pub items: Vec<ContextItem>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextSnapshot {
pub version: String,
pub created_at: String,
pub project_name: String,
pub project_path: String,
pub name: String,
pub description: Option<String>,
pub conversation: Option<Vec<ConversationTurn>>,
pub modified_files: Vec<FileChange>,
pub git_diff: Option<String>,
pub git_branch: Option<String>,
pub tasks: Vec<Task>,
pub notes: Vec<String>,
pub relevant_files: Vec<String>,
pub code_snippets: Vec<CodeReference>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConversationTurn {
pub role: String,
pub content: String,
pub timestamp: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileChange {
pub path: String,
pub change_type: String,
pub summary: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Task {
pub description: String,
pub status: String,
pub priority: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CodeReference {
pub file_path: String,
pub start_line: usize,
pub end_line: usize,
pub content: String,
pub note: Option<String>,
}
pub struct ContextManager<'a> {
db: &'a Database,
project_name: String,
}
impl<'a> ContextManager<'a> {
pub fn new(db: &'a Database) -> Self {
let project_name = db
.project_root
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
Self { db, project_name }
}
#[allow(dead_code)]
pub fn with_project_name(db: &'a Database, project_name: &str) -> Self {
Self {
db,
project_name: project_name.to_string(),
}
}
pub fn project_name(&self) -> &str {
&self.project_name
}
pub fn save_context(
&self,
name: Option<String>,
description: Option<String>,
) -> Result<SessionContext> {
let now = Utc::now().to_rfc3339();
let context_name = name.unwrap_or_else(|| {
format!(
"{}_{}",
self.project_name,
Utc::now().format("%Y%m%d_%H%M%S")
)
});
if let Some(existing) = self.db.get_context(&context_name)? {
let updated = SessionContext {
id: existing.id,
name: context_name,
description: description.or(existing.description),
created_at: existing.created_at,
updated_at: now,
project_path: self.db.project_root.to_string_lossy().to_string(),
};
self.db.insert_context(&updated)?;
return Ok(updated);
}
let context = SessionContext {
id: Uuid::new_v4().to_string(),
name: context_name,
description,
created_at: now.clone(),
updated_at: now,
project_path: self.db.project_root.to_string_lossy().to_string(),
};
self.db.insert_context(&context)?;
Ok(context)
}
pub fn load_context(&self, id_or_name: &str) -> Result<Option<FullContext>> {
let context = match self.db.get_context(id_or_name)? {
Some(ctx) => ctx,
None => return Ok(None),
};
let items = self.db.get_context_items(&context.id)?;
Ok(Some(FullContext { context, items }))
}
pub fn list_contexts(&self) -> Result<Vec<ContextSummary>> {
let contexts = self.db.list_contexts()?;
let mut summaries = Vec::new();
for ctx in contexts {
let items = self.db.get_context_items(&ctx.id)?;
let project_name = std::path::Path::new(&ctx.project_path)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
summaries.push(ContextSummary {
id: ctx.id,
name: ctx.name,
project_name,
description: ctx.description,
created_at: ctx.created_at,
updated_at: ctx.updated_at,
item_count: items.len(),
});
}
Ok(summaries)
}
pub fn list_contexts_by_project(&self, project_name: &str) -> Result<Vec<ContextSummary>> {
let all = self.list_contexts()?;
Ok(all
.into_iter()
.filter(|c| c.project_name == project_name)
.collect())
}
pub fn delete_context(&self, id_or_name: &str) -> Result<bool> {
self.db.delete_context(id_or_name)
}
pub fn add_item(
&self,
context_id_or_name: &str,
key: &str,
value: &str,
item_type: ItemType,
) -> Result<ContextItem> {
let context = self
.db
.get_context(context_id_or_name)?
.ok_or_else(|| anyhow::anyhow!("Context not found: {}", context_id_or_name))?;
let item = ContextItem {
id: Uuid::new_v4().to_string(),
context_id: context.id.clone(),
key: key.to_string(),
value: value.to_string(),
item_type: item_type.as_str().to_string(),
created_at: Utc::now().to_rfc3339(),
};
self.db.insert_context_item(&item)?;
let updated_context = SessionContext {
updated_at: Utc::now().to_rfc3339(),
..context
};
self.db.insert_context(&updated_context)?;
Ok(item)
}
#[allow(dead_code)]
pub fn get_items(&self, context_id_or_name: &str) -> Result<Vec<ContextItem>> {
let context = self
.db
.get_context(context_id_or_name)?
.ok_or_else(|| anyhow::anyhow!("Context not found: {}", context_id_or_name))?;
self.db.get_context_items(&context.id)
}
#[allow(dead_code)]
pub fn get_items_by_type(
&self,
context_id_or_name: &str,
item_type: ItemType,
) -> Result<Vec<ContextItem>> {
let items = self.get_items(context_id_or_name)?;
Ok(items
.into_iter()
.filter(|i| i.item_type == item_type.as_str())
.collect())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ItemType {
Note,
FileRef,
Task,
Decision,
Finding,
Summary,
Custom,
}
impl ItemType {
pub fn as_str(&self) -> &'static str {
match self {
Self::Note => "note",
Self::FileRef => "file_ref",
Self::Task => "task",
Self::Decision => "decision",
Self::Finding => "finding",
Self::Summary => "summary",
Self::Custom => "custom",
}
}
pub fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"note" => Self::Note,
"file_ref" | "file" => Self::FileRef,
"task" | "todo" => Self::Task,
"decision" => Self::Decision,
"finding" => Self::Finding,
"summary" => Self::Summary,
_ => Self::Custom,
}
}
}
impl<'a> ContextManager<'a> {
pub fn add_note(&self, context_id: &str, key: &str, note: &str) -> Result<ContextItem> {
self.add_item(context_id, key, note, ItemType::Note)
}
pub fn add_file_reference(
&self,
context_id: &str,
file_path: &str,
note: &str,
) -> Result<ContextItem> {
let value = serde_json::json!({
"path": file_path,
"note": note
})
.to_string();
self.add_item(context_id, file_path, &value, ItemType::FileRef)
}
pub fn add_task(&self, context_id: &str, task: &str, status: &str) -> Result<ContextItem> {
let value = serde_json::json!({
"task": task,
"status": status
})
.to_string();
self.add_item(context_id, task, &value, ItemType::Task)
}
#[allow(dead_code)]
pub fn add_decision(
&self,
context_id: &str,
decision: &str,
rationale: &str,
) -> Result<ContextItem> {
let value = serde_json::json!({
"decision": decision,
"rationale": rationale
})
.to_string();
self.add_item(context_id, decision, &value, ItemType::Decision)
}
#[allow(dead_code)]
pub fn add_finding(
&self,
context_id: &str,
finding: &str,
details: &str,
) -> Result<ContextItem> {
let value = serde_json::json!({
"finding": finding,
"details": details
})
.to_string();
self.add_item(context_id, finding, &value, ItemType::Finding)
}
#[allow(dead_code)]
pub fn set_summary(&self, context_id: &str, summary: &str) -> Result<ContextItem> {
self.add_item(context_id, "session_summary", summary, ItemType::Summary)
}
}
impl FullContext {
pub fn to_json(&self) -> Result<String> {
serde_json::to_string_pretty(self).map_err(Into::into)
}
pub fn to_markdown(&self) -> String {
let mut md = String::new();
md.push_str(&format!("# Context: {}\n\n", self.context.name));
if let Some(desc) = &self.context.description {
md.push_str(&format!("**Description:** {}\n\n", desc));
}
md.push_str(&format!("**Project:** {}\n", self.context.project_path));
md.push_str(&format!("**Created:** {}\n", self.context.created_at));
md.push_str(&format!("**Updated:** {}\n\n", self.context.updated_at));
if !self.items.is_empty() {
md.push_str("## Context Items\n\n");
let mut by_type: std::collections::HashMap<String, Vec<&ContextItem>> =
std::collections::HashMap::new();
for item in &self.items {
by_type
.entry(item.item_type.clone())
.or_default()
.push(item);
}
for (item_type, items) in by_type {
md.push_str(&format!("### {}\n\n", item_type.to_uppercase()));
for item in items {
md.push_str(&format!("- **{}**: {}\n", item.key, item.value));
}
md.push('\n');
}
}
md
}
}
impl<'a> ContextManager<'a> {
pub fn create_snapshot(
&self,
name: String,
description: Option<String>,
include_git: bool,
conversation: Option<Vec<ConversationTurn>>,
) -> Result<ContextSnapshot> {
let timestamp = Utc::now().to_rfc3339();
let (git_diff, git_branch) = if include_git {
(self.get_git_diff()?, self.get_git_branch()?)
} else {
(None, None)
};
let modified_files = self.detect_modified_files()?;
Ok(ContextSnapshot {
version: "1.0".to_string(),
created_at: timestamp,
project_name: self.project_name.clone(),
project_path: self.db.project_root.to_string_lossy().to_string(),
name,
description,
conversation,
modified_files,
git_diff,
git_branch,
tasks: vec![],
notes: vec![],
relevant_files: vec![],
code_snippets: vec![],
})
}
pub fn export_snapshot(&self, snapshot: &ContextSnapshot, output_path: &str) -> Result<()> {
let json = serde_json::to_string_pretty(snapshot)?;
std::fs::write(output_path, json)?;
Ok(())
}
pub fn import_snapshot(&self, input_path: &str) -> Result<ContextSnapshot> {
let json = std::fs::read_to_string(input_path)?;
let snapshot: ContextSnapshot = serde_json::from_str(&json)?;
Ok(snapshot)
}
pub fn snapshot_to_context(
&self,
snapshot: ContextSnapshot,
new_name: Option<String>,
) -> Result<String> {
let name = new_name.unwrap_or(snapshot.name);
let description = snapshot.description.unwrap_or_else(|| {
format!(
"Imported from: {} ({})",
snapshot.project_name, snapshot.created_at
)
});
let context = self.save_context(Some(name), Some(description))?;
let context_id = context.id.clone();
if let Some(conv) = snapshot.conversation {
for (i, turn) in conv.iter().enumerate() {
let value = serde_json::json!({
"role": turn.role,
"content": turn.content,
"timestamp": turn.timestamp
})
.to_string();
self.add_item(&context_id, &format!("conv_{}", i), &value, ItemType::Note)?;
}
}
for file in snapshot.modified_files {
self.add_file_reference(&context_id, &file.path, &file.change_type)?;
}
if let Some(diff) = snapshot.git_diff {
self.add_note(&context_id, "git_diff", &diff)?;
}
for task in snapshot.tasks {
self.add_task(&context_id, &task.description, &task.status)?;
}
for (i, note) in snapshot.notes.iter().enumerate() {
self.add_note(&context_id, &format!("note_{}", i), note)?;
}
Ok(context_id)
}
fn get_git_diff(&self) -> Result<Option<String>> {
use std::process::Command;
let output = Command::new("git")
.args(&["diff", "--staged"])
.current_dir(&self.db.project_root)
.output()?;
if output.status.success() {
let diff = String::from_utf8_lossy(&output.stdout).to_string();
Ok(if diff.is_empty() { None } else { Some(diff) })
} else {
Ok(None)
}
}
fn get_git_branch(&self) -> Result<Option<String>> {
use std::process::Command;
let output = Command::new("git")
.args(&["branch", "--show-current"])
.current_dir(&self.db.project_root)
.output()?;
if output.status.success() {
let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(if branch.is_empty() {
None
} else {
Some(branch)
})
} else {
Ok(None)
}
}
fn detect_modified_files(&self) -> Result<Vec<FileChange>> {
use std::process::Command;
let output = Command::new("git")
.args(&["status", "--porcelain"])
.current_dir(&self.db.project_root)
.output()?;
let mut files = Vec::new();
if output.status.success() {
let status = String::from_utf8_lossy(&output.stdout);
for line in status.lines() {
if line.len() < 4 {
continue;
}
let status_code = &line[..2];
let path = line[3..].to_string();
let change_type = match status_code.trim() {
"M" | "MM" => "modified",
"A" | "AM" => "added",
"D" => "deleted",
"R" => "renamed",
"??" => "untracked",
_ => "unknown",
}
.to_string();
files.push(FileChange {
path,
change_type,
summary: None,
});
}
}
Ok(files)
}
}