1use 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#[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
35pub 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
114pub 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"), ¤t).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}