1use std::collections::{BTreeMap, HashMap, HashSet};
15use std::path::{Path, PathBuf};
16
17use anyhow::{Context, Result, anyhow, bail};
18use proc_macro2::{Delimiter, LineColumn, Spacing, TokenStream, TokenTree};
19use quote::ToTokens;
20use serde::{Deserialize, Serialize};
21use syn::visit::Visit;
22
23use crate::files::collect_rust_files_with_exclusions;
24use crate::operations::{BackupNode, NodeLocation};
25use crate::state::{
26 FileModification, RunMetadata, RunStatus, generate_run_id, hash_file, load_run_metadata,
27 save_backup_nodes, save_run_metadata,
28};
29
30pub const FILE_BACKUP_NODE_TYPE: &str = "file";
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "kebab-case")]
40pub enum CommentKind {
41 Line,
43 Block,
45 Doc,
47 InnerDoc,
49}
50
51#[derive(Debug, Clone, Copy)]
54struct RawComment {
55 start: usize,
56 end: usize,
57 kind: CommentKind,
58 is_block: bool,
59}
60
61const fn is_ident_char(b: u8) -> bool {
62 b.is_ascii_alphanumeric() || b == b'_' || b >= 0x80
63}
64
65fn classify_line(text: &[u8]) -> CommentKind {
66 if text.starts_with(b"//!") {
67 CommentKind::InnerDoc
68 } else if text.starts_with(b"///") && !text.starts_with(b"////") {
69 CommentKind::Doc
70 } else {
71 CommentKind::Line
72 }
73}
74
75fn classify_block(text: &[u8]) -> CommentKind {
76 if text.starts_with(b"/*!") {
77 CommentKind::InnerDoc
78 } else if text.starts_with(b"/**") && !text.starts_with(b"/***") && text != b"/**/" {
79 CommentKind::Doc
80 } else {
81 CommentKind::Block
82 }
83}
84
85const fn skip_string(b: &[u8], mut i: usize) -> usize {
88 while i < b.len() {
89 match b[i] {
90 b'\\' => i += 2,
91 b'"' => return i + 1,
92 _ => i += 1,
93 }
94 }
95 b.len()
96}
97
98fn skip_raw_string(b: &[u8], mut i: usize) -> Option<usize> {
101 let mut hashes = 0;
102 while i < b.len() && b[i] == b'#' {
103 hashes += 1;
104 i += 1;
105 }
106 if i >= b.len() || b[i] != b'"' {
107 return None;
108 }
109 i += 1;
110 while i < b.len() {
111 if b[i] == b'"'
112 && b[i + 1..]
113 .iter()
114 .take(hashes)
115 .filter(|c| **c == b'#')
116 .count()
117 == hashes
118 {
119 return Some(i + 1 + hashes);
120 }
121 i += 1;
122 }
123 Some(b.len())
124}
125
126fn lex_comments(src: &str) -> Vec<RawComment> {
128 let b = src.as_bytes();
129 let mut out = Vec::new();
130 let mut i = if b.starts_with(b"#!") && !src[2..].trim_start().starts_with('[') {
132 src.find('\n').unwrap_or(b.len())
133 } else {
134 0
135 };
136 while i < b.len() {
137 let c = b[i];
138 match c {
139 b'/' if b.get(i + 1) == Some(&b'/') => {
140 let end = src[i..].find('\n').map_or(b.len(), |n| i + n);
141 let end = if end > i && b[end - 1] == b'\r' {
143 end - 1
144 } else {
145 end
146 };
147 out.push(RawComment {
148 start: i,
149 end,
150 kind: classify_line(&b[i..end]),
151 is_block: false,
152 });
153 i = end;
154 }
155 b'/' if b.get(i + 1) == Some(&b'*') => {
156 let mut depth = 0usize;
157 let mut j = i;
158 while j < b.len() {
159 if b[j] == b'/' && b.get(j + 1) == Some(&b'*') {
160 depth += 1;
161 j += 2;
162 } else if b[j] == b'*' && b.get(j + 1) == Some(&b'/') {
163 depth -= 1;
164 j += 2;
165 if depth == 0 {
166 break;
167 }
168 } else {
169 j += 1;
170 }
171 }
172 let end = j.min(b.len());
173 out.push(RawComment {
174 start: i,
175 end,
176 kind: classify_block(&b[i..end]),
177 is_block: true,
178 });
179 i = end;
180 }
181 b'"' => i = skip_string(b, i + 1),
182 b'\'' => {
183 if b.get(i + 1) == Some(&b'\\') {
185 let mut j = i + 1;
186 while j < b.len() && b[j] != b'\'' && b[j] != b'\n' {
187 j += if b[j] == b'\\' { 2 } else { 1 };
188 }
189 i = j + 1;
190 } else if let Some(ch) = src[i + 1..].chars().next() {
191 let after = i + 1 + ch.len_utf8();
192 if b.get(after) == Some(&b'\'') {
193 i = after + 1;
194 } else {
195 i += 1;
196 }
197 } else {
198 i += 1;
199 }
200 }
201 _ if is_ident_char(c) => {
202 let prev_is_ident = i > 0 && is_ident_char(b[i - 1]);
203 if !prev_is_ident {
204 let (prefix_len, raw) = match (c, b.get(i + 1).copied()) {
206 (b'r', Some(b'"' | b'#')) => (1, true),
207 (b'b' | b'c', Some(b'r')) if matches!(b.get(i + 2), Some(b'"' | b'#')) => {
208 (2, true)
209 }
210 (b'b' | b'c', Some(b'"')) => (1, false),
211 (b'b', Some(b'\'')) => {
212 let mut j = i + 2;
214 while j < b.len() && b[j] != b'\'' && b[j] != b'\n' {
215 j += if b[j] == b'\\' { 2 } else { 1 };
216 }
217 i = j + 1;
218 continue;
219 }
220 _ => (0, false),
221 };
222 if prefix_len > 0 {
223 if raw {
224 if let Some(end) = skip_raw_string(b, i + prefix_len) {
225 i = end;
226 continue;
227 }
228 } else {
229 i = skip_string(b, i + prefix_len + 1);
230 continue;
231 }
232 }
233 }
234 i += 1;
235 while i < b.len() && is_ident_char(b[i]) {
236 i += 1;
237 }
238 }
239 _ => i += 1,
240 }
241 }
242 out
243}
244
245struct LineIndex {
250 starts: Vec<usize>,
251}
252
253impl LineIndex {
254 fn new(src: &str) -> Self {
255 let mut starts = vec![0];
256 for (i, b) in src.bytes().enumerate() {
257 if b == b'\n' {
258 starts.push(i + 1);
259 }
260 }
261 Self { starts }
262 }
263
264 fn line_of(&self, offset: usize) -> usize {
266 match self.starts.binary_search(&offset) {
267 Ok(i) => i + 1,
268 Err(i) => i,
269 }
270 }
271
272 fn offset_of(&self, src: &str, lc: LineColumn) -> Option<usize> {
274 let start = *self.starts.get(lc.line.checked_sub(1)?)?;
275 let rest = &src[start..];
276 let delta = rest
277 .char_indices()
278 .nth(lc.column)
279 .map_or(rest.len(), |(i, _)| i);
280 Some(start + delta)
281 }
282}
283
284fn short_hash(text: &str, ordinal: usize) -> String {
288 let mut hasher = blake3::Hasher::new();
289 hasher.update(&(ordinal as u64).to_le_bytes());
290 hasher.update(text.as_bytes());
291 hasher.finalize().to_hex().as_str()[..16].to_string()
292}
293
294fn is_ws(s: &str) -> bool {
295 s.chars().all(char::is_whitespace)
296}
297
298pub const ANNOTATION_MARKERS: [&str; 2] = ["@yah:", "@arch:"];
305
306#[derive(Default, Clone, Copy)]
309struct AnnoState {
310 depth: usize,
311 in_str: bool,
312}
313
314impl AnnoState {
315 const fn open(self) -> bool {
316 self.depth > 0
317 }
318
319 fn scan(mut self, text: &str) -> Self {
321 let mut chars = text.chars();
322 while let Some(ch) = chars.next() {
323 if self.in_str {
324 match ch {
325 '\\' => {
326 chars.next();
327 }
328 '"' => self.in_str = false,
329 _ => {}
330 }
331 continue;
332 }
333 match ch {
334 '"' if self.depth > 0 => self.in_str = true,
335 '(' => self.depth += 1,
336 ')' if self.depth > 0 => {
337 self.depth -= 1;
338 if self.depth == 0 {
339 return Self::default().scan_from_marker(chars.as_str());
341 }
342 }
343 _ => {}
344 }
345 }
346 self
347 }
348
349 fn scan_from_marker(self, text: &str) -> Self {
351 let Some(pos) = ANNOTATION_MARKERS.iter().filter_map(|m| text.find(m)).min() else {
352 return self;
353 };
354 let after = &text[pos..];
355 let name_end = after.find(':').map_or(after.len(), |i| i + 1);
356 let rest = &after[name_end..];
357 let ident_len = rest
358 .find(|c: char| !(c.is_alphanumeric() || c == '_'))
359 .unwrap_or(rest.len());
360 let tail = &rest[ident_len..];
361 if tail.starts_with('(') {
362 Self::default().scan(tail)
363 } else {
364 Self::default().scan_from_marker(tail)
365 }
366 }
367}
368
369fn has_marker(text: &str) -> bool {
370 ANNOTATION_MARKERS.iter().any(|m| text.contains(m))
371}
372
373fn annotation_flags(src: &str, raws: &[RawComment], lines: &LineIndex) -> Vec<bool> {
375 let mut flags = vec![false; raws.len()];
376 let mut state = AnnoState::default();
377 let mut prev_line: Option<(usize, bool)> = None; for (idx, rc) in raws.iter().enumerate() {
379 let text = &src[rc.start..rc.end];
380 let line = lines.line_of(rc.start);
381 let continues =
382 state.open() && !rc.is_block && matches!(prev_line, Some((l, true)) if l + 1 == line);
383 if !continues {
384 state = AnnoState::default();
385 }
386 if rc.is_block {
387 flags[idx] = has_marker(text);
388 state = AnnoState::default();
389 } else if continues {
390 flags[idx] = true;
391 state = state.scan(text);
392 } else if has_marker(text) {
393 flags[idx] = true;
394 state = AnnoState::default().scan_from_marker(text);
395 }
396 prev_line = Some((lines.line_of(rc.end), !rc.is_block));
397 }
398 flags
399}
400
401#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
406pub struct AttachedItem {
407 pub node_type: String,
408 pub name: String,
409 pub visibility: String,
410}
411
412#[derive(Default)]
413struct ItemMap {
414 heads: HashMap<usize, AttachedItem>,
416 bodies: Vec<(usize, usize)>,
418}
419
420fn vis_str(vis: &syn::Visibility) -> String {
421 match vis {
422 syn::Visibility::Inherited => "private".to_string(),
423 other => other
424 .to_token_stream()
425 .to_string()
426 .replace(" (", "(")
427 .replace("( ", "(")
428 .replace(" )", ")"),
429 }
430}
431
432fn doc_attr_len(tts: &[TokenTree], i: usize) -> Option<usize> {
435 let TokenTree::Punct(p) = &tts[i] else {
436 return None;
437 };
438 if p.as_char() != '#' {
439 return None;
440 }
441 let mut j = i + 1;
442 if let Some(TokenTree::Punct(bang)) = tts.get(j)
443 && bang.as_char() == '!'
444 {
445 j += 1;
446 }
447 let TokenTree::Group(g) = tts.get(j)? else {
448 return None;
449 };
450 if g.delimiter() != Delimiter::Bracket {
451 return None;
452 }
453 let mut inner = g.stream().into_iter();
454 let is_doc = matches!(inner.next(), Some(TokenTree::Ident(id)) if id == "doc")
455 && matches!(inner.next(), Some(TokenTree::Punct(eq)) if eq.as_char() == '=');
456 is_doc.then_some(j + 1 - i)
457}
458
459struct ItemCollector<'a> {
460 src: &'a str,
461 lines: &'a LineIndex,
462 map: ItemMap,
463}
464
465impl ItemCollector<'_> {
466 fn head_offset<T: ToTokens>(&self, node: &T) -> Option<usize> {
467 let tts: Vec<TokenTree> = node.to_token_stream().into_iter().collect();
468 let mut i = 0;
469 while i < tts.len() {
470 if let Some(n) = doc_attr_len(&tts, i) {
471 i += n;
472 continue;
473 }
474 return self.lines.offset_of(self.src, tts[i].span().start());
475 }
476 None
477 }
478
479 fn record<T: ToTokens>(&mut self, node: &T, node_type: &str, name: String, vis: String) {
480 if let Some(off) = self.head_offset(node) {
481 self.map.heads.entry(off).or_insert_with(|| AttachedItem {
482 node_type: node_type.to_string(),
483 name,
484 visibility: vis,
485 });
486 }
487 }
488
489 fn record_body(&mut self, block: &syn::Block) {
490 let open = self
491 .lines
492 .offset_of(self.src, block.brace_token.span.open().start());
493 let close = self
494 .lines
495 .offset_of(self.src, block.brace_token.span.close().start());
496 if let (Some(o), Some(c)) = (open, close) {
497 self.map.bodies.push((o, c));
498 }
499 }
500}
501
502impl<'ast> Visit<'ast> for ItemCollector<'_> {
503 fn visit_item(&mut self, item: &'ast syn::Item) {
504 use syn::Item;
505 let info: Option<(&str, String, String)> = match item {
506 Item::Fn(i) => Some(("function", i.sig.ident.to_string(), vis_str(&i.vis))),
507 Item::Struct(i) => Some(("struct", i.ident.to_string(), vis_str(&i.vis))),
508 Item::Enum(i) => Some(("enum", i.ident.to_string(), vis_str(&i.vis))),
509 Item::Union(i) => Some(("union", i.ident.to_string(), vis_str(&i.vis))),
510 Item::Trait(i) => Some(("trait", i.ident.to_string(), vis_str(&i.vis))),
511 Item::TraitAlias(i) => Some(("trait-alias", i.ident.to_string(), vis_str(&i.vis))),
512 Item::Mod(i) => Some(("mod", i.ident.to_string(), vis_str(&i.vis))),
513 Item::Const(i) => Some(("const", i.ident.to_string(), vis_str(&i.vis))),
514 Item::Static(i) => Some(("static", i.ident.to_string(), vis_str(&i.vis))),
515 Item::Type(i) => Some(("type-alias", i.ident.to_string(), vis_str(&i.vis))),
516 Item::ExternCrate(i) => Some(("extern-crate", i.ident.to_string(), vis_str(&i.vis))),
517 Item::Use(i) => Some((
518 "use",
519 i.tree.to_token_stream().to_string().replace(' ', ""),
520 vis_str(&i.vis),
521 )),
522 Item::Impl(i) => Some(("impl", impl_name(i), "private".to_string())),
523 Item::Macro(i) => Some((
524 "macro",
525 i.ident
526 .as_ref()
527 .map_or_else(|| path_str(&i.mac.path), ToString::to_string),
528 "private".to_string(),
529 )),
530 Item::ForeignMod(_) => Some(("extern-block", String::new(), "private".to_string())),
531 _ => None,
532 };
533 if let Some((kind, name, vis)) = info {
534 self.record(item, kind, name, vis);
535 }
536 if let Item::Fn(f) = item {
537 self.record_body(&f.block);
538 }
539 syn::visit::visit_item(self, item);
540 }
541
542 fn visit_item_impl(&mut self, imp: &'ast syn::ItemImpl) {
543 let owner = impl_name(imp);
544 for it in &imp.items {
545 match it {
546 syn::ImplItem::Fn(f) => {
547 self.record(
548 f,
549 "impl-method",
550 format!("{owner}::{}", f.sig.ident),
551 vis_str(&f.vis),
552 );
553 self.record_body(&f.block);
554 }
555 syn::ImplItem::Const(c) => self.record(
556 c,
557 "impl-const",
558 format!("{owner}::{}", c.ident),
559 vis_str(&c.vis),
560 ),
561 syn::ImplItem::Type(t) => self.record(
562 t,
563 "impl-type",
564 format!("{owner}::{}", t.ident),
565 vis_str(&t.vis),
566 ),
567 _ => {}
568 }
569 }
570 syn::visit::visit_item_impl(self, imp);
571 }
572
573 fn visit_item_trait(&mut self, tr: &'ast syn::ItemTrait) {
574 for it in &tr.items {
575 let name = |id: &syn::Ident| format!("{}::{id}", tr.ident);
576 match it {
577 syn::TraitItem::Fn(f) => {
578 self.record(f, "trait-method", name(&f.sig.ident), "private".into());
579 if let Some(b) = &f.default {
580 self.record_body(b);
581 }
582 }
583 syn::TraitItem::Const(c) => {
584 self.record(c, "trait-const", name(&c.ident), "private".into());
585 }
586 syn::TraitItem::Type(t) => {
587 self.record(t, "trait-type", name(&t.ident), "private".into());
588 }
589 _ => {}
590 }
591 }
592 syn::visit::visit_item_trait(self, tr);
593 }
594
595 fn visit_item_struct(&mut self, s: &'ast syn::ItemStruct) {
596 for (idx, f) in s.fields.iter().enumerate() {
597 let fname = f
598 .ident
599 .as_ref()
600 .map_or_else(|| idx.to_string(), ToString::to_string);
601 self.record(f, "field", format!("{}::{fname}", s.ident), vis_str(&f.vis));
602 }
603 syn::visit::visit_item_struct(self, s);
604 }
605
606 fn visit_item_enum(&mut self, e: &'ast syn::ItemEnum) {
607 for v in &e.variants {
608 self.record(
609 v,
610 "variant",
611 format!("{}::{}", e.ident, v.ident),
612 "private".into(),
613 );
614 for (idx, f) in v.fields.iter().enumerate() {
615 let fname = f
616 .ident
617 .as_ref()
618 .map_or_else(|| idx.to_string(), ToString::to_string);
619 self.record(
620 f,
621 "field",
622 format!("{}::{}::{fname}", e.ident, v.ident),
623 "private".into(),
624 );
625 }
626 }
627 syn::visit::visit_item_enum(self, e);
628 }
629}
630
631fn path_str(p: &syn::Path) -> String {
632 p.to_token_stream().to_string().replace(' ', "")
633}
634
635fn impl_name(imp: &syn::ItemImpl) -> String {
636 let ty = imp.self_ty.to_token_stream().to_string().replace(' ', "");
637 match &imp.trait_ {
638 Some((bang, path, _)) => format!(
639 "{}{} for {ty}",
640 if bang.is_some() { "!" } else { "" },
641 path_str(path)
642 ),
643 None => ty,
644 }
645}
646
647#[derive(Debug, Clone, Serialize, Deserialize)]
652pub struct CommentSpan {
653 pub file: PathBuf,
654 pub span: [usize; 2],
656 pub lines: [usize; 2],
658 pub kind: CommentKind,
659 pub text: String,
661 pub hash: String,
665 pub attached_item: Option<AttachedItem>,
666 pub in_body: bool,
667 pub annotation: bool,
669}
670
671#[derive(Debug, Clone, Serialize, Deserialize, Default)]
672pub struct ListReport {
673 pub files_scanned: usize,
674 pub span_count: usize,
675 pub annotation_span_count: usize,
676 pub annotation_line_count: usize,
679 pub parse_errors: Vec<(PathBuf, String)>,
681 pub spans: Vec<CommentSpan>,
682}
683
684pub struct ListArgs {
685 pub paths: Vec<PathBuf>,
686 pub exclude: Vec<String>,
687 pub exclude_annotations: bool,
689 pub lines: Option<[usize; 2]>,
691}
692
693pub fn parse_line_range(s: &str) -> Result<[usize; 2]> {
695 let (a, b) = s
696 .split_once('-')
697 .ok_or_else(|| anyhow!("--lines expects A-B (e.g. 1-200), got {s:?}"))?;
698 let a: usize = a
699 .trim()
700 .parse()
701 .with_context(|| format!("--lines: {a:?} is not a line number"))?;
702 let b: usize = b
703 .trim()
704 .parse()
705 .with_context(|| format!("--lines: {b:?} is not a line number"))?;
706 if a == 0 || b < a {
707 bail!("--lines {s:?}: lines are 1-indexed and the range must not be inverted");
708 }
709 Ok([a, b])
710}
711
712fn file_spans(path: &Path, src: &str) -> Result<(Vec<CommentSpan>, Option<String>)> {
720 let lines = LineIndex::new(src);
721 let raws = lex_comments(src);
722 let flags = annotation_flags(src, &raws, &lines);
723
724 let (map, parse_err) = match syn::parse_file(src) {
725 Ok(file) => {
726 let mut c = ItemCollector {
727 src,
728 lines: &lines,
729 map: ItemMap::default(),
730 };
731 c.visit_file(&file);
732 (c.map, None)
733 }
734 Err(e) => (ItemMap::default(), Some(e.to_string())),
735 };
736
737 let own_line = |rc: &RawComment| {
738 let ls = lines.starts[lines.line_of(rc.start) - 1];
739 is_ws(&src[ls..rc.start])
740 };
741
742 let mut groups: Vec<(usize, usize)> = Vec::new(); for (idx, rc) in raws.iter().enumerate() {
744 if let Some((gs, ge)) = groups.last_mut() {
745 let prev = &raws[*ge];
746 let joinable = !rc.is_block
747 && !prev.is_block
748 && prev.kind == rc.kind
749 && flags[*ge] == flags[idx]
750 && own_line(rc)
751 && own_line(&raws[*gs])
752 && lines.line_of(rc.start) == lines.line_of(prev.end) + 1
753 && is_ws(&src[prev.end..rc.start]);
754 if joinable {
755 *ge = idx;
756 continue;
757 }
758 }
759 groups.push((idx, idx));
760 }
761
762 let mut occurrences: HashMap<String, usize> = HashMap::new();
763 let spans: Vec<CommentSpan> = groups
764 .into_iter()
765 .map(|(gs, ge)| {
766 let start = raws[gs].start;
767 let end = raws[ge].end;
768 let kind = raws[gs].kind;
769 let text = src[start..end].to_string();
770 let attached_item = if kind == CommentKind::InnerDoc {
771 None
772 } else {
773 next_code_offset(src, &raws, ge).and_then(|p| map.heads.get(&p).cloned())
774 };
775 let ordinal = occurrences.entry(text.clone()).or_insert(0);
776 let hash = short_hash(&text, *ordinal);
777 *ordinal += 1;
778 CommentSpan {
779 file: path.to_path_buf(),
780 span: [start, end],
781 lines: [lines.line_of(start), lines.line_of(end.max(start + 1) - 1)],
782 kind,
783 hash,
784 text,
785 attached_item,
786 in_body: map.bodies.iter().any(|(o, c)| *o < start && start < *c),
787 annotation: flags[gs..=ge].iter().any(|f| *f),
788 }
789 })
790 .collect();
791
792 let mut seen_hashes: HashSet<&str> = HashSet::with_capacity(spans.len());
793 for s in &spans {
794 if !seen_hashes.insert(s.hash.as_str()) {
795 bail!(
796 "duplicate hash {} in {}: two spans hashed identically, which should be \
797 impossible under the occurrence-ordinal scheme -- refusing to list this file",
798 s.hash,
799 path.display()
800 );
801 }
802 }
803
804 Ok((spans, parse_err))
805}
806
807fn next_code_offset(src: &str, raws: &[RawComment], idx: usize) -> Option<usize> {
809 let b = src.as_bytes();
810 let mut pos = raws[idx].end;
811 let mut next = idx + 1;
812 loop {
813 while pos < b.len() && b[pos].is_ascii_whitespace() {
814 pos += 1;
815 }
816 if next < raws.len() && raws[next].start == pos {
817 pos = raws[next].end;
818 next += 1;
819 continue;
820 }
821 return (pos < b.len()).then_some(pos);
822 }
823}
824
825pub fn list(args: &ListArgs) -> Result<ListReport> {
826 let files = collect_rust_files_with_exclusions(&args.paths, &args.exclude)?;
827 let mut report = ListReport {
828 files_scanned: files.len(),
829 ..Default::default()
830 };
831 for path in files {
832 let src = std::fs::read_to_string(&path)
833 .with_context(|| format!("Failed to read {}", path.display()))?;
834 let (spans, err) = file_spans(&path, &src)?;
835 if let Some(e) = err {
836 report.parse_errors.push((path.clone(), e));
837 }
838 for s in spans {
839 if let Some([a, b]) = args.lines
840 && (s.lines[0] < a || s.lines[1] > b)
841 {
842 continue;
843 }
844 report.span_count += 1;
845 if s.annotation {
846 report.annotation_span_count += 1;
847 report.annotation_line_count += s.lines[1] - s.lines[0] + 1;
848 if args.exclude_annotations {
849 continue;
850 }
851 }
852 report.spans.push(s);
853 }
854 }
855 Ok(report)
856}
857
858pub fn to_batch(report: &ListReport) -> Vec<CommentOp> {
865 report
866 .spans
867 .iter()
868 .filter(|s| !s.annotation)
869 .map(|s| CommentOp {
870 file: s.file.clone(),
871 hash: s.hash.clone(),
872 op: OpKind::Keep,
873 text: Some(s.text.clone()),
874 lines: Some(s.lines),
875 })
876 .collect()
877}
878
879pub fn render_list(report: &ListReport) {
880 for s in &report.spans {
881 let attached = s
882 .attached_item
883 .as_ref()
884 .map(|a| format!(" -> {} {}", a.node_type, a.name))
885 .unwrap_or_default();
886 println!(
887 "{}:{}-{} [{}..{}] {:?}{}{}{} {}",
888 s.file.display(),
889 s.lines[0],
890 s.lines[1],
891 s.span[0],
892 s.span[1],
893 s.kind,
894 if s.in_body { " in-body" } else { "" },
895 if s.annotation { " ANNOTATION" } else { "" },
896 attached,
897 s.hash
898 );
899 }
900 println!(
901 "{} span(s) in {} file(s); {} annotation span(s) covering {} line(s)",
902 report.span_count,
903 report.files_scanned,
904 report.annotation_span_count,
905 report.annotation_line_count
906 );
907}
908
909#[derive(Debug, Clone)]
916struct CodeUnit {
917 label: String,
918 line: usize,
919 tokens: String,
920 children: Vec<Self>,
921}
922
923fn push_tokens(ts: TokenStream, out: &mut String) {
924 let tts: Vec<TokenTree> = ts.into_iter().collect();
925 let mut i = 0;
926 while i < tts.len() {
927 if let Some(n) = doc_attr_len(&tts, i) {
928 i += n;
929 continue;
930 }
931 match &tts[i] {
932 TokenTree::Group(g) => {
933 let (open, close) = match g.delimiter() {
934 Delimiter::Parenthesis => ("(", ")"),
935 Delimiter::Brace => ("{", "}"),
936 Delimiter::Bracket => ("[", "]"),
937 Delimiter::None => ("<<", ">>"),
938 };
939 out.push_str(open);
940 out.push(' ');
941 push_tokens(g.stream(), out);
942 out.push_str(close);
943 out.push(' ');
944 }
945 TokenTree::Punct(p) => {
946 out.push(p.as_char());
947 if p.spacing() == Spacing::Alone {
948 out.push(' ');
949 }
950 }
951 TokenTree::Ident(id) => {
952 out.push_str(&id.to_string());
953 out.push(' ');
954 }
955 TokenTree::Literal(l) => {
956 out.push_str(&l.to_string());
957 out.push(' ');
958 }
959 }
960 i += 1;
961 }
962}
963
964fn norm<T: ToTokens>(node: &T) -> String {
965 let mut s = String::new();
966 push_tokens(node.to_token_stream(), &mut s);
967 s
968}
969
970fn item_label(item: &syn::Item) -> String {
971 use syn::Item;
972 match item {
973 Item::Fn(i) => format!("fn {}", i.sig.ident),
974 Item::Struct(i) => format!("struct {}", i.ident),
975 Item::Enum(i) => format!("enum {}", i.ident),
976 Item::Union(i) => format!("union {}", i.ident),
977 Item::Trait(i) => format!("trait {}", i.ident),
978 Item::TraitAlias(i) => format!("trait {}", i.ident),
979 Item::Mod(i) => format!("mod {}", i.ident),
980 Item::Const(i) => format!("const {}", i.ident),
981 Item::Static(i) => format!("static {}", i.ident),
982 Item::Type(i) => format!("type {}", i.ident),
983 Item::ExternCrate(i) => format!("extern crate {}", i.ident),
984 Item::Use(i) => format!(
985 "use {}",
986 i.tree.to_token_stream().to_string().replace(' ', "")
987 ),
988 Item::Impl(i) => format!("impl {}", impl_name(i)),
989 Item::Macro(i) => i.ident.as_ref().map_or_else(
990 || format!("{}!", path_str(&i.mac.path)),
991 |id| format!("macro_rules! {id}"),
992 ),
993 Item::ForeignMod(_) => "extern block".to_string(),
994 _ => "item".to_string(),
995 }
996}
997
998fn unit_line<T: syn::spanned::Spanned>(node: &T) -> usize {
999 node.span().start().line
1000}
1001
1002fn item_units(items: &[syn::Item], prefix: &str) -> Vec<CodeUnit> {
1003 items
1004 .iter()
1005 .map(|item| {
1006 let label = format!("{prefix}{}", item_label(item));
1007 let children = match item {
1008 syn::Item::Impl(i) => i
1009 .items
1010 .iter()
1011 .map(|it| CodeUnit {
1012 label: format!("{label}::{}", impl_item_label(it)),
1013 line: unit_line(it),
1014 tokens: norm(it),
1015 children: Vec::new(),
1016 })
1017 .collect(),
1018 syn::Item::Trait(t) => t
1019 .items
1020 .iter()
1021 .map(|it| CodeUnit {
1022 label: format!("{label}::{}", trait_item_label(it)),
1023 line: unit_line(it),
1024 tokens: norm(it),
1025 children: Vec::new(),
1026 })
1027 .collect(),
1028 syn::Item::Mod(m) => m
1029 .content
1030 .as_ref()
1031 .map(|(_, items)| item_units(items, &format!("{label}::")))
1032 .unwrap_or_default(),
1033 _ => Vec::new(),
1034 };
1035 CodeUnit {
1036 label,
1037 line: unit_line(item),
1038 tokens: norm(item),
1039 children,
1040 }
1041 })
1042 .collect()
1043}
1044
1045fn impl_item_label(it: &syn::ImplItem) -> String {
1046 match it {
1047 syn::ImplItem::Fn(f) => format!("fn {}", f.sig.ident),
1048 syn::ImplItem::Const(c) => format!("const {}", c.ident),
1049 syn::ImplItem::Type(t) => format!("type {}", t.ident),
1050 syn::ImplItem::Macro(m) => format!("{}!", path_str(&m.mac.path)),
1051 _ => "item".to_string(),
1052 }
1053}
1054
1055fn trait_item_label(it: &syn::TraitItem) -> String {
1056 match it {
1057 syn::TraitItem::Fn(f) => format!("fn {}", f.sig.ident),
1058 syn::TraitItem::Const(c) => format!("const {}", c.ident),
1059 syn::TraitItem::Type(t) => format!("type {}", t.ident),
1060 syn::TraitItem::Macro(m) => format!("{}!", path_str(&m.mac.path)),
1061 _ => "item".to_string(),
1062 }
1063}
1064
1065fn file_units(src: &str) -> Result<Vec<CodeUnit>> {
1066 let file = syn::parse_file(src).map_err(|e| anyhow!("parse error: {e}"))?;
1067 let mut crate_attrs = String::new();
1068 for a in &file.attrs {
1069 push_tokens(a.to_token_stream(), &mut crate_attrs);
1070 }
1071 let mut units = vec![CodeUnit {
1072 label: "crate attributes".to_string(),
1073 line: 1,
1074 tokens: crate_attrs,
1075 children: Vec::new(),
1076 }];
1077 units.extend(item_units(&file.items, ""));
1078 Ok(units)
1079}
1080
1081#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1082pub struct Difference {
1083 pub before_item: Option<String>,
1085 pub before_line: Option<usize>,
1086 pub after_item: Option<String>,
1087 pub after_line: Option<usize>,
1088}
1089
1090impl std::fmt::Display for Difference {
1091 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1092 let side = |item: &Option<String>, line: &Option<usize>| match (item, line) {
1093 (Some(i), Some(l)) => format!("`{i}` (line {l})"),
1094 (Some(i), None) => format!("`{i}`"),
1095 _ => "<nothing>".to_string(),
1096 };
1097 if self.before_item == self.after_item {
1098 write!(
1099 f,
1100 "{} changed: before line {}, after line {}",
1101 side(&self.before_item, &None),
1102 self.before_line.unwrap_or(0),
1103 self.after_line.unwrap_or(0)
1104 )
1105 } else {
1106 write!(
1107 f,
1108 "before {} vs after {}",
1109 side(&self.before_item, &self.before_line),
1110 side(&self.after_item, &self.after_line)
1111 )
1112 }
1113 }
1114}
1115
1116fn first_difference(before: &[CodeUnit], after: &[CodeUnit]) -> Option<Difference> {
1117 let n = before.len().max(after.len());
1118 for i in 0..n {
1119 match (before.get(i), after.get(i)) {
1120 (Some(b), Some(a)) if b.label == a.label && b.tokens == a.tokens => {}
1121 (Some(b), Some(a)) => {
1122 if b.label == a.label
1124 && let Some(d) = first_difference(&b.children, &a.children)
1125 {
1126 return Some(d);
1127 }
1128 return Some(Difference {
1129 before_item: Some(b.label.clone()),
1130 before_line: Some(b.line),
1131 after_item: Some(a.label.clone()),
1132 after_line: Some(a.line),
1133 });
1134 }
1135 (b, a) => {
1136 return Some(Difference {
1137 before_item: b.map(|u| u.label.clone()),
1138 before_line: b.map(|u| u.line),
1139 after_item: a.map(|u| u.label.clone()),
1140 after_line: a.map(|u| u.line),
1141 });
1142 }
1143 }
1144 }
1145 None
1146}
1147
1148pub fn compare_sources(before: &str, after: &str) -> Result<Option<Difference>> {
1151 let b = file_units(before).context("before")?;
1152 let a = file_units(after).context("after")?;
1153 Ok(first_difference(&b, &a))
1154}
1155
1156#[derive(Debug, Clone, Serialize, Deserialize)]
1157pub struct FileVerdict {
1158 pub file: PathBuf,
1159 pub equal: bool,
1160 pub difference: Option<Difference>,
1161 pub error: Option<String>,
1163}
1164
1165#[derive(Debug, Clone, Serialize, Deserialize)]
1166pub struct VerifyReport {
1167 pub equal: bool,
1168 pub files: Vec<FileVerdict>,
1169}
1170
1171impl VerifyReport {
1172 fn from_files(files: Vec<FileVerdict>) -> Self {
1173 Self {
1174 equal: files.iter().all(|f| f.equal),
1175 files,
1176 }
1177 }
1178}
1179
1180fn verdict(file: PathBuf, before: &str, after: &str) -> FileVerdict {
1181 match compare_sources(before, after) {
1182 Ok(d) => FileVerdict {
1183 file,
1184 equal: d.is_none(),
1185 difference: d,
1186 error: None,
1187 },
1188 Err(e) => FileVerdict {
1189 file,
1190 equal: false,
1191 difference: None,
1192 error: Some(format!("{e:#}")),
1193 },
1194 }
1195}
1196
1197pub enum VerifyArgs {
1198 Files { before: String, after: PathBuf },
1200 RunId { run_id: String, state_dir: PathBuf },
1202}
1203
1204fn read_before(before: &str, after: &Path) -> Result<String> {
1205 let p = Path::new(before);
1206 if p.is_file() {
1207 return std::fs::read_to_string(p).with_context(|| format!("Failed to read {before}"));
1208 }
1209 let dir = after
1211 .parent()
1212 .filter(|d| !d.as_os_str().is_empty())
1213 .unwrap_or_else(|| Path::new("."));
1214 let name = after
1215 .file_name()
1216 .ok_or_else(|| anyhow!("--after has no file name"))?
1217 .to_string_lossy();
1218 let out = std::process::Command::new("git")
1219 .arg("-C")
1220 .arg(dir)
1221 .arg("show")
1222 .arg(format!("{before}:./{name}"))
1223 .output()
1224 .context("Failed to run git show")?;
1225 if !out.status.success() {
1226 bail!(
1227 "--before `{before}` is neither a file nor a git revision containing {}: {}",
1228 after.display(),
1229 String::from_utf8_lossy(&out.stderr).trim()
1230 );
1231 }
1232 Ok(String::from_utf8(out.stdout)?)
1233}
1234
1235pub fn verify(args: &VerifyArgs) -> Result<VerifyReport> {
1236 match args {
1237 VerifyArgs::Files { before, after } => {
1238 let before_src = read_before(before, after)?;
1239 let after_src = std::fs::read_to_string(after)
1240 .with_context(|| format!("Failed to read {}", after.display()))?;
1241 Ok(VerifyReport::from_files(vec![verdict(
1242 after.clone(),
1243 &before_src,
1244 &after_src,
1245 )]))
1246 }
1247 VerifyArgs::RunId { run_id, state_dir } => {
1248 let run = load_run_metadata(run_id, state_dir)?;
1249 let mut files = Vec::new();
1250 for fm in &run.files_modified {
1251 let Some(backup) = fm
1252 .backup_nodes
1253 .iter()
1254 .find(|n| n.node_type == FILE_BACKUP_NODE_TYPE)
1255 else {
1256 bail!(
1257 "run {run_id} has no whole-file backup for {} — only `comments apply` runs can be verified by run id",
1258 fm.path.display()
1259 );
1260 };
1261 let after_src = std::fs::read_to_string(&fm.path)
1262 .with_context(|| format!("Failed to read {}", fm.path.display()))?;
1263 files.push(verdict(
1264 fm.path.clone(),
1265 &backup.original_content,
1266 &after_src,
1267 ));
1268 }
1269 Ok(VerifyReport::from_files(files))
1270 }
1271 }
1272}
1273
1274pub fn render_verify(report: &VerifyReport) {
1275 for f in &report.files {
1276 if f.equal {
1277 println!(
1278 "✓ {}: code unchanged (comments/whitespace/doc ignored)",
1279 f.file.display()
1280 );
1281 } else if let Some(e) = &f.error {
1282 println!("✗ {}: {e}", f.file.display());
1283 } else if let Some(d) = &f.difference {
1284 println!("✗ {}: first differing item: {d}", f.file.display());
1285 }
1286 }
1287}
1288
1289#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1294#[serde(rename_all = "lowercase")]
1295pub enum OpKind {
1296 Keep,
1298 Delete,
1300 Replace,
1302}
1303
1304#[derive(Debug, Clone, Serialize, Deserialize)]
1311pub struct CommentOp {
1312 pub file: PathBuf,
1313 pub hash: String,
1316 pub op: OpKind,
1317 #[serde(default, skip_serializing_if = "Option::is_none")]
1321 pub text: Option<String>,
1322 #[serde(default, skip_serializing_if = "Option::is_none")]
1324 pub lines: Option<[usize; 2]>,
1325}
1326
1327#[derive(Deserialize)]
1328#[serde(untagged)]
1329enum BatchShape {
1330 Ops(Vec<CommentOp>),
1331 Wrapped { ops: Vec<CommentOp> },
1332}
1333
1334pub fn parse_batch(json: &str) -> Result<Vec<CommentOp>> {
1335 let shape: BatchShape = serde_json::from_str(json)
1336 .context("batch must be a JSON array of ops or {\"ops\": [...]}")?;
1337 Ok(match shape {
1338 BatchShape::Ops(v) | BatchShape::Wrapped { ops: v } => v,
1339 })
1340}
1341
1342#[derive(Debug, Clone, Serialize, Deserialize)]
1343pub struct OpOutcome {
1344 pub index: usize,
1346 pub file: PathBuf,
1347 pub span: Option<[usize; 2]>,
1350 pub reason: Option<String>,
1352}
1353
1354#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1355pub struct ApplyReport {
1356 pub dry_run: bool,
1357 pub run_id: Option<String>,
1359 pub accepted: Vec<OpOutcome>,
1360 pub refused: Vec<OpOutcome>,
1361 pub files_changed: Vec<PathBuf>,
1362 pub diff: String,
1364}
1365
1366pub struct ApplyArgs {
1367 pub ops: Vec<CommentOp>,
1368 pub apply: bool,
1369 pub allow_annotations: bool,
1370 pub state_dir: PathBuf,
1371 pub command_line: String,
1373}
1374
1375fn delete_range(src: &str, [s, e]: [usize; 2]) -> (usize, usize) {
1378 let ls = src[..s].rfind('\n').map_or(0, |i| i + 1);
1379 let le = src[e..].find('\n').map_or(src.len(), |i| e + i);
1380 if is_ws(&src[ls..s]) && is_ws(&src[e..le]) {
1381 (ls, (le + 1).min(src.len()))
1382 } else {
1383 let trimmed = src[..s].trim_end_matches([' ', '\t']).len();
1384 (trimmed, e)
1385 }
1386}
1387
1388pub fn apply(args: &ApplyArgs) -> Result<ApplyReport> {
1389 let mut report = ApplyReport {
1390 dry_run: !args.apply,
1391 ..Default::default()
1392 };
1393
1394 let mut by_file: BTreeMap<PathBuf, Vec<(usize, &CommentOp)>> = BTreeMap::new();
1395 for (i, op) in args.ops.iter().enumerate() {
1396 if op.op == OpKind::Keep {
1397 continue;
1398 }
1399 by_file.entry(op.file.clone()).or_default().push((i, op));
1400 }
1401
1402 let run_id = generate_run_id();
1403 let mut modifications = Vec::new();
1404
1405 for (file, ops) in by_file {
1406 let refuse =
1407 |report: &mut ApplyReport, i: usize, op: &CommentOp, span: Option<[usize; 2]>, why: String| {
1408 report.refused.push(OpOutcome {
1409 index: i,
1410 file: op.file.clone(),
1411 span,
1412 reason: Some(why),
1413 });
1414 };
1415 let src = match std::fs::read_to_string(&file) {
1416 Ok(s) => s,
1417 Err(e) => {
1418 for (i, op) in ops {
1419 refuse(&mut report, i, op, None, format!("cannot read file: {e}"));
1420 }
1421 continue;
1422 }
1423 };
1424 let lines = LineIndex::new(&src);
1425 let (spans, _parse_err) = file_spans(&file, &src)?;
1429 let by_hash: HashMap<&str, &CommentSpan> =
1430 spans.iter().map(|s| (s.hash.as_str(), s)).collect();
1431
1432 let mut edits: Vec<(usize, &CommentOp, (usize, usize), String)> = Vec::new();
1434 for (i, op) in ops {
1435 let Some(span) = by_hash.get(op.hash.as_str()).copied() else {
1436 refuse(
1437 &mut report,
1438 i,
1439 op,
1440 None,
1441 format!(
1442 "unknown hash {} in {}: no span currently has this hash — re-run \
1443 `comments list`",
1444 op.hash,
1445 file.display()
1446 ),
1447 );
1448 continue;
1449 };
1450 if span.annotation && !args.allow_annotations {
1451 refuse(
1452 &mut report,
1453 i,
1454 op,
1455 Some(span.span),
1456 "span holds an @yah:/@arch: annotation (pass --allow-annotations to edit it)"
1457 .to_string(),
1458 );
1459 continue;
1460 }
1461 let (range, text) = match op.op {
1462 OpKind::Keep => unreachable!("keep ops are filtered out of by_file above"),
1463 OpKind::Delete => (delete_range(&src, span.span), String::new()),
1464 OpKind::Replace => {
1465 let Some(text) = op.text.as_deref() else {
1466 refuse(
1467 &mut report,
1468 i,
1469 op,
1470 Some(span.span),
1471 "op: \"replace\" with no `text`".to_string(),
1472 );
1473 continue;
1474 };
1475 let rep = lex_comments(text);
1476 let mut pos = 0;
1477 let mut only_comments = true;
1478 for r in &rep {
1479 only_comments &= is_ws(&text[pos..r.start]);
1480 pos = r.end;
1481 }
1482 only_comments &= is_ws(&text[pos..]);
1483 if !only_comments {
1484 refuse(
1485 &mut report,
1486 i,
1487 op,
1488 Some(span.span),
1489 "replacement text must contain only comments and whitespace"
1490 .to_string(),
1491 );
1492 continue;
1493 }
1494 if !args.allow_annotations && has_marker(text) {
1495 refuse(
1496 &mut report,
1497 i,
1498 op,
1499 Some(span.span),
1500 "replacement text introduces an @yah:/@arch: annotation (pass --allow-annotations)"
1501 .to_string(),
1502 );
1503 continue;
1504 }
1505 if text.is_empty() {
1506 (delete_range(&src, span.span), String::new())
1507 } else {
1508 ((span.span[0], span.span[1]), text.to_string())
1509 }
1510 }
1511 };
1512 if let Some((j, _, r, _)) = edits
1513 .iter()
1514 .find(|(_, _, r, _)| range.0 < r.1 && r.0 < range.1)
1515 {
1516 refuse(
1517 &mut report,
1518 i,
1519 op,
1520 Some(span.span),
1521 format!("overlaps op #{j} range [{}, {})", r.0, r.1),
1522 );
1523 continue;
1524 }
1525 edits.push((i, op, range, text));
1526 }
1527 if edits.is_empty() {
1528 continue;
1529 }
1530
1531 edits.sort_by_key(|(_, _, r, _)| std::cmp::Reverse(r.0));
1532 let mut new_src = src.clone();
1533 for (_, _, (s, e), text) in &edits {
1534 new_src.replace_range(*s..*e, text);
1535 }
1536
1537 match compare_sources(&src, &new_src) {
1539 Ok(None) => {}
1540 other => {
1541 let why = match other {
1542 Ok(Some(d)) => format!("batch would change code in this file: {d}"),
1543 Err(e) => format!("batch would leave this file unparseable: {e:#}"),
1544 Ok(None) => unreachable!(),
1545 };
1546 for (i, op, range, _) in edits {
1547 refuse(&mut report, i, op, Some(range.into()), why.clone());
1548 }
1549 continue;
1550 }
1551 }
1552
1553 for (i, op, range, _) in &edits {
1554 report.accepted.push(OpOutcome {
1555 index: *i,
1556 file: op.file.clone(),
1557 span: Some((*range).into()),
1558 reason: None,
1559 });
1560 }
1561 report
1562 .diff
1563 .push_str(&crate::diff::generate_unified_diff(&file, &src, &new_src, 3).0);
1564 report.files_changed.push(file.clone());
1565
1566 if args.apply {
1567 let abs = std::fs::canonicalize(&file).unwrap_or_else(|_| file.clone());
1568 let backup = BackupNode {
1569 node_type: FILE_BACKUP_NODE_TYPE.to_string(),
1570 identifier: abs.display().to_string(),
1571 original_content: src.clone(),
1572 location: NodeLocation {
1573 line: 1,
1574 column: 0,
1575 end_line: lines.starts.len(),
1576 end_column: 0,
1577 },
1578 };
1579 let hash_before = hash_file(&abs)?;
1580 save_backup_nodes(
1581 &abs,
1582 std::slice::from_ref(&backup),
1583 &run_id,
1584 &args.state_dir,
1585 )?;
1586 std::fs::write(&abs, &new_src)
1587 .with_context(|| format!("Failed to write {}", abs.display()))?;
1588 modifications.push(FileModification {
1589 path: abs.clone(),
1590 hash_before,
1591 hash_after: hash_file(&abs)?,
1592 backup_nodes: vec![backup],
1593 });
1594 }
1595 }
1596
1597 report.accepted.sort_by_key(|o| o.index);
1598 report.refused.sort_by_key(|o| o.index);
1599
1600 if !modifications.is_empty() {
1601 save_run_metadata(
1602 &RunMetadata {
1603 run_id: run_id.clone(),
1604 timestamp: chrono::Utc::now(),
1605 command: args.command_line.clone(),
1606 operation: "comments-apply".to_string(),
1607 files_modified: modifications,
1608 status: RunStatus::Applied,
1609 can_revert: true,
1610 },
1611 &args.state_dir,
1612 )?;
1613 report.run_id = Some(run_id);
1614 }
1615 Ok(report)
1616}
1617
1618pub fn render_apply(report: &ApplyReport) {
1619 if !report.diff.is_empty() {
1620 println!("{}", report.diff);
1621 }
1622 for r in &report.refused {
1623 let loc = r
1624 .span
1625 .map(|[s, e]| format!(" [{s}, {e})"))
1626 .unwrap_or_default();
1627 println!(
1628 "✗ op #{} {}{loc}: {}",
1629 r.index,
1630 r.file.display(),
1631 r.reason.as_deref().unwrap_or("")
1632 );
1633 }
1634 println!(
1635 "{} op(s) accepted, {} refused, {} file(s) {}",
1636 report.accepted.len(),
1637 report.refused.len(),
1638 report.files_changed.len(),
1639 if report.dry_run {
1640 "would change (dry run; pass --apply to write)"
1641 } else {
1642 "changed"
1643 }
1644 );
1645 if let Some(id) = &report.run_id {
1646 println!("run_id: {id} (undo with `rs-hack revert {id}`)");
1647 }
1648}