1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum Profile {
45 Prose,
47 Code,
49 Mixed,
51}
52
53impl Profile {
54 pub fn chars_per_token(self) -> f64 {
56 match self {
57 Profile::Prose => 4.0,
58 Profile::Code => 3.0,
59 Profile::Mixed => 3.5,
60 }
61 }
62}
63
64pub fn estimate(text: &str, profile: Profile) -> usize {
69 if text.trim().is_empty() {
70 return 0;
71 }
72 let mut effective = 0usize;
75 let mut non_ascii = 0usize;
76 let mut prev_ws = false;
77 for ch in text.chars() {
78 if ch.is_whitespace() {
79 if !prev_ws {
80 effective += 1;
81 }
82 prev_ws = true;
83 continue;
84 }
85 prev_ws = false;
86 effective += 1;
87 if !ch.is_ascii() {
88 non_ascii += 1;
89 }
90 }
91 if effective == 0 {
92 return 0;
93 }
94 let total_chars = text.chars().count();
95 let non_ascii_ratio = if total_chars == 0 { 0.0 } else { non_ascii as f64 / total_chars as f64 };
96
97 let ratio = profile.chars_per_token();
100 let adjusted = ratio * (1.0 - non_ascii_ratio) + 1.0 * non_ascii_ratio;
101 let est = (effective as f64 / adjusted).ceil() as usize;
102 est.max(1)
103}
104
105pub fn estimate_auto(text: &str) -> usize {
110 estimate(text, infer_profile(text))
111}
112
113pub fn infer_profile(text: &str) -> Profile {
115 let total = text.chars().filter(|c| !c.is_whitespace()).count();
116 if total == 0 {
117 return Profile::Prose;
118 }
119 let codeish = text
120 .chars()
121 .filter(|c| matches!(c, '{' | '}' | '(' | ')' | '<' | '>' | ';' | '=' | '[' | ']' | '|' | '_'))
122 .count();
123 let density = codeish as f64 / total as f64;
124 if density > 0.08 {
125 Profile::Code
126 } else if density > 0.03 {
127 Profile::Mixed
128 } else {
129 Profile::Prose
130 }
131}
132
133pub fn fits(text: &str, budget: usize, profile: Profile) -> bool {
135 estimate(text, profile) <= budget
136}
137
138pub fn remaining(text: &str, budget: usize, profile: Profile) -> usize {
140 budget.saturating_sub(estimate(text, profile))
141}
142
143pub fn estimate_with_margin(text: &str, profile: Profile, margin_pct: u32) -> usize {
148 let base = estimate(text, profile) as f64;
149 (base * (1.0 + margin_pct as f64 / 100.0)).ceil() as usize
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155
156 #[test]
157 fn empty_is_zero() {
158 assert_eq!(estimate("", Profile::Prose), 0);
159 assert_eq!(estimate(" \n\t ", Profile::Prose), 0);
160 }
161
162 #[test]
163 fn non_empty_is_at_least_one() {
164 assert_eq!(estimate("a", Profile::Prose), 1);
165 assert_eq!(estimate("hi", Profile::Code), 1);
166 }
167
168 #[test]
169 fn code_estimates_higher_than_prose() {
170 let s = "fn main() { let x: Vec<u8> = vec![1,2,3]; }";
171 assert!(estimate(s, Profile::Code) > estimate(s, Profile::Prose));
172 }
173
174 #[test]
175 fn whitespace_runs_are_collapsed() {
176 let tight = "alpha beta gamma";
177 let padded = "alpha beta gamma";
178 assert_eq!(estimate(tight, Profile::Prose), estimate(padded, Profile::Prose));
179 }
180
181 #[test]
182 fn non_ascii_raises_the_estimate() {
183 let ascii = "aaaaaaaaaaaaaaaaaaaa";
184 let cjk = "説明説明説明説明説明説明説明説明説明説明";
185 assert!(estimate(cjk, Profile::Prose) > estimate(ascii, Profile::Prose));
186 }
187
188 #[test]
189 fn profile_inference() {
190 assert_eq!(infer_profile("A plain sentence about nothing at all."), Profile::Prose);
191 assert_eq!(infer_profile("if (a == b) { c[d] = e; }"), Profile::Code);
192 }
193
194 #[test]
195 fn auto_matches_inferred_profile() {
196 let s = "let y = f(x);";
197 assert_eq!(estimate_auto(s), estimate(s, infer_profile(s)));
198 }
199
200 #[test]
201 fn budget_helpers() {
202 let s = "a short line of prose";
203 let n = estimate(s, Profile::Prose);
204 assert!(fits(s, n, Profile::Prose));
205 assert!(!fits(s, n - 1, Profile::Prose));
206 assert_eq!(remaining(s, n + 5, Profile::Prose), 5);
207 assert_eq!(remaining(s, 0, Profile::Prose), 0);
208 }
209
210 #[test]
211 fn margin_inflates() {
212 let s = "some prose to measure against a margin";
213 let base = estimate(s, Profile::Prose);
214 assert!(estimate_with_margin(s, Profile::Prose, 20) > base);
215 assert_eq!(estimate_with_margin(s, Profile::Prose, 0), base);
216 }
217
218 #[test]
219 fn prose_estimate_is_in_a_sane_range() {
220 let s = "The quick brown fox jumps over the lazy dog.";
222 let n = estimate(s, Profile::Prose);
223 assert!((8..=14).contains(&n), "got {n}");
224 }
225}