Skip to main content

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}