cadmus_core/settings/
mod.rs

1mod preset;
2pub mod versioned;
3
4use crate::color::{Color, BLACK};
5use crate::device::CURRENT_DEVICE;
6use crate::fl;
7use crate::frontlight::LightLevels;
8use crate::i18n::I18nDisplay;
9use crate::metadata::{SortMethod, TextAlign};
10use crate::unit::mm_to_px;
11use fxhash::FxHashSet;
12use unic_langid::LanguageIdentifier;
13
14pub use self::preset::{guess_frontlight, LightPreset};
15use serde::{Deserialize, Serialize};
16use std::collections::{BTreeMap, HashMap};
17use std::env;
18use std::fmt::{self, Debug};
19use std::ops::{Index, IndexMut};
20use std::path::PathBuf;
21
22pub const SETTINGS_PATH: &str = "Settings.toml";
23pub const DEFAULT_FONT_PATH: &str = "/mnt/onboard/fonts";
24pub const INTERNAL_CARD_ROOT: &str = "/mnt/onboard";
25pub const EXTERNAL_CARD_ROOT: &str = "/mnt/sd";
26const LOGO_SPECIAL_PATH: &str = "logo:";
27const COVER_SPECIAL_PATH: &str = "cover:";
28
29/// How to display intermission screens.
30/// Logo and Cover are special values that map to built-in images.
31#[derive(Debug, Clone, Eq, PartialEq)]
32pub enum IntermissionDisplay {
33    /// Display the built-in logo image.
34    Logo,
35    /// Display the cover of the currently reading book.
36    Cover,
37    /// Display a custom image from the given path.
38    Image(PathBuf),
39}
40
41impl Serialize for IntermissionDisplay {
42    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
43    where
44        S: serde::Serializer,
45    {
46        match self {
47            IntermissionDisplay::Logo => serializer.serialize_str(LOGO_SPECIAL_PATH),
48            IntermissionDisplay::Cover => serializer.serialize_str(COVER_SPECIAL_PATH),
49            IntermissionDisplay::Image(path) => {
50                serializer.serialize_str(path.to_string_lossy().as_ref())
51            }
52        }
53    }
54}
55
56impl<'de> Deserialize<'de> for IntermissionDisplay {
57    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
58    where
59        D: serde::Deserializer<'de>,
60    {
61        let s = String::deserialize(deserializer)?;
62        Ok(match s.as_str() {
63            LOGO_SPECIAL_PATH => IntermissionDisplay::Logo,
64            COVER_SPECIAL_PATH => IntermissionDisplay::Cover,
65            _ => IntermissionDisplay::Image(PathBuf::from(s)),
66        })
67    }
68}
69
70impl fmt::Display for IntermissionDisplay {
71    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
72        match self {
73            IntermissionDisplay::Logo => write!(f, "Logo"),
74            IntermissionDisplay::Cover => write!(f, "Cover"),
75            IntermissionDisplay::Image(_) => write!(f, "Custom"),
76        }
77    }
78}
79
80// Default font size in points.
81pub const DEFAULT_FONT_SIZE: f32 = 11.0;
82// Default margin width in millimeters.
83pub const DEFAULT_MARGIN_WIDTH: i32 = 8;
84// Default line height in ems.
85pub const DEFAULT_LINE_HEIGHT: f32 = 1.2;
86// Default font family name.
87pub const DEFAULT_FONT_FAMILY: &str = "Libertinus Serif";
88// Default text alignment.
89pub const DEFAULT_TEXT_ALIGN: TextAlign = TextAlign::Left;
90pub const HYPHEN_PENALTY: i32 = 50;
91pub const STRETCH_TOLERANCE: f32 = 1.26;
92
93#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)]
94#[serde(rename_all = "kebab-case")]
95pub enum RotationLock {
96    Landscape,
97    Portrait,
98    Current,
99}
100
101#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)]
102#[serde(rename_all = "kebab-case")]
103pub enum ButtonScheme {
104    Natural,
105    Inverted,
106}
107
108impl fmt::Display for ButtonScheme {
109    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
110        Debug::fmt(self, f)
111    }
112}
113
114impl I18nDisplay for ButtonScheme {
115    fn to_i18n_string(&self) -> String {
116        match self {
117            ButtonScheme::Natural => fl!("settings-button-scheme-natural"),
118            ButtonScheme::Inverted => fl!("settings-button-scheme-inverted"),
119        }
120    }
121}
122
123#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
124#[serde(rename_all = "kebab-case")]
125pub enum IntermKind {
126    Suspend,
127    PowerOff,
128    Share,
129}
130
131impl IntermKind {
132    pub fn text(&self) -> &str {
133        match self {
134            IntermKind::Suspend => "Sleeping",
135            IntermKind::PowerOff => "Powered off",
136            IntermKind::Share => "Shared",
137        }
138    }
139}
140
141/// Configuration for intermission screen displays.
142#[derive(Debug, Clone, Serialize, Deserialize)]
143#[serde(rename_all = "kebab-case")]
144pub struct Intermissions {
145    suspend: IntermissionDisplay,
146    power_off: IntermissionDisplay,
147    share: IntermissionDisplay,
148}
149
150impl Index<IntermKind> for Intermissions {
151    type Output = IntermissionDisplay;
152
153    fn index(&self, key: IntermKind) -> &Self::Output {
154        match key {
155            IntermKind::Suspend => &self.suspend,
156            IntermKind::PowerOff => &self.power_off,
157            IntermKind::Share => &self.share,
158        }
159    }
160}
161
162impl IndexMut<IntermKind> for Intermissions {
163    fn index_mut(&mut self, key: IntermKind) -> &mut Self::Output {
164        match key {
165            IntermKind::Suspend => &mut self.suspend,
166            IntermKind::PowerOff => &mut self.power_off,
167            IntermKind::Share => &mut self.share,
168        }
169    }
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
173#[serde(default, rename_all = "kebab-case")]
174pub struct Settings {
175    pub selected_library: usize,
176    pub keyboard_layout: String,
177    pub frontlight: bool,
178    pub wifi: bool,
179    pub inverted: bool,
180    pub sleep_cover: bool,
181    pub auto_share: bool,
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub rotation_lock: Option<RotationLock>,
184    pub button_scheme: ButtonScheme,
185    pub auto_suspend: f32,
186    pub auto_power_off: f32,
187    pub time_format: String,
188    pub date_format: String,
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub external_urls_queue: Option<PathBuf>,
191    #[serde(skip_serializing_if = "Vec::is_empty")]
192    pub libraries: Vec<LibrarySettings>,
193    pub intermissions: Intermissions,
194    #[serde(skip_serializing_if = "Vec::is_empty")]
195    pub frontlight_presets: Vec<LightPreset>,
196    pub home: HomeSettings,
197    pub reader: ReaderSettings,
198    pub import: ImportSettings,
199    pub dictionary: DictionarySettings,
200    pub sketch: SketchSettings,
201    pub calculator: CalculatorSettings,
202    pub battery: BatterySettings,
203    pub frontlight_levels: LightLevels,
204    pub ota: OtaSettings,
205    pub logging: LoggingSettings,
206    pub settings_retention: usize,
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub locale: Option<LanguageIdentifier>,
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize)]
212#[serde(default, rename_all = "kebab-case")]
213pub struct LibrarySettings {
214    pub name: String,
215    pub path: PathBuf,
216    pub sort_method: SortMethod,
217    pub first_column: FirstColumn,
218    pub second_column: SecondColumn,
219    pub thumbnail_previews: bool,
220    #[serde(skip_serializing_if = "Vec::is_empty")]
221    pub hooks: Vec<Hook>,
222    #[serde(skip_serializing_if = "Option::is_none")]
223    pub finished: Option<FinishedAction>,
224}
225
226impl Default for LibrarySettings {
227    fn default() -> Self {
228        LibrarySettings {
229            name: "Unnamed".to_string(),
230            path: env::current_dir()
231                .ok()
232                .unwrap_or_else(|| PathBuf::from("/")),
233            sort_method: SortMethod::Opened,
234            first_column: FirstColumn::TitleAndAuthor,
235            second_column: SecondColumn::Progress,
236            thumbnail_previews: true,
237            hooks: Vec::new(),
238            finished: None,
239        }
240    }
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize)]
244#[serde(default, rename_all = "kebab-case")]
245pub struct ImportSettings {
246    pub startup_trigger: bool,
247    pub sync_metadata: bool,
248    pub metadata_kinds: FxHashSet<String>,
249    pub allowed_kinds: FxHashSet<String>,
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
253#[serde(default, rename_all = "kebab-case")]
254pub struct DictionarySettings {
255    pub margin_width: i32,
256    pub font_size: f32,
257    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
258    pub languages: BTreeMap<String, Vec<String>>,
259}
260
261impl Default for DictionarySettings {
262    fn default() -> Self {
263        DictionarySettings {
264            font_size: 11.0,
265            margin_width: 4,
266            languages: BTreeMap::new(),
267        }
268    }
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize)]
272#[serde(default, rename_all = "kebab-case")]
273pub struct SketchSettings {
274    pub save_path: PathBuf,
275    pub notify_success: bool,
276    pub pen: Pen,
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize)]
280#[serde(default, rename_all = "kebab-case")]
281pub struct CalculatorSettings {
282    pub font_size: f32,
283    pub margin_width: i32,
284    pub history_size: usize,
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize)]
288#[serde(default, rename_all = "kebab-case")]
289pub struct Pen {
290    pub size: i32,
291    pub color: Color,
292    pub dynamic: bool,
293    pub amplitude: f32,
294    pub min_speed: f32,
295    pub max_speed: f32,
296}
297
298impl Default for Pen {
299    fn default() -> Self {
300        Pen {
301            size: 2,
302            color: BLACK,
303            dynamic: true,
304            amplitude: 4.0,
305            min_speed: 0.0,
306            max_speed: mm_to_px(254.0, CURRENT_DEVICE.dpi),
307        }
308    }
309}
310
311impl Default for SketchSettings {
312    fn default() -> Self {
313        SketchSettings {
314            save_path: PathBuf::from("Sketches"),
315            notify_success: true,
316            pen: Pen::default(),
317        }
318    }
319}
320
321impl Default for CalculatorSettings {
322    fn default() -> Self {
323        CalculatorSettings {
324            font_size: 8.0,
325            margin_width: 2,
326            history_size: 4096,
327        }
328    }
329}
330
331#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
332#[serde(rename_all = "kebab-case")]
333pub struct Columns {
334    first: FirstColumn,
335    second: SecondColumn,
336}
337
338#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)]
339#[serde(rename_all = "kebab-case")]
340pub enum FirstColumn {
341    TitleAndAuthor,
342    FileName,
343}
344
345#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)]
346#[serde(rename_all = "kebab-case")]
347pub enum SecondColumn {
348    Progress,
349    Year,
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize)]
353#[serde(default, rename_all = "kebab-case")]
354pub struct Hook {
355    pub path: PathBuf,
356    pub program: PathBuf,
357    pub sort_method: Option<SortMethod>,
358    pub first_column: Option<FirstColumn>,
359    pub second_column: Option<SecondColumn>,
360}
361
362impl Default for Hook {
363    fn default() -> Self {
364        Hook {
365            path: PathBuf::default(),
366            program: PathBuf::default(),
367            sort_method: None,
368            first_column: None,
369            second_column: None,
370        }
371    }
372}
373
374#[derive(Debug, Clone, Serialize, Deserialize)]
375#[serde(default, rename_all = "kebab-case")]
376pub struct HomeSettings {
377    pub address_bar: bool,
378    pub navigation_bar: bool,
379    pub max_levels: usize,
380    pub max_trash_size: u64,
381}
382
383#[derive(Debug, Clone, Serialize, Deserialize)]
384#[serde(default, rename_all = "kebab-case")]
385pub struct RefreshRateSettings {
386    #[serde(flatten)]
387    pub global: RefreshRatePair,
388    #[serde(skip_serializing_if = "HashMap::is_empty")]
389    pub by_kind: HashMap<String, RefreshRatePair>,
390}
391
392#[derive(Debug, Clone, Serialize, Deserialize)]
393#[serde(rename_all = "kebab-case")]
394pub struct RefreshRatePair {
395    pub regular: u8,
396    pub inverted: u8,
397}
398
399#[derive(Debug, Clone, Serialize, Deserialize)]
400#[serde(default, rename_all = "kebab-case")]
401pub struct ReaderSettings {
402    pub finished: FinishedAction,
403    pub south_east_corner: SouthEastCornerAction,
404    pub bottom_right_gesture: BottomRightGestureAction,
405    pub south_strip: SouthStripAction,
406    pub west_strip: WestStripAction,
407    pub east_strip: EastStripAction,
408    pub strip_width: f32,
409    pub corner_width: f32,
410    pub font_path: String,
411    pub font_family: String,
412    pub font_size: f32,
413    pub min_font_size: f32,
414    pub max_font_size: f32,
415    pub text_align: TextAlign,
416    pub margin_width: i32,
417    pub min_margin_width: i32,
418    pub max_margin_width: i32,
419    pub line_height: f32,
420    pub continuous_fit_to_width: bool,
421    pub ignore_document_css: bool,
422    pub dithered_kinds: FxHashSet<String>,
423    pub paragraph_breaker: ParagraphBreakerSettings,
424    pub refresh_rate: RefreshRateSettings,
425}
426
427#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
428#[serde(default, rename_all = "kebab-case")]
429pub struct ParagraphBreakerSettings {
430    pub hyphen_penalty: i32,
431    pub stretch_tolerance: f32,
432}
433
434#[derive(Debug, Clone, Serialize, Deserialize)]
435#[serde(default, rename_all = "kebab-case")]
436pub struct BatterySettings {
437    pub warn: f32,
438    pub power_off: f32,
439}
440
441/// Configures structured logging to disk and optional OTLP export.
442#[derive(Debug, Clone, Serialize, Deserialize)]
443#[serde(default, rename_all = "kebab-case")]
444pub struct LoggingSettings {
445    /// Enables logging output when set to true.
446    pub enabled: bool,
447    /// Minimum log level to record (for example: "info", "debug").
448    pub level: String,
449    /// Maximum number of rotated log files to keep.
450    pub max_files: usize,
451    /// Directory where JSON log files are written.
452    pub directory: PathBuf,
453    /// Optional OTLP endpoint; env vars override this value.
454    #[serde(skip_serializing_if = "Option::is_none")]
455    pub otlp_endpoint: Option<String>,
456    /// Captures kernel logs via logread if kernel log capture is supported.
457    pub enable_kern_log: bool,
458    /// Captures D-Bus signals via the in-process zbus DbusMonitorTask when D-Bus log capture is supported.
459    pub enable_dbus_log: bool,
460}
461
462/// OTA update settings.
463///
464/// Authentication is handled via GitHub device auth flow — no token configuration
465/// is required in `Settings.toml`. The token is obtained interactively and
466/// persisted to disk by the application.
467#[derive(Debug, Clone, Default, Serialize, Deserialize)]
468#[serde(default, rename_all = "kebab-case")]
469pub struct OtaSettings {}
470
471#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)]
472#[serde(rename_all = "kebab-case")]
473pub enum FinishedAction {
474    Notify,
475    Close,
476    GoToNext,
477}
478
479impl fmt::Display for FinishedAction {
480    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
481        match self {
482            FinishedAction::Notify => write!(f, "Notify"),
483            FinishedAction::Close => write!(f, "Close"),
484            FinishedAction::GoToNext => write!(f, "Go to Next"),
485        }
486    }
487}
488
489impl I18nDisplay for FinishedAction {
490    fn to_i18n_string(&self) -> String {
491        match self {
492            FinishedAction::Notify => fl!("settings-finished-action-notify"),
493            FinishedAction::Close => fl!("settings-finished-action-close"),
494            FinishedAction::GoToNext => fl!("settings-finished-action-goto-next"),
495        }
496    }
497}
498
499#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
500#[serde(rename_all = "kebab-case")]
501pub enum SouthEastCornerAction {
502    NextPage,
503    GoToPage,
504}
505
506#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
507#[serde(rename_all = "kebab-case")]
508pub enum BottomRightGestureAction {
509    ToggleDithered,
510    ToggleInverted,
511}
512
513#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
514#[serde(rename_all = "kebab-case")]
515pub enum SouthStripAction {
516    ToggleBars,
517    NextPage,
518}
519
520#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
521#[serde(rename_all = "kebab-case")]
522pub enum EastStripAction {
523    PreviousPage,
524    NextPage,
525    None,
526}
527
528#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
529#[serde(rename_all = "kebab-case")]
530pub enum WestStripAction {
531    PreviousPage,
532    NextPage,
533    None,
534}
535
536impl Default for RefreshRateSettings {
537    fn default() -> Self {
538        RefreshRateSettings {
539            global: RefreshRatePair {
540                regular: 8,
541                inverted: 2,
542            },
543            by_kind: HashMap::new(),
544        }
545    }
546}
547
548impl Default for HomeSettings {
549    fn default() -> Self {
550        HomeSettings {
551            address_bar: false,
552            navigation_bar: true,
553            max_levels: 3,
554            max_trash_size: 32 * (1 << 20),
555        }
556    }
557}
558
559impl Default for ParagraphBreakerSettings {
560    fn default() -> Self {
561        ParagraphBreakerSettings {
562            hyphen_penalty: HYPHEN_PENALTY,
563            stretch_tolerance: STRETCH_TOLERANCE,
564        }
565    }
566}
567
568impl Default for ReaderSettings {
569    fn default() -> Self {
570        ReaderSettings {
571            finished: FinishedAction::Close,
572            south_east_corner: SouthEastCornerAction::GoToPage,
573            bottom_right_gesture: BottomRightGestureAction::ToggleDithered,
574            south_strip: SouthStripAction::ToggleBars,
575            west_strip: WestStripAction::PreviousPage,
576            east_strip: EastStripAction::NextPage,
577            strip_width: 0.6,
578            corner_width: 0.4,
579            font_path: DEFAULT_FONT_PATH.to_string(),
580            font_family: DEFAULT_FONT_FAMILY.to_string(),
581            font_size: DEFAULT_FONT_SIZE,
582            min_font_size: DEFAULT_FONT_SIZE / 2.0,
583            max_font_size: 3.0 * DEFAULT_FONT_SIZE / 2.0,
584            text_align: DEFAULT_TEXT_ALIGN,
585            margin_width: DEFAULT_MARGIN_WIDTH,
586            min_margin_width: DEFAULT_MARGIN_WIDTH.saturating_sub(8),
587            max_margin_width: DEFAULT_MARGIN_WIDTH.saturating_add(2),
588            line_height: DEFAULT_LINE_HEIGHT,
589            continuous_fit_to_width: true,
590            ignore_document_css: false,
591            dithered_kinds: ["cbz", "png", "jpg", "jpeg"]
592                .iter()
593                .map(|k| k.to_string())
594                .collect(),
595            paragraph_breaker: ParagraphBreakerSettings::default(),
596            refresh_rate: RefreshRateSettings::default(),
597        }
598    }
599}
600
601impl Default for ImportSettings {
602    fn default() -> Self {
603        ImportSettings {
604            startup_trigger: true,
605            sync_metadata: true,
606            metadata_kinds: ["epub", "pdf", "djvu"]
607                .iter()
608                .map(|k| k.to_string())
609                .collect(),
610            allowed_kinds: [
611                "pdf", "djvu", "epub", "fb2", "txt", "xps", "oxps", "mobi", "cbz",
612            ]
613            .iter()
614            .map(|k| k.to_string())
615            .collect(),
616        }
617    }
618}
619
620impl Default for BatterySettings {
621    fn default() -> Self {
622        BatterySettings {
623            warn: 10.0,
624            power_off: 3.0,
625        }
626    }
627}
628
629impl Default for LoggingSettings {
630    fn default() -> Self {
631        LoggingSettings {
632            enabled: true,
633            level: "info".to_string(),
634            max_files: 3,
635            directory: PathBuf::from("logs"),
636            otlp_endpoint: None,
637            enable_kern_log: false,
638            enable_dbus_log: false,
639        }
640    }
641}
642
643impl Default for Settings {
644    fn default() -> Self {
645        Settings {
646            selected_library: 0,
647            #[cfg(feature = "emulator")]
648            libraries: vec![LibrarySettings {
649                name: "Cadmus Source".to_string(),
650                path: PathBuf::from("."),
651                ..Default::default()
652            }],
653            #[cfg(not(feature = "emulator"))]
654            libraries: vec![
655                LibrarySettings {
656                    name: "On Board".to_string(),
657                    path: PathBuf::from(INTERNAL_CARD_ROOT),
658                    hooks: vec![Hook {
659                        path: PathBuf::from("Articles"),
660                        program: PathBuf::from("bin/article_fetcher/article_fetcher"),
661                        sort_method: Some(SortMethod::Added),
662                        first_column: Some(FirstColumn::TitleAndAuthor),
663                        second_column: Some(SecondColumn::Progress),
664                    }],
665                    ..Default::default()
666                },
667                LibrarySettings {
668                    name: "Removable".to_string(),
669                    path: PathBuf::from(EXTERNAL_CARD_ROOT),
670                    ..Default::default()
671                },
672                LibrarySettings {
673                    name: "Dropbox".to_string(),
674                    path: PathBuf::from("/mnt/onboard/.kobo/dropbox"),
675                    ..Default::default()
676                },
677                LibrarySettings {
678                    name: "KePub".to_string(),
679                    path: PathBuf::from("/mnt/onboard/.kobo/kepub"),
680                    ..Default::default()
681                },
682            ],
683            external_urls_queue: Some(PathBuf::from("bin/article_fetcher/urls.txt")),
684            keyboard_layout: "English".to_string(),
685            frontlight: true,
686            wifi: false,
687            inverted: false,
688            sleep_cover: true,
689            auto_share: false,
690            rotation_lock: None,
691            button_scheme: ButtonScheme::Natural,
692            auto_suspend: 30.0,
693            auto_power_off: 3.0,
694            time_format: "%H:%M".to_string(),
695            date_format: "%A, %B %-d, %Y".to_string(),
696            intermissions: Intermissions {
697                suspend: IntermissionDisplay::Logo,
698                power_off: IntermissionDisplay::Logo,
699                share: IntermissionDisplay::Logo,
700            },
701            home: HomeSettings::default(),
702            reader: ReaderSettings::default(),
703            import: ImportSettings::default(),
704            dictionary: DictionarySettings::default(),
705            sketch: SketchSettings::default(),
706            calculator: CalculatorSettings::default(),
707            battery: BatterySettings::default(),
708            frontlight_levels: LightLevels::default(),
709            frontlight_presets: Vec::new(),
710            ota: OtaSettings::default(),
711            logging: LoggingSettings::default(),
712            settings_retention: 3,
713            locale: None,
714        }
715    }
716}
717
718#[cfg(test)]
719mod tests {
720    use super::*;
721
722    #[test]
723    fn test_ota_settings_serializes_empty() {
724        let settings = OtaSettings::default();
725        let serialized = toml::to_string(&settings).expect("Failed to serialize");
726        assert!(
727            serialized.is_empty(),
728            "OtaSettings should serialize to an empty string"
729        );
730    }
731
732    #[test]
733    fn test_intermissions_struct_serialization() {
734        let intermissions = Intermissions {
735            suspend: IntermissionDisplay::Logo,
736            power_off: IntermissionDisplay::Cover,
737            share: IntermissionDisplay::Image(PathBuf::from("/custom/share.png")),
738        };
739
740        let serialized = toml::to_string(&intermissions).expect("Failed to serialize");
741
742        assert!(
743            serialized.contains("logo:"),
744            "Should contain logo: for suspend"
745        );
746        assert!(
747            serialized.contains("cover:"),
748            "Should contain cover: for power-off"
749        );
750        assert!(
751            serialized.contains("/custom/share.png"),
752            "Should contain custom path for share"
753        );
754    }
755
756    #[test]
757    fn test_intermissions_struct_deserialization() {
758        let toml_str = r#"
759suspend = "logo:"
760power-off = "cover:"
761share = "/path/to/custom.png"
762"#;
763
764        let intermissions: Intermissions = toml::from_str(toml_str).expect("Failed to deserialize");
765
766        assert!(
767            matches!(intermissions.suspend, IntermissionDisplay::Logo),
768            "suspend should deserialize to Logo"
769        );
770        assert!(
771            matches!(intermissions.power_off, IntermissionDisplay::Cover),
772            "power_off should deserialize to Cover"
773        );
774        assert!(
775            matches!(
776                intermissions.share,
777                IntermissionDisplay::Image(ref path) if path == &PathBuf::from("/path/to/custom.png")
778            ),
779            "share should deserialize to Image with correct path"
780        );
781    }
782
783    #[test]
784    fn test_intermissions_struct_round_trip() {
785        let original = Intermissions {
786            suspend: IntermissionDisplay::Logo,
787            power_off: IntermissionDisplay::Cover,
788            share: IntermissionDisplay::Image(PathBuf::from("/some/custom/image.jpg")),
789        };
790
791        let serialized = toml::to_string(&original).expect("Failed to serialize");
792        let deserialized: Intermissions =
793            toml::from_str(&serialized).expect("Failed to deserialize");
794
795        assert_eq!(
796            original.suspend, deserialized.suspend,
797            "suspend should survive round trip"
798        );
799        assert_eq!(
800            original.power_off, deserialized.power_off,
801            "power_off should survive round trip"
802        );
803        assert_eq!(
804            original.share, deserialized.share,
805            "share should survive round trip"
806        );
807    }
808}