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:
1 parent
cc8e6abc4f
commit
eff7834838
7 files changed
+666
No files matched your search
@@ -9,6 +9,7 @@ mod domain;
|
||||
mod export;
|
||||
mod reminders;
|
||||
mod settings;
|
||||
mod storage;
|
||||
mod time;
|
||||
|
||||
fn main() {}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(¤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<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());
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user