use std::collections::HashMap;
use std::fs::{self, File};
use std::io::Write;
use std::path::{Path, PathBuf};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
#[cfg(unix)]
use std::os::unix::io::AsRawFd;
#[cfg(unix)]
extern crate libc;
use anyhow::{Context, Result};
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::capture::snapshot::{AIEdit, EditContext, FileEditHistory};
use crate::core::attribution::ModelInfo;
use crate::privacy::redaction::{RedactionEvent, Redactor};
const PENDING_FILE: &str = ".whogitit-pending.json";
pub const DEFAULT_MAX_PENDING_AGE_HOURS: i64 = 24;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionInfo {
pub session_id: String,
pub model: ModelInfo,
pub started_at: String,
pub prompt_count: u32,
pub prompts: Vec<PromptRecord>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PromptRecord {
pub index: u32,
pub text: String,
pub timestamp: String,
pub affected_files: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub redaction_events: Vec<RedactionEvent>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingBuffer {
pub version: u8,
pub session: SessionInfo,
pub file_histories: HashMap<String, FileEditHistory>,
pub prompt_counter: u32,
#[serde(default)]
pub audit_logging_enabled: bool,
#[serde(default)]
pub total_redactions: u32,
}
impl PendingBuffer {
pub fn new(session_id: &str, model_id: &str) -> Self {
Self {
version: 3,
session: SessionInfo {
session_id: session_id.to_string(),
model: ModelInfo::claude(model_id),
started_at: Utc::now().to_rfc3339(),
prompt_count: 0,
prompts: Vec::new(),
},
file_histories: HashMap::new(),
prompt_counter: 0,
audit_logging_enabled: false,
total_redactions: 0,
}
}
pub fn new_with_audit(session_id: &str, model_id: &str) -> Self {
let mut buffer = Self::new(session_id, model_id);
buffer.audit_logging_enabled = true;
buffer
}
pub fn new_session(model_id: &str) -> Self {
let session_id = Uuid::new_v4().to_string();
Self::new(&session_id, model_id)
}
pub fn record_edit(
&mut self,
path: &str,
old_content: Option<&str>,
new_content: &str,
tool: &str,
prompt: &str,
redactor: Option<&Redactor>,
) {
let (redacted_prompt, redaction_events) = match redactor {
Some(r) if self.audit_logging_enabled => {
let result = r.redact_with_audit(prompt);
self.total_redactions += result.redaction_count as u32;
(result.text, result.events)
}
Some(r) => (r.redact(prompt), Vec::new()),
None => (prompt.to_string(), Vec::new()),
};
let prompt_index = self.prompt_counter;
self.prompt_counter += 1;
self.session.prompt_count = self.prompt_counter;
self.session.prompts.push(PromptRecord {
index: prompt_index,
text: redacted_prompt.clone(),
timestamp: Utc::now().to_rfc3339(),
affected_files: vec![path.to_string()],
redaction_events,
});
let history = self
.file_histories
.entry(path.to_string())
.or_insert_with(|| FileEditHistory::new(path, old_content));
let before_content = if history.edits.is_empty() {
old_content.unwrap_or("")
} else {
&history.latest_ai_content().content
};
let edit = AIEdit::new(
&redacted_prompt,
prompt_index,
tool,
before_content,
new_content,
);
history.add_edit(edit);
}
#[allow(clippy::too_many_arguments)]
pub fn record_edit_with_context(
&mut self,
path: &str,
old_content: Option<&str>,
new_content: &str,
tool: &str,
prompt: &str,
redactor: Option<&Redactor>,
context: Option<EditContext>,
) {
let (redacted_prompt, redaction_events) = match redactor {
Some(r) if self.audit_logging_enabled => {
let result = r.redact_with_audit(prompt);
self.total_redactions += result.redaction_count as u32;
(result.text, result.events)
}
Some(r) => (r.redact(prompt), Vec::new()),
None => (prompt.to_string(), Vec::new()),
};
let prompt_index = self.prompt_counter;
self.prompt_counter += 1;
self.session.prompt_count = self.prompt_counter;
self.session.prompts.push(PromptRecord {
index: prompt_index,
text: redacted_prompt.clone(),
timestamp: Utc::now().to_rfc3339(),
affected_files: vec![path.to_string()],
redaction_events,
});
let history = self
.file_histories
.entry(path.to_string())
.or_insert_with(|| FileEditHistory::new(path, old_content));
let before_content = if history.edits.is_empty() {
old_content.unwrap_or("")
} else {
&history.latest_ai_content().content
};
let edit = match context {
Some(ctx) => AIEdit::with_context(
&redacted_prompt,
prompt_index,
tool,
before_content,
new_content,
ctx,
),
None => AIEdit::new(
&redacted_prompt,
prompt_index,
tool,
before_content,
new_content,
),
};
history.add_edit(edit);
}
pub fn get_file_history(&self, path: &str) -> Option<&FileEditHistory> {
self.file_histories.get(path)
}
pub fn files(&self) -> Vec<&str> {
self.file_histories.keys().map(|s| s.as_str()).collect()
}
pub fn has_changes(&self) -> bool {
!self.file_histories.is_empty()
}
pub fn total_edits(&self) -> usize {
self.file_histories.values().map(|h| h.edits.len()).sum()
}
pub fn file_count(&self) -> usize {
self.file_histories.len()
}
pub fn total_lines(&self) -> u32 {
self.file_histories
.values()
.map(|h| {
h.edits
.iter()
.map(|e| {
let before_lines = e.before.line_count;
let after_lines = e.after.line_count;
after_lines.saturating_sub(before_lines) as u32
})
.sum::<u32>()
})
.sum()
}
pub fn clear(&mut self) {
self.file_histories.clear();
self.session.prompts.clear();
}
pub fn get_prompt(&self, index: u32) -> Option<&PromptRecord> {
self.session.prompts.iter().find(|p| p.index == index)
}
pub fn is_stale(&self) -> bool {
self.is_stale_hours(DEFAULT_MAX_PENDING_AGE_HOURS)
}
pub fn is_stale_hours(&self, max_hours: i64) -> bool {
if let Ok(started) = DateTime::parse_from_rfc3339(&self.session.started_at) {
let age = Utc::now().signed_duration_since(started);
age > Duration::hours(max_hours)
} else {
true
}
}
pub fn age_string(&self) -> String {
if let Ok(started) = DateTime::parse_from_rfc3339(&self.session.started_at) {
let age = Utc::now().signed_duration_since(started);
if age.num_hours() > 0 {
format!("{} hours ago", age.num_hours())
} else if age.num_minutes() > 0 {
format!("{} minutes ago", age.num_minutes())
} else {
"just now".to_string()
}
} else {
"unknown".to_string()
}
}
pub fn validate(&self) -> Result<(), String> {
if self.version != 2 && self.version != 3 {
return Err(format!("Unsupported buffer version: {}", self.version));
}
if Uuid::parse_str(&self.session.session_id).is_err() {
return Err("Invalid session ID format".to_string());
}
if self.session.prompt_count != self.session.prompts.len() as u32 {
return Err(format!(
"Prompt count mismatch: {} vs {}",
self.session.prompt_count,
self.session.prompts.len()
));
}
for (path, history) in &self.file_histories {
if history.edits.is_empty() {
return Err(format!("File '{}' has no edits", path));
}
}
Ok(())
}
}
const LOCK_FILE: &str = ".whogitit-pending.lock";
#[cfg(unix)]
fn acquire_lock(lock_path: &Path) -> Result<File> {
use std::io::ErrorKind;
let lock_file = File::create(lock_path).context("Failed to create lock file")?;
let fd = lock_file.as_raw_fd();
let result = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
if result != 0 {
let err = std::io::Error::last_os_error();
if err.kind() == ErrorKind::WouldBlock {
eprintln!(
"whogitit: Warning - another process is accessing the pending buffer, waiting..."
);
let result = unsafe { libc::flock(fd, libc::LOCK_EX) };
if result != 0 {
return Err(std::io::Error::last_os_error())
.context("Failed to acquire lock on pending buffer");
}
} else {
return Err(err).context("Failed to acquire lock on pending buffer");
}
}
Ok(lock_file)
}
#[cfg(not(unix))]
fn acquire_lock(_lock_path: &Path) -> Result<File> {
File::create(_lock_path).context("Failed to create lock file")
}
#[cfg(unix)]
fn release_lock(lock_file: &File) {
let fd = lock_file.as_raw_fd();
unsafe {
libc::flock(fd, libc::LOCK_UN);
}
}
#[cfg(not(unix))]
fn release_lock(_lock_file: &File) {
}
pub struct PendingStore {
file_path: PathBuf,
repo_root: PathBuf,
lock_path: PathBuf,
}
impl PendingStore {
pub fn new(repo_root: &Path) -> Self {
Self {
file_path: repo_root.join(PENDING_FILE),
repo_root: repo_root.to_path_buf(),
lock_path: repo_root.join(LOCK_FILE),
}
}
pub fn load(&self) -> Result<Option<PendingBuffer>> {
if !self.file_path.exists() {
return Ok(None);
}
let lock_file = acquire_lock(&self.lock_path)?;
let content =
fs::read_to_string(&self.file_path).context("Failed to read pending buffer file")?;
release_lock(&lock_file);
match serde_json::from_str::<PendingBuffer>(&content) {
Ok(buffer) => {
if let Err(e) = buffer.validate() {
eprintln!(
"whogitit: Warning - pending buffer validation failed: {}",
e
);
eprintln!("whogitit: The pending buffer may be corrupted. Run 'whogitit clear' to reset.");
}
if buffer.is_stale() {
eprintln!(
"whogitit: Warning - pending buffer is stale (started {})",
buffer.age_string()
);
eprintln!("whogitit: Consider running 'whogitit clear' if these changes are no longer relevant.");
}
Ok(Some(buffer))
}
Err(e) => {
eprintln!("whogitit: Warning - failed to parse pending buffer: {}", e);
let backup_name = format!(
".whogitit-pending.corrupted.{}",
chrono::Utc::now().format("%Y%m%d-%H%M%S")
);
let backup_path = self.repo_root.join(&backup_name);
if let Err(backup_err) = fs::copy(&self.file_path, &backup_path) {
eprintln!(
"whogitit: Warning - failed to backup corrupted file: {}",
backup_err
);
} else {
eprintln!(
"whogitit: Corrupted file backed up to: {}",
backup_path.display()
);
#[cfg(unix)]
{
if let Ok(metadata) = fs::metadata(&backup_path) {
let mut perms = metadata.permissions();
perms.set_mode(0o600);
let _ = fs::set_permissions(&backup_path, perms);
}
}
}
eprintln!("whogitit: Run 'whogitit clear' to reset and start fresh.");
Ok(None)
}
}
}
pub fn load_quiet(&self) -> Result<Option<PendingBuffer>> {
if !self.file_path.exists() {
return Ok(None);
}
let content =
fs::read_to_string(&self.file_path).context("Failed to read pending buffer file")?;
match serde_json::from_str::<PendingBuffer>(&content) {
Ok(buffer) => Ok(Some(buffer)),
Err(_) => Ok(None),
}
}
pub fn save(&self, buffer: &PendingBuffer) -> Result<()> {
if let Err(e) = buffer.validate() {
anyhow::bail!("Cannot save invalid buffer: {}", e);
}
let lock_file = acquire_lock(&self.lock_path)?;
let content =
serde_json::to_string_pretty(buffer).context("Failed to serialize pending buffer")?;
let temp_path = self.repo_root.join(".whogitit-pending.tmp");
let mut temp_file =
File::create(&temp_path).context("Failed to create temporary pending buffer file")?;
temp_file
.write_all(content.as_bytes())
.context("Failed to write to temporary pending buffer file")?;
temp_file
.sync_all()
.context("Failed to sync temporary pending buffer file")?;
drop(temp_file);
fs::rename(&temp_path, &self.file_path)
.context("Failed to rename temporary pending buffer file")?;
#[cfg(unix)]
{
let mut perms = fs::metadata(&self.file_path)?.permissions();
perms.set_mode(0o600);
fs::set_permissions(&self.file_path, perms)
.context("Failed to set permissions on pending buffer file")?;
}
release_lock(&lock_file);
Ok(())
}
pub fn delete(&self) -> Result<()> {
let temp_path = self.repo_root.join(".whogitit-pending.tmp");
if temp_path.exists() {
let _ = fs::remove_file(&temp_path);
}
if self.file_path.exists() {
fs::remove_file(&self.file_path).context("Failed to delete pending buffer file")?;
}
Ok(())
}
pub fn exists(&self) -> bool {
self.file_path.exists()
}
pub fn path(&self) -> &Path {
&self.file_path
}
pub fn backup(&self) -> Result<Option<PathBuf>> {
if !self.file_path.exists() {
return Ok(None);
}
let backup_name = format!(
".whogitit-pending.backup.{}",
Utc::now().format("%Y%m%d-%H%M%S")
);
let backup_path = self.repo_root.join(backup_name);
fs::copy(&self.file_path, &backup_path)
.context("Failed to create backup of pending buffer")?;
#[cfg(unix)]
{
let mut perms = fs::metadata(&backup_path)?.permissions();
perms.set_mode(0o600);
fs::set_permissions(&backup_path, perms)
.context("Failed to set permissions on backup file")?;
}
Ok(Some(backup_path))
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_record_edit_new_file() {
let mut buffer = PendingBuffer::new("test-session", "claude-opus-4-5-20251101");
buffer.record_edit(
"src/new.rs",
None, "fn new_function() {}\n",
"Write",
"Create new file",
None,
);
assert!(buffer.has_changes());
assert_eq!(buffer.file_count(), 1);
let history = buffer.get_file_history("src/new.rs").unwrap();
assert!(history.was_new_file);
assert_eq!(history.edits.len(), 1);
assert_eq!(history.edits[0].prompt, "Create new file");
assert_eq!(history.edits[0].after.content, "fn new_function() {}\n");
}
#[test]
fn test_record_edit_existing_file() {
let mut buffer = PendingBuffer::new("test-session", "claude-opus-4-5-20251101");
buffer.record_edit(
"src/main.rs",
Some("fn main() {}\n"),
"fn main() {\n println!(\"Hello\");\n}\n",
"Edit",
"Add println statement",
None,
);
let history = buffer.get_file_history("src/main.rs").unwrap();
assert!(!history.was_new_file);
assert_eq!(history.original.content, "fn main() {}\n");
assert_eq!(history.edits[0].before.content, "fn main() {}\n");
assert!(history.edits[0].after.content.contains("println"));
}
#[test]
fn test_multiple_edits_same_file() {
let mut buffer = PendingBuffer::new("test-session", "claude-opus-4-5-20251101");
buffer.record_edit(
"test.rs",
Some("line1\n"),
"line1\nline2\n",
"Edit",
"Add line2",
None,
);
buffer.record_edit(
"test.rs",
None, "line1\nline2\nline3\n",
"Edit",
"Add line3",
None,
);
let history = buffer.get_file_history("test.rs").unwrap();
assert_eq!(history.edits.len(), 2);
assert_eq!(history.original.content, "line1\n");
assert_eq!(history.edits[0].prompt_index, 0);
assert_eq!(history.edits[1].prompt_index, 1);
assert_eq!(history.edits[1].before.content, "line1\nline2\n");
assert_eq!(history.edits[1].after.content, "line1\nline2\nline3\n");
}
#[test]
fn test_prompt_tracking() {
let mut buffer = PendingBuffer::new("test-session", "claude-opus-4-5-20251101");
buffer.record_edit("a.rs", None, "a\n", "Write", "prompt 1", None);
buffer.record_edit("b.rs", None, "b\n", "Write", "prompt 2", None);
assert_eq!(buffer.session.prompt_count, 2);
assert_eq!(buffer.session.prompts.len(), 2);
assert_eq!(buffer.session.prompts[0].text, "prompt 1");
assert_eq!(buffer.session.prompts[1].text, "prompt 2");
}
#[test]
fn test_store_roundtrip() {
let dir = TempDir::new().unwrap();
let store = PendingStore::new(dir.path());
let session_id = Uuid::new_v4().to_string();
let mut buffer = PendingBuffer::new(&session_id, "claude-opus-4-5-20251101");
buffer.record_edit(
"test.rs",
Some("before\n"),
"after\n",
"Edit",
"test prompt",
None,
);
store.save(&buffer).unwrap();
assert!(store.exists());
let loaded = store.load_quiet().unwrap().unwrap();
assert_eq!(loaded.session.session_id, session_id);
assert_eq!(loaded.file_count(), 1);
let history = loaded.get_file_history("test.rs").unwrap();
assert_eq!(history.original.content, "before\n");
assert_eq!(history.edits[0].after.content, "after\n");
store.delete().unwrap();
assert!(!store.exists());
}
#[test]
fn test_redaction() {
use crate::privacy::Redactor;
let mut buffer = PendingBuffer::new("test-session", "claude-opus-4-5-20251101");
let redactor = Redactor::default_patterns();
buffer.record_edit(
"config.rs",
None,
"api_key = \"secret\"\n",
"Write",
"Set api_key = sk-12345 for auth",
Some(&redactor),
);
let history = buffer.get_file_history("config.rs").unwrap();
assert!(!history.edits[0].prompt.contains("sk-12345"));
assert!(history.edits[0].prompt.contains("[REDACTED]"));
}
}