cadmus_core/settings/
mod.rs

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