actions_rs/annotation.rs
1//! Annotation builder for `notice` / `warning` / `error` commands.
2//!
3//! Annotations may carry a source location (file + line/column range) and a title;
4//! GitHub renders them inline in the diff and in the run summary.\
5//! The property names emitted here match `@actions/core`'s mapping: its public `startLine`/`startColumn`
6//! become the wire properties `line`/`col`.
7
8use crate::command::WorkflowCommand;
9
10/// Which annotation channel to emit on.
11///
12/// # Examples
13///
14/// ```
15/// use actions_rs::{Annotation, AnnotationKind};
16/// let c = Annotation::new().command(AnnotationKind::Warning, "heads up");
17/// assert_eq!(c.to_string(), "::warning::heads up");
18/// ```
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum AnnotationKind {
21 /// A neutral `::notice::` annotation.
22 Notice,
23 /// A `::warning::` annotation (does not fail the job).
24 Warning,
25 /// An `::error::` annotation (does not by itself fail the job;
26 /// pair with a non-zero exit code or [`crate::log::set_failed`]).
27 Error,
28}
29
30impl AnnotationKind {
31 const fn command_name(self) -> &'static str {
32 match self {
33 AnnotationKind::Notice => "notice",
34 AnnotationKind::Warning => "warning",
35 AnnotationKind::Error => "error",
36 }
37 }
38}
39
40/// A valid annotation span.
41///
42/// # Examples
43///
44/// ```
45/// use actions_rs::{Annotation, AnnotationKind, AnnotationSpan};
46/// let c = Annotation::new()
47/// .span(AnnotationSpan::Column { line: 7, start: 3, end: Some(9) })
48/// .command(AnnotationKind::Error, "bad token");
49/// assert_eq!(c.to_string(), "::error line=7,col=3,endColumn=9::bad token");
50/// ```
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum AnnotationSpan {
53 /// A whole-line span.
54 Line {
55 /// The 1-based start line.
56 start: u32,
57 /// The optional 1-based end line.
58 end: Option<u32>,
59 },
60 /// A same-line column span.
61 /// When `end` is omitted GitHub treats the span as a single column.
62 Column {
63 /// The 1-based line.
64 line: u32,
65 /// The 1-based start column.
66 start: u32,
67 /// The optional 1-based end column.
68 end: Option<u32>,
69 },
70}
71
72/// Fluent builder for a located annotation.
73///
74/// All fields are optional — an empty `Annotation` simply produces a plain annotation with no location.
75/// Build it, then emit with [`Annotation::notice`], [`Annotation::warning`] or [`Annotation::error`].
76///
77/// ```
78/// use actions_rs::Annotation;
79/// let cmd = Annotation::new()
80/// .file("src/lib.rs")
81/// .line(10)
82/// .end_line(12)
83/// .title("clippy")
84/// .command(actions_rs::AnnotationKind::Warning, "unused variable");
85/// assert_eq!(
86/// cmd.to_string(),
87/// "::warning title=clippy,file=src/lib.rs,line=10,endLine=12::unused variable"
88/// );
89/// ```
90#[derive(Debug, Clone, Default, PartialEq, Eq)]
91pub struct Annotation {
92 title: Option<String>,
93 file: Option<String>,
94 line: Option<u32>,
95 end_line: Option<u32>,
96 col: Option<u32>,
97 end_column: Option<u32>,
98}
99
100impl Annotation {
101 /// Create an empty annotation (no location, no title).
102 ///
103 /// # Examples
104 ///
105 /// ```
106 /// use actions_rs::{Annotation, AnnotationKind};
107 /// let c = Annotation::new().command(AnnotationKind::Notice, "hi");
108 /// assert_eq!(c.to_string(), "::notice::hi");
109 /// ```
110 #[must_use]
111 pub fn new() -> Self {
112 Self::default()
113 }
114
115 /// Set the annotation title shown in the GitHub UI.
116 ///
117 /// # Examples
118 ///
119 /// ```
120 /// use actions_rs::{Annotation, AnnotationKind};
121 /// let c = Annotation::new().title("clippy").command(AnnotationKind::Warning, "w");
122 /// assert_eq!(c.to_string(), "::warning title=clippy::w");
123 /// ```
124 #[must_use]
125 pub fn title(mut self, title: impl Into<String>) -> Self {
126 self.title = Some(title.into());
127 self
128 }
129
130 /// Set the file path the annotation refers to (relative to the workspace).
131 ///
132 /// # Examples
133 ///
134 /// ```
135 /// use actions_rs::{Annotation, AnnotationKind};
136 /// let c = Annotation::new().file("src/lib.rs").command(AnnotationKind::Error, "e");
137 /// assert_eq!(c.to_string(), "::error file=src/lib.rs::e");
138 /// ```
139 #[must_use]
140 pub fn file(mut self, file: impl Into<String>) -> Self {
141 self.file = Some(file.into());
142 self
143 }
144
145 /// Set the (1-based) start line.
146 ///
147 /// # Examples
148 ///
149 /// ```
150 /// use actions_rs::{Annotation, AnnotationKind};
151 /// let c = Annotation::new().file("x").line(42).command(AnnotationKind::Warning, "w");
152 /// assert_eq!(c.to_string(), "::warning file=x,line=42::w");
153 /// ```
154 #[must_use]
155 pub fn line(mut self, line: u32) -> Self {
156 self.line = Some(line);
157 self
158 }
159
160 /// Set the (1-based) end line of a multi-line span.
161 ///
162 /// # Examples
163 ///
164 /// ```
165 /// use actions_rs::{Annotation, AnnotationKind};
166 /// let c = Annotation::new().file("x").line(10).end_line(12)
167 /// .command(AnnotationKind::Warning, "w");
168 /// assert_eq!(c.to_string(), "::warning file=x,line=10,endLine=12::w");
169 /// ```
170 #[must_use]
171 pub fn end_line(mut self, end_line: u32) -> Self {
172 self.end_line = Some(end_line);
173 self
174 }
175
176 /// Set the (1-based) start column.
177 ///
178 /// # Examples
179 ///
180 /// ```
181 /// use actions_rs::{Annotation, AnnotationKind};
182 /// let c = Annotation::new().file("x").line(7).col(3)
183 /// .command(AnnotationKind::Warning, "w");
184 /// assert_eq!(c.to_string(), "::warning file=x,line=7,col=3,endColumn=3::w");
185 /// ```
186 #[must_use]
187 pub fn col(mut self, col: u32) -> Self {
188 self.col = Some(col);
189 self
190 }
191
192 /// Set the (1-based) end column.
193 ///
194 /// # Examples
195 ///
196 /// ```
197 /// use actions_rs::{Annotation, AnnotationKind};
198 /// let c = Annotation::new().file("x").line(7).col(3).end_column(9)
199 /// .command(AnnotationKind::Warning, "w");
200 /// assert_eq!(c.to_string(), "::warning file=x,line=7,col=3,endColumn=9::w");
201 /// ```
202 #[must_use]
203 pub fn end_column(mut self, end_column: u32) -> Self {
204 self.end_column = Some(end_column);
205 self
206 }
207
208 /// Replace the current location fields with a span that is valid by construction.
209 ///
210 /// # Examples
211 ///
212 /// ```
213 /// use actions_rs::{Annotation, AnnotationKind, AnnotationSpan};
214 /// let c = Annotation::new()
215 /// .span(AnnotationSpan::Line { start: 4, end: Some(6) })
216 /// .command(AnnotationKind::Notice, "block");
217 /// assert_eq!(c.to_string(), "::notice line=4,endLine=6::block");
218 /// ```
219 #[must_use]
220 pub fn span(mut self, span: AnnotationSpan) -> Self {
221 match span {
222 AnnotationSpan::Line { start, end } => {
223 self.line = Some(start);
224 self.end_line = end;
225 self.col = None;
226 self.end_column = None;
227 }
228 AnnotationSpan::Column { line, start, end } => {
229 self.line = Some(line);
230 self.end_line = None;
231 self.col = Some(start);
232 self.end_column = end;
233 }
234 }
235 self
236 }
237
238 /// Build the [`WorkflowCommand`] for this annotation and `message` without emitting it.
239 /// Useful for testing or custom sinks.
240 ///
241 /// Property order matches `@actions/core`: `title, file, line, endLine, col, endColumn`.
242 ///
243 /// # Examples
244 ///
245 /// ```
246 /// use actions_rs::{Annotation, AnnotationKind};
247 /// // Inspect the wire form without writing to stdout.
248 /// let cmd = Annotation::new().file("a.rs").line(1).command(AnnotationKind::Error, "e");
249 /// assert_eq!(cmd.to_string(), "::error file=a.rs,line=1::e");
250 /// ```
251 #[must_use]
252 pub fn command(&self, kind: AnnotationKind, message: impl Into<String>) -> WorkflowCommand {
253 let line = self.line;
254 let end_line = self.end_line.filter(|_| line.is_some());
255 let same_line = match (line, end_line) {
256 (Some(_), None) => true,
257 (Some(start), Some(end)) => start == end,
258 _ => false,
259 };
260 let col = if same_line { self.col } else { None };
261 let end_column = if same_line {
262 col.map(|start| self.end_column.unwrap_or(start))
263 } else {
264 None
265 };
266
267 WorkflowCommand::new(kind.command_name())
268 .property_opt("title", self.title.clone())
269 .property_opt("file", self.file.clone())
270 .property_opt("line", line.map(|n| n.to_string()))
271 .property_opt("endLine", end_line.map(|n| n.to_string()))
272 .property_opt("col", col.map(|n| n.to_string()))
273 .property_opt("endColumn", end_column.map(|n| n.to_string()))
274 .message(message)
275 }
276
277 /// Emit a `::notice::` annotation to stdout.
278 ///
279 /// # Examples
280 ///
281 /// ```
282 /// use actions_rs::Annotation;
283 /// Annotation::new().file("README.md").line(1).notice("looks good");
284 /// ```
285 pub fn notice(&self, message: impl Into<String>) {
286 self.command(AnnotationKind::Notice, message).issue();
287 }
288
289 /// Emit a `::warning::` annotation to stdout.
290 ///
291 /// # Examples
292 ///
293 /// ```
294 /// use actions_rs::Annotation;
295 /// Annotation::new().file("src/lib.rs").line(42).title("lint").warning("unused import");
296 /// ```
297 pub fn warning(&self, message: impl Into<String>) {
298 self.command(AnnotationKind::Warning, message).issue();
299 }
300
301 /// Emit an `::error::` annotation to stdout.
302 ///
303 /// # Examples
304 ///
305 /// ```
306 /// use actions_rs::Annotation;
307 /// Annotation::new().file("src/main.rs").line(7).error("type mismatch");
308 /// ```
309 pub fn error(&self, message: impl Into<String>) {
310 self.command(AnnotationKind::Error, message).issue();
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317
318 #[test]
319 fn empty_annotation_is_plain() {
320 let c = Annotation::new().command(AnnotationKind::Error, "boom");
321 assert_eq!(c.to_string(), "::error::boom");
322 }
323
324 #[test]
325 fn full_property_order() {
326 let c = Annotation::new()
327 .title("t")
328 .file("f.rs")
329 .line(1)
330 .end_line(2)
331 .col(3)
332 .end_column(4)
333 .command(AnnotationKind::Notice, "msg");
334 assert_eq!(
335 c.to_string(),
336 "::notice title=t,file=f.rs,line=1,endLine=2::msg"
337 );
338 }
339
340 #[test]
341 fn partial_skips_unset() {
342 let c = Annotation::new()
343 .file("x")
344 .line(7)
345 .command(AnnotationKind::Warning, "w");
346 assert_eq!(c.to_string(), "::warning file=x,line=7::w");
347 }
348
349 #[test]
350 fn multiline_range_drops_columns() {
351 let c = Annotation::new()
352 .file("x")
353 .line(7)
354 .end_line(8)
355 .col(3)
356 .end_column(9)
357 .command(AnnotationKind::Warning, "w");
358 assert_eq!(c.to_string(), "::warning file=x,line=7,endLine=8::w");
359 }
360
361 #[test]
362 fn column_span_defaults_end_column() {
363 let c = Annotation::new()
364 .span(AnnotationSpan::Column {
365 line: 7,
366 start: 3,
367 end: None,
368 })
369 .command(AnnotationKind::Warning, "w");
370 assert_eq!(c.to_string(), "::warning line=7,col=3,endColumn=3::w");
371 }
372}