1use serde::{Deserialize, Serialize};
6
7use crate::actions::Via;
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
11#[serde(tag = "kind", rename_all = "snake_case")]
12pub enum Origin {
13 Bug {
15 unit: String,
17 requirement: String,
19 task: String,
21 triad_sha256: String,
23 by: String,
25 via: Via,
27 at: u64,
29 },
30 Issue {
32 number: u64,
34 repo: Option<String>,
36 context_sha256: Option<String>,
38 },
39}
40
41impl Origin {
42 #[must_use]
44 pub fn describe(&self) -> String {
45 match self {
46 Self::Bug {
47 requirement,
48 by,
49 via,
50 ..
51 } => format!("bug {requirement}, recorded by {by} ({via})"),
52 Self::Issue { number, repo, .. } => match repo {
53 Some(r) => format!("issue {r}#{number}"),
54 None => format!("issue #{number}"),
55 },
56 }
57 }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
64#[serde(rename_all = "snake_case")]
65pub enum Rung {
66 Quick,
68 Bug,
71 Feature,
73}
74
75impl Rung {
76 #[must_use]
78 pub const fn as_str(self) -> &'static str {
79 match self {
80 Self::Quick => "quick",
81 Self::Bug => "bug",
82 Self::Feature => "feature",
83 }
84 }
85}
86
87#[must_use]
91pub fn rung_of(unit: &str, standing: &[(String, Rung)]) -> Rung {
92 standing
93 .iter()
94 .find(|(name, _)| {
95 unit == name
96 || unit
97 .strip_prefix(name.as_str())
98 .is_some_and(|rest| rest.starts_with('/'))
99 })
100 .map_or(Rung::Feature, |(_, r)| *r)
101}
102
103#[must_use]
105pub fn subject(unit: &str, requirement: &str) -> String {
106 format!("{unit}/{requirement}")
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
112#[serde(rename_all = "snake_case")]
113pub enum RungPick {
114 Bug,
116 Quick,
118 Feature,
120 Ask,
122 None,
124}
125
126impl RungPick {
127 pub const ORDER: [Self; 4] = [Self::Bug, Self::Quick, Self::Feature, Self::Ask];
129
130 #[must_use]
132 pub const fn as_str(self) -> &'static str {
133 match self {
134 Self::Bug => "bug",
135 Self::Quick => "quick",
136 Self::Feature => "feature",
137 Self::Ask => "ask",
138 Self::None => "none",
139 }
140 }
141
142 #[must_use]
144 pub const fn label(self) -> &'static str {
145 match self {
146 Self::Bug => "Bug fix",
147 Self::Quick => "Quick change",
148 Self::Feature => "Feature",
149 Self::Ask => "Just ask",
150 Self::None => "Nothing",
151 }
152 }
153
154 #[must_use]
156 pub const fn ceremony(self) -> &'static str {
157 match self {
158 Self::Bug => "a reproduction must fail first, then the fix is judged",
159 Self::Quick => "one requirement, judged like a task",
160 Self::Feature => "the spec tool writes a spec; you review it",
161 Self::Ask => "a read-only answer, nothing changes",
162 Self::None => "",
163 }
164 }
165
166 #[must_use]
168 pub const fn available(self) -> bool {
169 matches!(self, Self::Bug | Self::Quick | Self::Feature)
170 }
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
175#[serde(rename_all = "snake_case")]
176pub enum RungCueRule {
177 Question,
179 WrongResult,
181 SeveralCapabilities,
183 Long,
185 Default,
187}
188
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
192pub struct RungCue {
193 pub rule: RungCueRule,
195 pub matched: Vec<String>,
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
203pub struct RungSuggestion {
204 pub rung: RungPick,
206 pub available: bool,
208 pub cue: RungCue,
210 pub reason: String,
212 pub alternatives: Vec<RungPick>,
214 pub command: Option<String>,
216}
217
218pub const LONG_WORDS: usize = 25;
220
221const INTERROGATIVES: &[&str] = &[
225 "why", "how", "what", "which", "who", "whom", "whose", "does", "do", "did", "is", "are", "can",
226 "could", "should", "would", "will", "was", "were",
227];
228
229const WRONG_RESULT: &[&str] = &[
231 "crash",
232 "crashes",
233 "crashed",
234 "crashing",
235 "broken",
236 "breaks",
237 "wrong",
238 "incorrect",
239 "regression",
240 "regressed",
241 "error",
242 "errors",
243 "fails",
244 "failing",
245 "failed",
246 "fails when",
247 "panic",
248 "panics",
249 "hangs",
250 "freezes",
251 "exception",
252 "is empty",
253 "are empty",
254 "comes back empty",
255 "is missing",
256 "are missing",
257 "doesn't",
258 "does not",
259 "don't",
260 "do not",
261 "didn't",
262 "won't",
263 "can't",
264 "cannot",
265 "no longer",
266 "instead of",
267 "not working",
268 "bug",
269];
270
271fn words_of(text: &str) -> Vec<&str> {
274 text.split(|c: char| !(c.is_alphanumeric() || c == '\'' || c == '’' || c == '-'))
275 .map(|w| w.trim_matches(|c| c == '\'' || c == '’' || c == '-'))
276 .filter(|w| !w.is_empty())
277 .collect()
278}
279
280fn lower(w: &str) -> String {
281 w.to_lowercase().replace('’', "'")
282}
283
284fn phrases(words: &[&str], lexicon: &[&str]) -> Vec<String> {
286 let lowered: Vec<String> = words.iter().map(|w| lower(w)).collect();
287 let mut out: Vec<(usize, String)> = Vec::new();
288 for phrase in lexicon {
289 let parts: Vec<&str> = phrase.split(' ').collect();
290 if parts.len() > lowered.len() {
291 continue;
292 }
293 for i in 0..=lowered.len() - parts.len() {
294 if parts.iter().enumerate().all(|(j, p)| lowered[i + j] == *p) {
295 out.push((i, words[i..i + parts.len()].join(" ")));
296 }
297 }
298 }
299 out.sort();
300 let mut kept: Vec<(usize, String)> = Vec::new();
302 for (i, p) in out {
303 if let Some(last) = kept.last_mut()
304 && last.0 == i
305 {
306 if p.len() > last.1.len() {
307 *last = (i, p);
308 }
309 continue;
310 }
311 kept.push((i, p));
312 }
313 let mut seen = std::collections::BTreeSet::new();
314 kept.into_iter()
315 .map(|(_, p)| p)
316 .filter(|p| seen.insert(lower(p)))
317 .collect()
318}
319
320fn joiners(text: &str) -> Vec<String> {
323 let mut clauses: Vec<String> = vec![String::new()];
324 let mut joins: Vec<String> = Vec::new();
325 for line in text.lines() {
326 let t = line.trim_start();
327 let bullet = t.starts_with("- ")
328 || t.starts_with("* ")
329 || t.split_once(". ").is_some_and(|(n, _)| {
330 !n.is_empty() && n.len() <= 2 && n.chars().all(|c| c.is_ascii_digit())
331 });
332 if bullet && clauses.last().is_some_and(|c| !c.trim().is_empty()) {
333 clauses.push(String::new());
334 joins.push("list".to_owned());
335 }
336 let mut word = String::new();
337 let flush = |word: &mut String, clauses: &mut Vec<String>, joins: &mut Vec<String>| {
338 if word.eq_ignore_ascii_case("and") {
339 clauses.push(String::new());
340 joins.push(std::mem::take(word));
341 } else if !word.is_empty() {
342 if let Some(c) = clauses.last_mut() {
343 c.push(' ');
344 c.push_str(word);
345 }
346 word.clear();
347 }
348 };
349 for ch in t.chars() {
350 match ch {
351 ';' | ',' => {
352 flush(&mut word, &mut clauses, &mut joins);
353 clauses.push(String::new());
354 joins.push(ch.to_string());
355 }
356 c if c.is_whitespace() => flush(&mut word, &mut clauses, &mut joins),
357 c => word.push(c),
358 }
359 }
360 flush(&mut word, &mut clauses, &mut joins);
361 }
362 let real: Vec<bool> = clauses
364 .iter()
365 .map(|c| c.split_whitespace().count() >= 2)
366 .collect();
367 if real.iter().filter(|r| **r).count() < 2 {
368 return Vec::new();
369 }
370 let mut out = Vec::new();
371 for (i, j) in joins.into_iter().enumerate() {
372 if real.get(i).copied().unwrap_or(false) && real.get(i + 1).copied().unwrap_or(false) {
374 out.push(j);
375 }
376 }
377 out
378}
379
380#[must_use]
382pub fn quoted(text: &str) -> String {
383 let one: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
384 format!("\"{}\"", one.replace('\\', "\\\\").replace('"', "\\\""))
385}
386
387#[must_use]
390pub fn command_for(rung: RungPick, text: &str, create_skill: Option<&str>) -> Option<String> {
391 let q = quoted(text);
392 match rung {
393 RungPick::Bug => Some(format!("speccoder bug {q}")),
394 RungPick::Quick => Some(format!("speccoder do {q}")),
395 RungPick::Feature => create_skill.map(|s| format!("speccoder spec {s} {q}")),
396 RungPick::Ask | RungPick::None => None,
397 }
398}
399
400#[must_use]
407pub fn suggest(text: &str) -> RungSuggestion {
408 let trimmed = text.trim();
409 let words = words_of(trimmed);
410 let wrong = phrases(&words, WRONG_RESULT);
411 let first = words.first().map(|w| lower(w));
412 let asks = trimmed.ends_with('?')
413 || (wrong.is_empty()
414 && first
415 .as_deref()
416 .is_some_and(|f| INTERROGATIVES.contains(&f)));
417 let joined = joiners(trimmed);
418 let (rung, rule, matched) = if words.is_empty() {
419 (RungPick::None, RungCueRule::Default, Vec::new())
420 } else if asks {
421 let m = if trimmed.ends_with('?') {
422 vec!["?".to_owned()]
423 } else {
424 words
425 .first()
426 .map(|w| vec![(*w).to_owned()])
427 .unwrap_or_default()
428 };
429 (RungPick::Ask, RungCueRule::Question, m)
430 } else if !wrong.is_empty() {
431 (RungPick::Bug, RungCueRule::WrongResult, wrong)
432 } else if !joined.is_empty() {
433 (RungPick::Feature, RungCueRule::SeveralCapabilities, joined)
434 } else if words.len() > LONG_WORDS {
435 (RungPick::Feature, RungCueRule::Long, Vec::new())
436 } else {
437 (RungPick::Quick, RungCueRule::Default, Vec::new())
438 };
439 let shown = |m: &[String]| {
440 m.iter()
441 .map(|w| format!("\"{w}\""))
442 .collect::<Vec<_>>()
443 .join(", ")
444 };
445 let reason = match rule {
446 RungCueRule::Question => "This reads like a question; Ask arrives with the Ask panel — pick a rung to make it a change".to_owned(),
447 RungCueRule::WrongResult => format!(
448 "Why \"Bug fix\": the text names a wrong result ({}) of existing behaviour.",
449 shown(&matched)
450 ),
451 RungCueRule::SeveralCapabilities => {
452 "Why \"Feature\": the text joins two or more capabilities.".to_owned()
453 }
454 RungCueRule::Long => format!(
455 "Why \"Feature\": {} words is more than one requirement holds.",
456 words.len()
457 ),
458 RungCueRule::Default if rung == RungPick::None => "Describe the change first.".to_owned(),
459 RungCueRule::Default => {
460 "Why \"Quick change\": one change, no wrong result named and no list of capabilities."
461 .to_owned()
462 }
463 };
464 RungSuggestion {
465 rung,
466 available: rung.available(),
467 cue: RungCue { rule, matched },
468 reason,
469 alternatives: RungPick::ORDER.into_iter().filter(|p| *p != rung).collect(),
470 command: None,
471 }
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477
478 #[test]
479 fn origins_carry_their_kind_and_read_back() {
480 let o = Origin::Issue {
481 number: 42,
482 repo: Some("o/r".to_owned()),
483 context_sha256: None,
484 };
485 let v = serde_json::to_value(&o).unwrap_or_default();
486 assert_eq!(v["kind"], "issue");
487 assert_eq!(serde_json::from_value::<Origin>(v).ok(), Some(o.clone()));
488 assert_eq!(o.describe(), "issue o/r#42");
489 assert_eq!(subject("007-bugfixes", "FR-003"), "007-bugfixes/FR-003");
490 }
491
492 #[test]
493 fn every_rung_is_decided_by_the_standing_units() {
494 let standing = vec![
495 ("004-quick-changes".to_owned(), Rung::Quick),
496 ("007-bugfixes".to_owned(), Rung::Bug),
497 (".specify/bugs".to_owned(), Rung::Bug),
498 ];
499 assert_eq!(rung_of("004-quick-changes", &standing), Rung::Quick);
500 assert_eq!(rung_of("007-bugfixes", &standing), Rung::Bug);
501 assert_eq!(rung_of(".specify/bugs/empty-title", &standing), Rung::Bug);
502 assert_eq!(rung_of("007-bugfixes-extra", &standing), Rung::Feature);
503 assert_eq!(rung_of("001-greet", &standing), Rung::Feature);
504 assert_eq!(rung_of("001-greet", &[]), Rung::Feature);
505 assert_eq!(Rung::Quick.as_str(), "quick");
506 }
507}