1use std::path::Path;
14use std::sync::OnceLock;
15
16use serde_json::Value;
17
18#[must_use]
22pub fn escape_message(text: &str) -> String {
23 text.replace('%', "%25")
24 .replace('\r', "%0D")
25 .replace('\n', "%0A")
26}
27
28#[must_use]
32pub fn escape_property(text: &str) -> String {
33 escape_message(text).replace(',', "%2C").replace(':', "%3A")
34}
35
36#[must_use]
39pub const fn clamp_line(line: u64) -> u64 {
40 if line < 1 { 1 } else { line }
41}
42
43#[must_use]
46pub const fn one_based_col(col: u64) -> u64 {
47 col + 1
48}
49
50#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
54pub enum AnnotationLevel {
55 Error,
56 Warning,
57 Notice,
58}
59
60impl AnnotationLevel {
61 #[must_use]
63 pub const fn command(self) -> &'static str {
64 match self {
65 Self::Error => "error",
66 Self::Warning => "warning",
67 Self::Notice => "notice",
68 }
69 }
70}
71
72#[derive(Debug)]
75pub struct Annotation {
76 pub level: AnnotationLevel,
77 pub path: String,
79 pub line: Option<u64>,
81 pub end_line: Option<u64>,
82 pub col: Option<u64>,
84 pub title: String,
85 pub message: String,
86}
87
88#[must_use]
91pub fn render_annotation(annotation: &Annotation) -> String {
92 use std::fmt::Write as _;
93 let mut props = format!("file={}", escape_property(&annotation.path));
94 if let Some(line) = annotation.line {
95 let _ = write!(props, ",line={}", clamp_line(line));
96 }
97 if let Some(end_line) = annotation.end_line {
98 let _ = write!(props, ",endLine={end_line}");
99 }
100 if let Some(col) = annotation.col {
101 let _ = write!(props, ",col={col}");
102 }
103 format!(
104 "::{} {props},title={}::{}",
105 annotation.level.command(),
106 escape_property(&annotation.title),
107 escape_message(&annotation.message)
108 )
109}
110
111pub fn sort_annotations(annotations: &mut [Annotation]) {
116 annotations.sort_by(|a, b| {
117 a.level
118 .cmp(&b.level)
119 .then_with(|| a.path.cmp(&b.path))
120 .then_with(|| a.line.unwrap_or(0).cmp(&b.line.unwrap_or(0)))
121 });
122}
123
124#[must_use]
130pub fn budget_notice(total: usize) -> Option<String> {
131 (total > 0).then(|| {
132 let noun = if total == 1 {
133 "annotation"
134 } else {
135 "annotations"
136 };
137 format!(
138 "::notice::fallow emitted {total} {noun}; GitHub shows at most 10 per type per step"
139 )
140 })
141}
142
143#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
147pub enum PackageManager {
148 #[default]
149 Npm,
150 Pnpm,
151 Yarn,
152 Bun,
153}
154
155impl PackageManager {
156 #[must_use]
158 pub fn remove_command(self, package: &str) -> String {
159 match self {
160 Self::Npm => format!("npm uninstall {package}"),
161 Self::Pnpm => format!("pnpm remove {package}"),
162 Self::Yarn => format!("yarn remove {package}"),
163 Self::Bun => format!("bun remove {package}"),
164 }
165 }
166
167 #[must_use]
169 pub fn add_command(self, package: &str) -> String {
170 match self {
171 Self::Npm => format!("npm install {package}"),
172 Self::Pnpm => format!("pnpm add {package}"),
173 Self::Yarn => format!("yarn add {package}"),
174 Self::Bun => format!("bun add {package}"),
175 }
176 }
177}
178
179#[must_use]
183pub fn resolve_package_manager(env_value: Option<&str>, root: &Path) -> PackageManager {
184 if let Some(value) = env_value {
185 return match value {
186 "pnpm" => PackageManager::Pnpm,
187 "yarn" => PackageManager::Yarn,
188 "bun" => PackageManager::Bun,
189 _ => PackageManager::Npm,
190 };
191 }
192 if root.join("pnpm-lock.yaml").is_file() {
193 PackageManager::Pnpm
194 } else if root.join("yarn.lock").is_file() {
195 PackageManager::Yarn
196 } else if root.join("bun.lock").is_file() || root.join("bun.lockb").is_file() {
197 PackageManager::Bun
198 } else {
199 PackageManager::Npm
200 }
201}
202
203#[derive(Clone, PartialEq, Eq, Debug, Default)]
209pub enum PathRebase {
210 #[default]
212 None,
213 Prefix(String),
215}
216
217impl PathRebase {
218 #[must_use]
220 pub fn apply(&self, path: &str) -> String {
221 match self {
222 Self::None => path.to_owned(),
223 Self::Prefix(prefix) => format!("{prefix}/{path}"),
224 }
225 }
226
227 #[must_use]
233 pub fn prefix(&self) -> &str {
234 match self {
235 Self::None => "",
236 Self::Prefix(prefix) => prefix,
237 }
238 }
239
240 #[must_use]
244 pub fn resolve(root: &Path, explicit: Option<&str>) -> Self {
245 if let Some(prefix) = explicit {
246 return Self::from_explicit_prefix(prefix);
247 }
248 let Some(toplevel) = crate::base_worktree::git_toplevel(root) else {
249 return Self::None;
250 };
251 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
252 Self::from_root_offset(&canonical_root, &toplevel)
253 }
254
255 fn from_explicit_prefix(prefix: &str) -> Self {
256 let normalized = prefix.replace('\\', "/");
257 let trimmed = normalized.trim_matches('/');
258 if trimmed.is_empty() {
259 Self::None
260 } else {
261 Self::Prefix(trimmed.to_owned())
262 }
263 }
264
265 #[must_use]
268 pub fn from_root_offset(root: &Path, toplevel: &Path) -> Self {
269 match root.strip_prefix(toplevel) {
270 Ok(offset) if offset.as_os_str().is_empty() => Self::None,
271 Ok(offset) => Self::Prefix(offset.display().to_string().replace('\\', "/")),
272 Err(_) => Self::None,
273 }
274 }
275}
276
277static REPORT_PATH_PREFIX: OnceLock<Option<String>> = OnceLock::new();
281
282pub fn set_report_path_prefix(prefix: Option<String>) {
284 let _ = REPORT_PATH_PREFIX.set(prefix);
285}
286
287fn report_path_prefix() -> Option<&'static str> {
288 REPORT_PATH_PREFIX
289 .get()
290 .and_then(|prefix| prefix.as_deref())
291}
292
293static RESOLVED_REPORT_PREFIX: OnceLock<String> = OnceLock::new();
299
300pub fn init_report_prefix(root: &Path) {
303 let _ = RESOLVED_REPORT_PREFIX.set(report_rebase(root).prefix().to_owned());
304}
305
306#[must_use]
309pub fn report_prefix() -> &'static str {
310 RESOLVED_REPORT_PREFIX.get().map_or("", String::as_str)
311}
312
313#[must_use]
319pub fn report_rebase(root: &Path) -> PathRebase {
320 PathRebase::resolve(root, report_path_prefix())
321}
322
323#[derive(Debug, Default)]
326pub struct RenderOptions {
327 pub rebase: PathRebase,
328 pub pm: PackageManager,
329}
330
331#[must_use]
333pub fn resolve_render_options(root: &Path) -> RenderOptions {
334 let env_pm = std::env::var("PKG_MANAGER").ok();
335 RenderOptions {
336 rebase: report_rebase(root),
337 pm: resolve_package_manager(env_pm.as_deref(), root),
338 }
339}
340
341pub fn arr<'a>(value: &'a Value, key: &str) -> impl Iterator<Item = &'a Value> {
343 value
344 .get(key)
345 .and_then(Value::as_array)
346 .map(Vec::as_slice)
347 .unwrap_or_default()
348 .iter()
349}
350
351#[must_use]
353pub fn s<'a>(value: &'a Value, key: &str) -> &'a str {
354 value.get(key).and_then(Value::as_str).unwrap_or_default()
355}
356
357#[must_use]
359pub fn u(value: &Value, key: &str) -> u64 {
360 value.get(key).and_then(Value::as_u64).unwrap_or_default()
361}
362
363#[must_use]
365pub fn b(value: &Value, key: &str) -> bool {
366 value.get(key).and_then(Value::as_bool).unwrap_or_default()
367}
368
369#[must_use]
372pub fn fmt_num(value: &Value) -> String {
373 if let Some(int) = value.as_i64() {
374 return int.to_string();
375 }
376 value.as_f64().map_or_else(String::new, |float| {
377 if float.fract() == 0.0 && float.abs() < 1e15 {
378 format!("{}", float as i64)
379 } else {
380 format!("{float}")
381 }
382 })
383}
384
385#[must_use]
387pub fn num(value: &Value, key: &str) -> String {
388 value.get(key).map_or_else(|| "0".to_owned(), fmt_num)
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 #[test]
396 fn escape_property_path_with_comma() {
397 assert_eq!(escape_property("src/a,b.ts"), "src/a%2Cb.ts");
398 }
399
400 #[test]
401 fn escape_property_title_with_colon() {
402 assert_eq!(escape_property("High: complexity"), "High%3A complexity");
403 }
404
405 #[test]
406 fn escape_message_percent() {
407 assert_eq!(escape_message("100% sure"), "100%25 sure");
408 }
409
410 #[test]
411 fn escape_message_crlf_reason() {
412 assert_eq!(escape_message("reason\r\nnext"), "reason%0D%0Anext");
413 }
414
415 #[test]
416 fn escape_message_non_ascii_passes_through_as_utf8() {
417 assert_eq!(escape_message("München.ts"), "München.ts");
418 assert_eq!(escape_property("München.ts"), "München.ts");
419 }
420
421 #[test]
422 fn escape_message_empty_string() {
423 assert_eq!(escape_message(""), "");
424 assert_eq!(escape_property(""), "");
425 }
426
427 #[test]
428 fn escape_message_already_escaped_input_escapes_again() {
429 assert_eq!(escape_message("50%25 done"), "50%2525 done");
431 }
432
433 #[test]
434 fn escape_message_leaves_comma_and_colon_untouched() {
435 assert_eq!(escape_message("a,b: c"), "a,b: c");
436 }
437
438 #[test]
439 fn escape_property_escapes_all_reserved_characters_together() {
440 assert_eq!(escape_property("a,b:c%d\r\ne"), "a%2Cb%3Ac%25d%0D%0Ae");
441 }
442
443 #[test]
444 fn clamp_line_floors_zero_to_one() {
445 assert_eq!(clamp_line(0), 1);
446 }
447
448 #[test]
449 fn clamp_line_keeps_positive_lines() {
450 assert_eq!(clamp_line(1), 1);
451 assert_eq!(clamp_line(42), 42);
452 }
453
454 #[test]
455 fn one_based_col_converts_zero_based() {
456 assert_eq!(one_based_col(0), 1);
457 assert_eq!(one_based_col(7), 8);
458 }
459
460 fn annotation(level: AnnotationLevel, path: &str, line: Option<u64>) -> Annotation {
461 Annotation {
462 level,
463 path: path.to_owned(),
464 line,
465 end_line: None,
466 col: None,
467 title: "T".to_owned(),
468 message: "m".to_owned(),
469 }
470 }
471
472 #[test]
473 fn render_annotation_property_order_matches_jq_layer() {
474 let rendered = render_annotation(&Annotation {
475 level: AnnotationLevel::Warning,
476 path: "src/a.ts".to_owned(),
477 line: Some(3),
478 end_line: Some(9),
479 col: Some(5),
480 title: "Code duplication".to_owned(),
481 message: "first\nsecond".to_owned(),
482 });
483 assert_eq!(
484 rendered,
485 "::warning file=src/a.ts,line=3,endLine=9,col=5,title=Code duplication::first%0Asecond"
486 );
487 }
488
489 #[test]
490 fn render_annotation_omits_absent_properties_and_clamps_line() {
491 let rendered =
492 render_annotation(&annotation(AnnotationLevel::Error, "pkg/a,b.ts", Some(0)));
493 assert_eq!(rendered, "::error file=pkg/a%2Cb.ts,line=1,title=T::m");
494 let no_line = render_annotation(&annotation(AnnotationLevel::Notice, "a.ts", None));
495 assert_eq!(no_line, "::notice file=a.ts,title=T::m");
496 }
497
498 #[test]
499 fn sort_annotations_orders_severity_then_path_then_line() {
500 let mut annotations = vec![
501 annotation(AnnotationLevel::Notice, "a/a.ts", Some(1)),
502 annotation(AnnotationLevel::Warning, "z/z.ts", Some(9)),
503 annotation(AnnotationLevel::Error, "z/late.ts", Some(2)),
504 annotation(AnnotationLevel::Warning, "a/a.ts", Some(5)),
505 annotation(AnnotationLevel::Warning, "a/a.ts", Some(2)),
506 ];
507 sort_annotations(&mut annotations);
508 let order: Vec<(AnnotationLevel, &str, Option<u64>)> = annotations
509 .iter()
510 .map(|a| (a.level, a.path.as_str(), a.line))
511 .collect();
512 assert_eq!(
513 order,
514 vec![
515 (AnnotationLevel::Error, "z/late.ts", Some(2)),
516 (AnnotationLevel::Warning, "a/a.ts", Some(2)),
517 (AnnotationLevel::Warning, "a/a.ts", Some(5)),
518 (AnnotationLevel::Warning, "z/z.ts", Some(9)),
519 (AnnotationLevel::Notice, "a/a.ts", Some(1)),
520 ]
521 );
522 }
523
524 #[test]
525 fn budget_notice_appears_only_when_annotations_exist() {
526 assert_eq!(budget_notice(0), None);
527 assert_eq!(
528 budget_notice(12).as_deref(),
529 Some(
530 "::notice::fallow emitted 12 annotations; GitHub shows at most 10 per type per step"
531 )
532 );
533 }
534
535 #[test]
536 fn budget_notice_uses_singular_noun_for_one_annotation() {
537 assert_eq!(
538 budget_notice(1).as_deref(),
539 Some(
540 "::notice::fallow emitted 1 annotation; GitHub shows at most 10 per type per step"
541 )
542 );
543 }
544
545 #[test]
546 fn package_manager_env_wins_and_unrecognized_falls_back_to_npm() {
547 let dir = tempfile::tempdir().expect("tempdir");
548 std::fs::write(dir.path().join("pnpm-lock.yaml"), "").expect("write");
549 assert_eq!(
550 resolve_package_manager(Some("yarn"), dir.path()),
551 PackageManager::Yarn
552 );
553 assert_eq!(
554 resolve_package_manager(Some("something-else"), dir.path()),
555 PackageManager::Npm
556 );
557 }
558
559 #[test]
560 fn package_manager_lockfile_sniffing_covers_bun() {
561 let dir = tempfile::tempdir().expect("tempdir");
562 assert_eq!(
563 resolve_package_manager(None, dir.path()),
564 PackageManager::Npm
565 );
566 std::fs::write(dir.path().join("bun.lockb"), "").expect("write");
567 assert_eq!(
568 resolve_package_manager(None, dir.path()),
569 PackageManager::Bun
570 );
571 std::fs::write(dir.path().join("bun.lock"), "").expect("write");
572 assert_eq!(
573 resolve_package_manager(None, dir.path()),
574 PackageManager::Bun
575 );
576 std::fs::write(dir.path().join("yarn.lock"), "").expect("write");
577 assert_eq!(
578 resolve_package_manager(None, dir.path()),
579 PackageManager::Yarn
580 );
581 std::fs::write(dir.path().join("pnpm-lock.yaml"), "").expect("write");
582 assert_eq!(
583 resolve_package_manager(None, dir.path()),
584 PackageManager::Pnpm
585 );
586 }
587
588 #[test]
589 fn package_manager_commands() {
590 assert_eq!(PackageManager::Npm.remove_command("x"), "npm uninstall x");
591 assert_eq!(PackageManager::Pnpm.remove_command("x"), "pnpm remove x");
592 assert_eq!(PackageManager::Yarn.add_command("x"), "yarn add x");
593 assert_eq!(PackageManager::Bun.add_command("x"), "bun add x");
594 assert_eq!(PackageManager::Bun.remove_command("x"), "bun remove x");
595 }
596
597 #[test]
598 fn path_rebase_from_root_offset_prefixes_subdirectory_roots() {
599 use std::path::Path;
600 let rebase =
601 PathRebase::from_root_offset(Path::new("/repo/packages/app"), Path::new("/repo"));
602 assert_eq!(rebase, PathRebase::Prefix("packages/app".to_owned()));
603 assert_eq!(rebase.apply("src/a.ts"), "packages/app/src/a.ts");
604 }
605
606 #[test]
607 fn path_rebase_is_none_at_repo_root_or_outside_toplevel() {
608 use std::path::Path;
609 assert_eq!(
610 PathRebase::from_root_offset(Path::new("/repo"), Path::new("/repo")),
611 PathRebase::None
612 );
613 assert_eq!(
614 PathRebase::from_root_offset(Path::new("/elsewhere"), Path::new("/repo")),
615 PathRebase::None
616 );
617 assert_eq!(PathRebase::None.apply("src/a.ts"), "src/a.ts");
618 }
619
620 #[test]
621 fn path_rebase_explicit_prefix_wins_and_is_trimmed() {
622 use std::path::Path;
623 let rebase = PathRebase::resolve(Path::new("/nonexistent-fallow-root"), Some("/pkg/app/"));
624 assert_eq!(rebase, PathRebase::Prefix("pkg/app".to_owned()));
625 let empty = PathRebase::resolve(Path::new("/nonexistent-fallow-root"), Some("//"));
626 assert_eq!(empty, PathRebase::None);
627 }
628
629 #[test]
630 fn fmt_num_matches_jq_interpolation() {
631 assert_eq!(fmt_num(&serde_json::json!(42)), "42");
632 assert_eq!(fmt_num(&serde_json::json!(24.0)), "24");
633 assert_eq!(fmt_num(&serde_json::json!(30.5)), "30.5");
634 assert_eq!(fmt_num(&serde_json::json!(-3)), "-3");
635 }
636
637 #[test]
638 fn value_helpers_default_missing_fields() {
639 let value = serde_json::json!({"name": "x", "line": 4, "flag": true, "items": [1]});
640 assert_eq!(s(&value, "name"), "x");
641 assert_eq!(s(&value, "missing"), "");
642 assert_eq!(u(&value, "line"), 4);
643 assert_eq!(u(&value, "missing"), 0);
644 assert!(b(&value, "flag"));
645 assert!(!b(&value, "missing"));
646 assert_eq!(arr(&value, "items").count(), 1);
647 assert_eq!(arr(&value, "missing").count(), 0);
648 assert_eq!(num(&value, "line"), "4");
649 assert_eq!(num(&value, "missing"), "0");
650 }
651}