use super::record::{
invalid, millis, CohortHeader, DraftOrigin, DraftRecord, Snapshot, SourceObservation,
WorkspaceBinding, FORMAT_VERSION,
};
use crate::session::{persistence, SessionError};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use strop_core::id::{BufferRevision, DocumentId};
const MAGIC: [u8; 8] = *b"STROPDR1";
const HEADER_LEN_BYTES: u64 = 8;
const HEADER_SLACK: u64 = 64 * 1024;
pub struct PendingRecord {
pub document: DocumentId,
pub origin: DraftOrigin,
pub revision: BufferRevision,
pub source_path: Option<PathBuf>,
pub text: ropey::Rope,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PublishedRecord {
pub document: DocumentId,
pub revision: BufferRevision,
pub captured: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Published {
pub cohort: u64,
pub captured_ms: u64,
pub bytes: u64,
pub records: Vec<PublishedRecord>,
pub over_bound: Vec<(DocumentId, u64)>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StoredCohort {
pub header: CohortHeader,
pub texts: Vec<Option<Vec<u8>>>,
}
pub fn checkpoint_path(base_dir: &Path, cwd: &Path) -> PathBuf {
use std::hash::{Hash, Hasher};
let mut hasher = std::hash::DefaultHasher::new();
cwd.hash(&mut hasher);
let key = format!("{:016x}", hasher.finish());
base_dir
.join("strop")
.join("recovery")
.join(format!("{key}.recovery"))
}
fn rope_bytes(rope: &ropey::Rope) -> Vec<u8> {
let mut bytes = Vec::with_capacity(rope.len_bytes());
for chunk in rope.chunks() {
bytes.extend_from_slice(chunk.as_bytes());
}
bytes
}
fn observe(path: &Path) -> Option<SourceObservation> {
let metadata = std::fs::metadata(path).ok()?;
Some(SourceObservation {
modified_ms: metadata.modified().map_or(0, millis),
len: metadata.len(),
})
}
pub fn publish_cohort(
path: &Path,
cohort: u64,
captured_ms: u64,
workspace: WorkspaceBinding,
records: Vec<PendingRecord>,
) -> Result<Published, SessionError> {
let mut drafted = Vec::with_capacity(records.len());
for record in records {
let source = record.source_path.as_deref().and_then(observe);
drafted.push((record, source));
}
let mut budget = MAGIC.len() as u64 + HEADER_LEN_BYTES + HEADER_SLACK;
let mut snapshots: Vec<Snapshot> = Vec::with_capacity(drafted.len());
for (record, _) in &drafted {
let len = record.text.len_bytes() as u64;
if strop_core::cohortguard::fits(budget, len, persistence::MAX_BYTES) {
budget += len;
snapshots.push(Snapshot::Captured { text_len: len });
} else {
snapshots.push(Snapshot::OverBound { bytes: len });
}
}
let build_header = |snapshots: &[Snapshot]| -> Result<Vec<u8>, SessionError> {
let header = CohortHeader {
version: FORMAT_VERSION,
cohort,
captured_ms,
workspace: workspace.clone(),
records: drafted
.iter()
.zip(snapshots)
.map(|((record, source), snapshot)| DraftRecord {
origin: record.origin.clone(),
revision: record.revision,
document: record.document,
source: *source,
snapshot: snapshot.clone(),
})
.collect(),
};
Ok(serde_json::to_vec(&header)?)
};
let mut header = build_header(&snapshots)?;
let mut captured_bytes: u64 = snapshots
.iter()
.map(|snapshot| match snapshot {
Snapshot::Captured { text_len } => *text_len,
Snapshot::OverBound { .. } => 0,
})
.sum();
while MAGIC.len() as u64 + HEADER_LEN_BYTES + header.len() as u64 + captured_bytes
> persistence::MAX_BYTES
{
let Some(index) = snapshots
.iter()
.rposition(|snapshot| matches!(snapshot, Snapshot::Captured { .. }))
else {
return Err(invalid("header alone exceeds the capture limit".into()));
};
let Snapshot::Captured { text_len } = snapshots[index] else {
continue;
};
snapshots[index] = Snapshot::OverBound { bytes: text_len };
captured_bytes -= text_len;
header = build_header(&snapshots)?;
}
let mut texts = Vec::new();
for ((record, _), snapshot) in drafted.iter().zip(&snapshots) {
if matches!(snapshot, Snapshot::Captured { .. }) {
texts.push(rope_bytes(&record.text));
}
}
let total = MAGIC.len() as u64
+ HEADER_LEN_BYTES
+ header.len() as u64
+ texts.iter().map(|text| text.len() as u64).sum::<u64>();
persistence::publish(path, |file| {
use std::io::Write;
file.write_all(&MAGIC)?;
file.write_all(&(header.len() as u64).to_le_bytes())?;
file.write_all(&header)?;
for text in &texts {
file.write_all(text)?;
}
file.sync_all()
})?;
Ok(Published {
cohort,
captured_ms,
bytes: total,
records: drafted
.iter()
.zip(&snapshots)
.map(|((record, _), snapshot)| PublishedRecord {
document: record.document,
revision: record.revision,
captured: matches!(snapshot, Snapshot::Captured { .. }),
})
.collect(),
over_bound: drafted
.iter()
.zip(&snapshots)
.filter_map(|((record, _), snapshot)| match snapshot {
Snapshot::OverBound { bytes } => Some((record.document, *bytes)),
Snapshot::Captured { .. } => None,
})
.collect(),
})
}
pub fn load(path: &Path) -> Result<Option<StoredCohort>, SessionError> {
let bytes = match persistence::read(path) {
Ok(bytes) => bytes,
Err(SessionError::Io { source, .. }) if source.kind() == std::io::ErrorKind::NotFound => {
return Ok(None)
}
Err(error) => return Err(error),
};
if bytes.len() < MAGIC.len() + HEADER_LEN_BYTES as usize || bytes[..8] != MAGIC {
return Err(invalid("bad magic".into()));
}
let mut len_bytes = [0u8; 8];
len_bytes.copy_from_slice(&bytes[8..16]);
let header_len = u64::from_le_bytes(len_bytes) as usize;
let header_end = 16usize.saturating_add(header_len);
if header_end > bytes.len() {
return Err(invalid("truncated header".into()));
}
let header: CohortHeader = serde_json::from_slice(&bytes[16..header_end])?;
header.validate()?;
let mut texts = Vec::with_capacity(header.records.len());
let mut offset = header_end;
for record in &header.records {
match record.snapshot {
Snapshot::Captured { text_len } => {
let end = offset.saturating_add(text_len as usize);
if end > bytes.len() {
return Err(invalid("truncated draft text".into()));
}
texts.push(Some(bytes[offset..end].to_vec()));
offset = end;
}
Snapshot::OverBound { .. } => texts.push(None),
}
}
if offset != bytes.len() {
return Err(invalid("trailing bytes after the cohort".into()));
}
Ok(Some(StoredCohort { header, texts }))
}
pub fn discard(
path: &Path,
stored: &StoredCohort,
index: usize,
) -> Result<StoredCohort, SessionError> {
if index >= stored.header.records.len() {
return Err(invalid(format!("no recovery record #{}", index + 1)));
}
let kept: StoredCohort = StoredCohort {
header: CohortHeader {
records: stored
.header
.records
.iter()
.enumerate()
.filter(|(i, _)| *i != index)
.map(|(_, record)| record.clone())
.collect(),
..stored.header.clone()
},
texts: stored
.texts
.iter()
.enumerate()
.filter(|(i, _)| *i != index)
.map(|(_, text)| text.clone())
.collect(),
};
let header = serde_json::to_vec(&kept.header)?;
let captured: Vec<&[u8]> = kept.texts.iter().flatten().map(Vec::as_slice).collect();
let total = MAGIC.len() as u64
+ HEADER_LEN_BYTES
+ header.len() as u64
+ captured.iter().map(|text| text.len() as u64).sum::<u64>();
if total > persistence::MAX_BYTES {
return Err(invalid("cohort exceeds the capture limit".into()));
}
persistence::publish(path, |file| {
use std::io::Write;
file.write_all(&MAGIC)?;
file.write_all(&(header.len() as u64).to_le_bytes())?;
file.write_all(&header)?;
for text in &captured {
file.write_all(text)?;
}
file.sync_all()
})?;
Ok(kept)
}
pub fn now_ms() -> u64 {
millis(SystemTime::now())
}