1use 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
19pub const DOCUMENT_FORMAT: &str = "adr-lint.candidates/1";
21pub const RULING_FORMAT: &str = "adr-lint.ruling/1";
23const BLOCK_OPEN: &str = "<<<CANDIDATES sha=";
25const BLOCK_CLOSE: &str = ">>>";
26pub const UNAVAILABLE: &str = "<<<CANDIDATES unavailable>>>";
28pub const BLOCK_END: &str = "<<<END CANDIDATES>>>";
30const MIN_PREFIX_HEX: usize = 12;
32const TOOL: &str = "amont-agent";
33const RULINGS_DIR: &str = "rulings";
34const UNKNOWN_REVIEW: &str = "unknown";
36
37#[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#[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
74pub 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
130fn flat(text: &str) -> String {
132 text.split_whitespace().collect::<Vec<_>>().join(" ")
133}
134
135pub 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
152pub 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
186pub 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#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct Listed {
198 pub id: String,
199 pub rule: String,
200 pub pattern_id: String,
201 pub path: String,
204 pub line: usize,
205 pub pattern: Option<String>,
207}
208
209#[derive(Debug, Clone, PartialEq, Eq)]
211pub enum Prompted {
212 Unlisted,
214 NotRecorded,
216 Unavailable,
218 Block(Vec<Listed>),
220}
221
222fn 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
242fn 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
254pub 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
315pub struct Ruling {
316 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
342pub 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#[derive(Debug, Clone, PartialEq, Eq)]
371pub struct Reviewed {
372 pub review: String,
373 pub prompted: Prompted,
374 pub rulings: Vec<Ruling>,
375}
376
377#[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 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 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 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 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 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 _ => 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
554pub 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
575fn 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
613pub 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 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}