Skip to main content

agm_core/error/
output.rs

1//! Error output formatting (spec section 21.4).
2
3use std::fmt;
4
5use serde::{Deserialize, Serialize};
6
7use super::diagnostic::AgmError;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "lowercase")]
11pub enum ErrorOutputFormat {
12    Text,
13    Json,
14}
15
16impl fmt::Display for ErrorOutputFormat {
17    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
18        match self {
19            Self::Text => write!(f, "text"),
20            Self::Json => write!(f, "json"),
21        }
22    }
23}
24
25impl std::str::FromStr for ErrorOutputFormat {
26    type Err = String;
27
28    fn from_str(s: &str) -> Result<Self, Self::Err> {
29        match s.to_lowercase().as_str() {
30            "text" => Ok(Self::Text),
31            "json" => Ok(Self::Json),
32            other => Err(format!(
33                "unknown error output format: {other} (expected `text` or `json`)"
34            )),
35        }
36    }
37}
38
39#[must_use]
40pub fn format_error_text(error: &AgmError) -> String {
41    let file = error.location.file.as_deref().unwrap_or("<unknown>");
42    let location = match error.location.line {
43        Some(line) => format!("{file}:{line}"),
44        None => file.to_string(),
45    };
46    format!(
47        "{location} [{code}] {severity}: {message}",
48        code = error.code,
49        severity = error.severity,
50        message = error.message,
51    )
52}
53
54#[must_use]
55pub fn format_error_json(error: &AgmError) -> serde_json::Value {
56    let mut map = serde_json::Map::new();
57    map.insert(
58        "code".to_string(),
59        serde_json::Value::String(error.code.to_string()),
60    );
61    map.insert(
62        "severity".to_string(),
63        serde_json::Value::String(error.severity.to_string()),
64    );
65    map.insert(
66        "message".to_string(),
67        serde_json::Value::String(error.message.clone()),
68    );
69    if let Some(ref file) = error.location.file {
70        map.insert("file".to_string(), serde_json::Value::String(file.clone()));
71    }
72    if let Some(line) = error.location.line {
73        map.insert(
74            "line".to_string(),
75            serde_json::Value::Number(serde_json::Number::from(line)),
76        );
77    }
78    if let Some(ref node) = error.location.node {
79        map.insert("node".to_string(), serde_json::Value::String(node.clone()));
80    }
81    serde_json::Value::Object(map)
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87    use crate::error::codes::ErrorCode;
88    use crate::error::diagnostic::{AgmError, ErrorLocation};
89
90    #[test]
91    fn test_format_error_text_full_location() {
92        let err = AgmError::new(
93            ErrorCode::V003,
94            "Duplicate node ID: `auth.login`",
95            ErrorLocation::full("file.agm", 42, "auth.login"),
96        );
97        let text = format_error_text(&err);
98        assert_eq!(
99            text,
100            "file.agm:42 [AGM-V003] error: Duplicate node ID: `auth.login`"
101        );
102    }
103
104    #[test]
105    fn test_format_error_text_warning() {
106        let err = AgmError::new(
107            ErrorCode::V010,
108            "Node type `workflow` typically includes field `steps` (missing)",
109            ErrorLocation::full("file.agm", 87, "deploy.step3"),
110        );
111        let text = format_error_text(&err);
112        assert_eq!(
113            text,
114            "file.agm:87 [AGM-V010] warning: Node type `workflow` typically includes field `steps` (missing)"
115        );
116    }
117
118    #[test]
119    fn test_format_error_text_no_file_uses_unknown() {
120        let err = AgmError::new(
121            ErrorCode::P008,
122            "Empty file (no nodes)",
123            ErrorLocation::default(),
124        );
125        let text = format_error_text(&err);
126        assert_eq!(text, "<unknown> [AGM-P008] error: Empty file (no nodes)");
127    }
128
129    #[test]
130    fn test_format_error_text_file_without_line() {
131        let err = AgmError::new(
132            ErrorCode::P008,
133            "Empty file (no nodes)",
134            ErrorLocation::new(Some("empty.agm".to_string()), None, None),
135        );
136        let text = format_error_text(&err);
137        assert_eq!(text, "empty.agm [AGM-P008] error: Empty file (no nodes)");
138    }
139
140    #[test]
141    fn test_format_error_json_full_location() {
142        let err = AgmError::new(
143            ErrorCode::V003,
144            "Duplicate node ID: `auth.login`",
145            ErrorLocation::full("file.agm", 42, "auth.login"),
146        );
147        let json = format_error_json(&err);
148        assert_eq!(json["code"], "AGM-V003");
149        assert_eq!(json["severity"], "error");
150        assert_eq!(json["message"], "Duplicate node ID: `auth.login`");
151        assert_eq!(json["file"], "file.agm");
152        assert_eq!(json["line"], 42);
153        assert_eq!(json["node"], "auth.login");
154    }
155
156    #[test]
157    fn test_format_error_json_minimal_location() {
158        let err = AgmError::new(
159            ErrorCode::P008,
160            "Empty file (no nodes)",
161            ErrorLocation::default(),
162        );
163        let json = format_error_json(&err);
164        assert_eq!(json["code"], "AGM-P008");
165        assert_eq!(json["severity"], "error");
166        assert!(json.get("file").is_none());
167        assert!(json.get("line").is_none());
168        assert!(json.get("node").is_none());
169    }
170
171    #[test]
172    fn test_format_error_json_is_valid_json() {
173        let err = AgmError::new(
174            ErrorCode::V003,
175            "test message",
176            ErrorLocation::full("file.agm", 42, "auth.login"),
177        );
178        let json = format_error_json(&err);
179        let json_string = serde_json::to_string(&json).unwrap();
180        let reparsed: serde_json::Value = serde_json::from_str(&json_string).unwrap();
181        assert_eq!(json, reparsed);
182    }
183
184    #[test]
185    fn test_format_error_json_warning_severity_value() {
186        let err = AgmError::new(ErrorCode::V010, "test", ErrorLocation::default());
187        let json = format_error_json(&err);
188        assert_eq!(json["severity"], "warning");
189    }
190
191    #[test]
192    fn test_format_error_json_info_severity_value() {
193        let err = AgmError::new(ErrorCode::P010, "test", ErrorLocation::default());
194        let json = format_error_json(&err);
195        assert_eq!(json["severity"], "info");
196    }
197
198    #[test]
199    fn test_error_output_format_from_str_text() {
200        assert_eq!(
201            "text".parse::<ErrorOutputFormat>().unwrap(),
202            ErrorOutputFormat::Text
203        );
204        assert_eq!(
205            "TEXT".parse::<ErrorOutputFormat>().unwrap(),
206            ErrorOutputFormat::Text
207        );
208    }
209
210    #[test]
211    fn test_error_output_format_from_str_json() {
212        assert_eq!(
213            "json".parse::<ErrorOutputFormat>().unwrap(),
214            ErrorOutputFormat::Json
215        );
216        assert_eq!(
217            "JSON".parse::<ErrorOutputFormat>().unwrap(),
218            ErrorOutputFormat::Json
219        );
220    }
221
222    #[test]
223    fn test_error_output_format_from_str_invalid() {
224        assert!("xml".parse::<ErrorOutputFormat>().is_err());
225    }
226
227    #[test]
228    fn test_error_output_format_display() {
229        assert_eq!(ErrorOutputFormat::Text.to_string(), "text");
230        assert_eq!(ErrorOutputFormat::Json.to_string(), "json");
231    }
232}