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    /// A unit started from a quick change or bug that grew (promotion).
40    PromotedFrom {
41        /// The lower unit.
42        from_unit: String,
43        /// Its requirement, carried verbatim.
44        requirement: String,
45        /// The task that showed the signals.
46        task: String,
47        /// The requirement's words' digest when promoted.
48        requirement_sha256: String,
49        /// The Ask conversations carried, `id` and digest.
50        conversations: Vec<(String, String)>,
51        /// The signals that held.
52        signals: Vec<String>,
53        /// Who.
54        by: String,
55        /// Through what.
56        via: Via,
57        /// Unix seconds.
58        at: u64,
59    },
60    /// Started from an Explore session's brief, verbatim.
61    ExploredFrom {
62        /// The session (`ex-…`).
63        session: String,
64        /// The brief's digest (hex SHA-256 of its body).
65        digest: String,
66        /// Who started it.
67        by: String,
68        /// Through what.
69        via: Via,
70        /// Unix seconds.
71        at: u64,
72    },
73    /// Suggested in an Ask conversation and confirmed by a person.
74    Asked {
75        /// The conversation (`c-…`).
76        conversation: String,
77        /// The turn whose proposal it was, when known.
78        #[serde(default, skip_serializing_if = "Option::is_none")]
79        turn: Option<u32>,
80        /// Unix seconds.
81        at: u64,
82    },
83}
84
85/// The origin a proposal confirmed from Ask records: `asked` is the
86/// conversation, or `conversation#turn` for the turn that proposed it.
87#[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    /// One line for `status` and the trace.
102    #[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/// The rung a unit stands on: a quick change (L1), a bug (L2) or
133/// a feature. A quick-change or bug unit's pipeline authors and confirms
134/// its own acceptance, so the review writes no tests for it.
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
136#[serde(rename_all = "snake_case")]
137pub enum Rung {
138    /// A quick change's standing unit (`speccoder do`).
139    Quick,
140    /// A bug's home: a standing bug-fix unit or a bug of the format's own
141    /// bug structure.
142    Bug,
143    /// Any other unit.
144    Feature,
145}
146
147impl Rung {
148    /// Its recorded name.
149    #[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/// The rung of `unit`, from the standing units the format names for the
160/// project (`(unit, rung)`): a standing unit, or a unit under one
161/// (`<standing>/<slug>`), is on its rung; every other unit is a feature.
162#[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/// What promotion signals are computed from: facts speccoder recorded,
176/// never a model's opinion.
177#[derive(Debug, Clone, Default, PartialEq, Eq)]
178pub struct SignalInput {
179    /// Files the task's change touched.
180    pub files_changed: u64,
181    /// Packages (manifest roots) among them.
182    pub packages_changed: u64,
183    /// The causes of its failed attempts, one per attempt.
184    pub red_causes: Vec<String>,
185    /// Ambiguity markers in its requirement's words.
186    pub ambiguity_markers: u64,
187    /// Files among them that decide verdicts (the checks' configuration).
188    pub judge_paths: Vec<String>,
189    /// `[ladder] promote_files`.
190    pub promote_files: u64,
191}
192
193/// A promotion signal that holds, with what was measured.
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
195pub struct Signal {
196    /// `S1` (size), `S2` (repeated distinct failures), `S3` (ambiguous
197    /// words), `S4` (touches what judges).
198    pub id: String,
199    /// What it measured, in a line.
200    pub text: String,
201    /// The measured value.
202    pub value: u64,
203    /// The threshold it passed.
204    pub threshold: u64,
205}
206
207/// The signals that hold for `i`: a quick change or bug that grew past
208/// one requirement's ceremony. Pure and deterministic.
209#[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/// The subject a bug's origin is recorded for: `<unit>/<requirement>`.
275#[must_use]
276pub fn subject(unit: &str, requirement: &str) -> String {
277    format!("{unit}/{requirement}")
278}
279
280/// A rung the composer can open for a description: a bug fix, a quick
281/// change, a feature, or a question (`ask`); `none` only for an empty text.
282#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
283#[serde(rename_all = "snake_case")]
284pub enum RungPick {
285    /// A wrong result of existing behaviour (`speccoder bug`).
286    Bug,
287    /// One requirement, judged like a task (`speccoder do`).
288    Quick,
289    /// A spec the format's skill writes (`speccoder spec specify`).
290    Feature,
291    /// A question: a read-only answer.
292    Ask,
293    /// An idea still weighing its approaches (`speccoder spec explore`).
294    Explore,
295    /// Nothing to suggest: the text is empty.
296    None,
297}
298
299impl RungPick {
300    /// Every rung, in the composer's order.
301    pub const ORDER: [Self; 5] = [
302        Self::Bug,
303        Self::Quick,
304        Self::Feature,
305        Self::Explore,
306        Self::Ask,
307    ];
308
309    /// Its name as serialized (`quick`).
310    #[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    /// How the composer names it (`Bug fix`).
323    #[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    /// Its one-line ceremony.
336    #[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    /// Whether this build has the rung.
349    #[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/// The rule that decided a suggestion.
359#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
360#[serde(rename_all = "snake_case")]
361pub enum RungCueRule {
362    /// Words that weigh approaches ("should we", "which is better").
363    Weighing,
364    /// An interrogative first word or a trailing `?`.
365    Question,
366    /// A word for a wrong result of existing behaviour.
367    WrongResult,
368    /// Two or more capabilities joined.
369    SeveralCapabilities,
370    /// More than [`LONG_WORDS`] words.
371    Long,
372    /// None of the above.
373    Default,
374}
375
376/// What decided a suggestion: the rule and the words of the text that met
377/// it, verbatim.
378#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
379pub struct RungCue {
380    /// The rule.
381    pub rule: RungCueRule,
382    /// The words, as written.
383    pub matched: Vec<String>,
384}
385
386/// The composer's suggestion for a text (`rung_suggestion`): computed by
387/// code from the text alone, shown with its reason, never applied without
388/// the person's key or click.
389#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
390pub struct RungSuggestion {
391    /// The suggested rung.
392    pub rung: RungPick,
393    /// Whether this build has it.
394    pub available: bool,
395    /// What decided it.
396    pub cue: RungCue,
397    /// Why, in one sentence.
398    pub reason: String,
399    /// The other rungs, in the composer's order.
400    pub alternatives: Vec<RungPick>,
401    /// The command that takes the suggested rung (none for `none`).
402    pub command: Option<String>,
403}
404
405/// More words than this suggest a feature.
406pub const LONG_WORDS: usize = 25;
407
408/// Interrogatives that make a text a question when it starts with one.
409/// `when` and `where` start bug reports as often as questions, so only a
410/// trailing `?` makes those a question.
411const 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
416/// Words and phrases for a wrong result of existing behaviour.
417/// Words that weigh approaches rather than name one: an idea to explore.
418const 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
473/// Words in `text` with their original spelling: runs of letters, digits,
474/// apostrophes and hyphens.
475fn 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
486/// The phrases of `lexicon` the words contain, each as written, in text order.
487fn 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    // A longer phrase covers the shorter one at the same place ("fails when").
503    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
522/// The joiners of two or more capability clauses: `and`, `;`, `,` or list
523/// bullets between clauses of two words or more.
524fn 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    // A clause is a capability when it has two words or more.
565    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        // The joiner sits between clause i and clause i + 1.
575        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/// The text as one argument of a command line: in double quotes, with
583/// what a shell would still expand there (`\`, `"`, `$`, `` ` ``)
584/// escaped — model-written words pasted into a shell stay words.
585#[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/// The command that takes `rung` for `text`, with the format's creating
601/// skill for a feature (`specify`, `propose`).
602#[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/// The rung a text suggests, by ordered rules (the first that holds
616/// decides): a question → Ask; a wrong result → Bug fix; two or more
617/// capabilities joined, or more than [`LONG_WORDS`] words → Feature; else
618/// Quick change. A pure function of the text; no model takes part. The
619/// `command` is left for the caller, who knows the format
620/// ([`command_for`]).
621#[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        // Monotone in files: past the threshold it keeps holding.
757        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}