Run cumulus for real: the app core and its files (stage 8a)

The playground becomes app::App, and `cumulus` runs it: the main loop
with terminal setup and a panic hook, and the startup rules (./ideas.cum,
else ~/ideas.cum, made the first time; files and folders by name;
`cumulus new`; --paths).

The documents' files:
- autosave a second after the latest change (`:set noautosave` for
  vim-style :w), [+] in the document bar for unsaved changes; quitting
  saves first; without autosave :q refuses, :q! drops, ZZ saves first;
- a file changed outside cumulus is read again as an undo step; with
  unsaved changes here too, cumulus asks which to keep; a file with a
  mistake in it holds autosave until it's fixed or :w saves over it; a
  deleted one waits for :w; nothing is reloaded under an edit;
- the day's first save backs the file up to the data folder (30 days);
- a document open in another cumulus is read-only ([RO]) until that one
  lets go: OS file locks in the data folder, so a crash leaves none;
- where you left off (cursor, zoom, scroll), the recent documents (now
  in the documents picker) and the command history, in memory.ron;
- :e (alone: read it again; ! drops changes), :new, :bd, :w, :wa, :x,
  :qa, <Space>i for :in, and :set for every setting. glyphs=ascii now
  reaches the frames and bars too, not just the canvas and outline.

