diff --git a/src/main.rs b/src/main.rs index 061bfe1..f464c7c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod analysis; mod domain; mod export; mod reminders; +mod settings; mod time; fn main() {} diff --git a/src/settings.rs b/src/settings.rs new file mode 100644 index 0000000..3625055 --- /dev/null +++ b/src/settings.rs @@ -0,0 +1,913 @@ +//! Every setting skyshift has, with its default value. +//! +//! [`FIELDS`] describes each setting once: its name in `config.toml`, its +//! label and help in the Settings screen, and how to read and change it. +//! The config file and the Settings screen are both built from that list, so +//! adding a setting takes three steps: a field in [`Settings`], its default +//! in `Settings::default`, and an entry in [`FIELDS`]. + +use jiff::SignedDuration; +use jiff::civil::Weekday; +use jiff::fmt::friendly::{Direction, SpanPrinter}; +use ratatui::style::Color; + +use crate::export::ExportFormat; +use crate::export::clipboard::ClipboardMode; +use crate::export::timesheet::{CopyPreset, Rounding}; +use crate::reminders::PomodoroSettings; +use crate::time::clock::parse_zone; +use crate::time::{DecimalSeparator, DurationStyle, Formatter, PunchPrecision, TimeFormat}; + +#[derive(Debug, Clone, PartialEq)] +pub struct Settings { + // Display + pub time_format: TimeFormat, + pub duration_style: DurationStyle, + pub week_start: Weekday, + pub show_seconds: bool, + pub big_timer: bool, + pub accent_color: Color, + pub mouse: bool, + // Keys + pub which_key: bool, + pub which_key_delay: SignedDuration, + pub adjust_step: SignedDuration, + // Goals + pub daily_target: SignedDuration, + pub weekly_target: SignedDuration, + // Clock + pub timezone: String, + pub clock_adjustment: SignedDuration, + pub punch_precision: PunchPrecision, + // Work day + pub new_day_after_break: SignedDuration, + // Timesheet + pub copy_preset: CopyPreset, + pub rounding: Rounding, + pub rounding_step: SignedDuration, + pub decimal_separator: DecimalSeparator, + pub decimal_places: u8, + pub custom_template: String, + // Export + pub export_format: ExportFormat, + pub export_folder: String, + // Reminders + pub pomodoro: bool, + pub focus_length: SignedDuration, + pub short_break: SignedDuration, + pub long_break: SignedDuration, + pub long_break_every: u32, + pub snooze: SignedDuration, + pub still_working_check: SignedDuration, + pub reminder_bell: bool, + // Insights + pub long_session: SignedDuration, + // Storage and system + pub data_file: String, + pub backups: u32, + pub clipboard: ClipboardMode, + pub clipboard_command: String, +} + +impl Default for Settings { + fn default() -> Self { + Settings { + time_format: TimeFormat::Hour24, + duration_style: DurationStyle::HoursMinutes, + week_start: Weekday::Monday, + show_seconds: true, + big_timer: true, + accent_color: Color::Cyan, + mouse: true, + which_key: true, + which_key_delay: SignedDuration::from_millis(300), + adjust_step: SignedDuration::from_mins(5), + daily_target: SignedDuration::from_hours(8), + weekly_target: SignedDuration::from_hours(40), + timezone: "system".to_string(), + clock_adjustment: SignedDuration::ZERO, + punch_precision: PunchPrecision::Minute, + new_day_after_break: SignedDuration::from_hours(8), + copy_preset: CopyPreset::TotalClock, + rounding: Rounding::None, + rounding_step: SignedDuration::from_mins(15), + decimal_separator: DecimalSeparator::Dot, + decimal_places: 2, + custom_template: "{date}{tab}{in}{tab}{out}{tab}{total}".to_string(), + export_format: ExportFormat::Csv, + export_folder: String::new(), + pomodoro: true, + focus_length: SignedDuration::from_mins(25), + short_break: SignedDuration::from_mins(5), + long_break: SignedDuration::from_mins(15), + long_break_every: 4, + snooze: SignedDuration::from_mins(5), + still_working_check: SignedDuration::from_hours(10), + reminder_bell: false, + long_session: SignedDuration::from_hours(2), + data_file: String::new(), + backups: 14, + clipboard: ClipboardMode::Auto, + clipboard_command: String::new(), + } + } +} + +impl Settings { + /// The formatter for times and durations, following these settings. + pub fn formatter(&self) -> Formatter { + Formatter { + time_format: self.time_format, + duration_style: self.duration_style, + decimal_places: self.decimal_places, + decimal_separator: self.decimal_separator, + } + } + + /// The pomodoro rhythm, from these settings. + pub fn pomodoro(&self) -> PomodoroSettings { + PomodoroSettings { + enabled: self.pomodoro, + focus: self.focus_length, + short_break: self.short_break, + long_break: self.long_break, + long_break_every: self.long_break_every, + } + } +} + +// ----- How settings are described ------------------------------------------ + +/// One setting: where it lives in config.toml, how it's shown, and how to +/// read and change it (always as text, whether it comes from the file or +/// from the Settings screen). +pub struct Field { + /// The `[section]` in config.toml. + pub section: &'static str, + /// The name in config.toml. + pub key: &'static str, + /// The name in the Settings screen. + pub label: &'static str, + /// Shown under the settings list, and written as a comment in config.toml. + pub help: &'static str, + /// Hidden in the Settings screen until expert settings are shown. + pub expert: bool, + pub kind: Kind, + /// The current value, as text. + pub get: fn(&Settings) -> String, + /// Reads text and stores it, or explains what's wrong with it. + pub set: fn(&mut Settings, &str) -> Result<(), String>, +} + +/// What kind of value a setting holds. Decides how it's edited in the +/// Settings screen and how it's written in config.toml. +pub enum Kind { + /// One value from a fixed list (cycled with left/right). Gives the names. + Choice(fn() -> Vec<&'static str>), + /// On or off. + Toggle, + /// A duration like "8h", "7h 30m" or "90m". + Duration, + /// A whole number. + Number, + /// Free text. + Text, +} + +/// The sections of config.toml, in order, with a one-line description. +pub const SECTIONS: [(&str, &str); 10] = [ + ("display", "How things look"), + ("keys", "Keyboard"), + ("goals", "What you aim for"), + ("clock", "Where the time comes from"), + ("workday", "How work days open and close"), + ("timesheet", "What gets copied for your timesheet"), + ("export", "Report files"), + ("reminders", "Gentle nudges while you work"), + ("insights", "The Insights screen"), + ("advanced", "Storage and system"), +]; + +pub static FIELDS: &[Field] = &[ + // ----- display ----- + Field { + section: "display", + key: "time_format", + label: "Time format", + help: "How clock times are shown and copied: 24h (22:30) or 12h (10:30 PM). When you type a time, both styles always work.", + expert: false, + kind: Kind::Choice(names::), + get: |s| s.time_format.name().into(), + set: |s, text| { + s.time_format = choice(text)?; + Ok(()) + }, + }, + Field { + section: "display", + key: "duration_style", + label: "Durations", + help: "How durations are shown: hours-minutes (7h 45m), clock (7:45) or decimal (7.75).", + expert: false, + kind: Kind::Choice(names::), + get: |s| s.duration_style.name().into(), + set: |s, text| { + s.duration_style = choice(text)?; + Ok(()) + }, + }, + Field { + section: "display", + key: "week_start", + label: "Week starts on", + help: "The first day of the week, for weekly totals and calendars.", + expert: false, + kind: Kind::Choice(names::), + get: |s| s.week_start.name().into(), + set: |s, text| { + s.week_start = choice(text)?; + Ok(()) + }, + }, + Field { + section: "display", + key: "show_seconds", + label: "Show seconds", + help: "Show seconds on the clock and on the running timer.", + expert: false, + kind: Kind::Toggle, + get: |s| s.show_seconds.to_string(), + set: |s, text| { + s.show_seconds = toggle(text)?; + Ok(()) + }, + }, + Field { + section: "display", + key: "big_timer", + label: "Big timer", + help: "Show the running timer in large digits on the Tracker screen. Off gives a compact view.", + expert: false, + kind: Kind::Toggle, + get: |s| s.big_timer.to_string(), + set: |s, text| { + s.big_timer = toggle(text)?; + Ok(()) + }, + }, + Field { + section: "display", + key: "accent_color", + label: "Accent color", + help: "The main highlight color: a name (cyan, green, magenta, yellow, blue, red, lightblue...) or a hex code like #5fafff.", + expert: false, + kind: Kind::Text, + get: |s| s.accent_color.to_string().to_lowercase(), + set: |s, text| { + s.accent_color = text + .trim() + .parse() + .map_err(|_| format!("\"{text}\" isn't a color name or a #rrggbb code"))?; + Ok(()) + }, + }, + Field { + section: "display", + key: "mouse", + label: "Mouse", + help: "Click buttons, tabs and rows with the mouse. Turn it off if it gets in the way of selecting text in your terminal. Takes effect after a restart.", + expert: true, + kind: Kind::Toggle, + get: |s| s.mouse.to_string(), + set: |s, text| { + s.mouse = toggle(text)?; + Ok(()) + }, + }, + // ----- keys ----- + Field { + section: "keys", + key: "which_key", + label: "Key hints (which-key)", + help: "After the first key of a sequence (like g, y or space), show a popup listing the keys that can follow. w toggles it too.", + expert: false, + kind: Kind::Toggle, + get: |s| s.which_key.to_string(), + set: |s, text| { + s.which_key = toggle(text)?; + Ok(()) + }, + }, + Field { + section: "keys", + key: "which_key_delay", + label: "Key hints delay", + help: "How long to wait before showing key hints, like 300ms. 0 shows them right away.", + expert: true, + kind: Kind::Duration, + get: |s| show_duration(s.which_key_delay), + set: |s, text| { + s.which_key_delay = duration_at_least_zero(text)?; + Ok(()) + }, + }, + Field { + section: "keys", + key: "adjust_step", + label: "Nudge step", + help: "How far Ctrl-x and Ctrl-a move the start of the running session (\"I started 5 minutes earlier\"), like 5m.", + expert: true, + kind: Kind::Duration, + get: |s| show_duration(s.adjust_step), + set: |s, text| { + let step = duration(text)?; + if step < SignedDuration::from_mins(1) { + return Err("the nudge step must be at least 1 minute".into()); + } + s.adjust_step = step; + Ok(()) + }, + }, + // ----- goals ----- + Field { + section: "goals", + key: "daily_target", + label: "Daily target", + help: "How long you aim to work on a work day. Fills the progress bar and sets the balance. 0 turns it off.", + expert: false, + kind: Kind::Duration, + get: |s| show_duration(s.daily_target), + set: |s, text| { + s.daily_target = duration_at_least_zero(text)?; + Ok(()) + }, + }, + Field { + section: "goals", + key: "weekly_target", + label: "Weekly target", + help: "How long you aim to work in a week, for the weekly overview. 0 turns it off.", + expert: false, + kind: Kind::Duration, + get: |s| show_duration(s.weekly_target), + set: |s, text| { + s.weekly_target = duration_at_least_zero(text)?; + Ok(()) + }, + }, + // ----- clock ----- + Field { + section: "clock", + key: "timezone", + label: "Time zone", + help: "system (your computer's setting), a name like America/Toronto or Europe/Paris, or a fixed offset like -05:00.", + expert: false, + kind: Kind::Text, + get: |s| s.timezone.clone(), + set: |s, text| { + parse_zone(text)?; + s.timezone = text.trim().to_string(); + Ok(()) + }, + }, + Field { + section: "clock", + key: "adjustment", + label: "Clock adjustment", + help: "Added to the system clock, if you know it's off: like 2m 30s, or -1h. 0s means no adjustment.", + expert: true, + kind: Kind::Duration, + get: |s| show_duration(s.clock_adjustment), + set: |s, text| { + let adjustment = duration(text)?; + if adjustment.abs() > SignedDuration::from_hours(24) { + return Err("the adjustment must be within 24 hours".into()); + } + s.clock_adjustment = adjustment; + Ok(()) + }, + }, + Field { + section: "clock", + key: "punch_precision", + label: "Punch precision", + help: "minute: Start and Pause record whole minutes, so the times and totals you see always add up. second: record seconds too.", + expert: true, + kind: Kind::Choice(names::), + get: |s| s.punch_precision.name().into(), + set: |s, text| { + s.punch_precision = choice(text)?; + Ok(()) + }, + }, + // ----- workday ----- + Field { + section: "workday", + key: "new_day_after_break", + label: "New day after a break of", + help: "Safety net for a forgotten End day: when a break lasts this long, the next Start opens a new work day. 0 turns it off.", + expert: false, + kind: Kind::Duration, + get: |s| show_duration(s.new_day_after_break), + set: |s, text| { + s.new_day_after_break = duration_at_least_zero(text)?; + Ok(()) + }, + }, + // ----- timesheet ----- + Field { + section: "timesheet", + key: "copy_preset", + label: "Copy format", + help: "What c copies for a day: total-clock (5:40), total-decimal (5.67), segments (22:00-01:30, 02:00-04:10), in-out-break (3 cells), or custom (the template below).", + expert: false, + kind: Kind::Choice(names::), + get: |s| s.copy_preset.name().into(), + set: |s, text| { + s.copy_preset = choice(text)?; + Ok(()) + }, + }, + Field { + section: "timesheet", + key: "rounding", + label: "Rounding", + help: "How copied totals are rounded to the rounding step: none, nearest, up or down.", + expert: false, + kind: Kind::Choice(names::), + get: |s| s.rounding.name().into(), + set: |s, text| { + s.rounding = choice(text)?; + Ok(()) + }, + }, + Field { + section: "timesheet", + key: "rounding_step", + label: "Rounding step", + help: "The unit copied totals are rounded to, like 15m, or 6m (a tenth of an hour).", + expert: false, + kind: Kind::Duration, + get: |s| show_duration(s.rounding_step), + set: |s, text| { + let step = duration(text)?; + if step < SignedDuration::from_mins(1) { + return Err("the rounding step must be at least 1 minute".into()); + } + s.rounding_step = step; + Ok(()) + }, + }, + Field { + section: "timesheet", + key: "decimal_separator", + label: "Decimal separator", + help: "The character in decimal hours: . (7.75) or , (7,75). With a comma, CSV exports put ; between columns, as spreadsheets expect.", + expert: false, + kind: Kind::Choice(names::), + get: |s| s.decimal_separator.name().into(), + set: |s, text| { + s.decimal_separator = choice(text)?; + Ok(()) + }, + }, + Field { + section: "timesheet", + key: "decimal_places", + label: "Decimal places", + help: "How many digits after the separator in decimal hours (0 to 4).", + expert: true, + kind: Kind::Number, + get: |s| s.decimal_places.to_string(), + set: |s, text| { + s.decimal_places = number(text, 0, 4)? as u8; + Ok(()) + }, + }, + Field { + section: "timesheet", + key: "custom_template", + label: "Custom template", + help: "Used by the custom copy format. Placeholders: {date} {day} {in} {out} {break} {total} {decimal} {segments} {tab}.", + expert: true, + kind: Kind::Text, + get: |s| s.custom_template.clone(), + set: |s, text| { + s.custom_template = text.to_string(); + Ok(()) + }, + }, + // ----- export ----- + Field { + section: "export", + key: "format", + label: "Export format", + help: "The file format exports start with: csv, json or markdown.", + expert: false, + kind: Kind::Choice(names::), + get: |s| s.export_format.name().into(), + set: |s, text| { + s.export_format = choice(text)?; + Ok(()) + }, + }, + Field { + section: "export", + key: "folder", + label: "Export folder", + help: "Where exports are saved. Empty means your Documents folder. ~ means your home folder.", + expert: false, + kind: Kind::Text, + get: |s| s.export_folder.clone(), + set: |s, text| { + s.export_folder = text.trim().to_string(); + Ok(()) + }, + }, + // ----- reminders ----- + Field { + section: "reminders", + key: "pomodoro", + label: "Pomodoro rhythm", + help: "Suggest breaks in a pomodoro rhythm: focus, short break, focus... and a long break every few rounds. It only advises: you still press Start and Pause.", + expert: false, + kind: Kind::Toggle, + get: |s| s.pomodoro.to_string(), + set: |s, text| { + s.pomodoro = toggle(text)?; + Ok(()) + }, + }, + Field { + section: "reminders", + key: "focus_length", + label: "Focus length", + help: "How long to work before a break is suggested, like 25m, 50m or 90m. Interruptions shorter than a short break don't reset it.", + expert: false, + kind: Kind::Duration, + get: |s| show_duration(s.focus_length), + set: |s, text| { + s.focus_length = duration_at_least(text, SignedDuration::from_mins(1))?; + Ok(()) + }, + }, + Field { + section: "reminders", + key: "short_break", + label: "Short break", + help: "The suggested break after a focus stretch, like 5m.", + expert: false, + kind: Kind::Duration, + get: |s| show_duration(s.short_break), + set: |s, text| { + s.short_break = duration_at_least(text, SignedDuration::from_mins(1))?; + Ok(()) + }, + }, + Field { + section: "reminders", + key: "long_break", + label: "Long break", + help: "The suggested break after every few focus stretches, like 15m or 30m.", + expert: false, + kind: Kind::Duration, + get: |s| show_duration(s.long_break), + set: |s, text| { + s.long_break = duration_at_least(text, SignedDuration::from_mins(1))?; + Ok(()) + }, + }, + Field { + section: "reminders", + key: "long_break_every", + label: "Long break every", + help: "After how many focus stretches the break is a long one (1 to 12).", + expert: false, + kind: Kind::Number, + get: |s| s.long_break_every.to_string(), + set: |s, text| { + s.long_break_every = number(text, 1, 12)?; + Ok(()) + }, + }, + Field { + section: "reminders", + key: "snooze", + label: "Snooze", + help: "How long zz silences a break suggestion, like 5m.", + expert: true, + kind: Kind::Duration, + get: |s| show_duration(s.snooze), + set: |s, text| { + s.snooze = duration_at_least(text, SignedDuration::from_mins(1))?; + Ok(()) + }, + }, + Field { + section: "reminders", + key: "still_working_check", + label: "Still working? check", + help: "When a session runs this long, skyshift asks whether you forgot to pause, like 10h. 0 turns it off.", + expert: false, + kind: Kind::Duration, + get: |s| show_duration(s.still_working_check), + set: |s, text| { + s.still_working_check = duration_at_least_zero(text)?; + Ok(()) + }, + }, + Field { + section: "reminders", + key: "bell", + label: "Reminder sound", + help: "Also ring the terminal bell with reminders.", + expert: false, + kind: Kind::Toggle, + get: |s| s.reminder_bell.to_string(), + set: |s, text| { + s.reminder_bell = toggle(text)?; + Ok(()) + }, + }, + // ----- insights ----- + Field { + section: "insights", + key: "long_session", + label: "Long session", + help: "Sessions at least this long count as long stretches without a break (hyperfocus) in Insights, like 2h.", + expert: true, + kind: Kind::Duration, + get: |s| show_duration(s.long_session), + set: |s, text| { + s.long_session = duration_at_least_zero(text)?; + Ok(()) + }, + }, + // ----- advanced ----- + Field { + section: "advanced", + key: "data_file", + label: "Data file", + help: "Where your shifts are stored. Empty means the default place. A synced folder (Syncthing, Dropbox...) shares it between computers. Takes effect after a restart.", + expert: true, + kind: Kind::Text, + get: |s| s.data_file.clone(), + set: |s, text| { + s.data_file = text.trim().to_string(); + Ok(()) + }, + }, + Field { + section: "advanced", + key: "backups", + label: "Daily backups kept", + help: "skyshift copies your data file once a day, when it starts. How many of those copies to keep (0 to 365).", + expert: true, + kind: Kind::Number, + get: |s| s.backups.to_string(), + set: |s, text| { + s.backups = number(text, 0, 365)?; + Ok(()) + }, + }, + Field { + section: "advanced", + key: "clipboard", + label: "Clipboard", + help: "auto (system, then terminal), system, terminal (OSC 52, works over SSH), or command (the command below). Takes effect after a restart.", + expert: true, + kind: Kind::Choice(names::), + get: |s| s.clipboard.name().into(), + set: |s, text| { + s.clipboard = choice(text)?; + Ok(()) + }, + }, + Field { + section: "advanced", + key: "clipboard_command", + label: "Clipboard command", + help: "For the command clipboard: a program that reads text on its input, like wl-copy, or xclip -selection clipboard.", + expert: true, + kind: Kind::Text, + get: |s| s.clipboard_command.clone(), + set: |s, text| { + s.clipboard_command = text.trim().to_string(); + Ok(()) + }, + }, +]; + +// ----- Reading values from text --------------------------------------------- + +/// A setting that picks one value from a fixed list, written in config.toml +/// by name (for example `time_format = "24h"`). +pub trait Choice: Sized + Copy + PartialEq + 'static { + /// Every value, in the order left/right cycles through them. + const ALL: &'static [Self]; + + /// The name in config.toml. + fn name(self) -> &'static str; + + fn from_name(name: &str) -> Option { + Self::ALL.iter().copied().find(|value| value.name().eq_ignore_ascii_case(name.trim())) + } +} + +fn names() -> Vec<&'static str> { + T::ALL.iter().map(|value| value.name()).collect() +} + +fn choice(text: &str) -> Result { + T::from_name(text).ok_or_else(|| format!("expected one of: {}", names::().join(", "))) +} + +fn toggle(text: &str) -> Result { + match text.trim().to_ascii_lowercase().as_str() { + "true" | "on" | "yes" => Ok(true), + "false" | "off" | "no" => Ok(false), + _ => Err("expected true or false".into()), + } +} + +fn number(text: &str, min: u32, max: u32) -> Result { + let error = || format!("expected a whole number from {min} to {max}"); + let value: u32 = text.trim().parse().map_err(|_| error())?; + if (min..=max).contains(&value) { Ok(value) } else { Err(error()) } +} + +/// Reads durations like "8h", "7h 30m", "90m", "-2m", "1h30m" or "0". +fn duration(text: &str) -> Result { + let text = text.trim(); + if text == "0" { + return Ok(SignedDuration::ZERO); + } + text.parse().map_err(|_| format!("\"{text}\" isn't a duration; write it like 8h, 7h 30m or 90m")) +} + +fn duration_at_least_zero(text: &str) -> Result { + duration_at_least(text, SignedDuration::ZERO) +} + +fn duration_at_least(text: &str, minimum: SignedDuration) -> Result { + let value = duration(text)?; + if value < minimum { Err(format!("this must be at least {}", show_duration(minimum))) } else { Ok(value) } +} + +/// Writes a duration the way `duration` reads it: "8h", "1h 30m", "-2m". +pub fn show_duration(value: SignedDuration) -> String { + static PRINTER: SpanPrinter = SpanPrinter::new().direction(Direction::Sign); + PRINTER.duration_to_string(&value) +} + +// ----- The names each choice uses in config.toml ---------------------------- + +impl Choice for TimeFormat { + const ALL: &'static [Self] = &[TimeFormat::Hour24, TimeFormat::Hour12]; + fn name(self) -> &'static str { + match self { + TimeFormat::Hour24 => "24h", + TimeFormat::Hour12 => "12h", + } + } +} + +impl Choice for DurationStyle { + const ALL: &'static [Self] = &[DurationStyle::HoursMinutes, DurationStyle::Clock, DurationStyle::Decimal]; + fn name(self) -> &'static str { + match self { + DurationStyle::HoursMinutes => "hours-minutes", + DurationStyle::Clock => "clock", + DurationStyle::Decimal => "decimal", + } + } +} + +impl Choice for Weekday { + const ALL: &'static [Self] = &[ + Weekday::Monday, + Weekday::Tuesday, + Weekday::Wednesday, + Weekday::Thursday, + Weekday::Friday, + Weekday::Saturday, + Weekday::Sunday, + ]; + fn name(self) -> &'static str { + match self { + Weekday::Monday => "monday", + Weekday::Tuesday => "tuesday", + Weekday::Wednesday => "wednesday", + Weekday::Thursday => "thursday", + Weekday::Friday => "friday", + Weekday::Saturday => "saturday", + Weekday::Sunday => "sunday", + } + } +} + +impl Choice for PunchPrecision { + const ALL: &'static [Self] = &[PunchPrecision::Minute, PunchPrecision::Second]; + fn name(self) -> &'static str { + match self { + PunchPrecision::Minute => "minute", + PunchPrecision::Second => "second", + } + } +} + +impl Choice for CopyPreset { + const ALL: &'static [Self] = &CopyPreset::ALL; + fn name(self) -> &'static str { + match self { + CopyPreset::TotalClock => "total-clock", + CopyPreset::TotalDecimal => "total-decimal", + CopyPreset::Segments => "segments", + CopyPreset::InOutBreak => "in-out-break", + CopyPreset::Custom => "custom", + } + } +} + +impl Choice for Rounding { + const ALL: &'static [Self] = &[Rounding::None, Rounding::Nearest, Rounding::Up, Rounding::Down]; + fn name(self) -> &'static str { + match self { + Rounding::None => "none", + Rounding::Nearest => "nearest", + Rounding::Up => "up", + Rounding::Down => "down", + } + } +} + +impl Choice for DecimalSeparator { + const ALL: &'static [Self] = &[DecimalSeparator::Dot, DecimalSeparator::Comma]; + fn name(self) -> &'static str { + match self { + DecimalSeparator::Dot => ".", + DecimalSeparator::Comma => ",", + } + } +} + +impl Choice for ExportFormat { + const ALL: &'static [Self] = &ExportFormat::ALL; + fn name(self) -> &'static str { + match self { + ExportFormat::Csv => "csv", + ExportFormat::Json => "json", + ExportFormat::Markdown => "markdown", + } + } +} + +impl Choice for ClipboardMode { + const ALL: &'static [Self] = + &[ClipboardMode::Auto, ClipboardMode::System, ClipboardMode::Terminal, ClipboardMode::Command]; + fn name(self) -> &'static str { + match self { + ClipboardMode::Auto => "auto", + ClipboardMode::System => "system", + ClipboardMode::Terminal => "terminal", + ClipboardMode::Command => "command", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_default_survives_a_round_trip_through_text() { + let defaults = Settings::default(); + let mut copy = Settings::default(); + for field in FIELDS { + let text = (field.get)(&defaults); + (field.set)(&mut copy, &text).unwrap_or_else(|error| panic!("{}: {error}", field.key)); + } + assert_eq!(copy, defaults); + } + + #[test] + fn every_field_has_a_known_section_and_a_unique_key() { + for (index, field) in FIELDS.iter().enumerate() { + assert!(SECTIONS.iter().any(|(name, _)| *name == field.section), "{}", field.key); + let duplicate = FIELDS[..index].iter().any(|other| other.key == field.key); + assert!(!duplicate, "{} appears twice", field.key); + } + } + + #[test] + fn durations_read_like_people_write_them() { + assert_eq!(duration("8h"), Ok(SignedDuration::from_hours(8))); + assert_eq!(duration("7h 30m"), Ok(SignedDuration::from_mins(450))); + assert_eq!(duration("90m"), Ok(SignedDuration::from_mins(90))); + assert_eq!(duration("-2m"), Ok(SignedDuration::from_mins(-2))); + assert_eq!(duration("0"), Ok(SignedDuration::ZERO)); + assert_eq!(show_duration(SignedDuration::from_mins(90)), "1h 30m"); + assert_eq!(show_duration(SignedDuration::from_mins(-2)), "-2m"); + assert!(duration("eight hours").is_err()); + } +}