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#[derive(Debug, Clone, Eq, PartialEq)]
30pub enum IntermissionDisplay {
31 Logo,
33 Cover,
35 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
78pub const DEFAULT_FONT_SIZE: f32 = 11.0;
80pub const DEFAULT_MARGIN_WIDTH: i32 = 8;
82pub const DEFAULT_LINE_HEIGHT: f32 = 1.2;
84pub const DEFAULT_FONT_FAMILY: &str = "Libertinus Serif";
86pub 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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
446#[serde(default, rename_all = "kebab-case")]
447pub struct LoggingSettings {
448 pub enabled: bool,
450 pub level: String,
452 pub max_files: usize,
454 pub directory: PathBuf,
456 #[serde(skip_serializing_if = "Option::is_none")]
458 pub otlp_endpoint: Option<String>,
459}
460
461#[derive(Debug, Clone, Deserialize)]
473#[serde(default, rename_all = "kebab-case")]
474pub struct OtaSettings {
475 #[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 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}