1use std::collections::HashSet;
3use std::fs::{self, OpenOptions};
4use std::io::{self, Write};
5use std::path::{Path, PathBuf};
6use std::thread;
7use std::time::{Duration, SystemTime, UNIX_EPOCH};
8
9use rusqlite::{Connection, Error as SqlError, ErrorCode, OptionalExtension, params};
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12use thiserror::Error;
13
14pub const HASH_ALGORITHM: &str = "anchor-v1";
15const BASE: u32 = 62;
16const ANCHOR_SPACE: u32 = BASE * BASE * BASE;
17const PROBE_STRIDE: u32 = BASE * BASE + BASE + 1;
18const ALPHABET: &[u8; 62] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
19
20#[derive(Debug, Error)]
21pub enum AnchorError {
22 #[error("I/O error for {path}: {source}")]
23 Io {
24 path: PathBuf,
25 #[source]
26 source: io::Error,
27 },
28 #[error("SQLite error for {path}: {source}")]
29 Sql {
30 path: PathBuf,
31 #[source]
32 source: SqlError,
33 },
34 #[error("invalid UTF-8 in {path}")]
35 InvalidUtf8 { path: PathBuf },
36 #[error("missing file: {0}")]
37 MissingFile(PathBuf),
38 #[error("corrupt state at {path}: {reason}")]
39 CorruptState { path: PathBuf, reason: String },
40 #[error("strict undo refused for {path}: current hash is {actual}, expected {expected}")]
41 UndoConflict {
42 path: PathBuf,
43 expected: String,
44 actual: String,
45 },
46 #[error("strict anchor resolution failed for {path}: {reason}")]
47 Resolve { path: PathBuf, reason: String },
48 #[error("change not found: {0}")]
49 ChangeNotFound(String),
50 #[error("file hash conflict for {path}: current hash is {actual}, expected {expected}")]
51 HashConflict {
52 path: PathBuf,
53 expected: String,
54 actual: String,
55 },
56 #[error("atomic replace is unsupported on Windows without ReplaceFileW: {0}")]
57 UnsupportedAtomicReplace(PathBuf),
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
61pub struct AnchorLine {
62 pub anchor: String,
63 pub hash: String,
64 pub line: String,
65}
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
67pub struct Snapshot {
68 pub snapshot_id: String,
69 pub path: String,
70 pub checksum: String,
71 pub line_count: usize,
72 pub hashes: Vec<String>,
73 pub hash_algorithm: String,
74 pub updated_at: u64,
75 pub anchors: Vec<String>,
76}
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
78pub struct ChangeRecord {
79 pub change_id: String,
80 pub path: String,
81 pub before_hash: String,
82 pub after_hash: String,
83 pub before_content: String,
84 pub after_content: String,
85 pub parent_change_id: Option<String>,
86 pub created_at: u64,
87}
88
89pub struct StateStore {
90 root: PathBuf,
91}
92impl StateStore {
93 pub fn new(root: impl Into<PathBuf>) -> Self {
94 Self { root: root.into() }
95 }
96 fn db_path(&self) -> PathBuf {
97 self.root.join("anchor-state.sqlite3")
98 }
99 fn connect(&self) -> Result<Connection, AnchorError> {
100 fs::create_dir_all(&self.root).map_err(|e| io_error(&self.root, e))?;
101 let path = self.db_path();
102 match self.open_connection(&path).and_then(|c| {
103 init_schema(&c, &path)?;
104 Ok(c)
105 }) {
106 Ok(c) => Ok(c),
107 Err(first) if path.exists() => {
108 let corrupt = self
109 .root
110 .join(format!("anchor-state.sqlite3.corrupt-{}", now()));
111 let _ = fs::rename(&path, &corrupt);
112 let _ = fs::remove_file(format!("{}-wal", path.display()));
113 let _ = fs::remove_file(format!("{}-shm", path.display()));
114 self.open_connection(&path)
115 .and_then(|c| {
116 init_schema(&c, &path)?;
117 Ok(c)
118 })
119 .map_err(|_| first)
120 }
121 Err(e) => Err(e),
122 }
123 }
124 fn open_connection(&self, path: &Path) -> Result<Connection, AnchorError> {
125 let c = retry_busy(|| Connection::open(path), path)?;
126 retry_busy(|| c.busy_timeout(Duration::from_millis(2500)), path)?;
127 retry_busy(|| c.pragma_update(None, "journal_mode", "WAL"), path)?;
128 retry_busy(|| c.pragma_update(None, "synchronous", "NORMAL"), path)?;
129 Ok(c)
130 }
131 pub fn snapshot(&self, path: &Path) -> Result<Option<Snapshot>, AnchorError> {
132 let c = self.connect()?;
133 let mut q = c
134 .prepare("SELECT data FROM snapshots WHERE path=?1")
135 .map_err(|e| sql_error(&self.db_path(), e))?;
136 let value = q
137 .query_row(params![path.to_string_lossy()], |r| r.get::<_, Vec<u8>>(0))
138 .optional()
139 .map_err(|e| sql_error(&self.db_path(), e))?;
140 value
141 .map(|b| serde_json::from_slice(&b).map_err(|e| corrupt(&self.db_path(), e)))
142 .transpose()
143 }
144 pub fn put_snapshot(&self, snapshot: Snapshot) -> Result<(), AnchorError> {
145 validate_snapshot(&snapshot)?;
146 let data = serde_json::to_vec(&snapshot).map_err(|e| corrupt(&self.db_path(), e))?;
147 let c = self.connect()?;
148 retry_busy(|| c.execute("INSERT INTO snapshots(path,data) VALUES(?1,?2) ON CONFLICT(path) DO UPDATE SET data=excluded.data", params![snapshot.path, data]), &self.db_path()).map(|_| ())
149 }
150 pub fn record_change(&self, change: ChangeRecord) -> Result<(), AnchorError> {
151 let data = serde_json::to_vec(&change).map_err(|e| corrupt(&self.db_path(), e))?;
152 let c = self.connect()?;
153 retry_busy(|| c.execute("INSERT OR REPLACE INTO changes(change_id,path,created_at,data) VALUES(?1,?2,?3,?4)", params![change.change_id, change.path, change.created_at, data]), &self.db_path()).map(|_| ())
154 }
155 pub fn latest_change(&self, path: &Path) -> Result<Option<ChangeRecord>, AnchorError> {
156 let c = self.connect()?;
157 let data = c.query_row("SELECT data FROM changes WHERE path=?1 ORDER BY created_at DESC, change_id DESC LIMIT 1", params![path.to_string_lossy()], |r| r.get::<_, Vec<u8>>(0)).optional().map_err(|e| sql_error(&self.db_path(), e))?;
158 data.map(|b| serde_json::from_slice(&b).map_err(|e| corrupt(&self.db_path(), e)))
159 .transpose()
160 }
161 pub fn change(&self, change_id: &str) -> Result<Option<ChangeRecord>, AnchorError> {
162 let c = self.connect()?;
163 let data = c
164 .query_row(
165 "SELECT data FROM changes WHERE change_id=?1",
166 params![change_id],
167 |r| r.get::<_, Vec<u8>>(0),
168 )
169 .optional()
170 .map_err(|e| sql_error(&self.db_path(), e))?;
171 data.map(|b| serde_json::from_slice(&b).map_err(|e| corrupt(&self.db_path(), e)))
172 .transpose()
173 }
174 pub fn undo_strict(
175 &self,
176 path: &Path,
177 change: &ChangeRecord,
178 current: &[u8],
179 ) -> Result<(), AnchorError> {
180 let actual = file_hash(current);
181 if actual != change.after_hash {
182 return Err(AnchorError::UndoConflict {
183 path: path.to_path_buf(),
184 expected: change.after_hash.clone(),
185 actual,
186 });
187 }
188 atomic_replace(path, change.before_content.as_bytes())
189 }
190}
191
192fn init_schema(c: &Connection, path: &Path) -> Result<(), AnchorError> {
193 c.execute_batch("CREATE TABLE IF NOT EXISTS snapshots(path TEXT PRIMARY KEY, data BLOB NOT NULL); CREATE TABLE IF NOT EXISTS changes(change_id TEXT PRIMARY KEY, path TEXT NOT NULL, created_at INTEGER NOT NULL, data BLOB NOT NULL); CREATE INDEX IF NOT EXISTS changes_path_created ON changes(path, created_at);").map_err(|e| sql_error(path, e))
194}
195fn retry_busy<T, F: FnMut() -> Result<T, SqlError>>(
196 mut f: F,
197 path: &Path,
198) -> Result<T, AnchorError> {
199 for attempt in 0..6 {
200 match f() {
201 Ok(v) => return Ok(v),
202 Err(e) if is_busy(&e) && attempt < 5 => {
203 thread::sleep(Duration::from_millis(10 * (attempt + 1)))
204 }
205 Err(e) => return Err(sql_error(path, e)),
206 }
207 }
208 unreachable!()
209}
210fn is_busy(e: &SqlError) -> bool {
211 matches!(e, SqlError::SqliteFailure(x, _) if matches!(x.code, ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked))
212}
213fn corrupt(path: &Path, e: impl std::fmt::Display) -> AnchorError {
214 AnchorError::CorruptState {
215 path: path.to_path_buf(),
216 reason: e.to_string(),
217 }
218}
219fn sql_error(path: &Path, e: SqlError) -> AnchorError {
220 AnchorError::Sql {
221 path: path.to_path_buf(),
222 source: e,
223 }
224}
225fn io_error(path: &Path, source: io::Error) -> AnchorError {
226 AnchorError::Io {
227 path: path.to_path_buf(),
228 source,
229 }
230}
231fn validate_snapshot(s: &Snapshot) -> Result<(), AnchorError> {
232 if s.hash_algorithm != HASH_ALGORITHM
233 || s.hashes.len() != s.line_count
234 || s.anchors.len() != s.line_count
235 || s.anchors
236 .iter()
237 .any(|a| a.len() != 3 || !a.bytes().all(|b| ALPHABET.contains(&b)))
238 {
239 return Err(corrupt(Path::new(&s.path), "invalid snapshot"));
240 }
241 Ok(())
242}
243
244pub fn file_hash(content: &[u8]) -> String {
245 let mut h = Sha256::new();
246 h.update(content);
247 format!("sha256:{:x}", h.finalize())
248}
249pub fn canonical_line(line: &str) -> &str {
250 line.strip_suffix('\r').unwrap_or(line).trim_end()
251}
252pub fn anchor_for_hash(hash: u32) -> String {
253 let mut v = hash % ANCHOR_SPACE;
254 let mut out = [b'A'; 3];
255 for i in (0..3).rev() {
256 out[i] = ALPHABET[(v % BASE) as usize];
257 v /= BASE;
258 }
259 String::from_utf8(out.to_vec()).unwrap()
260}
261pub fn line_hash(line: &str) -> u32 {
262 xxh32(canonical_line(line).as_bytes(), 0) >> 14
263}
264pub fn anchors_for_lines(
265 lines: &[String],
266 previous: Option<&Snapshot>,
267) -> Result<Vec<String>, AnchorError> {
268 let mut used = HashSet::new();
269 let mut result = Vec::with_capacity(lines.len());
270 for (i, line) in lines.iter().enumerate() {
271 let hash = format!("{:08x}", line_hash(line));
272 let mut value = previous
273 .and_then(|s| (s.hashes.get(i) == Some(&hash)).then(|| s.anchors[i].clone()))
274 .filter(|a| used.insert(a.clone()));
275 if value.is_none() {
276 let mut slot = line_hash(line) % ANCHOR_SPACE;
277 for _ in 0..ANCHOR_SPACE {
278 let a = anchor_for_hash(slot);
279 if used.insert(a.clone()) {
280 value = Some(a);
281 break;
282 }
283 slot = (slot + PROBE_STRIDE) % ANCHOR_SPACE;
284 }
285 }
286 result.push(value.ok_or_else(|| corrupt(Path::new("anchors"), "anchor space exhausted"))?);
287 }
288 Ok(result)
289}
290pub fn make_snapshot(
291 path: &Path,
292 content: &str,
293 previous: Option<&Snapshot>,
294) -> Result<Snapshot, AnchorError> {
295 let lines = split_lines(content);
296 let hashes = lines
297 .iter()
298 .map(|l| format!("{:08x}", line_hash(l)))
299 .collect();
300 let anchors = anchors_for_lines(&lines, previous)?;
301 let checksum = file_hash(content.as_bytes());
302 Ok(Snapshot {
303 snapshot_id: format!("snap_{}", &checksum[7..23]),
304 path: path.to_string_lossy().into(),
305 checksum,
306 line_count: lines.len(),
307 hashes,
308 hash_algorithm: HASH_ALGORITHM.into(),
309 updated_at: now(),
310 anchors,
311 })
312}
313pub fn split_lines(content: &str) -> Vec<String> {
314 if content.is_empty() {
315 Vec::new()
316 } else {
317 content.split_inclusive('\n').map(str::to_owned).collect()
318 }
319}
320
321pub fn read_anchor_text(path: &Path, store: &StateStore) -> Result<String, AnchorError> {
323 let content = read_text(path)?;
324 let previous = store.snapshot(path)?;
325 let snapshot = make_snapshot(path, &content, previous.as_ref())?;
326 let lines = split_lines(&content);
327 let output = lines
328 .iter()
329 .enumerate()
330 .map(|(i, line)| {
331 format!(
332 "{}:{}│{}",
333 i + 1,
334 snapshot.anchors[i],
335 line.trim_end_matches(['\r', '\n'])
336 )
337 })
338 .collect::<Vec<_>>()
339 .join("\n");
340 store.put_snapshot(snapshot)?;
341 Ok(output)
342}
343
344#[derive(Debug, Clone, Copy, PartialEq, Eq)]
346pub enum MutationOperation {
347 Replace,
348 Insert,
349 Remove,
350}
351
352impl MutationOperation {
353 fn parse(value: &str) -> Result<Self, AnchorError> {
354 match value {
355 "replace" => Ok(Self::Replace),
356 "insert" => Ok(Self::Insert),
357 "remove" => Ok(Self::Remove),
358 _ => Err(AnchorError::Resolve {
359 path: PathBuf::new(),
360 reason: format!("unknown operation {value}"),
361 }),
362 }
363 }
364}
365
366fn read_text(path: &Path) -> Result<String, AnchorError> {
367 let bytes = fs::read(path).map_err(|e| {
368 if e.kind() == io::ErrorKind::NotFound {
369 AnchorError::MissingFile(path.to_path_buf())
370 } else {
371 io_error(path, e)
372 }
373 })?;
374 String::from_utf8(bytes).map_err(|_| AnchorError::InvalidUtf8 {
375 path: path.to_path_buf(),
376 })
377}
378
379fn resolve_endpoint(
380 path: &Path,
381 snapshot: &Snapshot,
382 lines: &[String],
383 endpoint: &str,
384) -> Result<usize, AnchorError> {
385 if endpoint.len() != 3 || !endpoint.bytes().all(|byte| ALPHABET.contains(&byte)) {
386 return Err(AnchorError::Resolve {
387 path: path.to_path_buf(),
388 reason: format!("invalid endpoint {endpoint}; expected a 3-character anchor"),
389 });
390 }
391 let anchor = endpoint;
392 let matches: Vec<_> = snapshot
393 .anchors
394 .iter()
395 .enumerate()
396 .filter(|(_, candidate)| candidate.as_str() == anchor)
397 .map(|(index, _)| index)
398 .collect();
399 let raw_count = lines
400 .iter()
401 .filter(|line| anchor_for_hash(line_hash(line)) == anchor)
402 .count();
403 if raw_count > 1 {
404 return Err(AnchorError::Resolve {
405 path: path.to_path_buf(),
406 reason: format!("ambiguous endpoint {endpoint}"),
407 });
408 }
409 if matches.len() != 1 {
410 return Err(AnchorError::Resolve {
411 path: path.to_path_buf(),
412 reason: format!("stale or missing endpoint {endpoint}"),
413 });
414 }
415 Ok(matches[0])
416}
417
418#[allow(clippy::too_many_arguments)]
420pub fn edit_by_anchor(
421 path: &Path,
422 operation: &str,
423 target: Option<&str>,
424 from: Option<&str>,
425 to: Option<&str>,
426 at: Option<&str>,
427 position: Option<&str>,
428 content: Option<&str>,
429 store: &StateStore,
430) -> Result<ChangeRecord, AnchorError> {
431 let op = MutationOperation::parse(operation).map_err(|e| AnchorError::Resolve {
432 path: path.to_path_buf(),
433 reason: e.to_string(),
434 })?;
435 let before = read_text(path)?;
436 let old_hash = file_hash(before.as_bytes());
437 let old_snapshot = store.snapshot(path)?;
438 let current_snapshot = make_snapshot(path, &before, old_snapshot.as_ref())?;
439 let old_lines = split_lines(&before);
440 let (start, end) = match op {
441 MutationOperation::Replace | MutationOperation::Remove => {
442 if let Some(t) = target {
443 let i = resolve_endpoint(path, ¤t_snapshot, &old_lines, t)?;
444 (i, i)
445 } else {
446 (
447 resolve_endpoint(
448 path,
449 ¤t_snapshot,
450 &old_lines,
451 from.ok_or_else(|| AnchorError::Resolve {
452 path: path.to_path_buf(),
453 reason: "from is required".into(),
454 })?,
455 )?,
456 resolve_endpoint(
457 path,
458 ¤t_snapshot,
459 &old_lines,
460 to.ok_or_else(|| AnchorError::Resolve {
461 path: path.to_path_buf(),
462 reason: "to is required".into(),
463 })?,
464 )?,
465 )
466 }
467 }
468 MutationOperation::Insert => {
469 let e = at.or(target).or(from).ok_or_else(|| AnchorError::Resolve {
470 path: path.to_path_buf(),
471 reason: "insert endpoint is required".into(),
472 })?;
473 let i = resolve_endpoint(path, ¤t_snapshot, &old_lines, e)?;
474 if position == Some("after") {
475 (i + 1, i + 1)
476 } else {
477 (i, i)
478 }
479 }
480 };
481 if start > end || end > old_lines.len() {
482 return Err(AnchorError::Resolve {
483 path: path.to_path_buf(),
484 reason: "range endpoints are reversed".into(),
485 });
486 }
487 let inserted = content.unwrap_or("");
488 let mut new_lines = old_lines[..start].to_vec();
489 if op != MutationOperation::Remove {
490 new_lines.extend(split_lines(inserted));
491 }
492 if op == MutationOperation::Insert {
493 new_lines.extend(old_lines[start..].iter().cloned());
494 } else {
495 new_lines.extend(old_lines[end + 1..].iter().cloned());
496 }
497 let after = new_lines.concat();
498 let new_hash = file_hash(after.as_bytes());
499 atomic_replace(path, after.as_bytes())?;
500 let snapshot = make_snapshot(path, &after, old_snapshot.as_ref())?;
501 store.put_snapshot(snapshot)?;
502 let change = ChangeRecord {
503 change_id: format!("change_{}_{}", now(), &new_hash[7..19]),
504 path: path.to_string_lossy().into(),
505 before_hash: old_hash,
506 after_hash: new_hash,
507 before_content: before,
508 after_content: after,
509 parent_change_id: store.latest_change(path)?.map(|c| c.change_id),
510 created_at: now(),
511 };
512 store.record_change(change.clone())?;
513 Ok(change)
514}
515
516pub fn overwrite_with_hash(
517 path: &Path,
518 expected_file_hash: &str,
519 content: &str,
520 store: &StateStore,
521) -> Result<ChangeRecord, AnchorError> {
522 let before = read_text(path)?;
523 let actual = file_hash(before.as_bytes());
524 if actual != expected_file_hash {
525 return Err(AnchorError::HashConflict {
526 path: path.to_path_buf(),
527 expected: expected_file_hash.into(),
528 actual,
529 });
530 }
531 let previous = store.snapshot(path)?;
532 atomic_replace(path, content.as_bytes())?;
533 let snapshot = make_snapshot(path, content, previous.as_ref())?;
534 store.put_snapshot(snapshot)?;
535 let after_hash = file_hash(content.as_bytes());
536 let change = ChangeRecord {
537 change_id: format!("change_{}_{}", now(), &after_hash[7..19]),
538 path: path.to_string_lossy().into(),
539 before_hash: actual,
540 after_hash,
541 before_content: before,
542 after_content: content.into(),
543 parent_change_id: store.latest_change(path)?.map(|c| c.change_id),
544 created_at: now(),
545 };
546 store.record_change(change.clone())?;
547 Ok(change)
548}
549
550pub fn undo_change(change_id: &str, store: &StateStore) -> Result<(), AnchorError> {
551 let change = store
552 .change(change_id)?
553 .ok_or_else(|| AnchorError::ChangeNotFound(change_id.into()))?;
554 let path = Path::new(&change.path);
555 let current = fs::read(path).map_err(|e| io_error(path, e))?;
556 store.undo_strict(path, &change, ¤t)?;
557 let previous = store.snapshot(path)?;
558 let restored = make_snapshot(path, &change.before_content, previous.as_ref())?;
559 store.put_snapshot(restored)
560}
561
562pub fn atomic_replace(path: &Path, bytes: &[u8]) -> Result<(), AnchorError> {
563 #[cfg(windows)]
564 {
565 let _ = bytes;
566 return Err(AnchorError::UnsupportedAtomicReplace(path.to_path_buf()));
567 }
568 #[cfg(unix)]
569 {
570 atomic_replace_unix(path, bytes)
571 }
572}
573#[cfg(unix)]
574fn atomic_replace_unix(path: &Path, bytes: &[u8]) -> Result<(), AnchorError> {
575 let parent = path.parent().unwrap_or(Path::new("."));
576 fs::create_dir_all(parent).map_err(|e| io_error(parent, e))?;
577 let tmp = parent.join(format!(
578 ".{}.anchor-tmp-{}",
579 path.file_name().and_then(|n| n.to_str()).unwrap_or("file"),
580 now()
581 ));
582 let result = (|| {
583 let mut f = OpenOptions::new()
584 .write(true)
585 .create_new(true)
586 .open(&tmp)
587 .map_err(|e| io_error(&tmp, e))?;
588 f.write_all(bytes).map_err(|e| io_error(&tmp, e))?;
589 f.sync_all().map_err(|e| io_error(&tmp, e))?;
590 fs::rename(&tmp, path).map_err(|e| io_error(path, e))
591 })();
592 if result.is_err() {
593 let _ = fs::remove_file(&tmp);
594 }
595 result
596}
597pub fn read_utf8(path: &Path) -> Result<String, AnchorError> {
598 let bytes = fs::read(path).map_err(|e| {
599 if e.kind() == io::ErrorKind::NotFound {
600 AnchorError::MissingFile(path.to_path_buf())
601 } else {
602 io_error(path, e)
603 }
604 })?;
605 String::from_utf8(bytes).map_err(|_| AnchorError::InvalidUtf8 {
606 path: path.to_path_buf(),
607 })
608}
609fn now() -> u64 {
610 SystemTime::now()
611 .duration_since(UNIX_EPOCH)
612 .unwrap_or_default()
613 .as_secs()
614}
615fn xxh32(input: &[u8], seed: u32) -> u32 {
616 const P1: u32 = 0x9E3779B1;
617 const P2: u32 = 0x85EBCA77;
618 const P3: u32 = 0xC2B2AE3D;
619 const P4: u32 = 0x27D4EB2F;
620 const P5: u32 = 0x165667B1;
621 fn r(x: u32, n: u32) -> u32 {
622 x.rotate_left(n)
623 }
624 let mut h = seed.wrapping_add(P5).wrapping_add(input.len() as u32);
625 let mut i = 0;
626 while i + 4 <= input.len() {
627 h = h
628 .wrapping_add(u32::from_le_bytes(input[i..i + 4].try_into().unwrap()).wrapping_mul(P3));
629 h = r(h, 17).wrapping_mul(P4);
630 i += 4;
631 }
632 while i < input.len() {
633 h = h.wrapping_add((input[i] as u32).wrapping_mul(P5));
634 h = r(h, 11).wrapping_mul(P1);
635 i += 1;
636 }
637 h ^= h >> 15;
638 h = h.wrapping_mul(P2);
639 h ^= h >> 13;
640 h = h.wrapping_mul(P3);
641 h ^ (h >> 16)
642}