vivac 0.11.3

Provenance tree for work: every node knows which node it was born from
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
//! The store: one directory, three files.
//!
//! ```text
//! .vivac/
//!   events    append-only log, one JSON per line   <- SOURCE OF TRUTH
//!   config    project_id and opaque actor
//!   index     derived projection of `events`       <- DISPOSABLE, REGENERABLE
//! ```
//!
//! `index` is not SQLite and not a second home for any state `events` does
//! not already hold: deleting it changes no command's output, only how long
//! building a `Tree` takes. `index.rs` owns its format and every rule about
//! when it is trusted, refreshed or thrown away; this module only names
//! where it lives.

use crate::failure::Failure;
use crate::{clock, id};
use serde::{Deserialize, Serialize};
use std::ffi::OsStr;
use std::fs::{self, File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};

pub const DIR: &str = ".vivac";
pub const LOG: &str = "events";
pub const CONFIG: &str = "config";
pub const INDEX: &str = "index";

/// Where the global store lives, read from the environment.
///
/// `VIVAC_HOME` names the directory itself, the same shape as `CARGO_HOME`:
/// unset, it defaults to `$HOME/.cargo` and, set, *is* the directory. A Rust
/// developer already knows the rule.
pub fn store_dir() -> Option<PathBuf> {
    resolve_store_dir(
        std::env::var_os("VIVAC_HOME").as_deref(),
        std::env::var_os("HOME").as_deref(),
        std::env::var_os("USERPROFILE").as_deref(),
    )
}

/// Pure: given the three variables, where does the store go?
///
/// Split from `store_dir` so the tests never mutate the environment.
/// `std::env::set_var` is process-global and the test harness runs threads in
/// parallel; two tests setting `VIVAC_HOME` would race and the failure would
/// be intermittent, which is worse than no test at all.
fn resolve_store_dir(
    vivac_home: Option<&OsStr>,
    home: Option<&OsStr>,
    userprofile: Option<&OsStr>,
) -> Option<PathBuf> {
    if let Some(v) = non_blank(vivac_home) {
        return Some(PathBuf::from(v));
    }
    resolve_home_dir(home, userprofile).map(|h| h.join(DIR))
}

/// Where the user's home directory is, from the environment: `HOME` and
/// then `USERPROFILE`, the same two variables `store_dir` falls back to once
/// `VIVAC_HOME` is not set. Split out so `setup` can refuse to run there
/// without a second search of its own (`t579` §4.1).
pub fn home_dir() -> Option<PathBuf> {
    resolve_home_dir(
        std::env::var_os("HOME").as_deref(),
        std::env::var_os("USERPROFILE").as_deref(),
    )
}

/// Pure half of `home_dir`, for the same reason `resolve_store_dir` is split
/// from `store_dir`.
fn resolve_home_dir(home: Option<&OsStr>, userprofile: Option<&OsStr>) -> Option<PathBuf> {
    if let Some(h) = non_blank(home) {
        return Some(PathBuf::from(h));
    }
    if let Some(u) = non_blank(userprofile) {
        return Some(PathBuf::from(u));
    }
    None
}

/// `None` for a variable that is unset, empty or made only of whitespace: an
/// exported-but-empty variable is a common shell accident, and treating it as
/// "the store is at the filesystem root" would be actively harmful.
fn non_blank(v: Option<&OsStr>) -> Option<&OsStr> {
    let v = v?;
    match v.to_str() {
        Some(s) if s.trim().is_empty() => None,
        _ => Some(v),
    }
}

/// `config`'s `version`, once it is known to be one of the two shapes this
/// release can act on. `d444`: a tree that gains its first pillar or rule
/// turns this from `One` to `Locked`, in place, before the event that
/// creates it is appended -- and a release earlier than that fails to parse
/// `Locked`'s own sentence, which is the whole point.
///
/// No `#[derive(Serialize, Deserialize)]`: neither shape is an enum tag in
/// the usual sense, one is the bare integer `1` and the other is a string,
/// and `check_config_version` -- not this type -- is what tells a genuinely
/// unknown version apart from one of these two.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigVersion {
    One,
    Locked,
}

