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