Stage 8 comes in four parts with a commit each (the user's choice); 8b
is the settings files. The example is now a playground running the app
on copies of the sample documents in a temporary folder, and the
end-to-end tests moved to src/app/tests.rs, with tests for the files.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
skkeyeandClaude Opus 5.5 committed 2026-10-01 23:18:21 -04:00
1 parent e6f3c06ecc
commit f6dc4564aa
47 files changed
+3985 -1024

No files matched your search

+657
View File
@@ -0,0 +1,657 @@
//! The documents' files: opening, saving, daily backups, reloading what
//! changed outside cumulus, read-only documents, and where you left off
//! (DESIGN.md, "Files and saving").
//!
//! - A change is saved about a second after it (`autosave`; off, `:w` saves,
//! as in vim). Undoing back to what's on disk counts as no change.
//! - Every second, each file's [stamp](crate::storage::stamp) is checked. A
//! file changed outside cumulus is read again, as an undo step (`u`
//! brings back what was on screen). If there were unsaved changes too,
//! cumulus asks which to keep; if the file has a mistake in it (a hand
//! edit in progress), autosave waits until it's fixed.
//! - The day's first save copies the file to the backups first.
//! - A document another cumulus has open is read-only here, until that one
//! lets go of it.
use std::io;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use thiserror::Error;
use super::action::{DocAction, Keep, Message, SaveWhich, Tone};
use super::buffer::Buffer;
use super::documents::{Access, BufferId, DocFile, Documents, Open, Trouble};
use super::mode::Mode;
use super::popup::Popup;
use super::{App, Pane};
use crate::config::paths::Paths;
use crate::model::{CanvasPos, DocUid, Document, NodeId, Title};
use crate::storage::backup::{self, Day};
use crate::storage::document_file::{self, LoadError};
use crate::storage::lock::Lock;
use crate::storage::memory::LeftOff;
use crate::storage::names::{self, file_name, title_from_file};
use crate::storage::stamp::Stamp;
use crate::storage::write_atomically;
use crate::view::canvas::{Camera, Zoom};
/// How long autosave waits after the latest change.
const AUTOSAVE_AFTER: Duration = Duration::from_secs(1);
/// How often the files are checked for changes made outside cumulus.
const WATCH_EVERY: Duration = Duration::from_secs(1);
/// A document ready to open: read from its file, or new.
#[derive(Debug)]
pub struct Loaded {
pub doc: Document,
/// Where its file is, in full.
pub path: PathBuf,
/// The file as it was read; `None` for a new document, whose file is
/// written at its first change.
pub stamp: Option<Stamp>,
}
/// Why a document couldn't be opened or made.
#[derive(Debug, Error)]
pub enum OpenError {
#[error("{}: {error}", .path.display())]
Read { path: PathBuf, error: io::Error },
#[error("{}: {error}", .path.display())]
Load { path: PathBuf, error: LoadError },
#[error("{}: can't write it: {error}", .path.display())]
Write { path: PathBuf, error: io::Error },
}
impl OpenError {
/// The error with just the file's name, for the status bar.
fn short(&self) -> String {
let (path, error) = match self {
OpenError::Read { path, error } => (path, error.to_string()),
OpenError::Load { path, error } => (path, error.to_string()),
OpenError::Write { path, error } => (path, format!("can't write it: {error}")),
};
format!("{}: {error}", short_name(path))
}
}
/// Reads the document at `path`. With no file there, it's a new document
/// named after the file (written once something is added to it).
pub fn load(path: &Path) -> Result<Loaded, OpenError> {
let path = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
let stamp = Stamp::of(&path).map_err(|error| OpenError::Read { path: path.clone(), error })?;
if stamp.is_none() {
return Ok(Loaded { doc: Document::new(title_from_file(&path)), path, stamp });
}
let text =
std::fs::read_to_string(&path).map_err(|error| OpenError::Read { path: path.clone(), error })?;
let doc = document_file::parse(&text).map_err(|error| OpenError::Load { path: path.clone(), error })?;
Ok(Loaded { doc, path, stamp })
}
/// Makes a new document at `path` and writes it right away (`cumulus new`,
/// `:new`).
pub fn create(path: &Path, title: Title) -> Result<Loaded, OpenError> {
let path = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
let doc = Document::new(title);
let write = |error: io::Error| OpenError::Write { path: path.clone(), error };
let text = document_file::write(&doc).map_err(|error| write(io::Error::other(error)))?;
write_atomically(&path, &text).map_err(write)?;
let stamp = Stamp::of(&path).map_err(write)?;
Ok(Loaded { doc, path, stamp })
}
/// The first document, which starts the list of open ones.
pub(super) fn open_first(loaded: Loaded, paths: &Paths) -> (Documents, Option<Message>) {
let (buffer, file, said) = opened(loaded, paths);
(Documents::new(buffer, file), said)
}
/// A loaded document as a buffer and its file, with its lock taken if it's
/// free, and what to say about it.
fn opened(loaded: Loaded, paths: &Paths) -> (Buffer, DocFile, Option<Message>) {
let Loaded { doc, path, stamp } = loaded;
let name = short_name(&path);
let access = access(paths, doc.uid());
let read_only = matches!(access, Access::ReadOnly);
let said = read_only.then(|| Message::info(format!("{name} is open in another cumulus: read-only here")));
let said = said.or_else(|| {
let shown = shown(&path, &paths.home);
stamp
.is_none()
.then(|| Message::info(format!("a new document: it's saved as {shown} once you add something")))
});
let buffer = Buffer::new(doc);
let saved = buffer.history().state();
(buffer, DocFile { path, stamp, saved, access, changed: None, trouble: None }, said)
}
/// Takes a document's lock, if it's free.
fn access(paths: &Paths, uid: DocUid) -> Access {
match Lock::take(&paths.locks(), uid) {
Ok(Some(lock)) => Access::Locked(lock),
Ok(None) => Access::ReadOnly,
// Without a lock file (the data folder can't be written), the
// document can still be edited.
Err(_) => Access::Unlocked,
}
}
/// Where a file is, for a message: from `~` when it's in the home folder.
fn shown(path: &Path, home: &Path) -> String {
match path.strip_prefix(home) {
Ok(rest) => {
let parts: Vec<String> = rest.iter().map(|part| part.to_string_lossy().into_owned()).collect();
format!("~/{}", parts.join("/"))
}
Err(_) => path.display().to_string(),
}
}
/// A file's name, for messages: `garden-plans.cum`.
fn short_name(path: &Path) -> String {
path.file_name().map_or_else(|| path.display().to_string(), |name| name.to_string_lossy().into_owned())
}
/// A zoom by the name the memory keeps.
fn zoom_named(name: &str) -> Zoom {
[Zoom::Normal, Zoom::Far, Zoom::Overview]
.into_iter()
.find(|zoom| zoom.name().eq_ignore_ascii_case(name))
.unwrap_or_default()
}
impl App {
/// Opens another document (or finds it open already: the same file, or
/// the same document under another path). Returns it and what to say.
pub(super) fn add_document(&mut self, loaded: Loaded) -> (BufferId, Option<Message>) {
let docs = &self.shared.docs;
if let Some(id) = docs.find_path(&loaded.path).or_else(|| docs.find_uid(loaded.doc.uid())) {
return (id, None);
}
let (buffer, file, said) = opened(loaded, &self.paths);
let id = self.shared.docs.add(buffer, file);
self.came_back_to(id);
(id, said)
}
/// A document was opened: back to where you left off in it, and it's
/// the most recent document now.
pub(super) fn came_back_to(&mut self, id: BufferId) {
let open = self.shared.docs.get_mut(id);
let uid = open.buffer.doc().uid().to_string();
if let Some(place) = self.memory.places.get(&uid) {
let cursor = NodeId::new(place.cursor);
if open.buffer.doc().contains(cursor) {
open.buffer.apply(DocAction::MoveCursor { to: cursor, jump: false });
}
open.camera =
Some(Camera { origin: CanvasPos::new(place.x, place.y), zoom: zoom_named(&place.zoom) });
}
let (path, title) = (open.file.path.clone(), open.name());
self.memory.opened(&path, uid, title);
}
/// Keeps each pane's camera with its document: the focused pane's last,
/// so it's the one remembered.
pub(super) fn keep_cameras(&mut self) {
let focused = self.panes.focused();
let mut cameras: Vec<(bool, BufferId, Camera)> = self
.panes
.iter_mut()
.filter_map(|(id, pane)| Some((id == focused, pane.buffer, pane.view.camera()?)))
.collect();
cameras.sort_by_key(|(focused, _, _)| *focused);
for (_, buffer, camera) in cameras {
self.shared.docs.get_mut(buffer).camera = Some(camera);
}
}
/// Notes where you are in a document, for next time.
fn note_place(&mut self, id: BufferId) {
let open = self.shared.docs.get(id);
let camera = open.camera.unwrap_or_default();
let place = LeftOff {
cursor: open.buffer.cursor().get(),
zoom: camera.zoom.name().to_lowercase(),
x: camera.origin.x,
y: camera.origin.y,
};
self.memory.places.insert(open.buffer.doc().uid().to_string(), place);
}
/// Writes what cumulus remembers between runs: where you left off in
/// each open document, the documents opened lately, the command history.
pub fn remember(&mut self) -> io::Result<()> {
self.keep_cameras();
for id in self.shared.docs.ids() {
self.note_place(id);
}
self.memory.history = self.command_history.lines().to_vec();
// A new document never written, or one deleted since: nothing to
// open there next time.
self.memory.recent.retain(|recent| recent.path.exists());
self.memory.save(&self.paths.memory())
}
// ----- Time passing ---------------------------------------------------
/// What happens as time passes: autosave, and every second a look at
/// the files for changes made outside cumulus. Returns whether there's
/// something new to draw.
pub fn tick(&mut self, now: Instant) -> bool {
let mut changed = false;
if now.saturating_duration_since(self.watched) >= WATCH_EVERY {
self.watched = now;
changed |= self.watch();
}
changed |= self.autosave(now);
changed | self.ask_about_conflicts()
}
/// Saves the documents whose latest change is a second old. One whose
/// file changed outside cumulus since isn't saved over: that's looked
/// at first.
fn autosave(&mut self, now: Instant) -> bool {
if !self.settings.autosave {
return false;
}
let due: Vec<BufferId> = self
.shared
.docs
.iter()
.filter(|open| {
open.is_dirty()
&& !open.is_read_only()
&& open.file.trouble.is_none()
&& open.file.changed.is_some_and(|at| now.saturating_duration_since(at) >= AUTOSAVE_AFTER)
})
.map(|open| open.id)
.collect();
for &id in &due {
let said = if self.changed_outside(id) { self.look_at(id) } else { self.save(id).err() };
if let Some(message) = said {
self.message = Some(message);
}
}
!due.is_empty()
}
/// Whether a document's file changed since cumulus last read or wrote
/// it.
fn changed_outside(&self, id: BufferId) -> bool {
let file = &self.shared.docs.get(id).file;
Stamp::of(&file.path).is_ok_and(|stamp| stamp != file.stamp)
}
/// Writes a document to its file: the day's first save copies the old
/// file to the backups first.
fn save(&mut self, id: BufferId) -> Result<(), Message> {
let backups = self.paths.backups();
let open = self.shared.docs.get_mut(id);
if open.is_read_only() {
return Err(read_only(open));
}
let path = open.file.path.clone();
let failed = |open: &mut Open, error: String| {
let text = format!("couldn't save {}: {error}", open.file_name());
open.file.trouble = Some(Trouble::SaveFailed(text.clone()));
Message::problem(text)
};
let text = match document_file::write(open.buffer.doc()) {
Ok(text) => text,
Err(error) => return Err(failed(open, error.to_string())),
};
// A backup that fails mustn't stop the save itself.
let _ = backup::daily(&backups, open.buffer.doc().uid(), &path, Day::today(), backup::KEEP);
if let Err(error) = write_atomically(&path, &text) {
return Err(failed(open, error.to_string()));
}
open.file.stamp = Stamp::of(&path).ok().flatten();
open.file.saved = open.buffer.history().state();
open.file.changed = None;
open.file.trouble = None;
Ok(())
}
/// Looks at every file for changes made outside cumulus, and lets
/// read-only documents be edited once the other cumulus lets go.
fn watch(&mut self) -> bool {
let mut said = Vec::new();
for id in self.shared.docs.ids() {
said.extend(self.look_at(id));
let open = self.shared.docs.get_mut(id);
if open.is_read_only()
&& let Access::Locked(lock) = access(&self.paths, open.buffer.doc().uid())
{
open.file.access = Access::Locked(lock);
said.push(Message::info(format!("{}: you can edit it now", open.file_name())));
}
}
let changed = !said.is_empty();
if let Some(message) = Message::joined(said) {
self.message = Some(message);
}
changed
}
/// Looks at one document's file, and acts on what changed. Not while
/// its text is being edited or a node moved: the document stays as it
/// is under the edit, and the file is looked at again after.
fn look_at(&mut self, id: BufferId) -> Option<Message> {
let busy = matches!(self.shared.mode, Mode::Text(_) | Mode::Move(_))
|| self.shared.mode.any_edit().is_some();
if busy && id == self.buffer() {
return None;
}
let open = self.shared.docs.get_mut(id);
let stamp = Stamp::of(&open.file.path).ok()?;
if stamp == open.file.stamp {
return None;
}
let name = open.file_name();
let Some(stamp) = stamp else {
// Gone: deleted, or moved away. Writing it back right away
// could undo a move, so that waits for `:w`.
open.file.stamp = None;
open.file.trouble = Some(Trouble::Gone);
return Some(Message::problem(format!("{name} was deleted or moved: :w writes it here again")));
};
let text = std::fs::read_to_string(&open.file.path).ok()?;
open.file.stamp = Some(stamp);
let newer = match document_file::parse(&text) {
Ok(newer) => newer,
Err(error) => {
open.file.trouble = Some(Trouble::Broken(error.to_string()));
return Some(Message::problem(format!(
"{name} changed outside cumulus, with a mistake in it: {error} · autosave waits until it's fixed (:w saves over it)"
)));
}
};
if open.is_dirty() {
open.file.trouble = Some(Trouble::Conflict { newer: Box::new(newer), asked: false });
return None;
}
Some(self.take_in(id, newer, "{} changed outside cumulus: here it is · u undoes that"))
}
/// Puts a document read again from its file in place. `said` is what to
/// say when that's an undo step, about the file `{}`; a different
/// document has its own message.
fn take_in(&mut self, id: BufferId, newer: Document, said: &str) -> Message {
let open = self.shared.docs.get_mut(id);
let name = open.file_name();
let undoable = open.buffer.reload(newer);
open.file.saved = open.buffer.history().state();
open.file.changed = None;
open.file.trouble = None;
if undoable {
return Message::info(said.replace("{}", &name));
}
// Another document: the lock it needs is its own.
open.file.access = access(&self.paths, open.buffer.doc().uid());
Message::info(format!("{name} holds another document now: here it is"))
}
/// Asks which version to keep, for a document whose file changed while
/// it had unsaved changes here: in normal mode, not to interrupt typing.
fn ask_about_conflicts(&mut self) -> bool {
if !matches!(self.shared.mode, Mode::Normal) {
return false;
}
for open in self.shared.docs.iter_mut() {
if let Some(Trouble::Conflict { asked, .. }) = &mut open.file.trouble
&& !*asked
{
*asked = true;
self.shared.mode = Mode::Popup(Popup::conflict(open.id, &open.file_name()));
return true;
}
}
false
}
/// Keeps one version of a document whose file changed outside cumulus.
pub(super) fn resolve(&mut self, id: BufferId, keep: Keep) -> Option<Message> {
if !self.shared.docs.contains(id) {
return None;
}
let open = self.shared.docs.get_mut(id);
let Some(Trouble::Conflict { newer, .. }) = open.file.trouble.take() else { return None };
match keep {
Keep::Mine => Some(match self.save(id) {
Ok(()) => {
Message::done(format!("kept yours: saved {}", self.shared.docs.get(id).file_name()))
}
Err(problem) => problem,
}),
Keep::Theirs => Some(self.take_in(id, *newer, "took the file's {} · u brings yours back")),
}
}
// ----- Commands -------------------------------------------------------
/// `:w`, `:x` and `:wa`.
pub(super) fn write(&mut self, which: SaveWhich) -> Option<Message> {
let here = self.buffer();
let ids: Vec<BufferId> = match which {
SaveWhich::This => vec![here],
SaveWhich::IfChanged => {
let open = self.shared.docs.get(here);
if !open.is_dirty() && open.file.trouble.is_none() {
return None;
}
vec![here]
}
SaveWhich::All => {
self.shared.docs.iter().filter(|open| open.is_dirty()).map(|open| open.id).collect()
}
};
if ids.is_empty() {
return Some(Message::info("nothing to save: every document is saved"));
}
let mut names = Vec::new();
for id in ids {
// `:w` settles a conflict: what's on screen is what you keep.
self.shared.docs.get_mut(id).file.trouble = None;
if let Err(problem) = self.save(id) {
return Some(problem);
}
names.push(self.shared.docs.get(id).file_name());
}
Some(Message::done(format!("saved {}", names.join(", "))))
}
/// `:e`: opens a document in the focused pane, or (with no path) reads
/// the focused one again from its file.
pub(super) fn edit(&mut self, path: Option<String>, force: bool) -> Option<Message> {
let Some(path) = path else { return self.read_again(force) };
let path = self.resolve_path(&path);
let loaded = match load(&path) {
Ok(loaded) => loaded,
Err(error) => return Some(Message::problem(error.short())),
};
let (id, said) = self.add_document(loaded);
self.show(id);
said
}
/// `:e` and `:e!` with no file: the focused document as its file has
/// it. `:e` refuses to drop unsaved changes.
fn read_again(&mut self, force: bool) -> Option<Message> {
let id = self.buffer();
let open = self.shared.docs.get(id);
let name = open.file_name();
if open.is_dirty() && !force {
return Some(Message::problem(format!("{name} has unsaved changes: :e! drops them")));
}
let loaded = match load(&open.file.path) {
Ok(loaded) if loaded.stamp.is_some() => loaded,
Ok(_) => return Some(Message::problem(format!("{name} isn't on disk yet"))),
Err(error) => return Some(Message::problem(error.short())),
};
let open = self.shared.docs.get_mut(id);
open.file.stamp = loaded.stamp;
Some(self.take_in(id, loaded.doc, "read {} again from the file · u undoes that"))
}
/// A path typed in `:e` or `:new`: `~` is the home folder, relative paths
/// start at the focused document's folder, a folder means its default
/// document, and `.cum` is added when there's no extension.
fn resolve_path(&self, typed: &str) -> PathBuf {
let path = self.paths.expand(typed.trim());
let path = if path.is_relative() { self.folder().join(path) } else { path };
if path.is_dir() {
return path.join(format!("{}.{}", self.settings.default_document, names::EXTENSION));
}
if path.extension().is_none() { path.with_extension(names::EXTENSION) } else { path }
}
/// The focused document's folder.
pub(super) fn folder(&self) -> PathBuf {
let path = &self.shared.docs.get(self.buffer()).file.path;
path.parent().map_or_else(|| PathBuf::from("."), Path::to_path_buf)
}
/// `:new`: a new document next to the focused one, written right away,
/// and shown. With no title, it's the folder's default document.
pub(super) fn new_document(&mut self, title: Option<Title>) -> Option<Message> {
let name = match &title {
Some(title) => file_name(title.as_str()),
None => format!("{}.{}", self.settings.default_document, names::EXTENSION),
};
let path = self.folder().join(&name);
if path.exists() {
return match self.edit(Some(path.display().to_string()), false) {
Some(problem) if problem.tone == Tone::Problem => Some(problem),
_ => Some(Message::info(format!("{name} is here already: here it is"))),
};
}
let title = title.unwrap_or_else(|| title_from_file(&path));
match create(&path, title) {
Ok(loaded) => {
let (id, _) = self.add_document(loaded);
self.show(id);
Some(Message::done(format!("made {name}")))
}
Err(error) => Some(Message::problem(error.short())),
}
}
/// `:bd`: closes the focused document; its panes show another one.
pub(super) fn close_document(&mut self, force: bool) -> Option<Message> {
let id = self.buffer();
if self.shared.docs.count() == 1 {
return Some(Message::info("it's the only open document: :q quits"));
}
if let Some(problem) = self.before_closing(&[id], force, ":bd!") {
return Some(problem);
}
self.keep_cameras();
self.note_place(id);
let index = self.shared.docs.index_of(id);
let name = self.shared.docs.get(id).file_name();
self.shared.docs.remove(id);
let next = self.shared.docs.at(index.saturating_sub(1));
let camera = self.shared.docs.get(next).camera;
for (_, pane) in self.panes.iter_mut().filter(|(_, pane)| pane.buffer == id) {
*pane = Pane::new(next, pane.view.kind(), camera);
}
Some(Message::done(format!("closed {name}")))
}
/// `:qa`, and `:q` on the last pane.
pub(super) fn quit(&mut self, force: bool) -> Option<Message> {
let ids = self.shared.docs.ids();
if let Some(problem) = self.before_closing(&ids, force, ":q!") {
return Some(problem);
}
self.quit = true;
None
}
/// What has to happen before documents close: with autosave, their
/// changes are saved; without, unsaved changes stop it (unless `force`,
/// which `bang` names).
fn before_closing(&mut self, ids: &[BufferId], force: bool, bang: &str) -> Option<Message> {
if force {
return None;
}
for &id in ids {
// Not over a change made outside cumulus: that's looked at first.
if self.changed_outside(id) {
self.look_at(id);
}
let open = self.shared.docs.get(id);
let name = open.file_name();
match &open.file.trouble {
Some(Trouble::Conflict { .. }) => {
return Some(Message::problem(format!(
"{name} changed outside cumulus: :w keeps yours, :e! takes the file's, {bang} neither"
)));
}
Some(Trouble::Broken(_)) if open.is_dirty() => {
return Some(Message::problem(format!(
"{name} has a mistake in it on disk: :w saves yours over it, {bang} doesn't"
)));
}
Some(Trouble::Gone) if open.is_dirty() => {
return Some(Message::problem(format!(
"{name} was deleted or moved: :w writes it here again, {bang} doesn't"
)));
}
_ => {}
}
if !open.is_dirty() {
continue;
}
if !self.settings.autosave {
return Some(Message::problem(format!(
"{name} has unsaved changes: :w saves them, {bang} drops them"
)));
}
if let Err(problem) = self.save(id) {
return Some(problem);
}
}
None
}
/// The focused pane shows document `id`, in the same kind of view. Its
/// canvas camera stays with the document it showed.
pub(super) fn show(&mut self, id: BufferId) {
let pane = self.panes.current_mut();
if pane.buffer == id {
return;
}
if let Some(camera) = pane.view.camera() {
self.shared.docs.get_mut(pane.buffer).camera = Some(camera);
}
let camera = self.shared.docs.get(id).camera;
*pane = Pane::new(id, pane.view.kind(), camera);
}
/// The message for a change refused because the document is read-only.
pub(super) fn refuse_read_only(&self) -> Option<Message> {
let open = self.shared.docs.get(self.buffer());
open.is_read_only().then(|| read_only(open))
}
}
fn read_only(open: &Open) -> Message {
Message::problem(format!("{} is open in another cumulus: read-only here", open.file_name()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn paths_in_messages_start_from_home() {
let home = Path::new("/home/sam");
assert_eq!(shown(&home.join("ideas.cum"), home), "~/ideas.cum");
assert_eq!(shown(&home.join("notes").join("a.cum"), home), "~/notes/a.cum");
let elsewhere = Path::new("/srv/b.cum");
assert_eq!(shown(elsewhere, home), elsewhere.display().to_string());
}
}