Skip to main content

speccoder_core/
ladder.rs

1//! The ladder: where a unit of work came from. One provenance
2//! record per subject, written once. This build knows the variants
3//! a bug writes; later rungs add theirs as variants.
4
5use serde::{Deserialize, Serialize};
6
7use crate::actions::Via;
8
9/// Where a unit or a requirement came from.
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
11#[serde(tag = "kind", rename_all = "snake_case")]
12pub enum Origin {
13    /// Recorded as a bug (`speccoder bug`).
14    Bug {
15        /// The unit.
16        unit: String,
17        /// The requirement.
18        requirement: String,
19        /// The task.
20        task: String,
21        /// The triad's digest.
22        triad_sha256: String,
23        /// Who.
24        by: String,
25        /// Through what.
26        via: Via,
27        /// Unix seconds.
28        at: u64,
29    },
30    /// From a GitHub issue.
31    Issue {
32        /// Its number.
33        number: u64,
34        /// `owner/repo`.
35        repo: Option<String>,
36        /// The digest of its context-manifest entry.
37        context_sha256: Option<String>,
38    },
39}
40
41impl Origin {
42    /// One line for `status` and the trace.
43    #[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/// The rung a unit stands on: a quick change (L1), a bug (L2) or
61/// a feature. A quick-change or bug unit's pipeline authors and confirms
62/// its own acceptance, so the review writes no tests for it.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
64#[serde(rename_all = "snake_case")]
65pub enum Rung {
66    /// A quick change's standing unit (`speccoder do`).
67    Quick,
68    /// A bug's home: a standing bug-fix unit or a bug of the format's own
69    /// bug structure.
70    Bug,
71    /// Any other unit.
72    Feature,
73}
74
75impl Rung {
76    /// Its recorded name.
77    #[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/// The rung of `unit`, from the standing units the format names for the
88/// project (`(unit, rung)`): a standing unit, or a unit under one
89/// (`<standing>/<slug>`), is on its rung; every other unit is a feature.
90#[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/// The subject a bug's origin is recorded for: `<unit>/<requirement>`.
104#[must_use]
105pub fn subject(unit: &str, requirement: &str) -> String {
106    format!("{unit}/{requirement}")
107}
108
109/// A rung the composer can open for a description: a bug fix, a quick
110/// change, a feature, or a question (`ask`); `none` only for an empty text.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
112#[serde(rename_all = "snake_case")]
113pub enum RungPick {
114    /// A wrong result of existing behaviour (`speccoder bug`).
115    Bug,
116    /// One requirement, judged like a task (`speccoder do`).
117    Quick,
118    /// A spec the format's skill writes (`speccoder spec specify`).
119    Feature,
120    /// A question: a read-only answer.
121    Ask,
122    /// Nothing to suggest: the text is empty.
123    None,
124}
125
126impl RungPick {
127    /// Every rung, in the composer's order.
128    pub const ORDER: [Self; 4] = [Self::Bug, Self::Quick, Self::Feature, Self::Ask];
129
130    /// Its name as serialized (`quick`).
131    #[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    /// How the composer names it (`Bug fix`).
143    #[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    /// Its one-line ceremony.
155    #[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    /// Whether this build has the rung (Ask arrives with the Ask panel).
167    #[must_use]
168    pub const fn available(self) -> bool {
169        matches!(self, Self::Bug | Self::Quick | Self::Feature)
170    }
171}
172
173/// The rule that decided a suggestion.
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
175#[serde(rename_all = "snake_case")]
176pub enum RungCueRule {
177    /// An interrogative first word or a trailing `?`.
178    Question,
179    /// A word for a wrong result of existing behaviour.
180    WrongResult,
181    /// Two or more capabilities joined.
182    SeveralCapabilities,
183    /// More than [`LONG_WORDS`] words.
184    Long,
185    /// None of the above.
186    Default,
187}
188
189/// What decided a suggestion: the rule and the words of the text that met
190/// it, verbatim.
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
192pub struct RungCue {
193    /// The rule.
194    pub rule: RungCueRule,
195    /// The words, as written.
196    pub matched: Vec<String>,
197}
198
199/// The composer's suggestion for a text (`rung_suggestion`): computed by
200/// code from the text alone, shown with its reason, never applied without
201/// the person's key or click.
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
203pub struct RungSuggestion {
204    /// The suggested rung.
205    pub rung: RungPick,
206    /// Whether this build has it (`false` for `ask` until the Ask panel).
207    pub available: bool,
208    /// What decided it.
209    pub cue: RungCue,
210    /// Why, in one sentence.
211    pub reason: String,
212    /// The other rungs, in the composer's order.
213    pub alternatives: Vec<RungPick>,
214    /// The command that takes the suggested rung (none for `ask` and `none`).
215    pub command: Option<String>,
216}
217
218/// More words than this suggest a feature.
219pub const LONG_WORDS: usize = 25;
220
221/// Interrogatives that make a text a question when it starts with one.
222/// `when` and `where` start bug reports as often as questions, so only a
223/// trailing `?` makes those a question.
224const 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
229/// Words and phrases for a wrong result of existing behaviour.
230const 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
271/// Words in `text` with their original spelling: runs of letters, digits,
272/// apostrophes and hyphens.
273fn 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
284/// The phrases of `lexicon` the words contain, each as written, in text order.
285fn 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    // A longer phrase covers the shorter one at the same place ("fails when").
301    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
320/// The joiners of two or more capability clauses: `and`, `;`, `,` or list
321/// bullets between clauses of two words or more.
322fn 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    // A clause is a capability when it has two words or more.
363    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        // The joiner sits between clause i and clause i + 1.
373        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/// The text as one argument of a command line (double quotes escaped).
381#[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/// The command that takes `rung` for `text`, with the format's creating
388/// skill for a feature (`specify`, `propose`).
389#[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/// The rung a text suggests, by ordered rules (the first that holds
401/// decides): a question → Ask; a wrong result → Bug fix; two or more
402/// capabilities joined, or more than [`LONG_WORDS`] words → Feature; else
403/// Quick change. A pure function of the text; no model takes part. The
404/// `command` is left for the caller, who knows the format
405/// ([`command_for`]).
406#[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}