Skip to main content

amont_agent_process/
candidates_block.rs

1//! adr-lint's candidates in an implementation-review brief, and the
2//! reviewer's rulings on them (decisions ADR-0044, `harness.tool-boundaries`).
3//!
4//! adr-lint owns the candidate document (`adr-lint.candidates/1`) and the
5//! ruling record (`adr-lint.ruling/1`); this module reads the first and
6//! writes the second. The brief prints a `<<<CANDIDATES …>>>` block for the
7//! reviewer's prompt; the push check reads that block from the prompt and
8//! the `Candidates:` rulings from the reviewer's result. Candidates are
9//! advisory input: nothing here holds a push.
10
11use std::collections::{BTreeMap, BTreeSet};
12use std::io::Write;
13use std::path::Path;
14
15use amont_agent_guard::journal;
16
17use crate::implementation_review::is_sha256;
18
19/// The format token of the document adr-lint prints.
20pub const DOCUMENT_FORMAT: &str = "adr-lint.candidates/1";
21/// The format token of one ruling record.
22pub const RULING_FORMAT: &str = "adr-lint.ruling/1";
23/// Opens the block when adr-lint answered.
24const BLOCK_OPEN: &str = "<<<CANDIDATES sha=";
25const BLOCK_CLOSE: &str = ">>>";
26/// Stands in the block when adr-lint could not answer.
27pub const UNAVAILABLE: &str = "<<<CANDIDATES unavailable>>>";
28/// Closes the block.
29pub const BLOCK_END: &str = "<<<END CANDIDATES>>>";
30/// A ruling may name its candidate by a prefix this long or longer.
31const MIN_PREFIX_HEX: usize = 12;
32const TOOL: &str = "amont-agent";
33const RULINGS_DIR: &str = "rulings";
34/// A review whose agent id is unknown.
35const UNKNOWN_REVIEW: &str = "unknown";
36
37/// One line adr-lint marked for the reviewer.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct Candidate {
40    pub id: String,
41    pub rule: String,
42    pub pattern_id: String,
43    pub pattern: String,
44    pub path: String,
45    pub line: u64,
46    pub column: u64,
47    pub text: String,
48}
49
50/// What adr-lint printed: the candidates, the per-rule overflow, and the
51/// hash of the exact bytes.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct Document {
54    pub sha: String,
55    pub candidates: Vec<Candidate>,
56    pub overflow: BTreeMap<String, u64>,
57}
58
59fn text_field(object: &serde_json::Value, field: &str, what: &str) -> Result<String, String> {
60    object
61        .get(field)
62        .and_then(serde_json::Value::as_str)
63        .map(str::to_string)
64        .ok_or_else(|| format!("{what} has no string `{field}`"))
65}
66
67fn number_field(object: &serde_json::Value, field: &str, what: &str) -> Result<u64, String> {
68    object
69        .get(field)
70        .and_then(serde_json::Value::as_u64)
71        .ok_or_else(|| format!("{what} has no number `{field}`"))
72}
73
74/// Reads the document adr-lint printed. A format token other than
75/// [`DOCUMENT_FORMAT`] is refused; fields this reader does not know are
76/// ignored.
77pub fn parse_document(stdout: &[u8]) -> Result<Document, String> {
78    let document: serde_json::Value = serde_json::from_slice(stdout)
79        .map_err(|e| format!("adr-lint candidates printed output that is not JSON: {e}"))?;
80    match document.get("format").and_then(serde_json::Value::as_str) {
81        Some(DOCUMENT_FORMAT) => {}
82        Some(other) => {
83            return Err(format!(
84                "adr-lint candidates printed format `{other}`, not `{DOCUMENT_FORMAT}`"
85            ))
86        }
87        None => return Err("adr-lint candidates printed no `format` token".to_string()),
88    }
89    let rows = document
90        .get("candidates")
91        .and_then(serde_json::Value::as_array)
92        .ok_or("adr-lint candidates printed no `candidates` array")?;
93    let mut candidates = Vec::with_capacity(rows.len());
94    for (index, row) in rows.iter().enumerate() {
95        let what = format!("candidate {index}");
96        let id = text_field(row, "id", &what)?;
97        if !is_sha256(&id) {
98            return Err(format!("{what} has an `id` that is not 64 hex"));
99        }
100        candidates.push(Candidate {
101            id: id.to_ascii_lowercase(),
102            rule: text_field(row, "rule", &what)?,
103            pattern_id: text_field(row, "pattern_id", &what)?,
104            pattern: text_field(row, "pattern", &what)?,
105            path: text_field(row, "path", &what)?,
106            line: number_field(row, "line", &what)?,
107            column: number_field(row, "col", &what)?,
108            text: text_field(row, "text", &what)?,
109        });
110    }
111    let mut overflow = BTreeMap::new();
112    if let Some(counts) = document
113        .get("overflow")
114        .and_then(serde_json::Value::as_object)
115    {
116        for (rule, count) in counts {
117            let count = count
118                .as_u64()
119                .ok_or_else(|| format!("overflow of `{rule}` is not a number"))?;
120            overflow.insert(rule.clone(), count);
121        }
122    }
123    Ok(Document {
124        sha: crate::review_brief::sha_of(stdout),
125        candidates,
126        overflow,
127    })
128}
129
130/// One line of a prompt, kept on one line.
131fn flat(text: &str) -> String {
132    text.split_whitespace().collect::<Vec<_>>().join(" ")
133}
134
135/// The block a reviewer's prompt carries, whole: pasted verbatim.
136pub fn block(document: &Document) -> String {
137    let mut out = format!(
138        "{BLOCK_OPEN}{} n={}{BLOCK_CLOSE}\n",
139        document.sha,
140        document.candidates.len()
141    );
142    for candidate in &document.candidates {
143        out.push_str(&format!(
144            "{} {} {} {}:{}\n",
145            candidate.id, candidate.rule, candidate.pattern_id, candidate.path, candidate.line
146        ));
147    }
148    out.push_str(BLOCK_END);
149    out
150}
151
152/// The `## Candidates` section of the brief body for an answer.
153pub fn section(document: &Document) -> String {
154    let mut out = String::from("## Candidates\n\n");
155    if document.candidates.is_empty() {
156        out.push_str(
157            "adr-lint found no candidate on the lines this branch adds: no ruling is needed.\n",
158        );
159        return out;
160    }
161    out.push_str(
162        "adr-lint marked these added lines. A candidate is a line to look at, not a finding. \
163         Your result must contain a `Candidates:` block with one line per listed id: \
164         `<id> violation: <finding>` or `<id> fine: <one clause quoted from the rule body>`.\n\n",
165    );
166    for candidate in &document.candidates {
167        out.push_str(&format!(
168            "- {} {}:{} rule {} \u{2014} {}\n  text: {}\n",
169            candidate.id,
170            candidate.path,
171            candidate.line,
172            candidate.rule,
173            flat(&candidate.pattern),
174            flat(&candidate.text)
175        ));
176    }
177    if !document.overflow.is_empty() {
178        out.push_str("\nCandidates beyond the limit, per rule:\n");
179        for (rule, count) in &document.overflow {
180            out.push_str(&format!("- {rule}: {count}\n"));
181        }
182    }
183    out
184}
185
186/// The unavailable marker and the section naming the cause.
187pub fn unavailable_section(cause: &str) -> String {
188    format!(
189        "## Candidates\n\nadr-lint's candidates are unavailable ({}): no ruling is needed. \
190         They are advisory input; the review goes on without them.\n",
191        flat(cause)
192    )
193}
194
195/// One listed candidate as the prompt's block names it.
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct Listed {
198    pub id: String,
199    pub rule: String,
200    pub pattern_id: String,
201    /// Where the candidate is: the repository-relative path (spaces kept) and
202    /// its 1-based line.
203    pub path: String,
204    pub line: usize,
205    /// The pattern message from the prompt's section, when it has one.
206    pub pattern: Option<String>,
207}
208
209/// What a reviewer's prompt said about candidates.
210#[derive(Debug, Clone, PartialEq, Eq)]
211pub enum Prompted {
212    /// No block.
213    Unlisted,
214    /// A pass from before candidates were stored: nothing is known.
215    NotRecorded,
216    /// The unavailable marker.
217    Unavailable,
218    /// A block and the candidates it lists.
219    Block(Vec<Listed>),
220}
221
222/// `<id> <rule> <pattern_id> <path>:<line>`: the location is the rest of the
223/// line after the third word, and its line number follows the last `:`, so a
224/// path with spaces parses.
225fn block_entry(line: &str) -> Option<Listed> {
226    let (id, rest) = line.split_once(' ')?;
227    let (rule, rest) = rest.split_once(' ')?;
228    let (pattern_id, location) = rest.split_once(' ')?;
229    let (path, number) = location.rsplit_once(':')?;
230    let line_number: usize = number.parse().ok()?;
231    let well_formed = !path.is_empty() && !rule.is_empty() && !pattern_id.is_empty();
232    (is_sha256(id) && well_formed).then(|| Listed {
233        id: id.to_ascii_lowercase(),
234        rule: rule.to_string(),
235        pattern_id: pattern_id.to_string(),
236        path: path.to_string(),
237        line: line_number,
238        pattern: None,
239    })
240}
241
242/// `- <id> <path>:<line> rule <rule> — <pattern>` of the brief's section:
243/// the id and the pattern message.
244fn section_pattern(line: &str) -> Option<(String, String)> {
245    let rest = line.strip_prefix("- ")?;
246    let (id, rest) = rest.split_once(' ')?;
247    if !is_sha256(id) {
248        return None;
249    }
250    let (_, pattern) = rest.split_once(" rule ")?.1.split_once(" \u{2014} ")?;
251    Some((id.to_ascii_lowercase(), pattern.trim().to_string()))
252}
253
254/// The candidates a prompt lists. A header that is not well formed is not a
255/// block, and a block with no end line is not one either.
256pub fn parse_prompt(prompt: &str) -> Prompted {
257    if prompt.lines().any(|line| line.trim_end() == UNAVAILABLE) {
258        return Prompted::Unavailable;
259    }
260    let lines: Vec<&str> = prompt.lines().map(str::trim_end).collect();
261    for (at, line) in lines.iter().enumerate() {
262        let Some(header) = line
263            .strip_prefix(BLOCK_OPEN)
264            .and_then(|rest| rest.strip_suffix(BLOCK_CLOSE))
265        else {
266            continue;
267        };
268        let Some((sha, count)) = header.split_once(" n=") else {
269            continue;
270        };
271        let Ok(count) = count.parse::<usize>() else {
272            continue;
273        };
274        if !is_sha256(sha) {
275            continue;
276        }
277        let Some(length) = lines[at + 1..].iter().position(|l| *l == BLOCK_END) else {
278            continue;
279        };
280        let listed: Option<Vec<Listed>> = lines[at + 1..at + 1 + length]
281            .iter()
282            .map(|l| block_entry(l))
283            .collect();
284        let Some(mut listed) = listed.filter(|entries| entries.len() == count) else {
285            continue;
286        };
287        let patterns: BTreeMap<String, String> =
288            lines.iter().filter_map(|l| section_pattern(l)).collect();
289        for entry in &mut listed {
290            entry.pattern = patterns.get(&entry.id).cloned();
291        }
292        return Prompted::Block(listed);
293    }
294    Prompted::Unlisted
295}
296
297/// What a reviewer ruled on one candidate.
298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299pub enum Verdict {
300    Violation,
301    Fine,
302}
303
304impl Verdict {
305    pub fn as_str(self) -> &'static str {
306        match self {
307            Verdict::Violation => "violation",
308            Verdict::Fine => "fine",
309        }
310    }
311}
312
313/// One `<id> violation|fine: <text>` line of a result.
314#[derive(Debug, Clone, PartialEq, Eq)]
315pub struct Ruling {
316    /// The id as written: 64 hex, or a prefix of 12 or more.
317    pub id: String,
318    pub verdict: Verdict,
319    pub clause: String,
320}
321
322fn ruling_line(line: &str) -> Option<Ruling> {
323    let line = line.trim().trim_start_matches(['-', '*', '`', ' ']);
324    let (id, rest) = line.split_once(' ')?;
325    let id = id.trim_end_matches('`');
326    if id.len() < MIN_PREFIX_HEX || id.len() > 64 || !id.bytes().all(|b| b.is_ascii_hexdigit()) {
327        return None;
328    }
329    let (word, clause) = rest.trim_start().split_once(':')?;
330    let verdict = match word.trim().trim_matches('*') {
331        "violation" => Verdict::Violation,
332        "fine" => Verdict::Fine,
333        _ => return None,
334    };
335    Some(Ruling {
336        id: id.to_ascii_lowercase(),
337        verdict,
338        clause: clause.trim().trim_start_matches('*').trim().to_string(),
339    })
340}
341
342/// The rulings of the `Candidates:` block of a result: the consecutive
343/// ruling lines after the header, blank lines between them allowed.
344pub fn parse_rulings(result: &str) -> Vec<Ruling> {
345    let mut lines = result.lines().map(str::trim);
346    let is_header = |line: &str| {
347        line.trim_matches(['*', '#', ' '])
348            .trim_end_matches(':')
349            .trim_matches('*')
350            .eq_ignore_ascii_case("Candidates")
351    };
352    if !lines.any(is_header) {
353        return Vec::new();
354    }
355    let mut rulings = Vec::new();
356    for line in lines {
357        if line.is_empty() {
358            continue;
359        }
360        match ruling_line(line) {
361            Some(ruling) => rulings.push(ruling),
362            None => break,
363        }
364    }
365    rulings
366}
367
368/// Everything the push check keeps of one review's candidates: the agent
369/// that wrote them, what its prompt listed and what its result ruled.
370#[derive(Debug, Clone, PartialEq, Eq)]
371pub struct Reviewed {
372    pub review: String,
373    pub prompted: Prompted,
374    pub rulings: Vec<Ruling>,
375}
376
377/// A listed candidate with the reviewer's ruling on it.
378#[derive(Debug, Clone, PartialEq, Eq)]
379pub struct Disposed {
380    pub listed: Listed,
381    pub verdict: Verdict,
382    pub clause: String,
383}
384
385impl Reviewed {
386    pub fn read(agent_id: Option<&str>, prompt: &str, result: &str) -> Reviewed {
387        Reviewed {
388            review: agent_id.unwrap_or(UNKNOWN_REVIEW).to_string(),
389            prompted: parse_prompt(prompt),
390            rulings: parse_rulings(result),
391        }
392    }
393
394    /// A pass from before candidates were stored, or one a person overruled.
395    pub fn not_recorded() -> Reviewed {
396        Reviewed {
397            review: UNKNOWN_REVIEW.to_string(),
398            prompted: Prompted::NotRecorded,
399            rulings: Vec::new(),
400        }
401    }
402
403    /// The ruling on each listed candidate that has one. A ruling names its
404    /// candidate by the full id or by a prefix that only one listed id has.
405    pub fn disposed(&self) -> Vec<Disposed> {
406        let Prompted::Block(listed) = &self.prompted else {
407            return Vec::new();
408        };
409        listed
410            .iter()
411            .filter_map(|entry| {
412                let ruling = self.rulings.iter().find(|ruling| {
413                    entry.id.starts_with(&ruling.id)
414                        && listed
415                            .iter()
416                            .filter(|other| other.id.starts_with(&ruling.id))
417                            .count()
418                            == 1
419                })?;
420                Some(Disposed {
421                    listed: entry.clone(),
422                    verdict: ruling.verdict,
423                    clause: ruling.clause.clone(),
424                })
425            })
426            .collect()
427    }
428
429    /// The journal excerpt for how the reviewer disposed of the listed ids.
430    pub fn excerpt(&self) -> String {
431        match &self.prompted {
432            Prompted::Unlisted => "review=candidates-unlisted".to_string(),
433            Prompted::NotRecorded => "review=candidates-unknown".to_string(),
434            Prompted::Unavailable => "review=candidates-unavailable".to_string(),
435            Prompted::Block(listed) => {
436                let undisposed = listed.len() - self.disposed().len();
437                if undisposed == 0 {
438                    format!("review=candidates-ruled n={}", listed.len())
439                } else {
440                    format!(
441                        "review=candidates-undisposed n={undisposed}/{}",
442                        listed.len()
443                    )
444                }
445            }
446        }
447    }
448
449    /// The stored form, kept in a pass file.
450    pub fn to_json(&self) -> serde_json::Value {
451        let (state, listed) = match &self.prompted {
452            Prompted::Unlisted => ("unlisted", Vec::new()),
453            Prompted::NotRecorded => ("not-recorded", Vec::new()),
454            Prompted::Unavailable => ("unavailable", Vec::new()),
455            Prompted::Block(listed) => (
456                "block",
457                listed
458                    .iter()
459                    .map(|entry| {
460                        serde_json::json!({
461                            "id": entry.id,
462                            "rule": entry.rule,
463                            "pattern_id": entry.pattern_id,
464                            "path": entry.path,
465                            "line": entry.line,
466                            "pattern": entry.pattern,
467                        })
468                    })
469                    .collect(),
470            ),
471        };
472        serde_json::json!({
473            "review": self.review,
474            "state": state,
475            "listed": listed,
476            "rulings": self.rulings.iter().map(|ruling| serde_json::json!({
477                "id": ruling.id,
478                "ruling": ruling.verdict.as_str(),
479                "clause": ruling.clause,
480            })).collect::<Vec<_>>(),
481        })
482    }
483
484    /// Reads the stored form; a pass from before it existed reads as
485    /// [`Reviewed::not_recorded`].
486    pub fn from_json(stored: Option<&serde_json::Value>) -> Reviewed {
487        let Some(stored) = stored.filter(|value| !value.is_null()) else {
488            return Reviewed::not_recorded();
489        };
490        let field = |value: &serde_json::Value, name: &str| {
491            value
492                .get(name)
493                .and_then(serde_json::Value::as_str)
494                .map(str::to_string)
495        };
496        let prompted = match field(stored, "state").as_deref() {
497            Some("unavailable") => Prompted::Unavailable,
498            Some("not-recorded") => Prompted::NotRecorded,
499            Some("block") => Prompted::Block(
500                stored
501                    .get("listed")
502                    .and_then(serde_json::Value::as_array)
503                    .map(|rows| {
504                        rows.iter()
505                            .filter_map(|row| {
506                                Some(Listed {
507                                    id: field(row, "id")?,
508                                    rule: field(row, "rule")?,
509                                    pattern_id: field(row, "pattern_id")?,
510                                    path: field(row, "path")?,
511                                    line: row
512                                        .get("line")
513                                        .and_then(serde_json::Value::as_u64)
514                                        .and_then(|n| usize::try_from(n).ok())?,
515                                    pattern: field(row, "pattern"),
516                                })
517                            })
518                            .collect()
519                    })
520                    .unwrap_or_default(),
521            ),
522            Some("unlisted") => Prompted::Unlisted,
523            // A state this reader does not know says nothing it can trust.
524            _ => Prompted::NotRecorded,
525        };
526        let rulings = stored
527            .get("rulings")
528            .and_then(serde_json::Value::as_array)
529            .map(|rows| {
530                rows.iter()
531                    .filter_map(|row| {
532                        let verdict = match field(row, "ruling")?.as_str() {
533                            "violation" => Verdict::Violation,
534                            "fine" => Verdict::Fine,
535                            _ => return None,
536                        };
537                        Some(Ruling {
538                            id: field(row, "id")?,
539                            verdict,
540                            clause: field(row, "clause")?,
541                        })
542                    })
543                    .collect()
544            })
545            .unwrap_or_default();
546        Reviewed {
547            review: field(stored, "review").unwrap_or_else(|| UNKNOWN_REVIEW.to_string()),
548            prompted,
549            rulings,
550        }
551    }
552}
553
554/// `YYYY-MM-DDTHH:MM:SSZ` for a count of seconds since the epoch.
555pub fn rfc3339_utc(epoch_seconds: i64) -> String {
556    let (year, month, day) =
557        amont_agent_guard::civil::civil_from_days(epoch_seconds.div_euclid(86_400));
558    let in_day = epoch_seconds.rem_euclid(86_400);
559    format!(
560        "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
561        in_day / 3600,
562        in_day % 3600 / 60,
563        in_day % 60
564    )
565}
566
567fn now_rfc3339() -> String {
568    let seconds = std::time::SystemTime::now()
569        .duration_since(std::time::UNIX_EPOCH)
570        .map(|elapsed| elapsed.as_secs() as i64)
571        .unwrap_or(0);
572    rfc3339_utc(seconds)
573}
574
575/// The (review, candidate) pairs the file already holds, read once per push.
576/// A file that is missing holds nothing; a file that cannot be read, or a
577/// line that cannot be parsed, is an error, so nothing is appended blind.
578fn recorded_pairs(file: &Path) -> std::io::Result<BTreeSet<(String, String)>> {
579    let mut pairs = BTreeSet::new();
580    let text = match std::fs::read_to_string(file) {
581        Ok(text) => text,
582        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(pairs),
583        Err(error) => {
584            return Err(std::io::Error::new(
585                error.kind(),
586                format!("rulings file {} cannot be read: {error}", file.display()),
587            ))
588        }
589    };
590    for line in text.lines().filter(|line| !line.trim().is_empty()) {
591        let record = serde_json::from_str::<serde_json::Value>(line).map_err(|error| {
592            std::io::Error::new(
593                std::io::ErrorKind::InvalidData,
594                format!(
595                    "rulings file {} has a line that cannot be parsed: {error}",
596                    file.display()
597                ),
598            )
599        })?;
600        let text_of = |name: &str| {
601            record
602                .get(name)
603                .and_then(serde_json::Value::as_str)
604                .map(str::to_string)
605        };
606        if let (Some(review), Some(candidate)) = (text_of("review"), text_of("candidate")) {
607            pairs.insert((review, candidate));
608        }
609    }
610    Ok(pairs)
611}
612
613/// Appends one `adr-lint.ruling/1` record per disposed candidate to
614/// `<journal dir>/rulings/<repo>.jsonl`, skipping a (review, candidate) the
615/// file already holds. Returns the number appended. The file is created
616/// 0600 in a 0700 directory and only ever appended to.
617pub fn append_rulings(repo: &str, tree: &str, reviewed: &Reviewed) -> std::io::Result<usize> {
618    let disposed = reviewed.disposed();
619    if disposed.is_empty() {
620        return Ok(0);
621    }
622    if repo.is_empty() || repo.contains(['/', '\\']) || repo.contains("..") {
623        return Err(std::io::Error::new(
624            std::io::ErrorKind::InvalidInput,
625            format!("`{repo}` is not a name for a rulings file"),
626        ));
627    }
628    let dir = journal::dir()
629        .ok_or_else(|| std::io::Error::other("no amont-agent config directory"))?
630        .join(RULINGS_DIR);
631    std::fs::create_dir_all(&dir)?;
632    journal::private(&dir, 0o700);
633    let file = dir.join(format!("{repo}.jsonl"));
634    let mut recorded = recorded_pairs(&file)?;
635    let mut appended = 0;
636    for item in disposed {
637        if !recorded.insert((reviewed.review.clone(), item.listed.id.clone())) {
638            continue;
639        }
640        let record = serde_json::json!({
641            "format": RULING_FORMAT,
642            "review": reviewed.review,
643            "tool": TOOL,
644            "repo": repo,
645            "tree": tree,
646            "candidate": item.listed.id,
647            "rule": item.listed.rule,
648            "pattern_id": item.listed.pattern_id,
649            "pattern": item.listed.pattern.clone().unwrap_or_else(|| item.listed.rule.clone()),
650            "ruling": item.verdict.as_str(),
651            "clause": item.clause,
652            "at": now_rfc3339(),
653        });
654        let mut options = std::fs::OpenOptions::new();
655        options.create(true).append(true);
656        #[cfg(unix)]
657        {
658            use std::os::unix::fs::OpenOptionsExt;
659            options.mode(0o600);
660        }
661        let mut open = options.open(&file)?;
662        journal::private(&file, 0o600);
663        // One write per record, so a concurrent append never tears a line.
664        open.write_all(format!("{record}\n").as_bytes())?;
665        appended += 1;
666    }
667    Ok(appended)
668}
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673
674    const ID_A: &str = "aaaaaaaaaaaa1111111111111111111111111111111111111111111111111111";
675    const ID_B: &str = "aaaaaaaaaaaa2222222222222222222222222222222222222222222222222222";
676    const ID_C: &str = "bbbbbbbbbbbb3333333333333333333333333333333333333333333333333333";
677
678    fn prompt(ids: &[&str]) -> String {
679        let mut out = format!(
680            "{BLOCK_OPEN}{} n={}{BLOCK_CLOSE}\n",
681            "0".repeat(64),
682            ids.len()
683        );
684        for id in ids {
685            out.push_str(&format!("{id} names.reveal-intent p1 src/a.rs:3\n"));
686        }
687        out.push_str(BLOCK_END);
688        out.push('\n');
689        out
690    }
691
692    #[test]
693    fn a_prompt_block_lists_its_candidates_and_a_short_block_is_ignored() {
694        let Prompted::Block(listed) = parse_prompt(&prompt(&[ID_A, ID_C])) else {
695            panic!("a block");
696        };
697        assert_eq!(listed.len(), 2);
698        assert_eq!(listed[1].id, ID_C);
699        assert_eq!(listed[1].pattern_id, "p1");
700        let truncated = prompt(&[ID_A]).replace("n=1", "n=2");
701        assert_eq!(parse_prompt(&truncated), Prompted::Unlisted);
702        assert_eq!(parse_prompt("no block"), Prompted::Unlisted);
703        assert_eq!(
704            parse_prompt(&format!("x\n{UNAVAILABLE}\n")),
705            Prompted::Unavailable
706        );
707    }
708
709    #[test]
710    fn a_ruling_matches_by_full_id_or_a_unique_prefix_only() {
711        let reviewed = Reviewed::read(
712            Some("a1"),
713            &prompt(&[ID_A, ID_B, ID_C]),
714            &format!(
715                "Verdict: approve\n\nCandidates:\n{ID_A} fine: \"No placeholder\"\n- `bbbbbbbbbbbb` violation: bad name\naaaaaaaaaaaa fine: ambiguous\n"
716            ),
717        );
718        let disposed = reviewed.disposed();
719        let ids: Vec<&str> = disposed.iter().map(|d| d.listed.id.as_str()).collect();
720        assert_eq!(ids, vec![ID_A, ID_C]);
721        assert_eq!(disposed[1].verdict, Verdict::Violation);
722        assert_eq!(disposed[1].clause, "bad name");
723        assert_eq!(reviewed.excerpt(), "review=candidates-undisposed n=1/3");
724    }
725
726    #[test]
727    fn every_listed_id_ruled_is_ruled_and_zero_listed_is_ruled_too() {
728        let ruled = Reviewed::read(
729            None,
730            &prompt(&[ID_A]),
731            &format!("Candidates:\n{ID_A} fine: x"),
732        );
733        assert_eq!(ruled.excerpt(), "review=candidates-ruled n=1");
734        let none = Reviewed::read(None, &prompt(&[]), "Verdict: approve");
735        assert_eq!(none.excerpt(), "review=candidates-ruled n=0");
736    }
737
738    #[test]
739    fn the_stored_form_round_trips() {
740        let reviewed = Reviewed::read(
741            Some("a9"),
742            &prompt(&[ID_A]),
743            &format!("Candidates:\n{ID_A} violation: why"),
744        );
745        let back = Reviewed::from_json(Some(&reviewed.to_json()));
746        assert_eq!(back, reviewed);
747        assert_eq!(
748            Reviewed::from_json(None).excerpt(),
749            "review=candidates-unknown"
750        );
751        assert_eq!(
752            Reviewed::from_json(Some(&serde_json::Value::Null)).prompted,
753            Prompted::NotRecorded
754        );
755    }
756
757    #[test]
758    fn a_document_with_another_format_token_is_refused_and_unknown_fields_are_ignored() {
759        let wrong = br#"{"format":"adr-lint.candidates/2","candidates":[]}"#;
760        assert!(parse_document(wrong).unwrap_err().contains("candidates/2"));
761        let row = format!(
762            r#"{{"id":"{ID_A}","rule":"r","pattern_id":"p","pattern":"m","path":"a","line":1,"col":2,"text":"t","extra":1}}"#
763        );
764        let good = format!(
765            r#"{{"format":"{DOCUMENT_FORMAT}","base":"b","head":"h","future":true,"candidates":[{row}],"overflow":{{"r":3}}}}"#
766        );
767        let document = parse_document(good.as_bytes()).unwrap();
768        assert_eq!(document.candidates.len(), 1);
769        assert_eq!(document.overflow["r"], 3);
770        assert!(section(&document).contains("rule r"));
771    }
772
773    #[test]
774    fn a_path_with_spaces_round_trips_through_the_block() {
775        let row = format!(
776            r#"{{"id":"{ID_A}","rule":"r.x","pattern_id":"p","pattern":"m","path":"src/my file.rs","line":7,"col":2,"text":"t"}}"#
777        );
778        let good = format!(r#"{{"format":"{DOCUMENT_FORMAT}","candidates":[{row}]}}"#);
779        let document = parse_document(good.as_bytes()).unwrap();
780        let brief = format!("{}\n\n{}", block(&document), section(&document));
781        let Prompted::Block(listed) = parse_prompt(&brief) else {
782            panic!("a block");
783        };
784        assert_eq!(listed.len(), 1);
785        assert_eq!(listed[0].id, ID_A);
786        assert_eq!(listed[0].rule, "r.x");
787        assert_eq!(listed[0].pattern_id, "p");
788        assert_eq!(listed[0].path, "src/my file.rs");
789        assert_eq!(listed[0].line, 7);
790    }
791
792    #[test]
793    fn the_section_and_the_block_agree_on_the_pattern_message() {
794        let row = format!(
795            r#"{{"id":"{ID_A}","rule":"r.x","pattern_id":"p","pattern":"a message","path":"a.rs","line":1,"col":2,"text":"t"}}"#
796        );
797        let good = format!(r#"{{"format":"{DOCUMENT_FORMAT}","candidates":[{row}]}}"#);
798        let document = parse_document(good.as_bytes()).unwrap();
799        let brief = format!("{}\n\n{}", block(&document), section(&document));
800        let Prompted::Block(listed) = parse_prompt(&brief) else {
801            panic!("a block");
802        };
803        assert_eq!(listed[0].pattern.as_deref(), Some("a message"));
804    }
805
806    #[test]
807    fn a_time_is_rfc3339_utc() {
808        assert_eq!(rfc3339_utc(0), "1970-01-01T00:00:00Z");
809        assert_eq!(rfc3339_utc(1_000_000_000), "2001-09-09T01:46:40Z");
810    }
811}