/// The sentence a config's `version` becomes the moment its tree gains a
/// pillar or a rule. Literal and without a number: release-plz decides the
/// number when it publishes, and every release from here on reads the
/// sentence exactly as it reads `1`. `d444`.
pub const LOCK_SENTENCE: &str =
    "this tree holds pillars and rules, and this vivac is too old to read them: update vivac";

impl Serialize for ConfigVersion {
    fn serialize<S>(&self, s: S) -> Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        match self {
            ConfigVersion::One => s.serialize_u32(1),
            ConfigVersion::Locked => s.serialize_str(LOCK_SENTENCE),
        }
    }
}

impl<'de> Deserialize<'de> for ConfigVersion {
    /// Only ever reached once `check_config_version` has already let the raw
    /// value through: a `1`, or the lock sentence. Anything else refuses
    /// generically here, which is `d444`'s "como hoy" for a version this
    /// deserializer was never meant to explain -- negative, a float, an
    /// object, `null`.
    fn deserialize<D>(d: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        let v = serde_json::Value::deserialize(d)?;
        match &v {
            serde_json::Value::Number(n) if n.as_u64() == Some(1) => Ok(ConfigVersion::One),
            serde_json::Value::String(s) if s == LOCK_SENTENCE => Ok(ConfigVersion::Locked),
            _ => Err(serde::de::Error::custom("unsupported config version")),
        }
    }
}

#[derive(Debug, Serialize, Deserialize)]
pub struct Config {
    pub version: ConfigVersion,
    pub project_id: String,
    /// Opaque identifier for this install. **It carries no email and no name**:
    /// the security pillar forbids it, and vetoes `MODEL.md` §3.4.
    pub actor: String,
}

impl Config {
    fn new_seeded() -> Config {
        Config {
            version: ConfigVersion::One,
            project_id: id::ulid(),
            actor: format!("a_{}", &id::ulid()[..12]),
        }
    }
}

pub struct Store {
    pub root: PathBuf,
    pub config: Config,
}

/// Walks up from `from_dir` looking for a `.vivac/`. No daemon and no environment
/// variable: the same rule as git, already in everyone's fingers.
///
/// The global store is a `.vivac/` as well, and it sits in the home directory,
/// so without this it answers the walk: any directory under a home and outside
/// a project resolves to the home itself, and a `push` there writes into the
/// global store instead of refusing. `d206` already said the upward search must
/// not find it, and this is that sentence. It asks what the directory holds
/// rather than where it sits, because `VIVAC_HOME` can move the store and a
/// rule that compared paths would fail exactly when somebody moved it.
pub fn find_root(from_dir: &Path) -> Option<PathBuf> {
    let mut d = from_dir.to_path_buf();
    loop {
        let candidate = d.join(DIR);
        if candidate.is_dir() && !crate::registry::marks_global_store(&candidate) {
            return Some(d);
        }
        if !d.pop() {
            return None;
        }
    }
}

/// Whether `root/.vivac/` already holds a tree worth opening rather than
/// creating: a config or a log. `f566`: an empty `.vivac/` -- one that exists
/// as a directory but holds neither -- is planted like a new one, and `init`
/// and `setup` share this one check rather than each guessing it their own
/// way.
pub fn already_planted(root: &Path) -> bool {
    let dir = root.join(DIR);
    dir.join(CONFIG).is_file() || dir.join(LOG).is_file()
}

/// The `id` of line 1 of `<root>/.vivac/events`, without folding the rest of
/// the log. An empty log, an unreadable file or a first line that will not
/// parse all come back `None`; the caller decides what that means.
pub fn first_event_id(root: &Path) -> Option<String> {
    let f = File::open(root.join(DIR).join(LOG)).ok()?;
    let mut line = String::new();
    BufReader::new(f).read_line(&mut line).ok()?;
    if line.trim().is_empty() {
        return None;
    }
    let e: crate::event::Event = serde_json::from_str(line.trim_end()).ok()?;
    Some(e.id)
}

