use chrono::Utc;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::utils::{hex, CONTENT_HASH_BYTES};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EditContext {
#[serde(default)]
pub plan_mode: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub subagent_id: Option<String>,
#[serde(default)]
pub agent_depth: u8,
#[serde(skip_serializing_if = "Option::is_none")]
pub plan_step: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContentSnapshot {
pub content: String,
pub content_hash: String,
pub timestamp: String,
pub line_count: usize,
}
impl ContentSnapshot {
pub fn new(content: &str) -> Self {
Self {
content: content.to_string(),
content_hash: compute_hash(content),
timestamp: Utc::now().to_rfc3339(),
line_count: content.lines().count(),
}
}
pub fn empty() -> Self {
Self::new("")
}
pub fn lines(&self) -> Vec<&str> {
self.content.lines().collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AIEdit {
pub edit_id: String,
pub prompt: String,
pub prompt_index: u32,
pub tool: String,
pub before: ContentSnapshot,
pub after: ContentSnapshot,
pub timestamp: String,
#[serde(default, skip_serializing_if = "is_default_context")]
pub context: EditContext,
}
fn is_default_context(ctx: &EditContext) -> bool {
!ctx.plan_mode && ctx.subagent_id.is_none() && ctx.agent_depth == 0 && ctx.plan_step.is_none()
}
impl AIEdit {
pub fn new(
prompt: &str,
prompt_index: u32,
tool: &str,
before_content: &str,
after_content: &str,
) -> Self {
Self {
edit_id: uuid::Uuid::new_v4().to_string(),
prompt: prompt.to_string(),
prompt_index,
tool: tool.to_string(),
before: ContentSnapshot::new(before_content),
after: ContentSnapshot::new(after_content),
timestamp: Utc::now().to_rfc3339(),
context: EditContext::default(),
}
}
pub fn with_context(
prompt: &str,
prompt_index: u32,
tool: &str,
before_content: &str,
after_content: &str,
context: EditContext,
) -> Self {
Self {
edit_id: uuid::Uuid::new_v4().to_string(),
prompt: prompt.to_string(),
prompt_index,
tool: tool.to_string(),
before: ContentSnapshot::new(before_content),
after: ContentSnapshot::new(after_content),
timestamp: Utc::now().to_rfc3339(),
context,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileEditHistory {
pub path: String,
pub original: ContentSnapshot,
pub edits: Vec<AIEdit>,
pub was_new_file: bool,
}
impl FileEditHistory {
pub fn new(path: &str, original_content: Option<&str>) -> Self {
let (original, was_new) = match original_content {
Some(content) => (ContentSnapshot::new(content), false),
None => (ContentSnapshot::empty(), true),
};
Self {
path: path.to_string(),
original,
edits: Vec::new(),
was_new_file: was_new,
}
}
pub fn add_edit(&mut self, edit: AIEdit) {
self.edits.push(edit);
}
pub fn latest_ai_content(&self) -> &ContentSnapshot {
self.edits
.last()
.map(|e| &e.after)
.unwrap_or(&self.original)
}
pub fn prompts(&self) -> Vec<&str> {
self.edits.iter().map(|e| e.prompt.as_str()).collect()
}
pub fn find_matching_edit(&self, content_hash: &str) -> Option<&AIEdit> {
self.edits
.iter()
.find(|e| e.after.content_hash == content_hash)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LineAttribution {
pub line_number: u32,
pub content: String,
pub source: LineSource,
pub edit_id: Option<String>,
pub prompt_index: Option<u32>,
pub confidence: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type")]
pub enum LineSource {
Original,
AI { edit_id: String },
AIModified { edit_id: String, similarity: f64 },
Human,
Unknown,
}
impl LineSource {
pub fn is_ai(&self) -> bool {
matches!(self, LineSource::AI { .. } | LineSource::AIModified { .. })
}
pub fn is_human(&self) -> bool {
matches!(self, LineSource::Original | LineSource::Human)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileAttributionResult {
pub path: String,
pub lines: Vec<LineAttribution>,
pub summary: AttributionSummary,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttributionSummary {
pub total_lines: usize,
pub ai_lines: usize,
pub ai_modified_lines: usize,
pub human_lines: usize,
pub original_lines: usize,
pub unknown_lines: usize,
}
impl FileAttributionResult {
pub fn compute_summary(lines: &[LineAttribution]) -> AttributionSummary {
let mut summary = AttributionSummary {
total_lines: lines.len(),
ai_lines: 0,
ai_modified_lines: 0,
human_lines: 0,
original_lines: 0,
unknown_lines: 0,
};
for line in lines {
match &line.source {
LineSource::Original => summary.original_lines += 1,
LineSource::AI { .. } => summary.ai_lines += 1,
LineSource::AIModified { .. } => summary.ai_modified_lines += 1,
LineSource::Human => summary.human_lines += 1,
LineSource::Unknown => summary.unknown_lines += 1,
}
}
summary
}
}
pub fn compute_hash(content: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
let result = hasher.finalize();
hex::encode(&result[..CONTENT_HASH_BYTES])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_content_snapshot() {
let snapshot = ContentSnapshot::new("line1\nline2\nline3");
assert_eq!(snapshot.line_count, 3);
assert!(!snapshot.content_hash.is_empty());
}
#[test]
fn test_content_hash_consistency() {
let content = "hello world";
let hash1 = compute_hash(content);
let hash2 = compute_hash(content);
assert_eq!(hash1, hash2);
let hash3 = compute_hash("different");
assert_ne!(hash1, hash3);
}
#[test]
fn test_file_edit_history() {
let mut history = FileEditHistory::new("test.rs", Some("original content"));
assert!(!history.was_new_file);
assert_eq!(history.original.content, "original content");
let edit = AIEdit::new("Add function", 0, "Edit", "original content", "new content");
history.add_edit(edit);
assert_eq!(history.edits.len(), 1);
assert_eq!(history.latest_ai_content().content, "new content");
}
#[test]
fn test_new_file_history() {
let history = FileEditHistory::new("new.rs", None);
assert!(history.was_new_file);
assert!(history.original.content.is_empty());
}
}