diff --git a/src/main.rs b/src/main.rs index f464c7c..4cfe254 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ mod domain; mod export; mod reminders; mod settings; +mod storage; mod time; fn main() {} diff --git a/src/storage/backup.rs b/src/storage/backup.rs new file mode 100644 index 0000000..8d25952 --- /dev/null +++ b/src/storage/backup.rs @@ -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 = 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(()) +} diff --git a/src/storage/journal_file.rs b/src/storage/journal_file.rs new file mode 100644 index 0000000..4084609 --- /dev/null +++ b/src/storage/journal_file.rs @@ -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, + #[serde(default)] + days: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CurrentEntry { + day: Datetime, + working_since: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct DayEntry { + date: Datetime, + #[serde(default)] + segments: Vec, +} + +#[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 { + 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(¤t.day).map_err(|error| format!("[current] day: {error}"))?; + match ¤t.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 = 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 { + 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 { + 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, + /// 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, +} + +/// 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 { + 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()); + } +} diff --git a/src/storage/lock.rs b/src/storage/lock.rs new file mode 100644 index 0000000..3b2974c --- /dev/null +++ b/src/storage/lock.rs @@ -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 { + 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"), + } + } +} diff --git a/src/storage/mod.rs b/src/storage/mod.rs new file mode 100644 index 0000000..f2c0a37 --- /dev/null +++ b/src/storage/mod.rs @@ -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) +} diff --git a/src/storage/paths.rs b/src/storage/paths.rs new file mode 100644 index 0000000..164631f --- /dev/null +++ b/src/storage/paths.rs @@ -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) -> anyhow::Result { + 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, + } + } +} diff --git a/src/storage/settings_file.rs b/src/storage/settings_file.rs new file mode 100644 index 0000000..ea1b74a --- /dev/null +++ b/src/storage/settings_file.rs @@ -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)> { + 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) { + 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 = 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 { + 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::::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); + } +}