Skip to main content

blockworx/
naming.rs

1//! What a new document is called, and where it is born (D20).
2//!
3//! A launch with no path, and File ▸ New, both create a container rather
4//! than a session that persists nothing — so append-on-commit holds from
5//! the first edit and Scratch stops being the native common case. The name
6//! is three hyphenated words, the docker convention: a generated name the
7//! user can say out loud, and one they can replace with their own the
8//! moment they want to.
9//!
10//! Native only. The browser has no documents directory to be born in, and
11//! its persistence story is Phase 8's.
12
13use std::path::{Path, PathBuf};
14
15use crate::file::CONTAINER_EXTENSION;
16use crate::store::container::ContainerError;
17use crate::store::handle::{Clock, Store};
18
19/// Where new documents are born.
20#[derive(Clone, Debug, Default, PartialEq, Eq)]
21pub enum Documents {
22    /// Nowhere to put one: the platform names no documents directory, no
23    /// home, and no working directory. A new document opens as a scratch
24    /// session rather than the app guessing at somewhere to write.
25    #[default]
26    Nowhere,
27    In(PathBuf),
28}
29
30/// Why a document could not be born. Every arm leaves the session usable —
31/// the editor opens on a scratch document — and is something the user is
32/// told, rather than a crash on the way up.
33#[derive(Debug, thiserror::Error)]
34pub enum BirthFailure {
35    #[error("there is no documents directory to create it in")]
36    Nowhere,
37    #[error("no unused name in {} tries", Documents::TRIES)]
38    Unnamed,
39    #[error(transparent)]
40    Create(#[from] ContainerError),
41}
42
43impl BirthFailure {
44    /// What the canvas is told, in the one wording both the startup and the
45    /// File ▸ New path use.
46    pub fn notice(&self) -> String {
47        format!("Failed to create a new diagram: {self}")
48    }
49}
50
51impl Documents {
52    /// How many names are tried before a directory is declared full. Three
53    /// words draw from a space six figures wide, so reaching this means
54    /// something other than luck is wrong.
55    const TRIES: usize = 16;
56
57    /// The platform's documents directory, falling back to the home
58    /// directory and then to the working directory — a machine with no
59    /// `Documents` folder still gets a document somewhere it can find it.
60    pub fn platform() -> Self {
61        match dirs::document_dir()
62            .or_else(dirs::home_dir)
63            .or_else(|| std::env::current_dir().ok())
64        {
65            Some(dir) => Documents::In(dir),
66            None => Documents::Nowhere,
67        }
68    }
69
70    pub fn at(dir: impl Into<PathBuf>) -> Self {
71        Documents::In(dir.into())
72    }
73
74    pub fn dir(&self) -> Option<&Path> {
75        match self {
76            Documents::Nowhere => None,
77            Documents::In(dir) => Some(dir),
78        }
79    }
80
81    /// Create a container for a new document, under a name nothing here is
82    /// using. A name already taken is not an error but a redraw: `draw`
83    /// supplies the numbers, so a test can stage the collision it wants.
84    ///
85    /// # Errors
86    /// [`BirthFailure`] — which the caller turns into a scratch session and
87    /// a notice, never a refusal to start.
88    pub fn create(&self, mut draw: impl FnMut() -> u32) -> Result<Store, BirthFailure> {
89        let Documents::In(dir) = self else {
90            return Err(BirthFailure::Nowhere);
91        };
92        std::fs::create_dir_all(dir).map_err(|e| BirthFailure::Create(e.into()))?;
93        for _ in 0..Self::TRIES {
94            let root = dir.join(format!("{}.{CONTAINER_EXTENSION}", name(&mut draw)));
95            if root.exists() {
96                continue;
97            }
98            match Store::create(&root, Clock::System) {
99                // Someone laid one down between the look and the create.
100                Err(ContainerError::Exists(_)) => {}
101                Err(e) => return Err(BirthFailure::Create(e)),
102                Ok(store) => return Ok(store),
103            }
104        }
105        Err(BirthFailure::Unnamed)
106    }
107}
108
109/// Three words, hyphenated: `happy-sunshine-fox`. One draw per word, so the
110/// generator is pure and a test says which name comes out.
111pub fn name(draw: &mut impl FnMut() -> u32) -> String {
112    fn pick<'a>(list: &[&'a str], drawn: u32) -> &'a str {
113        list[drawn as usize % list.len()]
114    }
115    let adjective = pick(ADJECTIVES, draw());
116    let noun = pick(NOUNS, draw());
117    let creature = pick(CREATURES, draw());
118    format!("{adjective}-{noun}-{creature}")
119}
120
121/// The app's own draw, for the three-word name a new diagram is born
122/// under. A v4 UUID's leading bytes carry none of its version or variant
123/// bits, so they are random through — this is the platform's randomness,
124/// reached through the crate that already wraps it per target.
125pub fn entropy() -> u32 {
126    let id = uuid::Uuid::new_v4();
127    let bytes = id.as_bytes();
128    u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])
129}
130
131/// Words rather than the `petname` crate, whose lists are exactly this: it
132/// depends on `rand` 0.10, a second major beside the one the tests already
133/// pull, and this is data rather than functionality — a list cannot drift
134/// from an upstream implementation the way a reimplemented algorithm can.
135/// Keeping it in the tree is also what keeps the vocabulary friendly and
136/// the combinations unembarrassing.
137const ADJECTIVES: &[&str] = &[
138    "amber",
139    "bold",
140    "brave",
141    "brisk",
142    "calm",
143    "clever",
144    "cosmic",
145    "crimson",
146    "curious",
147    "dapper",
148    "eager",
149    "electric",
150    "fearless",
151    "gentle",
152    "gilded",
153    "golden",
154    "happy",
155    "hidden",
156    "jolly",
157    "keen",
158    "lucky",
159    "merry",
160    "mighty",
161    "noble",
162    "patient",
163    "plucky",
164    "polished",
165    "quick",
166    "quiet",
167    "radiant",
168    "rapid",
169    "rustic",
170    "serene",
171    "silent",
172    "silver",
173    "smooth",
174    "snowy",
175    "solar",
176    "spry",
177    "sunny",
178    "swift",
179    "tidy",
180    "tranquil",
181    "velvet",
182    "vivid",
183    "wandering",
184    "witty",
185    "zesty",
186];
187
188const NOUNS: &[&str] = &[
189    "anchor", "autumn", "beacon", "blossom", "breeze", "brook", "canyon", "cedar", "cinder",
190    "comet", "coral", "crescent", "dawn", "delta", "ember", "fern", "forest", "garnet", "glacier",
191    "harbor", "harvest", "horizon", "island", "lantern", "ledger", "lichen", "meadow", "meridian",
192    "mirage", "monsoon", "nebula", "orchard", "pebble", "prairie", "quartz", "ridge", "river",
193    "sable", "summit", "sunrise", "sunshine", "thicket", "thunder", "tundra", "valley", "willow",
194    "window", "zephyr",
195];
196
197const CREATURES: &[&str] = &[
198    "badger", "bison", "cricket", "dolphin", "falcon", "ferret", "finch", "fox", "gecko", "gibbon",
199    "heron", "ibis", "jackal", "jaguar", "kestrel", "koala", "lemur", "lynx", "magpie", "marlin",
200    "marmot", "mole", "narwhal", "newt", "ocelot", "osprey", "otter", "panda", "pelican",
201    "penguin", "puffin", "quail", "rabbit", "raven", "robin", "salmon", "sparrow", "stoat",
202    "tapir", "teal", "toad", "turtle", "viper", "walrus", "weasel", "wombat", "wren", "yak",
203];
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use crate::store::container::MANIFEST;
209
210    /// A draw that hands out `0, 1, 2, …`, so a test names the container it
211    /// is about to get.
212    fn counting() -> impl FnMut() -> u32 {
213        let mut n = 0;
214        move || {
215            n += 1;
216            n - 1
217        }
218    }
219
220    #[test]
221    fn a_name_is_three_words_drawn_one_per_word() {
222        let mut draw = counting();
223        assert_eq!(
224            name(&mut draw),
225            format!("{}-{}-{}", ADJECTIVES[0], NOUNS[1], CREATURES[2]),
226            "the draws are consumed left to right, one per list",
227        );
228        let next = name(&mut draw);
229        assert_eq!(next.split('-').count(), 3, "{next} is not three words");
230        assert!(
231            next.chars().all(|c| c.is_ascii_lowercase() || c == '-'),
232            "{next} is not a name a filesystem and a human both read easily",
233        );
234    }
235
236    /// A draw past the end of a list wraps rather than panicking, which is
237    /// what lets the draw be any number at all.
238    #[test]
239    fn a_draw_wraps_onto_the_lists() {
240        let drawn = name(&mut || u32::MAX);
241        assert_eq!(drawn.split('-').count(), 3);
242    }
243
244    /// The lists share no word, so a name never says the same thing twice.
245    #[test]
246    fn the_lists_are_distinct_and_sorted() {
247        let mut all: Vec<&str> = [ADJECTIVES, NOUNS, CREATURES].concat();
248        let before = all.len();
249        all.sort_unstable();
250        all.dedup();
251        assert_eq!(all.len(), before, "a word appears in two lists");
252        for list in [ADJECTIVES, NOUNS, CREATURES] {
253            let mut sorted = list.to_vec();
254            sorted.sort_unstable();
255            assert_eq!(list, sorted.as_slice(), "the list is not in order");
256        }
257    }
258
259    #[test]
260    fn a_document_is_born_in_the_documents_directory() {
261        let dir = crate::atomic::tests::TempDir::new("naming-born");
262        let documents = Documents::at(dir.join("Documents"));
263        let store = documents.create(counting()).expect("the container");
264
265        assert_eq!(
266            store.root(),
267            dir.join("Documents").join(format!(
268                "{}-{}-{}.bwx",
269                ADJECTIVES[0], NOUNS[1], CREATURES[2]
270            )),
271            "the directory was not created under the drawn name",
272        );
273        assert!(
274            store.root().join(MANIFEST).is_file(),
275            "with a log to append to"
276        );
277        assert!(
278            store.read_only_reason().is_none(),
279            "a document born here must be writable from its first edit",
280        );
281    }
282
283    /// The retry the generator exists for: a name already taken is drawn
284    /// again rather than failing or opening someone else's container.
285    #[test]
286    fn a_name_already_taken_is_drawn_again() {
287        let dir = crate::atomic::tests::TempDir::new("naming-collision");
288        let documents = Documents::at(dir.path());
289        let taken = dir.join(&format!(
290            "{}-{}-{}.bwx",
291            ADJECTIVES[0], NOUNS[1], CREATURES[2]
292        ));
293        std::fs::create_dir_all(&taken).expect("the name is taken");
294        assert!(taken.exists(), "precondition: the first name is in use");
295
296        let store = documents.create(counting()).expect("the container");
297        assert_ne!(store.root(), taken, "the taken name was opened over");
298        assert_eq!(
299            store.root(),
300            dir.join(&format!(
301                "{}-{}-{}.bwx",
302                ADJECTIVES[3], NOUNS[4], CREATURES[5]
303            )),
304            "the retry did not draw the next name",
305        );
306    }
307
308    /// The retry is bounded: a draw that keeps returning a taken name gives
309    /// up and says so, rather than spinning.
310    #[test]
311    fn the_retry_gives_up_rather_than_spinning() {
312        let dir = crate::atomic::tests::TempDir::new("naming-exhausted");
313        let documents = Documents::at(dir.path());
314        let only = dir.join(&format!(
315            "{}-{}-{}.bwx",
316            ADJECTIVES[0], NOUNS[0], CREATURES[0]
317        ));
318        std::fs::create_dir_all(&only).expect("the one name is taken");
319
320        let failure = documents
321            .create(|| 0)
322            .err()
323            .expect("a draw with one name to give must give up");
324        assert!(matches!(failure, BirthFailure::Unnamed), "{failure}");
325    }
326
327    #[test]
328    fn nowhere_to_be_born_is_a_failure_and_not_a_guess() {
329        let failure = Documents::Nowhere
330            .create(counting())
331            .err()
332            .expect("nowhere cannot hold a document");
333        assert!(matches!(failure, BirthFailure::Nowhere), "{failure}");
334        assert_eq!(Documents::Nowhere.dir(), None);
335    }
336
337    /// The fallback chain: whatever this platform reports, it is somewhere
338    /// absolute a user can find again.
339    #[test]
340    fn the_platform_names_a_directory() {
341        let Some(dir) = Documents::platform().dir().map(Path::to_path_buf) else {
342            panic!("the platform named neither documents, home, nor a working directory");
343        };
344        assert!(
345            dir.is_absolute(),
346            "{} is not somewhere findable",
347            dir.display()
348        );
349    }
350}