impl Store {
    pub fn open(root: PathBuf) -> Result<Store, Failure> {
        let p = root.join(DIR).join(CONFIG);
        let config = match fs::read_to_string(&p) {
            Ok(s) => read_config(&s)?,
            Err(_) => {
                // A `.vivac/` with no config comes from an earlier version or a
                // half-finished delete. Fill it in rather than fail: the tree,
                // which is what matters, lives in `events`. `d444`: if the log
                // already carries a pillar or a rule, the regenerated config is
                // born locked -- deleting one file must never hand an older
                // release a config that looks readable over a tree it is not.
                let c = if log_already_governed(&root) {
                    Config {
                        version: ConfigVersion::Locked,
                        ..Config::new_seeded()
                    }
                } else {
                    Config::new_seeded()
                };
                write_config(&root, &c)?;
                c
            }
        };
        Ok(Store { root, config })
    }

    /// For a `.vivac/` that does not exist yet. `f566`: `init` on one that
    /// already does calls `open` instead, so this always writes a fresh
    /// config -- calling it over an existing tree would hand it a new
    /// `project_id` and drop `d444`'s lock back to `1`.
    pub fn create(root: &Path) -> std::io::Result<Store> {
        let d = root.join(DIR);
        fs::create_dir_all(&d)?;
        let config = Config::new_seeded();
        write_config(root, &config)?;
        if !d.join(LOG).exists() {
            File::create(d.join(LOG))?;
        }
        Ok(Store {
            root: root.to_path_buf(),
            config,
        })
    }

    pub fn log(&self) -> PathBuf {
        self.root.join(DIR).join(LOG)
    }

    pub fn index_path(&self) -> PathBuf {
        self.root.join(DIR).join(INDEX)
    }
}

fn write_config(root: &Path, c: &Config) -> std::io::Result<()> {
    let mut f = File::create(root.join(DIR).join(CONFIG))?;
    f.write_all(serde_json::to_string_pretty(c)?.as_bytes())?;
    f.write_all(b"\n")
}

/// `d444`'s own protection: the config is written to a sibling temporary
/// file and renamed over the real one, never edited in place. A process
/// that dies between the two steps leaves the old config exactly as it
/// was -- there is no window where `config` itself is half-written.
fn write_config_atomic(root: &Path, c: &Config) -> std::io::Result<()> {
    let dir = root.join(DIR);
    let tmp = dir.join("config.tmp");
    {
        let mut f = File::create(&tmp)?;
        f.write_all(serde_json::to_string_pretty(c)?.as_bytes())?;
        f.write_all(b"\n")?;
    }
    fs::rename(&tmp, dir.join(CONFIG))
}

/// Parses `config`'s text into a `Config`, refusing the two shapes of
/// `version` `t411` §27 gives a name to before letting `serde_json` see the
/// rest: a non-negative integer that is not `1`, or a string that is not
/// `d444`'s own sentence. Every other shape -- absent, negative, a float, an
/// object, `null` -- is left to `Config`'s own `Deserialize`, which fails
/// exactly as it did before `d444`.
fn read_config(raw: &str) -> Result<Config, Failure> {
    let v: serde_json::Value =
        serde_json::from_str(raw).map_err(|e| Failure::Io(std::io::Error::other(e)))?;
    check_config_version(v.get("version"))?;
    serde_json::from_value(v).map_err(|e| Failure::Io(std::io::Error::other(e)))
}

fn check_config_version(version: Option<&serde_json::Value>) -> Result<(), Failure> {
    match version {
        Some(serde_json::Value::Number(n)) => match n.as_u64() {
            Some(1) => Ok(()),
            Some(other) => Err(Failure::newer_vivac(format!(
                "This tree was written by a newer vivac: its config has version {other}, \
                 which this version does not know. Update vivac to read it. Nothing was \
                 written."
            ))),
            // Negative or non-integer: not one of the two known shapes, and
            // not a value worth a friendly message either. Falls through to
            // the generic config-read failure, same as before `d444`.
            None => Ok(()),
        },
        Some(serde_json::Value::String(s)) if s == LOCK_SENTENCE => Ok(()),
        Some(serde_json::Value::String(s)) => Err(Failure::newer_vivac(format!(
            "This tree was written by a newer vivac: its config says {s:?}. Update vivac \
             to read it. Nothing was written."
        ))),
        _ => Ok(()),
    }
}

