feluda 1.12.0

A CLI tool to check dependency licenses.
use crate::debug::{FeludaError, FeludaResult};
use serde::{Deserialize, Serialize};
use std::fs;

#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum IssueSeverity {
    Error,
    Warning,
    Info,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationIssue {
    pub severity: IssueSeverity,
    pub message: String,
    pub field: Option<String>,
    pub line: Option<usize>,
}

impl ValidationIssue {
    pub fn error(message: impl Into<String>) -> Self {
        Self {
            severity: IssueSeverity::Error,
            message: message.into(),
            field: None,
            line: None,
        }
    }

    pub fn warning(message: impl Into<String>) -> Self {
        Self {
            severity: IssueSeverity::Warning,
            message: message.into(),
            field: None,
            line: None,
        }
    }

    pub fn info(message: impl Into<String>) -> Self {
        Self {
            severity: IssueSeverity::Info,
            message: message.into(),
            field: None,
            line: None,
        }
    }

    pub fn with_field(mut self, field: impl Into<String>) -> Self {
        self.field = Some(field.into());
        self
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationReport {
    pub sbom_type: String,
    pub is_valid: bool,
    pub issues: Vec<ValidationIssue>,
    pub error_count: usize,
    pub warning_count: usize,
    pub info_count: usize,
}

impl ValidationReport {
    pub fn new(sbom_type: impl Into<String>) -> Self {
        Self {
            sbom_type: sbom_type.into(),
            is_valid: true,
            issues: Vec::new(),
            error_count: 0,
            warning_count: 0,
            info_count: 0,
        }
    }

    pub fn add_issue(&mut self, issue: ValidationIssue) {
        match issue.severity {
            IssueSeverity::Error => {
                self.error_count += 1;
                self.is_valid = false;
            }
            IssueSeverity::Warning => self.warning_count += 1,
            IssueSeverity::Info => self.info_count += 1,
        }
        self.issues.push(issue);
    }

    pub fn write_output(&self, json: bool, output: Option<String>) -> FeludaResult<()> {
        let output_string = if json {
            serde_json::to_string_pretty(&self).map_err(|e| {
                FeludaError::Serialization(format!("Failed to serialize report: {e}"))
            })?
        } else {
            self.format_text()
        };

        if let Some(path) = output {
            fs::write(&path, &output_string).map_err(|e| {
                FeludaError::FileWrite(format!("Failed to write report to {path}: {e}"))
            })?;
            println!("Report written to: {path}");
        } else {
            println!("{output_string}");
        }

        Ok(())
    }

    fn format_text(&self) -> String {
        use owo_colors::OwoColorize;

        let mut output = String::new();
        output.push_str(&format!("\n{}\n", "".repeat(60)).bold().to_string());
        let status_icon = if self.is_valid {
            format!("{}", "".green())
        } else {
            format!("{}", "".red())
        };
        output.push_str(&format!(
            "{} {} SBOM Validation Report\n",
            "".bold(),
            status_icon
        ));
        output.push_str(&format!("{}\n", "".repeat(60)).bold().to_string());

        output.push_str(&format!("SBOM Type: {}\n", self.sbom_type.bright_cyan()));
        output.push_str(&format!(
            "Status: {}\n",
            if self.is_valid {
                "VALID".green().to_string()
            } else {
                "INVALID".red().to_string()
            }
        ));

        output.push_str("\nIssues Summary:\n");
        output.push_str(&format!(
            "  Errors:   {}\n",
            if self.error_count > 0 {
                format!("{}", self.error_count).red().to_string()
            } else {
                format!("{}", self.error_count).green().to_string()
            }
        ));
        output.push_str(&format!(
            "  Warnings: {}\n",
            if self.warning_count > 0 {
                format!("{}", self.warning_count).yellow().to_string()
            } else {
                format!("{}", self.warning_count).green().to_string()
            }
        ));
        output.push_str(&format!("  Info:     {}\n", self.info_count.bright_blue()));

        if !self.issues.is_empty() {
            output.push_str("\nDetailed Issues:\n");
            output.push_str(&format!("{}\n", "".repeat(60)));

            for issue in &self.issues {
                let severity_str = match issue.severity {
                    IssueSeverity::Error => "[ERROR]".red().to_string(),
                    IssueSeverity::Warning => "[WARN]".yellow().to_string(),
                    IssueSeverity::Info => "[INFO]".blue().to_string(),
                };

                output.push_str(&format!("{severity_str} {}\n", issue.message));

                if let Some(ref field) = issue.field {
                    output.push_str(&format!("        Field: {}\n", field.bright_black()));
                }

                if let Some(line) = issue.line {
                    output.push_str(&format!("        Line: {line}\n"));
                }
            }

            output.push_str(&format!("{}\n", "".repeat(60)));
        }

        output
    }
}