use crate::config::Level;
use console::style;
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq)]
pub enum Line {
Upgraded {
action: String,
from: String,
to: String,
},
Added { action: String, version: String },
Removed { action: String },
Changed { action: String, detail: String },
Skipped { action: String, reason: String },
Warning { message: String },
LintDiag {
level: Level,
workflow: Option<String>,
line: Option<u32>,
rule: String,
message: String,
},
Summary { text: String },
LogPath { path: PathBuf },
CiNotice { message: String },
Blank,
}
impl Line {
#[must_use]
pub fn format_line(&self, use_color: bool) -> String {
match self {
Line::Upgraded { action, from, to } => {
let arrow = if use_color {
style("↑").cyan().to_string()
} else {
"↑".to_owned()
};
format!(" {arrow} {action:<30} {from} → {to}")
}
Line::Added { action, version } => {
let plus = if use_color {
style("+").green().to_string()
} else {
"+".to_owned()
};
format!(" {plus} {action:<30} {version}")
}
Line::Removed { action } => {
let minus = if use_color {
style("−").red().to_string()
} else {
"−".to_owned()
};
format!(" {minus} {action}")
}
Line::Changed { action, detail } => {
format!(" ~ {action:<30} {detail}")
}
Line::Skipped { action, reason } => {
format!(" - {action:<30} ({reason})")
}
Line::Warning { message } => {
let prefix = if use_color {
style("⚠").yellow().to_string()
} else {
"⚠".to_owned()
};
format!(" {prefix} {message}")
}
Line::LintDiag {
level,
workflow,
line,
rule,
message,
} => {
let colored_symbol = match level {
Level::Error => {
if use_color {
style("✗").red().to_string()
} else {
"✗".to_owned()
}
}
Level::Warn => {
if use_color {
style("⚠").yellow().to_string()
} else {
"⚠".to_owned()
}
}
Level::Off => String::new(),
};
let location = workflow
.as_ref()
.map(|w| match line {
Some(n) => format!("{w}:{n}: "),
None => format!("{w}: "),
})
.unwrap_or_default();
format!(" {colored_symbol} {location}{rule}: {message}")
}
Line::Summary { text } => {
let check = if use_color {
style("✓").green().to_string()
} else {
"✓".to_owned()
};
format!("\n {check} {text}")
}
Line::LogPath { path } => {
let icon = if use_color {
style("📋").to_string()
} else {
"📋".to_owned()
};
format!(" {icon} {}", path.display())
}
Line::CiNotice { message } => {
let prefix = if use_color {
style("ℹ").blue().to_string()
} else {
"ℹ".to_owned()
};
format!(" {prefix} {message}")
}
Line::Blank => String::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::{Level, Line};
use std::path::PathBuf;
#[test]
fn format_line_upgraded_no_color() {
let line = Line::Upgraded {
action: "actions/checkout".to_owned(),
from: "v3".to_owned(),
to: "v4".to_owned(),
};
let result = line.format_line(false);
assert!(result.contains("↑"));
assert!(result.contains("actions/checkout"));
assert!(result.contains("v3"));
assert!(result.contains("v4"));
}
#[test]
fn format_line_lint_diag_no_color() {
let line = Line::LintDiag {
level: Level::Error,
workflow: Some("ci.yml".to_owned()),
line: None,
rule: "pinned-version".to_owned(),
message: "action must be pinned".to_owned(),
};
let result = line.format_line(false);
assert!(result.contains("✗"));
assert!(result.contains("ci.yml"));
assert!(result.contains("pinned-version"));
assert!(result.contains("action must be pinned"));
assert!(result.contains("ci.yml: "));
assert!(!result.contains("ci.yml:1"));
}
#[test]
fn format_line_lint_diag_renders_source_line() {
let line = Line::LintDiag {
level: Level::Error,
workflow: Some("ci.yml".to_owned()),
line: Some(12),
rule: "unpinned".to_owned(),
message: "action must be pinned".to_owned(),
};
let result = line.format_line(false);
assert!(result.contains("ci.yml:12:"), "got: {result}");
}
#[test]
fn format_line_lint_diag_renders_location_once() {
let line = Line::LintDiag {
level: Level::Error,
workflow: Some(".github/workflows/ci.yml".to_owned()),
line: Some(7),
rule: "unpinned".to_owned(),
message: "action actions/checkout uses tag reference v4 instead of SHA pin".to_owned(),
};
let result = line.format_line(false);
assert_eq!(
result.matches(".github/workflows/ci.yml").count(),
1,
"workflow path must appear exactly once, got: {result}"
);
}
#[test]
fn format_line_summary_no_color() {
let line = Line::Summary {
text: "All done".to_owned(),
};
let result = line.format_line(false);
assert!(result.contains("✓"));
assert!(result.contains("All done"));
}
#[test]
fn format_line_blank_no_color() {
let line = Line::Blank;
let result = line.format_line(false);
assert_eq!(result, "");
}
#[test]
fn format_line_added_no_color() {
let line = Line::Added {
action: "actions/setup-node".to_owned(),
version: "v4".to_owned(),
};
let result = line.format_line(false);
assert!(result.contains('+'));
assert!(result.contains("actions/setup-node"));
assert!(result.contains("v4"));
}
#[test]
fn format_line_log_path_no_color() {
let line = Line::LogPath {
path: PathBuf::from("/tmp/gx/test.log"),
};
let result = line.format_line(false);
assert!(result.contains("📋"));
assert!(result.contains("test.log"));
}
}