drft-cli 0.7.0

A structural integrity checker for linked file systems
Documentation
use crate::config::RuleSeverity;
use serde::Serialize;

#[derive(Debug, Clone, Serialize)]
pub struct Diagnostic {
    pub rule: String,
    pub severity: RuleSeverity,
    pub message: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub source: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub target: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub node: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub via: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub path: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub fix: Option<String>,
}

impl Default for Diagnostic {
    fn default() -> Self {
        Diagnostic {
            rule: String::new(),
            severity: RuleSeverity::Warn,
            message: String::new(),
            source: None,
            target: None,
            node: None,
            via: None,
            path: None,
            fix: None,
        }
    }
}

impl Diagnostic {
    pub fn format_text(&self) -> String {
        let severity = match self.severity {
            RuleSeverity::Error => "error",
            RuleSeverity::Warn => "warn",
            RuleSeverity::Off => "off",
        };

        match (&self.source, &self.target) {
            (Some(source), Some(target)) => {
                let via_suffix = self
                    .via
                    .as_ref()
                    .map(|v| format!(" via {v}"))
                    .unwrap_or_default();
                format!(
                    "{severity}[{}]: {source} \u{2192} {target} ({}{})",
                    self.rule, self.message, via_suffix
                )
            }
            _ if self.path.is_some() => {
                let path = self.path.as_ref().unwrap();
                let cycle = path.join(" \u{2192} ");
                format!("{severity}[{}]: {}: {cycle}", self.rule, self.message)
            }
            _ if self.node.is_some() => {
                let node = self.node.as_ref().unwrap();
                match &self.via {
                    Some(via) => {
                        format!("{severity}[{}]: {node} ({} {via})", self.rule, self.message)
                    }
                    None => format!("{severity}[{}]: {node} ({})", self.rule, self.message),
                }
            }
            _ => format!("{severity}[{}]: {}", self.rule, self.message),
        }
    }

    pub fn format_text_color(&self) -> String {
        let (severity_str, color) = match self.severity {
            RuleSeverity::Error => ("error", "\x1b[1;31m"), // bold red
            RuleSeverity::Warn => ("warn", "\x1b[1;33m"),   // bold yellow
            RuleSeverity::Off => ("off", "\x1b[0m"),
        };
        let reset = "\x1b[0m";
        let bold = "\x1b[1m";
        let cyan = "\x1b[36m";

        match (&self.source, &self.target) {
            (Some(source), Some(target)) => {
                let via_suffix = self
                    .via
                    .as_ref()
                    .map(|v| format!(" via {cyan}{v}{reset}"))
                    .unwrap_or_default();
                format!(
                    "{color}{severity_str}{reset}[{bold}{}{reset}]: {cyan}{source}{reset} \u{2192} {cyan}{target}{reset} ({}{})",
                    self.rule, self.message, via_suffix
                )
            }
            _ if self.path.is_some() => {
                let path = self.path.as_ref().unwrap();
                let cycle = path
                    .iter()
                    .map(|p| format!("{cyan}{p}{reset}"))
                    .collect::<Vec<_>>()
                    .join(" \u{2192} ");
                format!(
                    "{color}{severity_str}{reset}[{bold}{}{reset}]: {}: {cycle}",
                    self.rule, self.message
                )
            }
            _ if self.node.is_some() => {
                let node = self.node.as_ref().unwrap();
                match &self.via {
                    Some(via) => format!(
                        "{color}{severity_str}{reset}[{bold}{}{reset}]: {cyan}{node}{reset} ({} {cyan}{via}{reset})",
                        self.rule, self.message
                    ),
                    None => format!(
                        "{color}{severity_str}{reset}[{bold}{}{reset}]: {cyan}{node}{reset} ({})",
                        self.rule, self.message
                    ),
                }
            }
            _ => format!(
                "{color}{severity_str}{reset}[{bold}{}{reset}]: {}",
                self.rule, self.message
            ),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn text_format_edge_rule() {
        let d = Diagnostic {
            rule: "unresolved-edge".into(),
            severity: RuleSeverity::Warn,
            message: "file not found".into(),
            source: Some("index.md".into()),
            target: Some("gone.md".into()),
            ..Default::default()
        };
        assert_eq!(
            d.format_text(),
            "warn[unresolved-edge]: index.md \u{2192} gone.md (file not found)"
        );
    }

    #[test]
    fn json_serialization() {
        let d = Diagnostic {
            rule: "unresolved-edge".into(),
            severity: RuleSeverity::Warn,
            message: "file not found".into(),
            source: Some("index.md".into()),
            target: Some("gone.md".into()),
            ..Default::default()
        };
        let json = serde_json::to_string(&d).unwrap();
        assert!(json.contains("\"rule\":\"unresolved-edge\""));
        assert!(json.contains("\"severity\":\"warn\""));
        assert!(json.contains("\"source\":\"index.md\""));
        assert!(json.contains("\"target\":\"gone.md\""));
        assert!(!json.contains("\"node\""));
    }
}