1use std::sync::LazyLock;
16
17const 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct Tip {
60 pub category: String,
62 pub text: String,
64}
65
66static TIPS: LazyLock<Vec<Tip>> = LazyLock::new(|| parse(TIPS_TOML).unwrap_or_default());
67
68#[must_use]
74pub fn all() -> &'static [Tip] {
75 &TIPS
76}
77
78pub 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#[must_use]
107pub fn random_index(len: usize, seed: u128) -> usize {
108 if len == 0 {
109 return 0;
110 }
111 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#[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 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}