use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DirtyMirror {
pub content: String,
pub cursor_row: usize,
pub cursor_col: usize,
pub captured_at: String,
}
impl DirtyMirror {
pub fn new(content: String, cursor_row: usize, cursor_col: usize) -> Self {
Self {
content,
cursor_row,
cursor_col,
captured_at: chrono::Utc::now()
.format("%Y-%m-%dT%H:%M:%SZ")
.to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RescueOutcome {
pub paragraph_rel_path: String,
pub rescue_path: String,
pub bytes: usize,
pub cursor_row: usize,
pub cursor_col: usize,
pub mirror_captured_at: String,
pub error: Option<String>,
}
pub fn flush_dirty_buffers(
project_path: Option<&std::path::Path>,
mirrors: &std::collections::HashMap<String, DirtyMirror>,
) -> Vec<RescueOutcome> {
let Some(project) = project_path else {
return Vec::new();
};
let mut outcomes = Vec::with_capacity(mirrors.len());
for (rel_path, mirror) in mirrors {
let abs_path = project.join(rel_path);
let rescue_path = rescue_path_for(&abs_path);
let bytes = mirror.content.len();
let err = super::write_atomic(&rescue_path, mirror.content.as_bytes())
.err()
.map(|e| e.to_string());
outcomes.push(RescueOutcome {
paragraph_rel_path: rel_path.clone(),
rescue_path: rescue_path.display().to_string(),
bytes,
cursor_row: mirror.cursor_row,
cursor_col: mirror.cursor_col,
mirror_captured_at: mirror.captured_at.clone(),
error: err,
});
}
outcomes
}
pub(crate) fn rescue_path_for(paragraph_path: &std::path::Path) -> std::path::PathBuf {
let mut s = paragraph_path.as_os_str().to_os_string();
s.push(".inkhaven-rescue");
std::path::PathBuf::from(s)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rescue_path_appends_extension() {
let p = std::path::Path::new("/tmp/proj/foo/bar.typ");
let rp = rescue_path_for(p);
assert_eq!(
rp.to_string_lossy(),
"/tmp/proj/foo/bar.typ.inkhaven-rescue"
);
}
#[test]
fn flush_with_no_project_returns_empty() {
let mut mirrors = std::collections::HashMap::new();
mirrors.insert(
"x.typ".into(),
DirtyMirror::new("hi".into(), 0, 0),
);
let outcomes = flush_dirty_buffers(None, &mirrors);
assert!(outcomes.is_empty());
}
#[test]
fn flush_with_project_writes_rescue_files() {
let dir = std::env::temp_dir().join(format!(
"inkhaven-rescue-test-{}",
std::process::id()
));
std::fs::create_dir_all(dir.join("book/ch1")).unwrap();
let original = dir.join("book/ch1/para.typ");
std::fs::write(&original, "original body").unwrap();
let mut mirrors = std::collections::HashMap::new();
mirrors.insert(
"book/ch1/para.typ".into(),
DirtyMirror::new("dirty body in memory".into(), 2, 4),
);
let outcomes = flush_dirty_buffers(Some(&dir), &mirrors);
assert_eq!(outcomes.len(), 1);
let o = &outcomes[0];
assert!(o.error.is_none(), "unexpected error: {:?}", o.error);
assert_eq!(o.bytes, "dirty body in memory".len());
assert_eq!(o.cursor_row, 2);
assert_eq!(o.cursor_col, 4);
let rescue = dir.join("book/ch1/para.typ.inkhaven-rescue");
assert!(rescue.exists());
let body = std::fs::read_to_string(&rescue).unwrap();
assert_eq!(body, "dirty body in memory");
assert_eq!(std::fs::read_to_string(&original).unwrap(), "original body");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn flush_records_per_file_errors_without_aborting() {
let dir = std::env::temp_dir().join(format!(
"inkhaven-rescue-err-test-{}",
std::process::id()
));
std::fs::create_dir_all(dir.join("ok")).unwrap();
let mut mirrors = std::collections::HashMap::new();
mirrors.insert(
"ok/good.typ".into(),
DirtyMirror::new("ok body".into(), 0, 0),
);
mirrors.insert(
"nonexistent/missing-parent/bad.typ".into(),
DirtyMirror::new("bad body".into(), 0, 0),
);
let outcomes = flush_dirty_buffers(Some(&dir), &mirrors);
assert_eq!(outcomes.len(), 2);
let ok = outcomes
.iter()
.find(|o| o.paragraph_rel_path == "ok/good.typ")
.unwrap();
let bad = outcomes
.iter()
.find(|o| o.paragraph_rel_path == "nonexistent/missing-parent/bad.typ")
.unwrap();
assert!(ok.error.is_none());
assert!(bad.error.is_some(), "expected an error for missing parent");
let _ = std::fs::remove_dir_all(&dir);
}
}