Skip to main content

termesh_workspace/
drafts.rs

1//! Crash-recovery drafts persisted through [`FileSystemService`] (ADR-0014 ยง5).
2
3use serde::{Deserialize, Serialize};
4use std::path::{Path, PathBuf};
5use std::time::{Duration, SystemTime, UNIX_EPOCH};
6use termesh_filesystem::{EntryKind, FileSystemService, FsError, FsResult};
7
8const DRAFT_VERSION: u32 = 1;
9pub const RETENTION: Duration = Duration::from_secs(60 * 60 * 24 * 14);
10
11/// Unsaved buffer text mirrored outside the project. Recovery only offers this text;
12/// applying it remains an explicit transaction in the application model.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct Draft {
15    pub path: PathBuf,
16    pub saved_at: SystemTime,
17    pub text: String,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct DraftDiagnostic {
22    pub file: PathBuf,
23    pub problem: String,
24    pub fallback: String,
25}
26
27#[derive(Serialize, Deserialize)]
28struct StoredDraft {
29    version: u32,
30    path: PathBuf,
31    saved_at_unix_seconds: u64,
32    text: String,
33}
34
35/// Stable, dependency-free FNV-1a over the absolute path. The readable suffix is only
36/// for humans inspecting the directory; identity comes from the whole path hash.
37pub fn draft_file_name(path: &Path) -> PathBuf {
38    let mut hash = 0xcbf29ce484222325u64;
39    for byte in path.to_string_lossy().as_bytes() {
40        hash ^= u64::from(*byte);
41        hash = hash.wrapping_mul(0x100000001b3);
42    }
43    let basename = path
44        .file_name()
45        .unwrap_or(path.as_os_str())
46        .to_string_lossy()
47        .chars()
48        .take(48)
49        .map(|value| {
50            if value.is_ascii_alphanumeric() || matches!(value, '.' | '-' | '_') {
51                value
52            } else {
53                '_'
54            }
55        })
56        .collect::<String>();
57    PathBuf::from(format!("{hash:016x}-{basename}.toml"))
58}
59
60pub fn encode_draft(draft: &Draft, file: &Path) -> FsResult<Vec<u8>> {
61    let saved_at_unix_seconds = draft
62        .saved_at
63        .duration_since(UNIX_EPOCH)
64        .map_err(|error| FsError::Other { path: file.to_path_buf(), message: error.to_string() })?
65        .as_secs();
66    let stored = StoredDraft {
67        version: DRAFT_VERSION,
68        path: draft.path.clone(),
69        saved_at_unix_seconds,
70        text: draft.text.clone(),
71    };
72    toml::to_string_pretty(&stored)
73        .map(String::into_bytes)
74        .map_err(|error| FsError::Other { path: file.to_path_buf(), message: error.to_string() })
75}
76
77pub fn parse_draft(bytes: &[u8], file: &Path) -> FsResult<Draft> {
78    let text = std::str::from_utf8(bytes).map_err(|error| FsError::Other {
79        path: file.to_path_buf(),
80        message: format!("draft is not valid UTF-8: {error}"),
81    })?;
82    let stored: StoredDraft = toml::from_str(text)
83        .map_err(|error| FsError::Other { path: file.to_path_buf(), message: error.to_string() })?;
84    if stored.version > DRAFT_VERSION {
85        return Err(FsError::Other {
86            path: file.to_path_buf(),
87            message: format!(
88                "draft version {} is newer than this build understands",
89                stored.version
90            ),
91        });
92    }
93    let saved_at = UNIX_EPOCH
94        .checked_add(Duration::from_secs(stored.saved_at_unix_seconds))
95        .ok_or(FsError::Other {
96            path: file.to_path_buf(),
97            message: "draft timestamp is out of range".into(),
98        })?;
99    Ok(Draft { path: stored.path, saved_at, text: stored.text })
100}
101
102pub fn write_draft(
103    fs: &dyn FileSystemService,
104    drafts_dir: &Path,
105    draft: &Draft,
106) -> FsResult<PathBuf> {
107    fs.create_dir(drafts_dir)?;
108    let file = drafts_dir.join(draft_file_name(&draft.path));
109    let bytes = encode_draft(draft, &file)?;
110    fs.write_file(&file, &bytes)?;
111    Ok(file)
112}
113
114/// Load valid drafts for one workspace. A corrupt sibling is diagnosed and skipped;
115/// one bad recovery file must not hide the rest.
116pub fn drafts_for(
117    fs: &dyn FileSystemService,
118    drafts_dir: &Path,
119    workspace_root: &Path,
120) -> FsResult<(Vec<Draft>, Vec<DraftDiagnostic>)> {
121    let entries = match fs.read_dir(drafts_dir) {
122        Ok(entries) => entries,
123        Err(FsError::NotFound(_)) => return Ok((Vec::new(), Vec::new())),
124        Err(error) => return Err(error),
125    };
126    let mut drafts = Vec::new();
127    let mut diagnostics = Vec::new();
128    for entry in entries {
129        if entry.kind != EntryKind::File {
130            continue;
131        }
132        let result = fs.read_file(&entry.path).and_then(|bytes| parse_draft(&bytes, &entry.path));
133        match result {
134            Ok(draft) if draft.path.starts_with(workspace_root) => drafts.push(draft),
135            Ok(_) => {}
136            Err(error) => diagnostics.push(DraftDiagnostic {
137                file: entry.path,
138                problem: error.to_string(),
139                fallback: "skipping this draft".into(),
140            }),
141        }
142    }
143    drafts.sort_by(|left, right| left.path.cmp(&right.path));
144    Ok((drafts, diagnostics))
145}
146
147pub fn reap_drafts(
148    fs: &dyn FileSystemService,
149    drafts_dir: &Path,
150    now: SystemTime,
151    retention: Duration,
152) -> FsResult<usize> {
153    let entries = match fs.read_dir(drafts_dir) {
154        Ok(entries) => entries,
155        Err(FsError::NotFound(_)) => return Ok(0),
156        Err(error) => return Err(error),
157    };
158    let mut reaped = 0;
159    for entry in entries {
160        if entry.kind != EntryKind::File {
161            continue;
162        }
163        let Ok(bytes) = fs.read_file(&entry.path) else { continue };
164        let Ok(draft) = parse_draft(&bytes, &entry.path) else { continue };
165        if now.duration_since(draft.saved_at).is_ok_and(|age| age >= retention) {
166            fs.remove_file(&entry.path)?;
167            reaped += 1;
168        }
169    }
170    Ok(reaped)
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use std::path::Path;
177    use std::time::{Duration, UNIX_EPOCH};
178    use termesh_test_support::FakeFileSystem;
179
180    #[test]
181    fn two_projects_with_the_same_relative_path_do_not_collide() {
182        let a = draft_file_name(Path::new("/a/src/main.rs"));
183        let b = draft_file_name(Path::new("/b/src/main.rs"));
184        assert_ne!(a, b);
185        assert!(a.to_string_lossy().contains("main.rs"));
186        assert!(b.to_string_lossy().contains("main.rs"));
187    }
188
189    #[test]
190    fn a_draft_round_trips_through_the_filesystem_service() {
191        let fs = FakeFileSystem::new();
192        let draft = Draft {
193            path: "/proj/src/main.rs".into(),
194            saved_at: UNIX_EPOCH + Duration::from_secs(1234),
195            text: "// unsaved\n".into(),
196        };
197
198        write_draft(&fs, Path::new("/cfg/drafts"), &draft).unwrap();
199        let (drafts, diagnostics) =
200            drafts_for(&fs, Path::new("/cfg/drafts"), Path::new("/proj")).unwrap();
201
202        assert!(diagnostics.is_empty());
203        assert_eq!(drafts, [draft]);
204    }
205
206    #[test]
207    fn drafts_older_than_the_retention_window_are_reaped() {
208        let fs = FakeFileSystem::new();
209        let now = UNIX_EPOCH + Duration::from_secs(60 * 60 * 24 * 60);
210        let old = Draft {
211            path: "/proj/old.rs".into(),
212            saved_at: now - Duration::from_secs(60 * 60 * 24 * 30),
213            text: "old".into(),
214        };
215        let current =
216            Draft { path: "/proj/current.rs".into(), saved_at: now, text: "current".into() };
217        write_draft(&fs, Path::new("/cfg/drafts"), &old).unwrap();
218        write_draft(&fs, Path::new("/cfg/drafts"), &current).unwrap();
219
220        assert_eq!(reap_drafts(&fs, Path::new("/cfg/drafts"), now, RETENTION).unwrap(), 1);
221        let (drafts, _) = drafts_for(&fs, Path::new("/cfg/drafts"), Path::new("/proj")).unwrap();
222        assert_eq!(drafts, [current]);
223    }
224}