Skip to main content

tix/
tips.rs

1//! The startup tips the TUI shows (tix #1632).
2//!
3//! The tips themselves live in `src/templates/tips/`, one TOML file per
4//! category, and are **compiled into the binary** with [`include_str!`]
5//! (concatenated in filename order): a shipped `tix` needs no files on disk,
6//! and the file a contributor edits is the file the modal shows. This module
7//! owns the parse and the lookup; the modal itself is [`crate::ui::tips`], and
8//! the on/off setting is [`crate::config::Config::tips_on_startup`].
9//!
10//! A malformed embedded file is not allowed to take the session down: [`all`]
11//! falls back to no tips (and thus no modal) rather than panicking, and the
12//! `embedded_tips_are_well_formed_and_plentiful` test is what makes a malformed
13//! file a test failure instead of a silently empty dialog.
14
15use std::sync::LazyLock;
16
17/// The embedded tips, one file per category, concatenated in filename order
18/// (the order the modal presents them).
19const TIPS_TOML: &str = concat!(
20    include_str!("templates/tips/getting-started.toml"),
21    include_str!("templates/tips/tui-navigation.toml"),
22    include_str!("templates/tips/the-board.toml"),
23    include_str!("templates/tips/the-detail-pane.toml"),
24    include_str!("templates/tips/relationships-and-parents.toml"),
25    include_str!("templates/tips/cycles-milestones-rollover.toml"),
26    include_str!("templates/tips/cycle-schedules.toml"),
27    include_str!("templates/tips/recurring-issues.toml"),
28    include_str!("templates/tips/triage-inbox.toml"),
29    include_str!("templates/tips/projects-epics-roadmap.toml"),
30    include_str!("templates/tips/health-and-stats.toml"),
31    include_str!("templates/tips/saved-views-and-filters.toml"),
32    include_str!("templates/tips/templates.toml"),
33    include_str!("templates/tips/work-in-progress-limits.toml"),
34    include_str!("templates/tips/workflow-categories.toml"),
35    include_str!("templates/tips/batch-editing.toml"),
36    include_str!("templates/tips/archiving.toml"),
37    include_str!("templates/tips/external-sync.toml"),
38    include_str!("templates/tips/mcp-and-agents.toml"),
39    include_str!("templates/tips/configuration-and-storage.toml"),
40    include_str!("templates/tips/about-this-dialog.toml"),
41);
42
43/// The file's shape: one table per category, each with a list of tips.
44#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
45struct TipsFile {
46    #[serde(default)]
47    categories: Vec<Category>,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
51struct Category {
52    name: String,
53    #[serde(default)]
54    tips: Vec<String>,
55}
56
57/// One tip, flattened with the category it was declared under.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct Tip {
60    /// The category's display name, as the modal's heading prints it.
61    pub category: String,
62    /// The tip's text, verbatim from `tips.toml`.
63    pub text: String,
64}
65
66static TIPS: LazyLock<Vec<Tip>> = LazyLock::new(|| parse(TIPS_TOML).unwrap_or_default());
67
68/// Every tip, flattened in file order (categories in declaration order, and each
69/// category's tips in order).
70///
71/// Parsed once per process. An unparsable embedded file yields an empty slice, so
72/// the TUI simply shows no tips rather than failing to start.
73#[must_use]
74pub fn all() -> &'static [Tip] {
75    &TIPS
76}
77
78/// Parse the tips file's text into the flattened tip list.
79///
80/// Exposed rather than private so the test can pin the embedded file's shape
81/// without going through the process-wide cache.
82///
83/// # Errors
84///
85/// Returns the TOML diagnostic when `text` is not a well-formed tips file.
86pub fn parse(text: &str) -> std::result::Result<Vec<Tip>, String> {
87    let file: TipsFile = toml::from_str(text).map_err(|e| e.to_string())?;
88    let mut tips = Vec::new();
89    for category in file.categories {
90        for text in category.tips {
91            tips.push(Tip {
92                category: category.name.clone(),
93                text,
94            });
95        }
96    }
97    Ok(tips)
98}
99
100/// A pseudo-random starting index below `len`.
101///
102/// Seeded from the wall clock and mixed, so opening the TUI twice in the same
103/// second (the clock's resolution on some platforms) does not show the same tip
104/// twice. `len == 0` yields `0` — the caller shows no tip in that case anyway, and
105/// this keeps the function total.
106#[must_use]
107pub fn random_index(len: usize, seed: u128) -> usize {
108    if len == 0 {
109        return 0;
110    }
111    // A 64-bit finalizer (splitmix64's): cheap, and enough to decorrelate the
112    // low bits a modulo reads.
113    let mut x = seed as u64;
114    x = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
115    x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
116    x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
117    x ^= x >> 31;
118    (x % len as u64) as usize
119}
120
121/// `random_index` for the current wall clock.
122#[must_use]
123pub fn random_start(len: usize) -> usize {
124    let seed = std::time::SystemTime::now()
125        .duration_since(std::time::UNIX_EPOCH)
126        .map_or(0, |d| d.as_nanos());
127    random_index(len, seed)
128}
129
130#[cfg(test)]
131mod tests {
132    #[allow(clippy::wildcard_imports)]
133    use super::*;
134
135    #[test]
136    fn embedded_tips_are_well_formed_and_plentiful() {
137        let tips = parse(TIPS_TOML).expect("tips.toml must parse");
138        assert!(
139            tips.len() >= 100,
140            "tix #1632 asks for at least 100 tips, found {}",
141            tips.len()
142        );
143        let categories: std::collections::BTreeSet<&str> =
144            tips.iter().map(|t| t.category.as_str()).collect();
145        assert!(
146            categories.len() >= 10,
147            "the tips must be categorized, found {} categories",
148            categories.len()
149        );
150        for tip in &tips {
151            assert!(!tip.text.trim().is_empty(), "a tip is blank");
152            assert!(
153                !tip.category.trim().is_empty(),
154                "a tip (\"{}\") has no category",
155                tip.text
156            );
157        }
158        let unique: std::collections::BTreeSet<&str> =
159            tips.iter().map(|t| t.text.as_str()).collect();
160        assert_eq!(unique.len(), tips.len(), "a tip is duplicated");
161    }
162
163    #[test]
164    fn all_is_populated_from_the_embedded_file() {
165        assert_eq!(all().len(), parse(TIPS_TOML).unwrap().len());
166    }
167
168    #[test]
169    fn parse_rejects_a_malformed_file() {
170        assert!(parse("categories = 3").is_err());
171    }
172
173    #[test]
174    fn an_empty_category_list_yields_no_tips() {
175        assert_eq!(parse("").unwrap(), [] as [crate::tips::Tip; 0]);
176    }
177
178    #[test]
179    fn random_index_is_in_range_and_total_at_zero() {
180        assert_eq!(random_index(0, 12345), 0);
181        for seed in 0..1000u128 {
182            let index = random_index(7, seed);
183            assert!(index < 7, "index {index} out of range");
184        }
185        assert!(random_start(10) < 10);
186    }
187
188    #[test]
189    fn random_index_spreads_its_seeds() {
190        // Consecutive nanosecond stamps must not all land on the same tip.
191        let bucket: std::collections::BTreeSet<usize> =
192            (0..64u128).map(|seed| random_index(16, seed)).collect();
193        assert!(
194            bucket.len() > 4,
195            "the seed mix is not spreading: {bucket:?}"
196        );
197    }
198}