concinnity_host/scratch.rs
1//! Scratch paths in the machine's temporary directory.
2//!
3//! An external compiler that will not take a pipe needs a file to read or
4//! write, and that file belongs to one call: nothing reads it afterwards, and
5//! two calls must never share it. Concurrent runs of one tool -- a parallel
6//! build, a test suite spread over processes, two exports of one project --
7//! collide on any name picked by hand, one resetting a path the other is
8//! part-way through writing.
9//!
10//! So a name here carries the process id and a counter, and is unique for as
11//! long as it names anything. [`Scratch`] removes it on drop, which is the half
12//! a hand-rolled path keeps getting wrong: the tool that fails is the one that
13//! returns early, and its intermediates are what stay behind.
14//!
15//! This is the only place in the workspace that names the temporary directory.
16//! `tests/file_access_discipline.rs` holds the rest of the workspace to that.
17
18use std::path::{Path, PathBuf};
19use std::sync::atomic::{AtomicU64, Ordering};
20
21/// A path in the machine's temporary directory, unique to this call.
22///
23/// Nothing is created: the caller writes the file, or hands the path to the
24/// tool that writes it. The unique part leads, so `name` keeps its extension
25/// for a tool that reads one.
26///
27/// Prefer [`Scratch`], which removes the path again. Reach for this only where
28/// something else already owns that.
29///
30/// ```
31/// let path = concinnity_host::scratch::path("shader.air");
32/// assert!(path.to_string_lossy().ends_with("shader.air"));
33/// assert_ne!(path, concinnity_host::scratch::path("shader.air"));
34/// ```
35pub fn path(name: &str) -> PathBuf {
36 static SEQ: AtomicU64 = AtomicU64::new(0);
37 std::env::temp_dir().join(format!(
38 "cn-{}-{}-{name}",
39 std::process::id(),
40 SEQ.fetch_add(1, Ordering::Relaxed)
41 ))
42}
43
44/// A scratch path that goes away when this value does.
45///
46/// Dropping it removes the path, so a tool that fails part-way leaves nothing
47/// behind and no caller has to remember an error path.
48///
49/// ```
50/// let scratch = concinnity_host::scratch::Scratch::file("notes.txt");
51/// std::fs::write(scratch.path(), b"working").unwrap();
52/// let path = scratch.path().to_path_buf();
53/// drop(scratch);
54/// assert!(!path.exists());
55/// ```
56pub struct Scratch {
57 path: PathBuf,
58 directory: bool,
59}
60
61impl Scratch {
62 /// A file path, unique to this call. Nothing is written yet.
63 pub fn file(name: &str) -> Self {
64 Self {
65 path: path(name),
66 directory: false,
67 }
68 }
69
70 /// A directory, unique to this call and created empty.
71 ///
72 /// # Errors
73 ///
74 /// If the directory cannot be created.
75 pub fn dir(name: &str) -> std::io::Result<Self> {
76 // The parent is the temporary directory, which is already there, so
77 // this is one call rather than a walk up the path.
78 let path = path(name);
79 std::fs::create_dir(&path)?;
80 Ok(Self {
81 path,
82 directory: true,
83 })
84 }
85
86 /// The path itself.
87 pub fn path(&self) -> &Path {
88 &self.path
89 }
90}
91
92impl Drop for Scratch {
93 fn drop(&mut self) {
94 let _ = if self.directory {
95 std::fs::remove_dir_all(&self.path)
96 } else {
97 std::fs::remove_file(&self.path)
98 };
99 }
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105
106 #[test]
107 fn a_path_is_never_handed_out_twice() {
108 let first = path("thing");
109 let second = path("thing");
110
111 assert_ne!(first, second, "two calls must not share a path");
112 assert!(
113 first.parent().is_some_and(|p| second.starts_with(p)),
114 "both live in the temporary directory"
115 );
116 }
117
118 // A tool that reads an extension has to find it, so the unique part leads.
119 #[test]
120 fn a_name_keeps_its_extension() {
121 let path = path("My-Game.iconset");
122
123 assert_eq!(path.extension().and_then(|e| e.to_str()), Some("iconset"));
124 }
125
126 // A sibling process sweeping its own leftovers must not match ours.
127 #[test]
128 fn a_name_carries_the_process_that_made_it() {
129 let path = path("thing");
130
131 assert!(
132 path.file_name()
133 .and_then(|n| n.to_str())
134 .is_some_and(|n| n.starts_with(&format!("cn-{}-", std::process::id()))),
135 "got {}",
136 path.display()
137 );
138 }
139
140 #[test]
141 fn a_file_goes_when_its_scratch_does() {
142 let scratch = Scratch::file("leftover");
143 std::fs::write(scratch.path(), b"work").expect("write");
144 let path = scratch.path().to_path_buf();
145
146 assert!(path.is_file());
147 drop(scratch);
148 assert!(!path.exists(), "the file went with the guard");
149 }
150
151 // The whole tree goes, not just the directory: what a tool leaves inside is
152 // exactly what nothing else would clean up.
153 #[test]
154 fn a_directory_goes_with_everything_in_it() {
155 let scratch = Scratch::dir("work").expect("create");
156 std::fs::write(scratch.path().join("inner"), b"work").expect("write");
157 let path = scratch.path().to_path_buf();
158
159 assert!(path.is_dir());
160 drop(scratch);
161 assert!(!path.exists(), "the tree went with the guard");
162 }
163
164 // Dropping is not conditional on the tool having succeeded, which is the
165 // whole reason the path is owned rather than remembered.
166 #[test]
167 fn a_path_that_was_never_written_drops_quietly() {
168 let scratch = Scratch::file("never-written");
169 let path = scratch.path().to_path_buf();
170
171 drop(scratch);
172 assert!(!path.exists());
173 }
174}