Add storage: config.toml, shifts.toml, backups and a lock

- config.toml is written from the settings table, with each help text as
  a comment; mistakes in it become warnings, never a crash.
- shifts.toml is plain, hand-editable TOML. Reading checks every journal
  rule and reports mistakes with the day and segment. Edits made while
  skyshift runs are reloaded; a broken file is never overwritten.
- Saves are atomic (temp file + rename), a copy is kept once a day, and
  a file lock stops two copies of skyshift from using the same data.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
skkeyeandClaude Opus 5.5 committed 2026-09-24 02:59:22 -04:00
1 parent cc8e6abc4f
commit eff7834838
7 files changed
+666

No files matched your search

+1
View File
@@ -9,6 +9,7 @@ mod domain;
mod export;
mod reminders;
mod settings;
mod storage;
mod time;
fn main() {}
+42
View File
@@ -0,0 +1,42 @@
//! One copy of the data file per day, made when skyshift starts: a safety
//! net for mistakes, including hand edits.
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use jiff::civil::Date;
/// Copies `data_file` into a `backups` folder next to it, as
/// `shifts-2026-09-24.toml` (at most once a day), then deletes the oldest
/// copies so that only `keep` remain. `keep = 0` turns backups off.
pub fn daily_backup(data_file: &Path, today: Date, keep: u32) -> io::Result<()> {
if keep == 0 || !data_file.exists() {
return Ok(());
}
let folder = data_file.with_file_name("backups");
fs::create_dir_all(&folder)?;
let stem = data_file.file_stem().and_then(|stem| stem.to_str()).unwrap_or("shifts");
let prefix = format!("{stem}-");
let todays_copy = folder.join(format!("{prefix}{today}.toml"));
if !todays_copy.exists() {
fs::copy(data_file, &todays_copy)?;
}
// The names end with an ISO date, so sorting them puts the oldest first.
let mut copies: Vec<PathBuf> = fs::read_dir(&folder)?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| {
let name = path.file_name().and_then(|name| name.to_str()).unwrap_or("");
name.starts_with(&prefix) && name.ends_with(".toml")
})
.collect();
copies.sort();
let extra = copies.len().saturating_sub(keep as usize);
for old_copy in &copies[..extra] {
fs::remove_file(old_copy)?;
}
Ok(())
}
+331
View File
@@ -0,0 +1,331 @@
//! Reading and writing the data file (shifts.toml) that holds your work.
//!
//! The file is plain TOML, meant to be readable and editable by hand:
//!
//! ```toml
//! version = 1
//!
//! [current]
//! day = 2026-09-24
//! working_since = 2026-09-25T02:00:00-04:00
//!
//! [[days]]
//! date = 2026-09-24
//! segments = [
//! { start = 2026-09-24T22:00:00-04:00, end = 2026-09-25T01:30:00-04:00 },
//! ]
//! ```
//!
//! Reading is strict: every rule of the journal is checked (a segment ends
//! after it starts, nothing overlaps...), and a mistake is reported with the
//! work day and segment it's in, instead of being silently "fixed".
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use anyhow::Context;
use jiff::Timestamp;
use jiff::civil::{Date, DateTime};
use jiff::tz::TimeZone;
use serde::Deserialize;
use toml::value::Datetime;
use super::write_atomically;
use crate::domain::{Journal, Segment, ShiftState};
use crate::time::format::rfc3339;
/// Bumped if the layout of the file ever changes, so an older skyshift
/// refuses a newer file instead of misreading it.
const FORMAT_VERSION: i64 = 1;
const HEADER: &str = "\
# skyshift data file: your work shifts.
#
# You can edit this file by hand. If skyshift is open, it notices and
# reloads it (and won't overwrite it while it has a mistake in it).
#
# - Each [[days]] block is one work day. `date` is the day it counts for,
# even when the work runs past midnight.
# - Each segment is one stretch of work: its start and end, with their UTC
# offset. You can leave the offset out (start = 2026-09-24T22:00:00): the
# time is then read in the time zone of your settings.
# - [current], when present, is the open work day. `working_since` is the
# start of the session in progress (absent while you're on a break).
#
# A copy of this file is saved once a day in the `backups` folder next to it.
";
// ----- The shape of the file ---------------------------------------------
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct DataFile {
version: i64,
current: Option<CurrentEntry>,
#[serde(default)]
days: Vec<DayEntry>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CurrentEntry {
day: Datetime,
working_since: Option<Datetime>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct DayEntry {
date: Datetime,
#[serde(default)]
segments: Vec<SegmentEntry>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct SegmentEntry {
start: Datetime,
end: Datetime,
}
// ----- Reading and writing ---------------------------------------------
/// Reads a journal from the file's text. Times without a UTC offset are read
/// in `zone`.
pub fn parse(text: &str, zone: &TimeZone) -> Result<Journal, String> {
let file: DataFile = toml::from_str(text).map_err(|error| error.to_string())?;
if file.version != FORMAT_VERSION {
return Err(format!(
"this file is format version {}, but this skyshift reads version {FORMAT_VERSION}",
file.version
));
}
let mut days = Vec::new();
for entry in &file.days {
let date =
to_date(&entry.date).map_err(|error| format!("a [[days]] block has a bad date: {error}"))?;
let mut segments = Vec::new();
for (index, segment) in entry.segments.iter().enumerate() {
let place = format!("work day {date}, segment {}", index + 1);
let start =
to_timestamp(&segment.start, zone).map_err(|error| format!("{place}: start: {error}"))?;
let end = to_timestamp(&segment.end, zone).map_err(|error| format!("{place}: end: {error}"))?;
segments.push(Segment::new(start, end).map_err(|error| format!("{place}: {error}"))?);
}
days.push((date, segments));
}
let state = match &file.current {
None => ShiftState::Idle,
Some(current) => {
let day = to_date(&current.day).map_err(|error| format!("[current] day: {error}"))?;
match &current.working_since {
None => ShiftState::OnBreak { day },
Some(since) => {
let since = to_timestamp(since, zone)
.map_err(|error| format!("[current] working_since: {error}"))?;
ShiftState::Working { day, since }
}
}
}
};
Journal::from_parts(days, state).map_err(|error| error.to_string())
}
/// Writes a journal as the file's text. Times are written in `zone`, with
/// their UTC offset.
pub fn to_toml(journal: &Journal, zone: &TimeZone) -> String {
let stamp = |moment: Timestamp| rfc3339(&moment.to_zoned(zone.clone()));
let mut lines: Vec<String> = HEADER.lines().map(String::from).collect();
lines.push(String::new());
lines.push(format!("version = {FORMAT_VERSION}"));
if let Some(day) = journal.state().open_day() {
lines.push(String::new());
lines.push("[current]".into());
lines.push(format!("day = {day}"));
if let ShiftState::Working { since, .. } = journal.state() {
lines.push(format!("working_since = {}", stamp(since)));
}
}
for day in journal.days() {
lines.push(String::new());
lines.push("[[days]]".into());
lines.push(format!("date = {}", day.date()));
if day.is_empty() {
lines.push("segments = []".into());
continue;
}
lines.push("segments = [".into());
for segment in day.segments() {
lines.push(format!(
" {{ start = {}, end = {} }},",
stamp(segment.start()),
stamp(segment.end())
));
}
lines.push("]".into());
}
lines.join("\n") + "\n"
}
/// A TOML date (`2026-09-24`) as a calendar date.
fn to_date(value: &Datetime) -> Result<Date, String> {
if value.time.is_some() {
return Err(format!("{value} should be a date only, like 2026-09-24"));
}
value.to_string().parse().map_err(|error| format!("{value}: {error}"))
}
/// A TOML date-time as an exact moment: with an offset
/// (`2026-09-24T22:00:00-04:00`) it's exact already; without one, it's read
/// in `zone`.
fn to_timestamp(value: &Datetime, zone: &TimeZone) -> Result<Timestamp, String> {
let text = value.to_string();
if value.date.is_none() || value.time.is_none() {
return Err(format!("{text} should be a date and a time, like 2026-09-24T22:00:00-04:00"));
}
if value.offset.is_some() {
return text.parse().map_err(|error| format!("{text}: {error}"));
}
let local: DateTime = text.parse().map_err(|error| format!("{text}: {error}"))?;
local.to_zoned(zone.clone()).map(|zoned| zoned.timestamp()).map_err(|error| format!("{text}: {error}"))
}
// ----- The file on disk ------------------------------------------------
/// The data file on disk, and what skyshift last read or wrote there, to
/// notice when someone else (you, in a text editor) changes it.
#[derive(Debug)]
pub struct JournalStore {
path: PathBuf,
/// The text skyshift last read or wrote.
known_text: Option<String>,
/// The file's modification time and size when last checked.
known_stamp: Option<(SystemTime, u64)>,
/// Set while the file on disk has a mistake in it: saving would erase
/// the hand edit being made, so it waits until the file is fixed.
outside_error: Option<String>,
}
/// Something that happened to the data file outside skyshift.
pub enum OutsideChange {
/// The file was edited and reads fine: here is its new content.
Reloaded(Journal),
/// The file was edited and has a mistake in it.
Broken(String),
}
impl JournalStore {
/// Opens the data file and reads it. A missing file means no work
/// recorded yet (it's created on the first save).
pub fn open(path: PathBuf, zone: &TimeZone) -> anyhow::Result<(JournalStore, Journal)> {
let text = match fs::read_to_string(&path) {
Ok(text) => Some(text),
Err(error) if error.kind() == io::ErrorKind::NotFound => None,
Err(error) => return Err(error).with_context(|| format!("can't read {}", path.display())),
};
let journal = match &text {
Some(text) => parse(text, zone).map_err(|error| {
anyhow::anyhow!("{} has a mistake in it: {error}\n(fix it in a text editor, or restore a copy from the backups folder)", path.display())
})?,
None => Journal::default(),
};
let store =
JournalStore { known_stamp: file_stamp(&path), path, known_text: text, outside_error: None };
Ok((store, journal))
}
pub fn save(&mut self, journal: &Journal, zone: &TimeZone) -> Result<(), String> {
if let Some(error) = &self.outside_error {
return Err(format!("not saved: shifts.toml has a mistake to fix first ({error})"));
}
let text = to_toml(journal, zone);
write_atomically(&self.path, &text)
.map_err(|error| format!("can't save {}: {error}", self.path.display()))?;
self.known_stamp = file_stamp(&self.path);
self.known_text = Some(text);
Ok(())
}
/// Checks whether the file changed since skyshift last read or wrote it.
/// Cheap enough to call every second: it only reads the file when its
/// modification time or size changed.
pub fn check_outside_changes(&mut self, zone: &TimeZone) -> Option<OutsideChange> {
let stamp = file_stamp(&self.path);
if stamp == self.known_stamp {
return None;
}
self.known_stamp = stamp;
let text = fs::read_to_string(&self.path).ok()?;
if self.known_text.as_ref() == Some(&text) {
return None;
}
self.known_text = Some(text.clone());
match parse(&text, zone) {
Ok(journal) => {
self.outside_error = None;
Some(OutsideChange::Reloaded(journal))
}
Err(error) => {
self.outside_error = Some(error.clone());
Some(OutsideChange::Broken(error))
}
}
}
}
fn file_stamp(path: &Path) -> Option<(SystemTime, u64)> {
let metadata = fs::metadata(path).ok()?;
Some((metadata.modified().ok()?, metadata.len()))
}
#[cfg(test)]
mod tests {
use super::*;
use jiff::civil::date;
use jiff::{SignedDuration, Zoned};
fn toronto() -> TimeZone {
TimeZone::get("America/Toronto").unwrap()
}
fn at(day: i8, hour: i8, minute: i8) -> Zoned {
date(2026, 9, day).at(hour, minute, 0, 0).to_zoned(toronto()).unwrap()
}
#[test]
fn a_journal_survives_writing_and_reading_back() {
let mut journal = Journal::default();
let new_day_after = SignedDuration::from_hours(8);
journal.start(&at(24, 22, 0), new_day_after).unwrap();
journal.pause(at(25, 1, 30).timestamp()).unwrap();
journal.start(&at(25, 2, 0), new_day_after).unwrap();
let text = to_toml(&journal, &toronto());
assert!(text.contains("{ start = 2026-09-24T22:00:00-04:00, end = 2026-09-25T01:30:00-04:00 },"));
assert!(text.contains("working_since = 2026-09-25T02:00:00-04:00"));
assert_eq!(parse(&text, &toronto()), Ok(journal));
}
#[test]
fn times_without_offset_use_the_time_zone() {
let text = "version = 1\n[[days]]\ndate = 2026-09-24\nsegments = [{ start = 2026-09-24T09:00:00, end = 2026-09-24T12:00:00 }]\n";
let journal = parse(text, &toronto()).unwrap();
let day = journal.day(date(2026, 9, 24)).unwrap();
assert_eq!(day.first_start(), Some(at(24, 9, 0).timestamp()));
}
#[test]
fn mistakes_say_where_they_are() {
let text = "version = 1\n[[days]]\ndate = 2026-09-24\nsegments = [{ start = 2026-09-24T12:00:00Z, end = 2026-09-24T09:00:00Z }]\n";
let error = parse(text, &toronto()).unwrap_err();
assert!(error.starts_with("work day 2026-09-24, segment 1:"), "{error}");
assert!(parse("version = 1\n[[days]]\ndate = 2026-09-24\nsegmnets = []\n", &toronto()).is_err());
}
}
+37
View File
@@ -0,0 +1,37 @@
//! Stops two copies of skyshift from using the same data file at once (they
//! would overwrite each other's changes).
use std::fs::{self, File, OpenOptions, TryLockError};
use std::path::Path;
use anyhow::{Context, bail};
/// Holds an exclusive lock on `shifts.lock` (next to the data file) for as
/// long as it's alive. The operating system releases it when skyshift
/// exits, even after a crash.
#[derive(Debug)]
pub struct InstanceLock {
_file: File,
}
impl InstanceLock {
pub fn acquire(data_file: &Path) -> anyhow::Result<InstanceLock> {
let path = data_file.with_extension("lock");
if let Some(folder) = path.parent() {
fs::create_dir_all(folder).with_context(|| format!("can't create {}", folder.display()))?;
}
let file = OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&path)
.with_context(|| format!("can't open {}", path.display()))?;
match file.try_lock() {
Ok(()) => Ok(InstanceLock { _file: file }),
Err(TryLockError::WouldBlock) => {
bail!("skyshift is already running with this data file ({})", data_file.display())
}
Err(TryLockError::Error(error)) => Err(error).context("can't lock the data file"),
}
}
}
+31
View File
@@ -0,0 +1,31 @@
//! Files on disk:
//! - [`paths`]: where everything lives on each operating system,
//! - [`settings_file`]: config.toml, your settings,
//! - [`journal_file`]: shifts.toml, your work,
//! - [`backup`]: a copy of shifts.toml once a day,
//! - [`lock`]: stops two copies of skyshift from writing the same data.
pub mod backup;
pub mod journal_file;
pub mod lock;
pub mod paths;
pub mod settings_file;
use std::fs::{self, File};
use std::io::{self, Write};
use std::path::Path;
/// Writes a file so it's never left half-written: the text goes to a
/// temporary file first, which then replaces the real one in a single step.
/// If the computer crashes midway, the previous version is still intact.
pub fn write_atomically(path: &Path, contents: &str) -> io::Result<()> {
if let Some(folder) = path.parent() {
fs::create_dir_all(folder)?;
}
let temporary = path.with_extension("tmp");
let mut file = File::create(&temporary)?;
file.write_all(contents.as_bytes())?;
file.sync_all()?;
drop(file);
fs::rename(&temporary, path)
}
+73
View File
@@ -0,0 +1,73 @@
//! Where skyshift keeps its files.
//!
//! | File | Windows | Linux |
//! |----------|-----------------------------------------|-------------------------------------|
//! | settings | `%APPDATA%\skyshift\config\config.toml` | `~/.config/skyshift/config.toml` |
//! | shifts | `%APPDATA%\skyshift\data\shifts.toml` | `~/.local/share/skyshift/shifts.toml` |
//!
//! Paths in the settings may start with `~` (your home folder). Relative
//! paths are relative to the folder of config.toml, which makes a portable
//! setup easy: `skyshift --config D:\skyshift\config.toml` with
//! `data_file = "shifts.toml"` keeps everything in `D:\skyshift`.
use std::path::{Path, PathBuf};
use anyhow::Context;
use directories::{ProjectDirs, UserDirs};
use crate::settings::Settings;
#[derive(Debug, Clone)]
pub struct Paths {
pub config_file: PathBuf,
/// The default folder for shifts.toml.
pub data_dir: PathBuf,
}
impl Paths {
/// The standard locations for this operating system. `config_override`
/// comes from the `--config` command line option.
pub fn locate(config_override: Option<PathBuf>) -> anyhow::Result<Paths> {
let dirs = ProjectDirs::from("", "", "skyshift").context("can't find your home folder")?;
Ok(Paths {
config_file: config_override.unwrap_or_else(|| dirs.config_dir().join("config.toml")),
data_dir: dirs.data_dir().to_path_buf(),
})
}
/// The data file: the `data_file` setting, or shifts.toml in the data folder.
pub fn data_file(&self, settings: &Settings) -> PathBuf {
if settings.data_file.is_empty() {
self.data_dir.join("shifts.toml")
} else {
self.resolve(&settings.data_file)
}
}
/// Where exports go: the `export.folder` setting, or your Documents folder.
pub fn export_folder(&self, settings: &Settings) -> PathBuf {
if !settings.export_folder.is_empty() {
return self.resolve(&settings.export_folder);
}
let user = UserDirs::new();
let documents = user.as_ref().and_then(|user| user.document_dir().map(Path::to_path_buf));
let home = user.as_ref().map(|user| user.home_dir().to_path_buf());
documents.or(home).unwrap_or_else(|| PathBuf::from("."))
}
/// A path from the settings: `~` means the home folder, and relative
/// paths start from the folder of config.toml.
fn resolve(&self, path: &str) -> PathBuf {
let path = match path.strip_prefix('~') {
Some(rest) => match UserDirs::new() {
Some(user) => user.home_dir().join(rest.trim_start_matches(['/', '\\'])),
None => PathBuf::from(path),
},
None => PathBuf::from(path),
};
match self.config_file.parent() {
Some(config_folder) if path.is_relative() => config_folder.join(path),
_ => path,
}
}
}
+151
View File
@@ -0,0 +1,151 @@
//! Reading and writing config.toml.
//!
//! The file is generated from the settings table ([`FIELDS`]), with each
//! setting's help written above it as a comment, so it documents itself.
//! Reading goes through the same table (the same checks as the Settings
//! screen), and a mistake in the file never stops skyshift: that setting
//! keeps its default and you get a warning.
use std::fs;
use std::io;
use std::path::Path;
use anyhow::Context;
use super::write_atomically;
use crate::settings::{FIELDS, Field, Kind, SECTIONS, Settings};
const HEADER: &str = "\
# skyshift settings
#
# Change them in the app (Settings tab) or here. skyshift rewrites this file
# when you change a setting in the app, so comments you add here are lost.
# Durations are written like 8h, 7h 30m or 90m.";
/// Reads the settings, with warnings about anything that couldn't be used.
/// When the file doesn't exist yet, it's created with the defaults.
pub fn load(path: &Path) -> anyhow::Result<(Settings, Vec<String>)> {
match fs::read_to_string(path) {
Ok(text) => Ok(from_toml(&text)),
Err(error) if error.kind() == io::ErrorKind::NotFound => {
let settings = Settings::default();
save(path, &settings)?;
Ok((settings, Vec::new()))
}
Err(error) => Err(error).with_context(|| format!("can't read {}", path.display())),
}
}
pub fn save(path: &Path, settings: &Settings) -> anyhow::Result<()> {
write_atomically(path, &to_toml(settings)).with_context(|| format!("can't write {}", path.display()))
}
/// Reads settings from TOML text. Anything missing keeps its default.
pub fn from_toml(text: &str) -> (Settings, Vec<String>) {
let mut settings = Settings::default();
let mut warnings = Vec::new();
let table: toml::Table = match text.parse() {
Ok(table) => table,
Err(error) => {
warnings.push(format!("config.toml isn't valid TOML, so the defaults are used: {error}"));
return (settings, warnings);
}
};
for (section, entries) in &table {
let Some(entries) = entries.as_table() else {
warnings.push(format!("config.toml: `{section}` should be a [section]"));
continue;
};
for (key, value) in entries {
let Some(field) = FIELDS.iter().find(|field| field.section == section && field.key == key) else {
warnings.push(format!("config.toml: unknown setting `{key}` in [{section}]"));
continue;
};
let text = match value {
toml::Value::String(text) => text.clone(),
toml::Value::Integer(number) => number.to_string(),
toml::Value::Float(number) => number.to_string(),
toml::Value::Boolean(on) => on.to_string(),
_ => {
warnings.push(format!("config.toml: [{section}] {key} should be a single value"));
continue;
}
};
if let Err(error) = (field.set)(&mut settings, &text) {
warnings.push(format!("config.toml: [{section}] {key}: {error}"));
}
}
}
(settings, warnings)
}
/// Writes every setting, grouped in sections, each with its help as a comment.
pub fn to_toml(settings: &Settings) -> String {
let mut lines: Vec<String> = HEADER.lines().map(String::from).collect();
for (section, description) in SECTIONS {
lines.push(String::new());
lines.push(format!("# ===== {description} ====="));
lines.push(format!("[{section}]"));
for field in FIELDS.iter().filter(|field| field.section == section) {
lines.push(String::new());
lines.extend(wrap(field.help, 76).into_iter().map(|line| format!("# {line}")));
lines.push(format!("{} = {}", field.key, toml_value(field, settings)));
}
}
lines.join("\n") + "\n"
}
/// The setting's value as TOML: true/false and numbers as they are,
/// everything else as a quoted string.
fn toml_value(field: &Field, settings: &Settings) -> String {
let text = (field.get)(settings);
match field.kind {
Kind::Toggle | Kind::Number => text,
Kind::Choice(_) | Kind::Duration | Kind::Text => toml::Value::String(text).to_string(),
}
}
/// Splits text into lines of at most `width` characters, between words.
fn wrap(text: &str, width: usize) -> Vec<String> {
let mut lines = Vec::new();
let mut line = String::new();
for word in text.split_whitespace() {
if !line.is_empty() && line.len() + 1 + word.len() > width {
lines.push(std::mem::take(&mut line));
}
if !line.is_empty() {
line.push(' ');
}
line.push_str(word);
}
if !line.is_empty() {
lines.push(line);
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn written_settings_read_back_the_same() {
let settings = Settings {
custom_template: "{date} \"quoted\" \\ {tab}".to_string(),
export_folder: r"C:\Users\me\Timesheets".to_string(),
decimal_places: 3,
..Settings::default()
};
let (read_back, warnings) = from_toml(&to_toml(&settings));
assert_eq!(warnings, Vec::<String>::new());
assert_eq!(read_back, settings);
}
#[test]
fn mistakes_become_warnings() {
let (settings, warnings) = from_toml("[display]\ntime_format = \"25h\"\ncolour = \"red\"\n");
assert_eq!(settings, Settings::default());
assert_eq!(warnings.len(), 2);
}
}