Skip to main content

blockworx_store/
naming.rs

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