1use crate::effect::Effect;
19use crate::meta::{self, Meta};
20use crate::store::Store;
21use fs2::FileExt;
22use serde::{Deserialize, Serialize};
23use std::collections::BTreeSet;
24use std::fs::{self, File, OpenOptions};
25use std::io::{self, BufRead, BufReader, Write};
26use std::path::{Component, Path, PathBuf};
27use std::time::{SystemTime, UNIX_EPOCH};
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31#[serde(tag = "type", rename_all = "snake_case")]
32pub enum Row {
33 Checkpoint {
34 id: String,
35 label: String,
36 ts: u64,
37 },
38 Effect {
39 seq: u64,
40 checkpoint: String,
41 ts: u64,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 agent: Option<String>,
44 effect: Effect,
45 },
46}
47
48#[derive(Debug, Default, Serialize, Deserialize)]
50struct State {
51 seq: u64,
53 checkpoint_high: u64,
55 current_checkpoint: Option<String>,
57 tracked: Vec<String>,
59}
60
61#[derive(Debug, Serialize, Deserialize)]
63struct RedoLog {
64 rows: Vec<Row>,
65 after: Vec<AfterSnap>,
66}
67
68#[derive(Debug, Serialize, Deserialize)]
69struct AfterSnap {
70 path: PathBuf,
71 state: AfterState,
72}
73
74#[derive(Debug, Serialize, Deserialize)]
75#[serde(tag = "t", rename_all = "snake_case")]
76enum AfterState {
77 File { blob: String, meta: Meta },
78 Symlink { target: PathBuf },
79 Dir { mode: u32 },
80 Absent,
81}
82
83#[derive(Debug, Serialize)]
85pub struct Status {
86 pub checkpoint: Option<(String, String)>,
87 pub effects: Vec<Effect>,
88}
89
90#[derive(Debug, Serialize)]
92pub struct RollbackReport {
93 pub checkpoint: String,
94 pub reverted: Vec<String>,
95 pub skipped: Vec<String>,
96 pub failed: Vec<String>,
97}
98
99#[derive(Debug, Serialize)]
101pub struct RedoReport {
102 pub restored: Vec<String>,
103 pub failed: Vec<String>,
104}
105
106pub struct Undo {
108 workdir: PathBuf,
109 root: PathBuf,
110 store: Store,
111}
112
113impl Undo {
114 pub fn dir_name() -> &'static str {
115 ".undo"
116 }
117
118 fn at(workdir: &Path) -> Undo {
119 let root = workdir.join(Self::dir_name());
120 let store = Store::new(root.join("objects"));
121 Undo {
122 workdir: workdir.to_path_buf(),
123 root,
124 store,
125 }
126 }
127
128 pub fn init(workdir: &Path) -> io::Result<Undo> {
131 let u = Undo::at(workdir);
132 fs::create_dir_all(&u.root)?;
133 u.store.ensure()?;
134 if !u.journal_path().exists() {
135 atomic_write(&u.journal_path(), b"")?;
136 }
137 if !u.state_path().exists() {
138 u.save_state(&State::default())?;
139 }
140 u.ensure_gitignore();
141 Ok(u)
142 }
143
144 pub fn discover(start: &Path) -> io::Result<Option<Undo>> {
146 let mut cur = Some(start.to_path_buf());
147 while let Some(dir) = cur {
148 if dir.join(Self::dir_name()).is_dir() {
149 let u = Undo::at(&dir);
150 u.ensure_state()?;
151 return Ok(Some(u));
152 }
153 cur = dir.parent().map(|p| p.to_path_buf());
154 }
155 Ok(None)
156 }
157
158 fn journal_path(&self) -> PathBuf {
159 self.root.join("journal.jsonl")
160 }
161 fn state_path(&self) -> PathBuf {
162 self.root.join("state.json")
163 }
164 fn redo_path(&self) -> PathBuf {
165 self.root.join("redo.json")
166 }
167
168 fn lock(&self) -> io::Result<File> {
170 let f = OpenOptions::new()
171 .create(true)
172 .write(true)
173 .truncate(false)
174 .open(self.root.join("lock"))?;
175 f.lock_exclusive()?;
176 Ok(f)
177 }
178
179 pub fn rows(&self) -> io::Result<Vec<Row>> {
183 let file = match fs::File::open(self.journal_path()) {
184 Ok(f) => f,
185 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(vec![]),
186 Err(e) => return Err(e),
187 };
188 let mut out = vec![];
189 for line in BufReader::new(file).lines() {
190 let line = line?;
191 if line.trim().is_empty() {
192 continue;
193 }
194 if let Ok(row) = serde_json::from_str::<Row>(&line) {
195 out.push(row);
196 }
197 }
198 Ok(out)
199 }
200
201 fn append_row(&self, row: &Row) -> io::Result<()> {
202 let mut f = OpenOptions::new()
203 .create(true)
204 .append(true)
205 .open(self.journal_path())?;
206 writeln!(f, "{}", serde_json::to_string(row).map_err(invalid_data)?)?;
207 f.sync_all()?;
208 Ok(())
209 }
210
211 fn rewrite_journal(&self, rows: &[Row]) -> io::Result<()> {
212 let mut buf = String::new();
213 for r in rows {
214 buf.push_str(&serde_json::to_string(r).map_err(invalid_data)?);
215 buf.push('\n');
216 }
217 atomic_write(&self.journal_path(), buf.as_bytes())
218 }
219
220 fn load_state(&self) -> io::Result<State> {
223 match fs::read(self.state_path()) {
224 Ok(b) => Ok(serde_json::from_slice(&b).unwrap_or_default()),
225 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(State::default()),
226 Err(e) => Err(e),
227 }
228 }
229
230 fn save_state(&self, s: &State) -> io::Result<()> {
231 let b = serde_json::to_vec_pretty(s).map_err(invalid_data)?;
232 atomic_write(&self.state_path(), &b)
233 }
234
235 fn rebuild_state(&self, rows: &[Row]) -> io::Result<()> {
237 let prev_high = self.load_state().map(|s| s.checkpoint_high).unwrap_or(0);
238 let mut st = State {
239 checkpoint_high: prev_high,
240 ..Default::default()
241 };
242 for r in rows {
243 match r {
244 Row::Checkpoint { id, .. } => {
245 st.current_checkpoint = Some(id.clone());
246 st.tracked.clear();
247 if let Some(n) = parse_cp(id) {
248 st.checkpoint_high = st.checkpoint_high.max(n);
249 }
250 }
251 Row::Effect { seq, effect, .. } => {
252 st.seq = st.seq.max(*seq);
253 if let Some(p) = effect.path() {
254 st.tracked.push(p.to_string_lossy().to_string());
255 }
256 }
257 }
258 }
259 self.save_state(&st)
260 }
261
262 fn ensure_state(&self) -> io::Result<()> {
263 if !self.state_path().exists() {
264 let rows = self.rows()?;
265 self.rebuild_state(&rows)?;
266 }
267 Ok(())
268 }
269
270 fn ensure_cp(&self, st: &mut State) -> io::Result<String> {
273 if let Some(id) = &st.current_checkpoint {
274 return Ok(id.clone());
275 }
276 st.checkpoint_high += 1;
277 let id = format!("cp{:03}", st.checkpoint_high);
278 st.current_checkpoint = Some(id.clone());
279 st.tracked.clear();
280 self.append_row(&Row::Checkpoint {
281 id: id.clone(),
282 label: "auto".to_string(),
283 ts: now_millis(),
284 })?;
285 Ok(id)
286 }
287
288 pub fn checkpoint(&self, label: &str) -> io::Result<String> {
292 let _lock = self.lock()?;
293 self.clear_redo();
294 let mut st = self.load_state()?;
295 st.checkpoint_high += 1;
296 let id = format!("cp{:03}", st.checkpoint_high);
297 st.current_checkpoint = Some(id.clone());
298 st.tracked.clear();
299 self.append_row(&Row::Checkpoint {
300 id: id.clone(),
301 label: label.to_string(),
302 ts: now_millis(),
303 })?;
304 self.save_state(&st)?;
305 Ok(id)
306 }
307
308 pub fn current_checkpoint(&self) -> io::Result<Option<String>> {
309 Ok(self.load_state()?.current_checkpoint)
310 }
311
312 pub fn workdir(&self) -> &Path {
314 &self.workdir
315 }
316
317 pub fn track(&self, path: &Path) -> io::Result<Vec<Effect>> {
321 let _lock = self.lock()?;
322 self.clear_redo();
323 let mut st = self.load_state()?;
324 let abs = self.resolve(path);
325 self.guard(&abs)?;
326
327 let abs_key = abs.to_string_lossy().to_string();
332 if st.tracked.iter().any(|t| t == &abs_key) {
333 return Ok(vec![]);
334 }
335
336 let cp = self.ensure_cp(&mut st)?;
337
338 let mut effects = vec![];
339 self.snapshot_path(&abs, &mut effects)?;
340
341 let mut tracked: BTreeSet<String> = st.tracked.iter().cloned().collect();
342 let mut recorded = vec![];
343 for e in effects {
344 let key = e
345 .path()
346 .map(|p| p.to_string_lossy().to_string())
347 .unwrap_or_default();
348 if !tracked.insert(key) {
349 continue; }
351 st.seq += 1;
352 self.append_row(&Row::Effect {
353 seq: st.seq,
354 checkpoint: cp.clone(),
355 ts: now_millis(),
356 agent: None,
357 effect: e.clone(),
358 })?;
359 recorded.push(e);
360 }
361 st.tracked = tracked.into_iter().collect();
362 self.save_state(&st)?;
363 Ok(recorded)
364 }
365
366 pub fn record(&self, effect: Effect, agent: Option<String>) -> io::Result<()> {
368 let _lock = self.lock()?;
369 self.clear_redo();
370 let mut st = self.load_state()?;
371 let cp = self.ensure_cp(&mut st)?;
372 st.seq += 1;
373 self.append_row(&Row::Effect {
374 seq: st.seq,
375 checkpoint: cp,
376 ts: now_millis(),
377 agent,
378 effect,
379 })?;
380 self.save_state(&st)?;
381 Ok(())
382 }
383
384 pub fn status(&self) -> io::Result<Status> {
387 let mut checkpoint = None;
388 let mut effects = vec![];
389 for r in self.rows()? {
390 match r {
391 Row::Checkpoint { id, label, .. } => {
392 checkpoint = Some((id, label));
393 effects.clear();
394 }
395 Row::Effect { effect, .. } => effects.push(effect),
396 }
397 }
398 Ok(Status {
399 checkpoint,
400 effects,
401 })
402 }
403
404 pub fn log(&self) -> io::Result<Vec<Row>> {
405 self.rows()
406 }
407
408 pub fn diff(&self) -> io::Result<Vec<crate::DiffEntry>> {
411 let status = self.status()?;
412 Ok(crate::diff::diff_effects(&status.effects, &self.store))
413 }
414
415 pub fn can_redo(&self) -> bool {
416 self.redo_path().exists()
417 }
418
419 pub fn rollback(&self, target: Option<&str>) -> io::Result<RollbackReport> {
425 let _lock = self.lock()?;
426 let rows = self.rows()?;
427 let cp_idx = match target {
428 Some(id) => rows
429 .iter()
430 .position(|r| matches!(r, Row::Checkpoint { id: cid, .. } if cid == id)),
431 None => rows
432 .iter()
433 .rposition(|r| matches!(r, Row::Checkpoint { .. })),
434 }
435 .ok_or_else(|| {
436 io::Error::new(
437 io::ErrorKind::NotFound,
438 "no matching checkpoint to roll back to",
439 )
440 })?;
441
442 let cp_id = match &rows[cp_idx] {
443 Row::Checkpoint { id, .. } => id.clone(),
444 _ => unreachable!(),
445 };
446
447 let after_rows: Vec<Row> = rows[cp_idx + 1..].to_vec();
448 let effects: Vec<Effect> = after_rows
449 .iter()
450 .filter_map(|r| match r {
451 Row::Effect { effect, .. } => Some(effect.clone()),
452 _ => None,
453 })
454 .collect();
455
456 let after = self.capture_after(&effects)?;
458
459 let mut reverted = vec![];
460 let mut skipped = vec![];
461 let mut failed = vec![];
462 for eff in effects.iter().rev() {
463 match self.invert(eff) {
464 Ok(Some(msg)) => reverted.push(msg),
465 Ok(None) => skipped.push(format!("{} (manual)", eff.describe())),
466 Err(e) => failed.push(format!("{} (error: {e})", eff.describe())),
467 }
468 }
469
470 if failed.is_empty() {
471 self.rewrite_journal(&rows[..=cp_idx])?;
472 self.rebuild_state(&rows[..=cp_idx])?;
473 self.save_redo(&RedoLog {
474 rows: after_rows,
475 after,
476 })?;
477 }
478 Ok(RollbackReport {
481 checkpoint: cp_id,
482 reverted,
483 skipped,
484 failed,
485 })
486 }
487
488 pub fn redo(&self) -> io::Result<RedoReport> {
491 let _lock = self.lock()?;
492 let redo = self
493 .load_redo()?
494 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "nothing to redo"))?;
495
496 let mut snaps = redo.after;
497 snaps.sort_by_key(order_key);
498
499 let mut restored = vec![];
500 let mut failed = vec![];
501 for s in &snaps {
502 match self.apply_after(s) {
503 Ok(msg) => restored.push(msg),
504 Err(e) => failed.push(format!("{} (error: {e})", s.path.display())),
505 }
506 }
507
508 if failed.is_empty() {
509 for r in &redo.rows {
510 self.append_row(r)?;
511 }
512 let rows = self.rows()?;
513 self.rebuild_state(&rows)?;
514 self.clear_redo();
515 }
516
517 Ok(RedoReport { restored, failed })
518 }
519
520 pub fn revert(&self, path: &Path) -> io::Result<Option<String>> {
525 let _lock = self.lock()?;
526 self.clear_redo();
527 let abs = self.resolve(path);
528 let rows = self.rows()?;
529
530 let idx = rows.iter().rposition(
531 |r| matches!(r, Row::Effect { effect, .. } if effect.path() == Some(abs.as_path())),
532 );
533 let Some(idx) = idx else {
534 return Ok(None);
535 };
536 let effect = match &rows[idx] {
537 Row::Effect { effect, .. } => effect.clone(),
538 _ => unreachable!(),
539 };
540
541 match effect {
542 Effect::File { .. } | Effect::PathCreate { .. } | Effect::Symlink { .. } => {
543 let msg = self.invert(&effect)?;
544 let mut kept = rows;
545 kept.remove(idx);
546 self.rewrite_journal(&kept)?;
547 self.rebuild_state(&kept)?;
548 Ok(msg)
549 }
550 _ => Err(io::Error::new(
551 io::ErrorKind::InvalidInput,
552 "selective revert is for single files — use rollback for directories",
553 )),
554 }
555 }
556
557 fn snapshot_path(&self, abs: &Path, out: &mut Vec<Effect>) -> io::Result<()> {
560 match fs::symlink_metadata(abs) {
561 Err(e) if e.kind() == io::ErrorKind::NotFound => {
562 out.push(Effect::PathCreate {
563 path: abs.to_path_buf(),
564 });
565 Ok(())
566 }
567 Err(e) => Err(e),
568 Ok(m) => {
569 let ft = m.file_type();
570 if ft.is_symlink() {
571 let target = fs::read_link(abs)?;
572 out.push(Effect::Symlink {
573 path: abs.to_path_buf(),
574 target,
575 });
576 } else if ft.is_dir() {
577 let mode = meta::capture(abs)?.mode;
578 let mut entries = vec![];
579 let mut children = vec![];
580 for ent in fs::read_dir(abs)? {
581 let ent = ent?;
582 let child = ent.path();
583 if child == self.root || is_ignored_name(&ent.file_name()) {
584 continue; }
586 entries.push(ent.file_name().to_string_lossy().to_string());
587 children.push(child);
588 }
589 entries.sort();
590 out.push(Effect::Dir {
591 path: abs.to_path_buf(),
592 mode,
593 entries,
594 });
595 for c in children {
596 self.snapshot_path(&c, out)?;
597 }
598 } else {
599 let blob = self.store.put_file(abs)?;
600 let meta = meta::capture(abs)?;
601 out.push(Effect::File {
602 path: abs.to_path_buf(),
603 prev_blob: blob,
604 meta,
605 });
606 }
607 Ok(())
608 }
609 }
610 }
611
612 fn invert(&self, eff: &Effect) -> io::Result<Option<String>> {
615 match eff {
616 Effect::PathCreate { path } => {
617 remove_any(path)?;
618 Ok(Some(format!("removed {}", path.display())))
619 }
620 Effect::File {
621 path,
622 prev_blob,
623 meta,
624 } => {
625 if let Some(parent) = path.parent() {
626 fs::create_dir_all(parent)?;
627 }
628 remove_if_incompatible(path)?;
629 let data = self.store.get(prev_blob)?;
630 atomic_write(path, &data)?;
631 meta::apply(path, *meta)?;
632 Ok(Some(format!("restored {}", path.display())))
633 }
634 Effect::Symlink { path, target } => {
635 remove_any(path)?;
636 if let Some(parent) = path.parent() {
637 fs::create_dir_all(parent)?;
638 }
639 symlink(target, path)?;
640 Ok(Some(format!("relinked {}", path.display())))
641 }
642 Effect::Dir {
643 path,
644 mode,
645 entries,
646 } => {
647 fs::create_dir_all(path)?;
648 meta::set_mode(path, *mode)?;
649 let keep: BTreeSet<&str> = entries.iter().map(String::as_str).collect();
650 for ent in fs::read_dir(path)? {
651 let ent = ent?;
652 let child = ent.path();
653 if child == self.root || is_ignored_name(&ent.file_name()) {
654 continue; }
656 let name = ent.file_name().to_string_lossy().to_string();
657 if !keep.contains(name.as_str()) {
658 remove_any(&child)?; }
660 }
661 Ok(Some(format!("dir {}", path.display())))
662 }
663 Effect::HttpMutation { .. } | Effect::Exec { .. } => Ok(None),
664 }
665 }
666
667 fn capture_after(&self, effects: &[Effect]) -> io::Result<Vec<AfterSnap>> {
673 let mut seen = BTreeSet::new();
674 let mut out = vec![];
675 for e in effects {
676 if let Some(p) = e.path() {
677 self.capture_after_path(p, &mut seen, &mut out)?;
678 }
679 }
680 Ok(out)
681 }
682
683 fn capture_after_path(
684 &self,
685 p: &Path,
686 seen: &mut BTreeSet<String>,
687 out: &mut Vec<AfterSnap>,
688 ) -> io::Result<()> {
689 if !seen.insert(p.to_string_lossy().to_string()) {
690 return Ok(());
691 }
692 let state = match fs::symlink_metadata(p) {
693 Err(e) if e.kind() == io::ErrorKind::NotFound => AfterState::Absent,
694 Err(e) => return Err(e),
695 Ok(m) => {
696 if m.file_type().is_symlink() {
697 AfterState::Symlink {
698 target: fs::read_link(p)?,
699 }
700 } else if m.is_dir() {
701 for ent in fs::read_dir(p)? {
702 let ent = ent?;
703 let child = ent.path();
704 if child == self.root || is_ignored_name(&ent.file_name()) {
705 continue;
706 }
707 self.capture_after_path(&child, seen, out)?;
708 }
709 AfterState::Dir {
710 mode: meta::capture(p)?.mode,
711 }
712 } else {
713 AfterState::File {
714 blob: self.store.put_file(p)?,
715 meta: meta::capture(p)?,
716 }
717 }
718 }
719 };
720 out.push(AfterSnap {
721 path: p.to_path_buf(),
722 state,
723 });
724 Ok(())
725 }
726
727 fn apply_after(&self, s: &AfterSnap) -> io::Result<String> {
728 match &s.state {
729 AfterState::Absent => {
730 remove_any(&s.path)?;
731 Ok(format!("removed {}", s.path.display()))
732 }
733 AfterState::File { blob, meta } => {
734 if let Some(parent) = s.path.parent() {
735 fs::create_dir_all(parent)?;
736 }
737 remove_if_incompatible(&s.path)?;
738 let data = self.store.get(blob)?;
739 atomic_write(&s.path, &data)?;
740 meta::apply(&s.path, *meta)?;
741 Ok(format!("restored {}", s.path.display()))
742 }
743 AfterState::Symlink { target } => {
744 remove_any(&s.path)?;
745 symlink(target, &s.path)?;
746 Ok(format!("relinked {}", s.path.display()))
747 }
748 AfterState::Dir { mode } => {
749 fs::create_dir_all(&s.path)?;
750 meta::set_mode(&s.path, *mode)?;
751 Ok(format!("dir {}", s.path.display()))
752 }
753 }
754 }
755
756 fn save_redo(&self, redo: &RedoLog) -> io::Result<()> {
759 let b = serde_json::to_vec(redo).map_err(invalid_data)?;
760 atomic_write(&self.redo_path(), &b)
761 }
762
763 fn load_redo(&self) -> io::Result<Option<RedoLog>> {
764 match fs::read(self.redo_path()) {
765 Ok(b) => Ok(serde_json::from_slice(&b).ok()),
766 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
767 Err(e) => Err(e),
768 }
769 }
770
771 fn clear_redo(&self) {
772 let _ = fs::remove_file(self.redo_path());
773 }
774
775 fn resolve(&self, path: &Path) -> PathBuf {
778 if path.is_absolute() {
779 path.to_path_buf()
780 } else {
781 self.workdir.join(path)
782 }
783 }
784
785 fn guard(&self, abs: &Path) -> io::Result<()> {
787 let norm = lexical_normalize(abs);
788 let wd = lexical_normalize(&self.workdir);
789 if !norm.starts_with(&wd) {
790 return Err(io::Error::new(
791 io::ErrorKind::PermissionDenied,
792 format!(
793 "refusing to track a path outside the project: {}",
794 abs.display()
795 ),
796 ));
797 }
798 if norm.starts_with(lexical_normalize(&self.root)) {
799 return Err(io::Error::new(
800 io::ErrorKind::PermissionDenied,
801 "refusing to track undo's own .undo directory",
802 ));
803 }
804 Ok(())
805 }
806
807 fn ensure_gitignore(&self) {
808 let gi = self.workdir.join(".gitignore");
809 let contents = fs::read_to_string(&gi).unwrap_or_default();
810 if contents
811 .lines()
812 .any(|l| matches!(l.trim(), ".undo" | ".undo/" | "/.undo" | "/.undo/"))
813 {
814 return;
815 }
816 let mut next = contents;
817 if !next.is_empty() && !next.ends_with('\n') {
818 next.push('\n');
819 }
820 next.push_str(
821 "# agent-undo: snapshots of your files (may contain secrets) — never commit\n.undo/\n",
822 );
823 let _ = fs::write(&gi, next); }
825}
826
827fn order_key(s: &AfterSnap) -> (u8, isize) {
830 let depth = s.path.components().count() as isize;
831 match s.state {
832 AfterState::Absent => (1, -depth),
834 _ => (0, depth),
835 }
836}
837
838const IGNORED_DIRS: &[&str] = &[
844 ".git",
845 ".undo",
846 "node_modules",
847 "target",
848 "dist",
849 "build",
850 ".next",
851 ".nuxt",
852 ".svelte-kit",
853 ".turbo",
854 ".venv",
855 "venv",
856 "__pycache__",
857 ".mypy_cache",
858 ".pytest_cache",
859 ".gradle",
860 ".idea",
861 ".cargo",
862 "vendor",
863];
864
865fn is_ignored_name(name: &std::ffi::OsStr) -> bool {
866 name.to_str().is_some_and(|n| IGNORED_DIRS.contains(&n))
867}
868
869pub fn path_is_ignored(path: &Path) -> bool {
873 path.components().any(|c| match c {
874 Component::Normal(n) => is_ignored_name(n),
875 _ => false,
876 })
877}
878
879fn remove_any(path: &Path) -> io::Result<()> {
880 match fs::symlink_metadata(path) {
881 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
882 Err(e) => Err(e),
883 Ok(m) => {
884 if m.is_dir() {
885 fs::remove_dir_all(path)
886 } else {
887 fs::remove_file(path) }
889 }
890 }
891}
892
893fn remove_if_incompatible(path: &Path) -> io::Result<()> {
896 match fs::symlink_metadata(path) {
897 Ok(m) if m.is_dir() || m.file_type().is_symlink() => remove_any(path),
898 _ => Ok(()),
899 }
900}
901
902#[cfg(unix)]
903fn symlink(target: &Path, link: &Path) -> io::Result<()> {
904 std::os::unix::fs::symlink(target, link)
905}
906
907#[cfg(windows)]
908fn symlink(target: &Path, link: &Path) -> io::Result<()> {
909 std::os::windows::fs::symlink_file(target, link)
910}
911
912fn atomic_write(path: &Path, data: &[u8]) -> io::Result<()> {
915 let dir = path.parent().unwrap_or_else(|| Path::new("."));
916 let tmp = dir.join(format!(".tmp-{}-{}", std::process::id(), now_nanos()));
917 {
918 let mut f = OpenOptions::new().write(true).create_new(true).open(&tmp)?;
919 f.write_all(data)?;
920 f.sync_all()?;
921 }
922 let mut attempt = 0;
926 loop {
927 match fs::rename(&tmp, path) {
928 Ok(()) => return Ok(()),
929 Err(e) => {
930 attempt += 1;
931 if attempt >= 10 {
932 let _ = fs::remove_file(&tmp);
933 return Err(e);
934 }
935 std::thread::sleep(std::time::Duration::from_millis(20));
936 }
937 }
938 }
939}
940
941fn lexical_normalize(p: &Path) -> PathBuf {
944 let mut out = PathBuf::new();
945 for comp in p.components() {
946 match comp {
947 Component::ParentDir => {
948 out.pop();
949 }
950 Component::CurDir => {}
951 other => out.push(other.as_os_str()),
952 }
953 }
954 out
955}
956
957fn parse_cp(id: &str) -> Option<u64> {
958 id.strip_prefix("cp").and_then(|n| n.parse().ok())
959}
960
961fn now_millis() -> u64 {
962 SystemTime::now()
963 .duration_since(UNIX_EPOCH)
964 .map(|d| d.as_millis() as u64)
965 .unwrap_or(0)
966}
967
968fn now_nanos() -> u128 {
969 SystemTime::now()
970 .duration_since(UNIX_EPOCH)
971 .map(|d| d.as_nanos())
972 .unwrap_or(0)
973}
974
975fn invalid_data(e: serde_json::Error) -> io::Error {
976 io::Error::new(io::ErrorKind::InvalidData, e)
977}