Skip to main content

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}