/// The rare path `Store::open` takes when `config` itself is missing:
/// whether the log already holds a pillar or a rule, in any state. Reads
/// the whole log -- something no ordinary read ever pays for -- because a
/// vanished config is itself the unusual case, and regenerating one that
/// looks readable by any release over a tree that already governs
/// something would undo the very lock `d444` exists to keep.
fn log_already_governed(root: &Path) -> bool {
    let Ok(f) = File::open(root.join(DIR).join(LOG)) else {
        return false;
    };
    for line in BufReader::new(f).lines().map_while(Result::ok) {
        if line.trim().is_empty() {
            continue;
        }
        let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) else {
            continue;
        };
        if v["payload"]["type"] == "node.created"
            && matches!(v["payload"]["kind"].as_str(), Some("pillar") | Some("rule"))
        {
            return true;
        }
    }
    false
}

impl Store {
    /// Reads the whole log. An unreadable line **does not abort**: it is
    /// counted and skipped. A half-written log has to stay readable, or the
    /// tool that keeps the thread becomes the one that loses it.
    ///
    /// One case refuses instead of skipping: `t411` §13, a line that is
    /// well-formed JSON but names an event type or a node kind this version
    /// does not know. That line was written by a newer vivac, and reading
    /// past it in silence would mean acting on a tree this version cannot
    /// actually see all of.
    pub fn read_all(&self) -> Result<(Vec<crate::event::Event>, usize), Failure> {
        read_all_from(&self.log())
    }

    /// Appends events at the end. One line per event, rewriting nothing.
    ///
    /// This is the critical path of the agent's turn: a p99 < 5 ms budget.
    /// That is why there is no `fsync` --on Windows it costs more than the
    /// whole budget-- and why it opens in `append` mode, which makes each
    /// single-line write atomic and removes the need for a lock.
    ///
    /// `tree_already_governed` is `d444`'s own check, paid before any of
    /// `body` reaches disk: the config locks in place, first, so a process
    /// that dies between the two leaves an unlocked config over a tree with
    /// no pillar and no rule, which is harmless.
    ///
    /// Returns the events as written, so a caller that keeps the tree in
    /// memory applies exactly those and never stamps them a second time
    /// (`f590`).
    pub fn append(
        &mut self,
        body: Vec<crate::event::Body>,
        from_seq: u64,
        tree_already_governed: bool,
    ) -> std::io::Result<Vec<crate::event::Event>> {
        self.lock_if_needed(&body, tree_already_governed)?;
        let mut buf = String::with_capacity(256 * body.len());
        let mut written = Vec::with_capacity(body.len());
        for (i, c) in body.into_iter().enumerate() {
            let e = crate::event::Event {
                seq: from_seq + i as u64 + 1,
                id: id::ulid(),
                ts: clock::now_rfc3339(),
                actor: self.config.actor.clone(),
                lane: "main".into(),
                payload: c,
            };
            buf.push_str(&serde_json::to_string(&e).map_err(std::io::Error::other)?);
            buf.push('\n');
            written.push(e);
        }
        let mut f = OpenOptions::new()
            .create(true)
            .append(true)
            .open(self.log())?;
        f.write_all(buf.as_bytes())?;
        Ok(written)
    }

    /// `d444`: locks the config in place the moment this tree gains its
    /// first pillar or rule -- before the event that creates one is
    /// appended. A no-op once the config is already locked, and a no-op for
    /// every write that neither creates a pillar or a rule nor lands on a
    /// tree that already has one.
    fn lock_if_needed(
        &mut self,
        body: &[crate::event::Body],
        tree_already_governed: bool,
    ) -> std::io::Result<()> {
        if self.config.version != ConfigVersion::One {
            return Ok(());
        }
        let creates_governance = body.iter().any(|b| {
            matches!(
                b,
                crate::event::Body::NodeCreated {
                    kind: crate::event::Kind::Pillar | crate::event::Kind::Rule,
                    ..
                }
            )
        });
        if !tree_already_governed && !creates_governance {
            return Ok(());
        }
        let locked = Config {
            version: ConfigVersion::Locked,
            project_id: self.config.project_id.clone(),
            actor: self.config.actor.clone(),
        };
        write_config_atomic(&self.root, &locked)?;
        self.config = locked;
        Ok(())
    }
}

