1use crate::class::Class;
10
11pub const ESTIMATOR: &str = "v1";
14
15#[derive(Debug, Clone, Copy, PartialEq, Default)]
17pub struct Features {
18 pub names_a_place: bool,
21 pub text_words: i64,
22 pub description_lines: i64,
23 pub repo_files: i64,
25 pub verify_seconds: Option<i64>,
27 pub prior_decided: i64,
29 pub prior_accepted: i64,
30}
31
32const LARGE_REPO: i64 = 500;
35
36const TERSE: i64 = 4;
38
39impl Features {
40 pub fn to_json(self) -> String {
43 serde_json::json!({
44 "names_a_place": self.names_a_place,
45 "text_words": self.text_words,
46 "description_lines": self.description_lines,
47 "repo_files": self.repo_files,
48 "verify_seconds": self.verify_seconds,
49 "prior_decided": self.prior_decided,
50 "prior_accepted": self.prior_accepted,
51 })
52 .to_string()
53 }
54
55 pub fn from_json(text: &str) -> Option<Features> {
58 let v: serde_json::Value = serde_json::from_str(text).ok()?;
59 let int = |k: &str| v[k].as_i64().unwrap_or(0);
60 Some(Features {
61 names_a_place: v["names_a_place"].as_bool().unwrap_or(false),
62 text_words: int("text_words"),
63 description_lines: int("description_lines"),
64 repo_files: int("repo_files"),
65 verify_seconds: v["verify_seconds"].as_i64(),
66 prior_decided: int("prior_decided"),
67 prior_accepted: int("prior_accepted"),
68 })
69 }
70
71 pub fn of(text: &str, description: &str) -> Features {
74 Features {
75 names_a_place: names_a_place(text) || names_a_place(description),
76 text_words: text.split_whitespace().count() as i64,
77 description_lines: description.lines().filter(|l| !l.trim().is_empty()).count() as i64,
78 ..Features::default()
79 }
80 }
81}
82
83fn names_a_place(text: &str) -> bool {
87 text.split_whitespace().any(|word| {
88 let word = word.trim_matches(|c: char| !c.is_alphanumeric() && c != ')' && c != '/');
89 word.ends_with("()")
90 || word.contains("::")
91 || (word.contains('/') && !word.contains("://") && is_path(word))
92 || is_file(word)
93 })
94}
95
96fn is_path(word: &str) -> bool {
98 word.ends_with('/')
99 || word.matches('/').count() >= 2
100 || word.rsplit('/').next().is_some_and(is_file)
101}
102
103fn is_file(word: &str) -> bool {
105 let word = match word.split_once(':') {
106 Some((file, at)) if at.chars().all(|c| c.is_ascii_digit() || c == '-') => file,
107 _ => word,
108 };
109 word.rsplit_once('.').is_some_and(|(stem, ext)| {
110 stem.len() >= 2
111 && (1..=4).contains(&ext.len())
112 && ext.chars().all(|c| c.is_ascii_alphabetic())
113 && !(ext == "js" && stem.starts_with(|c: char| c.is_ascii_uppercase()))
114 })
115}
116
117pub fn estimate(class: Class, f: &Features) -> i64 {
121 let mut score = match class {
122 Class::Mechanical => 2,
124 Class::Privileged => 3,
125 Class::Specified => 3,
126 Class::Judgment | Class::Never => 5,
127 };
128 if class == Class::Judgment || class == Class::Never {
129 return score;
130 }
131 if f.names_a_place {
132 score -= 1;
133 }
134 if f.description_lines >= 3 {
135 score -= 1;
136 }
137 if f.text_words < TERSE && f.description_lines == 0 {
138 score += 1;
139 }
140 if f.repo_files > LARGE_REPO {
141 score += 1;
142 }
143 if f.verify_seconds.is_none() {
144 score += 1;
145 }
146 if f.prior_decided >= 2 && f.prior_accepted * 2 < f.prior_decided {
149 score += 1;
150 }
151 score.clamp(1, 5)
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 fn specified() -> Features {
159 Features {
160 text_words: 8,
161 repo_files: 50,
162 verify_seconds: Some(10),
163 ..Features::default()
164 }
165 }
166
167 #[test]
168 fn a_path_or_a_symbol_counts_as_a_starting_point() {
169 for text in [
170 "fix src/scan.rs",
171 "glob_match() rejects **",
172 "Store::open leaks",
173 "update README.md",
174 "document docs/dev/",
175 "move the check to src/dispatch.rs:42",
176 ] {
177 assert!(names_a_place(text), "{text}");
178 }
179 for text in [
180 "make the parser faster",
181 "see https://example.com/x",
182 "decide on the api",
183 "keep and/or drop it",
184 "a flag, e.g. --all, or i.e. none",
185 "support Node.js and Vue.js",
186 ] {
187 assert!(!names_a_place(text), "{text}");
188 }
189 }
190
191 #[test]
192 fn a_bounded_class_with_a_check_is_simple() {
193 assert_eq!(estimate(Class::Mechanical, &specified()), 2);
194 let located = Features {
195 names_a_place: true,
196 ..specified()
197 };
198 assert_eq!(estimate(Class::Mechanical, &located), 1);
199 assert_eq!(estimate(Class::Specified, &located), 2);
200 }
201
202 #[test]
203 fn a_one_liner_in_a_large_repository_without_a_check_is_hard() {
204 let f = Features {
205 text_words: 2,
206 repo_files: 900,
207 verify_seconds: None,
208 ..Features::default()
209 };
210 assert_eq!(estimate(Class::Specified, &f), 5);
211 }
212
213 #[test]
216 fn a_description_lowers_the_estimate() {
217 let mut f = specified();
218 assert_eq!(estimate(Class::Specified, &f), 3);
219 f.description_lines = 3;
220 assert_eq!(estimate(Class::Specified, &f), 2);
221 }
222
223 #[test]
225 fn a_project_that_rejects_this_work_raises_it() {
226 let mut f = specified();
227 f.prior_decided = 4;
228 f.prior_accepted = 1;
229 assert_eq!(estimate(Class::Specified, &f), 4);
230 f.prior_accepted = 2;
231 assert_eq!(estimate(Class::Specified, &f), 3, "half is not worse");
232 f.prior_decided = 1;
233 f.prior_accepted = 0;
234 assert_eq!(estimate(Class::Specified, &f), 3, "one run decides nothing");
235 }
236
237 #[test]
238 fn judgment_work_is_five_whatever_else_is_true() {
239 let f = Features {
240 names_a_place: true,
241 description_lines: 10,
242 ..specified()
243 };
244 assert_eq!(estimate(Class::Judgment, &f), 5);
245 assert_eq!(estimate(Class::Never, &f), 5);
246 }
247
248 #[test]
249 fn features_round_trip_through_their_stored_shape() {
250 let f = Features {
251 names_a_place: true,
252 text_words: 9,
253 description_lines: 2,
254 repo_files: 120,
255 verify_seconds: Some(31),
256 prior_decided: 5,
257 prior_accepted: 3,
258 };
259 assert_eq!(Features::from_json(&f.to_json()), Some(f));
260 assert_eq!(
262 Features::from_json(r#"{"text_words":4}"#),
263 Some(Features {
264 text_words: 4,
265 ..Features::default()
266 })
267 );
268 assert_eq!(Features::from_json("not json"), None);
269 }
270
271 #[test]
272 fn features_read_the_text_and_the_description() {
273 let f = Features::of("fix the parser", "it drops src/a.rs\n\nand b\n");
274 assert_eq!(
275 (f.names_a_place, f.text_words, f.description_lines),
276 (true, 3, 2)
277 );
278 }
279}