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