/// The read `Store::read_all` runs, taken as a free function of a path
/// rather than a method: `index.rs`'s own tail read (`read_tracked`) keeps a
/// separate implementation for its own reasons (`LOADING.md` §4), but when
/// it hits a line `t411` §13 refuses over, it falls back to a full read from
/// byte zero rather than reconstructing this file's own line count -- and
/// that full read is this function, so the two paths report the very same
/// line number for the very same line.
pub(crate) fn read_all_from(path: &Path) -> Result<(Vec<crate::event::Event>, usize), Failure> {
    let f = match File::open(path) {
        Ok(f) => f,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((vec![], 0)),
        Err(e) => return Err(e.into()),
    };
    let mut events = Vec::new();
    let mut broken = 0usize;
    for (i, line) in BufReader::new(f).lines().enumerate() {
        let line = line?;
        if line.trim().is_empty() {
            continue;
        }
        match serde_json::from_str(&line) {
            Ok(e) => events.push(e),
            Err(_) => match crate::event::unknown_reason_for(&line) {
                Some(reason) => return Err(newer_vivac_failure(i + 1, reason)),
                None => broken += 1,
            },
        }
    }
    Ok((events, broken))
}

/// The exact wording of `t411` §13's refusal, for the one line that earned
/// it. `line_no` is 1-based, matching what a text editor would show.
pub(crate) fn newer_vivac_failure(line_no: usize, reason: crate::event::UnknownReason) -> Failure {
    let path = format!("{DIR}/{LOG}");
    let detail = match reason {
        crate::event::UnknownReason::EventType(t) => {
            format!("is an event this version does not know ({t})")
        }
        crate::event::UnknownReason::NodeKind(k) => {
            format!("creates a node of a type this version does not know ({k})")
        }
        crate::event::UnknownReason::Shape(t) => {
            format!("is a {t} event whose fields this version cannot read")
        }
    };
    Failure::newer_vivac(format!(
        "This tree was written by a newer vivac: line {line_no} of {path} {detail}. \
         Update vivac to read it. Nothing was written."
    ))
}

