Skip to main content

joy_core/
fortune.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4//! Random quotes and jokes for CLI output.
5//!
6//! Quotes are loaded from YAML files at compile time via `include_str!`.
7//! Categories: tech, science, humor.
8
9use serde::Deserialize;
10
11const TECH_YAML: &str = include_str!("../data/fortunes/tech.yaml");
12const SCIENCE_YAML: &str = include_str!("../data/fortunes/science.yaml");
13const HUMOR_YAML: &str = include_str!("../data/fortunes/humor.yaml");
14
15#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
16#[serde(rename_all = "lowercase")]
17pub enum Category {
18    Tech,
19    Science,
20    Humor,
21    All,
22}
23
24#[derive(Debug, Clone, Deserialize)]
25struct FortuneFile {
26    entries: Vec<FortuneEntry>,
27}
28
29#[derive(Debug, Clone, Deserialize)]
30struct FortuneEntry {
31    text: String,
32    #[serde(default)]
33    author: Option<String>,
34}
35
36/// Return a random fortune string, or None based on probability.
37///
38/// - `category`: which collection to pick from (None = All)
39/// - `probability`: chance of returning a fortune (0.0 to 1.0)
40pub fn fortune(category: Option<&Category>, probability: f32) -> Option<String> {
41    if probability <= 0.0 {
42        return None;
43    }
44    if probability < 1.0 {
45        let roll: f32 = simple_random_f32();
46        if roll > probability {
47            return None;
48        }
49    }
50
51    let cat = category.unwrap_or(&Category::All);
52    let entries = load_entries(cat);
53
54    if entries.is_empty() {
55        return None;
56    }
57
58    let idx = simple_random_usize(entries.len());
59    let entry = &entries[idx];
60
61    Some(match &entry.author {
62        Some(author) => format!("{} -- {}", entry.text, author),
63        None => entry.text.clone(),
64    })
65}
66
67fn load_entries(category: &Category) -> Vec<FortuneEntry> {
68    match category {
69        Category::Tech => parse_entries(TECH_YAML),
70        Category::Science => parse_entries(SCIENCE_YAML),
71        Category::Humor => parse_entries(HUMOR_YAML),
72        Category::All => {
73            let mut all = parse_entries(TECH_YAML);
74            all.extend(parse_entries(SCIENCE_YAML));
75            all.extend(parse_entries(HUMOR_YAML));
76            all
77        }
78    }
79}
80
81fn parse_entries(yaml: &str) -> Vec<FortuneEntry> {
82    serde_yaml_ng::from_str::<FortuneFile>(yaml)
83        .map(|f| f.entries)
84        .unwrap_or_default()
85}
86
87/// Simple pseudo-random f32 in [0, 1) using system time as seed.
88/// No external dependency needed for this use case.
89fn simple_random_f32() -> f32 {
90    let nanos = std::time::SystemTime::now()
91        .duration_since(std::time::UNIX_EPOCH)
92        .unwrap_or_default()
93        .subsec_nanos();
94    // xorshift-like mixing
95    let mut x = nanos;
96    x ^= x << 13;
97    x ^= x >> 17;
98    x ^= x << 5;
99    (x as f32) / (u32::MAX as f32)
100}
101
102/// Simple pseudo-random usize in [0, max) using system time as seed.
103fn simple_random_usize(max: usize) -> usize {
104    let nanos = std::time::SystemTime::now()
105        .duration_since(std::time::UNIX_EPOCH)
106        .unwrap_or_default()
107        .subsec_nanos();
108    let mut x = nanos;
109    x ^= x << 13;
110    x ^= x >> 17;
111    x ^= x << 5;
112    (x as usize) % max
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn fortune_returns_some_at_full_probability() {
121        let result = fortune(Some(&Category::Tech), 1.0);
122        assert!(result.is_some());
123    }
124
125    #[test]
126    fn fortune_returns_none_at_zero_probability() {
127        let result = fortune(Some(&Category::Tech), 0.0);
128        assert!(result.is_none());
129    }
130
131    #[test]
132    fn fortune_all_category_has_entries() {
133        let entries = load_entries(&Category::All);
134        assert!(entries.len() > 150); // 50+ per category
135    }
136
137    #[test]
138    fn fortune_each_category_has_entries() {
139        assert!(load_entries(&Category::Tech).len() >= 50);
140        assert!(load_entries(&Category::Science).len() >= 50);
141        assert!(load_entries(&Category::Humor).len() >= 50);
142    }
143
144    #[test]
145    fn fortune_format_with_author() {
146        let entry = FortuneEntry {
147            text: "Test quote".into(),
148            author: Some("Test Author".into()),
149        };
150        let formatted = match &entry.author {
151            Some(author) => format!("{} -- {}", entry.text, author),
152            None => entry.text.clone(),
153        };
154        assert_eq!(formatted, "Test quote -- Test Author");
155    }
156
157    #[test]
158    fn fortune_format_without_author() {
159        let entry = FortuneEntry {
160            text: "Anonymous quote".into(),
161            author: None,
162        };
163        let formatted = match &entry.author {
164            Some(author) => format!("{} -- {}", entry.text, author),
165            None => entry.text.clone(),
166        };
167        assert_eq!(formatted, "Anonymous quote");
168    }
169}