gx 0.8.5

CLI to manage Github Actions dependencies
use crate::config::Level;
use console::style;
use std::path::PathBuf;

/// Semantic output line variants produced by render functions.
/// Colors are applied at print boundary — no ANSI codes here.
///
/// Producers pass bare identifiers and never a noun naming their kind: the words a
/// user reads are chosen here, and gx labels workflows, composite actions and
/// components alike.
#[derive(Debug, Clone, PartialEq)]
pub enum Line {
    /// An entry was upgraded from one version to another.
    Upgraded {
        /// Identifier of the upgraded entry.
        id: String,
        from: String,
        to: String,
    },
    /// An entry was added.
    Added {
        /// Identifier of the added entry.
        id: String,
        version: String,
    },
    /// An entry was removed.
    Removed {
        /// Identifier of the removed entry.
        id: String,
    },
    /// An entry was changed with a detail note.
    Changed {
        /// Identifier of the changed entry.
        id: String,
        detail: String,
    },
    /// An entry was skipped with a reason.
    Skipped {
        /// Identifier of the skipped entry.
        id: String,
        reason: String,
    },
    /// A warning message.
    Warning { message: String },
    /// A lint diagnostic.
    LintDiag {
        level: Level,
        workflow: Option<String>,
        /// 1-based source line, rendered as `workflow:line` when both are present.
        line: Option<u32>,
        rule: String,
        message: String,
    },
    /// A summary line (success/result).
    Summary { text: String },
    /// The log file path shown at end of output.
    LogPath { path: PathBuf },
    /// CI mode notice.
    CiNotice { message: String },
    /// A blank separator line.
    Blank,
}

impl Line {
    /// Format this line into a printable string, optionally with ANSI color.
    #[must_use]
    pub fn format_line(&self, use_color: bool) -> String {
        match self {
            Line::Upgraded { id, from, to } => {
                let arrow = if use_color {
                    style("↑").cyan().to_string()
                } else {
                    "↑".to_owned()
                };
                format!(" {arrow} {id:<30} {from} → {to}")
            }
            Line::Added { id, version } => {
                let plus = if use_color {
                    style("+").green().to_string()
                } else {
                    "+".to_owned()
                };
                format!(" {plus} {id:<30} {version}")
            }
            Line::Removed { id } => {
                let minus = if use_color {
                    style("−").red().to_string()
                } else {
                    "−".to_owned()
                };
                format!(" {minus} {id}")
            }
            Line::Changed { id, detail } => {
                format!(" ~ {id:<30} {detail}")
            }
            Line::Skipped { id, reason } => {
                format!(" - {id:<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 {
            id: "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"));
        // No line known: the workflow renders as `ci.yml: ` with no numeric `:line` suffix.
        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);
        // A located violation renders `file:line` so the user can jump straight to it.
        assert!(result.contains("ci.yml:12:"), "got: {result}");
    }

    #[test]
    fn format_line_lint_diag_renders_location_once() {
        // The renderer is the single source of the workflow location: rule messages must
        // NOT embed the path themselves, or it prints twice. This guards against a rule
        // re-introducing a leading `{workflow}: ` in its message.
        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 {
            id: "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"));
    }
}