impl Store {
    /// Writes already-built events, keeping their original timestamp. Only
    /// `import` uses it: a tree from elsewhere keeps its dates, because
    /// otherwise the migration flattens the only timeline it had.
    pub fn write_raw(&self, events: &[crate::event::Event]) -> std::io::Result<()> {
        let mut buf = String::with_capacity(256 * events.len());
        for e in events {
            buf.push_str(&serde_json::to_string(e).map_err(std::io::Error::other)?);
            buf.push('\n');
        }
        let mut f = OpenOptions::new()
            .create(true)
            .append(true)
            .open(self.log())?;
        f.write_all(buf.as_bytes())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn search_upward() {
        let tmp = std::env::temp_dir().join(format!("vivac-t-{}", id::ulid()));
        let depth_of = tmp.join("a").join("b").join("c");
        fs::create_dir_all(&depth_of).unwrap();
        assert!(find_root(&depth_of).is_none());
        Store::create(&tmp).unwrap();
        assert_eq!(find_root(&depth_of).unwrap(), tmp);
        fs::remove_dir_all(&tmp).ok();
    }

    #[test]
    fn the_global_store_does_not_answer_the_walk() {
        // The collision as it shipped: the global store is a `.vivac/` too, so
        // a directory with no project above it resolved to the home directory
        // and wrote there without saying so.
        let tmp = std::env::temp_dir().join(format!("vivac-t-{}", id::ulid()));
        let deep = tmp.join("a").join("b");
        fs::create_dir_all(&deep).unwrap();
        Store::create(&tmp).unwrap();
        assert_eq!(find_root(&deep).unwrap(), tmp);
        crate::registry::note(&tmp.join(DIR), "01aaaaaaaaaaaaaaaaaaaaaaaa", &deep);
        assert_ne!(find_root(&deep), Some(tmp.clone()));
        fs::remove_dir_all(&tmp).ok();
    }

    #[test]
    fn a_project_under_the_global_store_still_wins() {
        // Skipping the global store must not cost a real project below it.
        let tmp = std::env::temp_dir().join(format!("vivac-t-{}", id::ulid()));
        let project = tmp.join("work");
        let deep = project.join("src").join("deep");
        fs::create_dir_all(&deep).unwrap();
        Store::create(&tmp).unwrap();
        crate::registry::note(&tmp.join(DIR), "01aaaaaaaaaaaaaaaaaaaaaaaa", &project);
        Store::create(&project).unwrap();
        assert_eq!(find_root(&deep).unwrap(), project);
        fs::remove_dir_all(&tmp).ok();
    }

    #[test]
    fn the_actor_carries_no_personal_data() {
        let c = Config::new_seeded();
        assert!(c.actor.starts_with("a_"));
        assert!(!c.actor.contains('@'));
        assert_ne!(c.actor, whoami_ish());
    }

    fn whoami_ish() -> String {
        std::env::var("USERNAME")
            .or_else(|_| std::env::var("USER"))
            .unwrap_or_default()
    }

    #[test]
    fn vivac_home_wins_and_is_used_as_is() {
        let got = resolve_store_dir(
            Some(OsStr::new("/somewhere/store")),
            Some(OsStr::new("/home/anyone")),
            Some(OsStr::new("C:\\Users\\anyone")),
        );
        assert_eq!(got, Some(PathBuf::from("/somewhere/store")));
    }

    #[test]
    fn blank_vivac_home_falls_through() {
        let got = resolve_store_dir(
            Some(OsStr::new("   ")),
            Some(OsStr::new("/home/anyone")),
            None,
        );
        assert_eq!(got, Some(PathBuf::from("/home/anyone").join(DIR)));
    }

    #[test]
    fn home_alone_appends_dir() {
        let got = resolve_store_dir(None, Some(OsStr::new("/home/anyone")), None);
        assert_eq!(got, Some(PathBuf::from("/home/anyone").join(DIR)));
    }

    #[test]
    fn userprofile_used_when_home_is_absent() {
        let got = resolve_store_dir(None, None, Some(OsStr::new("C:\\Users\\anyone")));
        assert_eq!(got, Some(PathBuf::from("C:\\Users\\anyone").join(DIR)));
    }

    #[test]
    fn home_wins_over_userprofile() {
        let got = resolve_store_dir(
            None,
            Some(OsStr::new("/home/anyone")),
            Some(OsStr::new("C:\\Users\\anyone")),
        );
        assert_eq!(got, Some(PathBuf::from("/home/anyone").join(DIR)));
    }

    #[test]
    fn nothing_set_means_no_global_store() {
        assert_eq!(resolve_store_dir(None, None, None), None);
    }

    #[test]
    fn first_event_id_on_an_empty_log_is_none() {
        let tmp = std::env::temp_dir().join(format!("vivac-fe-{}", id::ulid()));
        Store::create(&tmp).unwrap();
        assert_eq!(first_event_id(&tmp), None);
        fs::remove_dir_all(&tmp).ok();
    }

    #[test]
    fn first_event_id_reads_line_one_without_folding() {
        let tmp = std::env::temp_dir().join(format!("vivac-fe-{}", id::ulid()));
        let mut s = Store::create(&tmp).unwrap();
        // A log large enough that folding the whole thing would be visible
        // in the timing, if this ever regressed into calling `read_all`.
        for _ in 0..500 {
            s.append(
                vec![crate::event::Body::NodeNoted {
                    node: "t1".into(),
                    note: "filler".into(),
                }],
                0,
                false,
            )
            .unwrap();
        }
        let first_line = fs::read_to_string(s.log())
            .unwrap()
            .lines()
            .next()
            .unwrap()
            .to_string();
        let want: crate::event::Event = serde_json::from_str(&first_line).unwrap();
        assert_eq!(first_event_id(&tmp), Some(want.id));
        fs::remove_dir_all(&tmp).ok();
    }
}