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 PromotedFrom {
41 from_unit: String,
43 requirement: String,
45 task: String,
47 requirement_sha256: String,
49 conversations: Vec<(String, String)>,
51 signals: Vec<String>,
53 by: String,
55 via: Via,
57 at: u64,
59 },
60 ExploredFrom {
62 session: String,
64 digest: String,
66 by: String,
68 via: Via,
70 at: u64,
72 },
73 Asked {
75 conversation: String,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 turn: Option<u32>,
80 at: u64,
82 },
83}
84
85#[must_use]
88pub fn asked(asked: &str, at: u64) -> Origin {
89 let (conversation, turn) = match asked.split_once('#') {
90 Some((c, t)) => (c, t.parse().ok()),
91 None => (asked, None),
92 };
93 Origin::Asked {
94 conversation: conversation.to_owned(),
95 turn,
96 at,
97 }
98}
99
100impl Origin {
101 #[must_use]
103 pub fn describe(&self) -> String {
104 match self {
105 Self::Bug {
106 requirement,
107 by,
108 via,
109 ..
110 } => format!("bug {requirement}, recorded by {by} ({via})"),
111 Self::Issue { number, repo, .. } => match repo {
112 Some(r) => format!("issue {r}#{number}"),
113 None => format!("issue #{number}"),
114 },
115 Self::Asked { conversation, .. } => format!("asked in conversation {conversation}"),
116 Self::ExploredFrom {
117 session, digest, ..
118 } => format!(
119 "explored in {session}, brief {}",
120 digest.get(..12).unwrap_or(digest)
121 ),
122 Self::PromotedFrom {
123 from_unit,
124 requirement,
125 by,
126 ..
127 } => format!("promoted from {from_unit}/{requirement} by {by}"),
128 }
129 }
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
136#[serde(rename_all = "snake_case")]
137pub enum Rung {
138 Quick,
140 Bug,
143 Feature,
145}
146
147impl Rung {
148 #[must_use]
150 pub const fn as_str(self) -> &'static str {
151 match self {
152 Self::Quick => "quick",
153 Self::Bug => "bug",
154 Self::Feature => "feature",
155 }
156 }
157}
158
159#[must_use]
163pub fn rung_of(unit: &str, standing: &[(String, Rung)]) -> Rung {
164 standing
165 .iter()
166 .find(|(name, _)| {
167 unit == name
168 || unit
169 .strip_prefix(name.as_str())
170 .is_some_and(|rest| rest.starts_with('/'))
171 })
172 .map_or(Rung::Feature, |(_, r)| *r)
173}
174
175#[derive(Debug, Clone, Default, PartialEq, Eq)]
178pub struct SignalInput {
179 pub files_changed: u64,
181 pub packages_changed: u64,
183 pub red_causes: Vec<String>,
185 pub ambiguity_markers: u64,
187 pub judge_paths: Vec<String>,
189 pub promote_files: u64,
191}
192
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
195pub struct Signal {
196 pub id: String,
199 pub text: String,
201 pub value: u64,
203 pub threshold: u64,
205}
206
207#[must_use]
210pub fn signals(i: &SignalInput) -> Vec<Signal> {
211 let mut out = Vec::new();
212 let sig = |id: &str, text: String, value: u64, threshold: u64| Signal {
213 id: id.to_owned(),
214 text,
215 value,
216 threshold,
217 };
218 if i.files_changed > i.promote_files {
219 out.push(sig(
220 "S1",
221 format!(
222 "{} files changed (more than {})",
223 i.files_changed, i.promote_files
224 ),
225 i.files_changed,
226 i.promote_files,
227 ));
228 } else if i.packages_changed > 1 {
229 out.push(sig(
230 "S1",
231 format!("{} packages changed (more than 1)", i.packages_changed),
232 i.packages_changed,
233 1,
234 ));
235 }
236 let mut causes: Vec<&str> = i.red_causes.iter().map(String::as_str).collect();
237 causes.sort_unstable();
238 causes.dedup();
239 if causes.len() >= 2 {
240 out.push(sig(
241 "S2",
242 format!("{} failed attempts, each for another reason", causes.len()),
243 causes.len() as u64,
244 2,
245 ));
246 }
247 if i.ambiguity_markers > 0 {
248 out.push(sig(
249 "S3",
250 format!(
251 "{} ambiguous {} in the requirement",
252 i.ambiguity_markers,
253 if i.ambiguity_markers == 1 {
254 "word"
255 } else {
256 "words"
257 }
258 ),
259 i.ambiguity_markers,
260 0,
261 ));
262 }
263 if !i.judge_paths.is_empty() {
264 out.push(sig(
265 "S4",
266 format!("changes what judges: {}", i.judge_paths.join(", ")),
267 i.judge_paths.len() as u64,
268 0,
269 ));
270 }
271 out
272}
273
274#[must_use]
276pub fn subject(unit: &str, requirement: &str) -> String {
277 format!("{unit}/{requirement}")
278}
279
280#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
283#[serde(rename_all = "snake_case")]
284pub enum RungPick {
285 Bug,
287 Quick,
289 Feature,
291 Ask,
293 Explore,
295 None,
297}
298
299impl RungPick {
300 pub const ORDER: [Self; 5] = [
302 Self::Bug,
303 Self::Quick,
304 Self::Feature,
305 Self::Explore,
306 Self::Ask,
307 ];
308
309 #[must_use]
311 pub const fn as_str(self) -> &'static str {
312 match self {
313 Self::Bug => "bug",
314 Self::Quick => "quick",
315 Self::Feature => "feature",
316 Self::Ask => "ask",
317 Self::Explore => "explore",
318 Self::None => "none",
319 }
320 }
321
322 #[must_use]
324 pub const fn label(self) -> &'static str {
325 match self {
326 Self::Bug => "Bug fix",
327 Self::Quick => "Quick change",
328 Self::Feature => "Feature",
329 Self::Ask => "Just ask",
330 Self::Explore => "Explore",
331 Self::None => "Nothing",
332 }
333 }
334
335 #[must_use]
337 pub const fn ceremony(self) -> &'static str {
338 match self {
339 Self::Bug => "a reproduction must fail first, then the fix is judged",
340 Self::Quick => "one requirement, judged like a task",
341 Self::Feature => "the spec tool writes a spec; you review it",
342 Self::Ask => "a read-only answer, nothing changes",
343 Self::Explore => "approaches to choose from, then a brief that starts the spec",
344 Self::None => "",
345 }
346 }
347
348 #[must_use]
350 pub const fn available(self) -> bool {
351 matches!(
352 self,
353 Self::Bug | Self::Quick | Self::Feature | Self::Ask | Self::Explore
354 )
355 }
356}
357
358#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
360#[serde(rename_all = "snake_case")]
361pub enum RungCueRule {
362 Weighing,
364 Question,
366 WrongResult,
368 SeveralCapabilities,
370 Long,
372 Default,
374}
375
376#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
379pub struct RungCue {
380 pub rule: RungCueRule,
382 pub matched: Vec<String>,
384}
385
386#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
390pub struct RungSuggestion {
391 pub rung: RungPick,
393 pub available: bool,
395 pub cue: RungCue,
397 pub reason: String,
399 pub alternatives: Vec<RungPick>,
401 pub command: Option<String>,
403}
404
405pub const LONG_WORDS: usize = 25;
407
408const INTERROGATIVES: &[&str] = &[
412 "why", "how", "what", "which", "who", "whom", "whose", "does", "do", "did", "is", "are", "can",
413 "could", "should", "would", "will", "was", "were",
414];
415
416const WEIGHING: &[&str] = &[
419 "should we",
420 "should i",
421 "which is better",
422 "how could we",
423 "how should we",
424 "options for",
425 "compare",
426 "or should",
427 "best way to",
428 "trade-offs",
429 "tradeoffs",
430];
431
432const WRONG_RESULT: &[&str] = &[
433 "crash",
434 "crashes",
435 "crashed",
436 "crashing",
437 "broken",
438 "breaks",
439 "wrong",
440 "incorrect",
441 "regression",
442 "regressed",
443 "error",
444 "errors",
445 "fails",
446 "failing",
447 "failed",
448 "fails when",
449 "panic",
450 "panics",
451 "hangs",
452 "freezes",
453 "exception",
454 "is empty",
455 "are empty",
456 "comes back empty",
457 "is missing",
458 "are missing",
459 "doesn't",
460 "does not",
461 "don't",
462 "do not",
463 "didn't",
464 "won't",
465 "can't",
466 "cannot",
467 "no longer",
468 "instead of",
469 "not working",
470 "bug",
471];
472
473fn words_of(text: &str) -> Vec<&str> {
476 text.split(|c: char| !(c.is_alphanumeric() || c == '\'' || c == '’' || c == '-'))
477 .map(|w| w.trim_matches(|c| c == '\'' || c == '’' || c == '-'))
478 .filter(|w| !w.is_empty())
479 .collect()
480}
481
482fn lower(w: &str) -> String {
483 w.to_lowercase().replace('’', "'")
484}
485
486fn phrases(words: &[&str], lexicon: &[&str]) -> Vec<String> {
488 let lowered: Vec<String> = words.iter().map(|w| lower(w)).collect();
489 let mut out: Vec<(usize, String)> = Vec::new();
490 for phrase in lexicon {
491 let parts: Vec<&str> = phrase.split(' ').collect();
492 if parts.len() > lowered.len() {
493 continue;
494 }
495 for i in 0..=lowered.len() - parts.len() {
496 if parts.iter().enumerate().all(|(j, p)| lowered[i + j] == *p) {
497 out.push((i, words[i..i + parts.len()].join(" ")));
498 }
499 }
500 }
501 out.sort();
502 let mut kept: Vec<(usize, String)> = Vec::new();
504 for (i, p) in out {
505 if let Some(last) = kept.last_mut()
506 && last.0 == i
507 {
508 if p.len() > last.1.len() {
509 *last = (i, p);
510 }
511 continue;
512 }
513 kept.push((i, p));
514 }
515 let mut seen = std::collections::BTreeSet::new();
516 kept.into_iter()
517 .map(|(_, p)| p)
518 .filter(|p| seen.insert(lower(p)))
519 .collect()
520}
521
522fn joiners(text: &str) -> Vec<String> {
525 let mut clauses: Vec<String> = vec![String::new()];
526 let mut joins: Vec<String> = Vec::new();
527 for line in text.lines() {
528 let t = line.trim_start();
529 let bullet = t.starts_with("- ")
530 || t.starts_with("* ")
531 || t.split_once(". ").is_some_and(|(n, _)| {
532 !n.is_empty() && n.len() <= 2 && n.chars().all(|c| c.is_ascii_digit())
533 });
534 if bullet && clauses.last().is_some_and(|c| !c.trim().is_empty()) {
535 clauses.push(String::new());
536 joins.push("list".to_owned());
537 }
538 let mut word = String::new();
539 let flush = |word: &mut String, clauses: &mut Vec<String>, joins: &mut Vec<String>| {
540 if word.eq_ignore_ascii_case("and") {
541 clauses.push(String::new());
542 joins.push(std::mem::take(word));
543 } else if !word.is_empty() {
544 if let Some(c) = clauses.last_mut() {
545 c.push(' ');
546 c.push_str(word);
547 }
548 word.clear();
549 }
550 };
551 for ch in t.chars() {
552 match ch {
553 ';' | ',' => {
554 flush(&mut word, &mut clauses, &mut joins);
555 clauses.push(String::new());
556 joins.push(ch.to_string());
557 }
558 c if c.is_whitespace() => flush(&mut word, &mut clauses, &mut joins),
559 c => word.push(c),
560 }
561 }
562 flush(&mut word, &mut clauses, &mut joins);
563 }
564 let real: Vec<bool> = clauses
566 .iter()
567 .map(|c| c.split_whitespace().count() >= 2)
568 .collect();
569 if real.iter().filter(|r| **r).count() < 2 {
570 return Vec::new();
571 }
572 let mut out = Vec::new();
573 for (i, j) in joins.into_iter().enumerate() {
574 if real.get(i).copied().unwrap_or(false) && real.get(i + 1).copied().unwrap_or(false) {
576 out.push(j);
577 }
578 }
579 out
580}
581
582#[must_use]
586pub fn quoted(text: &str) -> String {
587 let one: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
588 let mut out = String::with_capacity(one.len() + 2);
589 out.push('"');
590 for c in one.chars() {
591 if matches!(c, '\\' | '"' | '$' | '`') {
592 out.push('\\');
593 }
594 out.push(c);
595 }
596 out.push('"');
597 out
598}
599
600#[must_use]
603pub fn command_for(rung: RungPick, text: &str, create_skill: Option<&str>) -> Option<String> {
604 let q = quoted(text);
605 match rung {
606 RungPick::Bug => Some(format!("speccoder bug {q}")),
607 RungPick::Quick => Some(format!("speccoder do {q}")),
608 RungPick::Feature => create_skill.map(|s| format!("speccoder spec {s} {q}")),
609 RungPick::Ask => Some(format!("speccoder ask {q}")),
610 RungPick::Explore => Some(format!("speccoder spec explore {q}")),
611 RungPick::None => None,
612 }
613}
614
615#[must_use]
622pub fn suggest(text: &str) -> RungSuggestion {
623 let trimmed = text.trim();
624 let words = words_of(trimmed);
625 let wrong = phrases(&words, WRONG_RESULT);
626 let first = words.first().map(|w| lower(w));
627 let asks = trimmed.ends_with('?')
628 || (wrong.is_empty()
629 && first
630 .as_deref()
631 .is_some_and(|f| INTERROGATIVES.contains(&f)));
632 let joined = joiners(trimmed);
633 let weighing = phrases(&words, WEIGHING);
634 let (rung, rule, matched) = if words.is_empty() {
635 (RungPick::None, RungCueRule::Default, Vec::new())
636 } else if !weighing.is_empty() {
637 (RungPick::Explore, RungCueRule::Weighing, weighing)
638 } else if asks {
639 let m = if trimmed.ends_with('?') {
640 vec!["?".to_owned()]
641 } else {
642 words
643 .first()
644 .map(|w| vec![(*w).to_owned()])
645 .unwrap_or_default()
646 };
647 (RungPick::Ask, RungCueRule::Question, m)
648 } else if !wrong.is_empty() {
649 (RungPick::Bug, RungCueRule::WrongResult, wrong)
650 } else if !joined.is_empty() {
651 (RungPick::Feature, RungCueRule::SeveralCapabilities, joined)
652 } else if words.len() > LONG_WORDS {
653 (RungPick::Feature, RungCueRule::Long, Vec::new())
654 } else {
655 (RungPick::Quick, RungCueRule::Default, Vec::new())
656 };
657 let shown = |m: &[String]| {
658 m.iter()
659 .map(|w| format!("\"{w}\""))
660 .collect::<Vec<_>>()
661 .join(", ")
662 };
663 let reason = match rule {
664 RungCueRule::Weighing => format!(
665 "Why \"Explore\": it weighs approaches ({}) — explore them, choose one, then the spec.",
666 shown(&matched)
667 ),
668 RungCueRule::Question => "Why \"Just ask\": it reads like a question — Ask answers it from what it reads and changes nothing.".to_owned(),
669 RungCueRule::WrongResult => format!(
670 "Why \"Bug fix\": the text names a wrong result ({}) of existing behaviour.",
671 shown(&matched)
672 ),
673 RungCueRule::SeveralCapabilities => {
674 "Why \"Feature\": the text joins two or more capabilities.".to_owned()
675 }
676 RungCueRule::Long => format!(
677 "Why \"Feature\": {} words is more than one requirement holds.",
678 words.len()
679 ),
680 RungCueRule::Default if rung == RungPick::None => "Describe the change first.".to_owned(),
681 RungCueRule::Default => {
682 "Why \"Quick change\": one change, no wrong result named and no list of capabilities."
683 .to_owned()
684 }
685 };
686 RungSuggestion {
687 rung,
688 available: rung.available(),
689 cue: RungCue { rule, matched },
690 reason,
691 alternatives: RungPick::ORDER.into_iter().filter(|p| *p != rung).collect(),
692 command: None,
693 }
694}
695
696#[cfg(test)]
697mod tests {
698 #[test]
699 fn a_quoted_argument_expands_nothing_in_a_shell() {
700 assert_eq!(quoted("log every\nsign-in"), "\"log every sign-in\"");
701 assert_eq!(
702 quoted("run $(curl x|sh) and `id` \"now\" \\"),
703 "\"run \\$(curl x|sh) and \\`id\\` \\\"now\\\" \\\\\""
704 );
705 }
706
707 use super::*;
708
709 #[test]
710 fn origins_carry_their_kind_and_read_back() {
711 let o = Origin::Issue {
712 number: 42,
713 repo: Some("o/r".to_owned()),
714 context_sha256: None,
715 };
716 let v = serde_json::to_value(&o).unwrap_or_default();
717 assert_eq!(v["kind"], "issue");
718 assert_eq!(serde_json::from_value::<Origin>(v).ok(), Some(o.clone()));
719 assert_eq!(o.describe(), "issue o/r#42");
720 assert_eq!(subject("007-bugfixes", "FR-003"), "007-bugfixes/FR-003");
721 }
722
723 #[test]
724 fn every_rung_is_decided_by_the_standing_units() {
725 let standing = vec![
726 ("004-quick-changes".to_owned(), Rung::Quick),
727 ("007-bugfixes".to_owned(), Rung::Bug),
728 (".specify/bugs".to_owned(), Rung::Bug),
729 ];
730 assert_eq!(rung_of("004-quick-changes", &standing), Rung::Quick);
731 assert_eq!(rung_of("007-bugfixes", &standing), Rung::Bug);
732 assert_eq!(rung_of(".specify/bugs/empty-title", &standing), Rung::Bug);
733 assert_eq!(rung_of("007-bugfixes-extra", &standing), Rung::Feature);
734 assert_eq!(rung_of("001-greet", &standing), Rung::Feature);
735 assert_eq!(rung_of("001-greet", &[]), Rung::Feature);
736 assert_eq!(Rung::Quick.as_str(), "quick");
737 }
738
739 fn input(files: u64) -> SignalInput {
740 SignalInput {
741 files_changed: files,
742 promote_files: 8,
743 ..SignalInput::default()
744 }
745 }
746
747 #[test]
748 fn signals_hold_on_recorded_facts_only() {
749 assert!(signals(&SignalInput::default()).is_empty());
750 assert!(signals(&input(8)).is_empty());
751 let s = signals(&input(11));
752 assert_eq!(
753 (s[0].id.as_str(), s[0].value, s[0].threshold),
754 ("S1", 11, 8)
755 );
756 for n in 9..40 {
758 assert_eq!(signals(&input(n)).len(), 1);
759 }
760 let two = SignalInput {
761 red_causes: vec!["a test failed".into(), "a test failed".into()],
762 ..input(1)
763 };
764 assert!(signals(&two).is_empty(), "the same cause twice is one");
765 let distinct = SignalInput {
766 red_causes: vec!["a test failed".into(), "the build broke".into()],
767 ambiguity_markers: 1,
768 judge_paths: vec!["speccoder.toml".into()],
769 packages_changed: 2,
770 ..input(3)
771 };
772 let ids: Vec<String> = signals(&distinct).into_iter().map(|s| s.id).collect();
773 assert_eq!(ids, ["S1", "S2", "S3", "S4"]);
774 assert_eq!(signals(&distinct), signals(&distinct.clone()));
775 }
776}