ito_core/validate/
issue.rs1use super::{
16 LEVEL_ERROR, LEVEL_INFO, LEVEL_WARNING, ValidationIssue, ValidationLevel,
17 format_specs::FormatSpecRef,
18};
19
20pub fn issue(
25 level: ValidationLevel,
26 path: impl AsRef<str>,
27 message: impl Into<String>,
28) -> ValidationIssue {
29 ValidationIssue {
30 level: level.to_string(),
31 path: path.as_ref().to_string(),
32 message: message.into(),
33 line: None,
34 column: None,
35 metadata: None,
36 }
37}
38
39pub fn error(path: impl AsRef<str>, message: impl Into<String>) -> ValidationIssue {
43 issue(LEVEL_ERROR, path, message)
44}
45
46pub fn warning(path: impl AsRef<str>, message: impl Into<String>) -> ValidationIssue {
51 issue(LEVEL_WARNING, path, message)
52}
53
54pub fn info(path: impl AsRef<str>, message: impl Into<String>) -> ValidationIssue {
59 issue(LEVEL_INFO, path, message)
60}
61
62pub fn with_line(mut i: ValidationIssue, line: u32) -> ValidationIssue {
66 i.line = Some(line);
67 i
68}
69
70pub fn with_loc(mut i: ValidationIssue, line: u32, column: u32) -> ValidationIssue {
74 i.line = Some(line);
75 i.column = Some(column);
76 i
77}
78
79pub fn with_metadata(mut i: ValidationIssue, metadata: serde_json::Value) -> ValidationIssue {
84 i.metadata = Some(metadata);
85 i
86}
87
88pub(crate) fn with_format_spec(mut i: ValidationIssue, spec: FormatSpecRef) -> ValidationIssue {
90 let mut obj = match i.metadata.take() {
91 Some(serde_json::Value::Object(map)) => map,
92 Some(other) => {
93 let mut map = serde_json::Map::new();
94 map.insert("original_metadata".to_string(), other);
95 map
96 }
97 None => serde_json::Map::new(),
98 };
99 obj.insert(
100 "validator_id".to_string(),
101 serde_json::Value::String(spec.validator_id.to_string()),
102 );
103 obj.insert(
104 "spec_path".to_string(),
105 serde_json::Value::String(spec.spec_path.to_string()),
106 );
107 i.metadata = Some(serde_json::Value::Object(obj));
108
109 if !i.message.contains(spec.validator_id) {
110 i.message = format!(
111 "{} (validator: {})",
112 i.message.trim_end(),
113 spec.validator_id
114 );
115 }
116 i
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122
123 #[test]
124 fn constructors_set_expected_fields() {
125 let err = error("spec.md", "missing requirement");
126 let warn = warning("spec.md", "brief purpose");
127 let info_issue = info("spec.md", "looks good");
128
129 assert_eq!(err.level, LEVEL_ERROR);
130 assert_eq!(err.path, "spec.md");
131 assert_eq!(err.message, "missing requirement");
132 assert_eq!(err.line, None);
133 assert_eq!(err.column, None);
134 assert_eq!(err.metadata, None);
135
136 assert_eq!(warn.level, LEVEL_WARNING);
137 assert_eq!(info_issue.level, LEVEL_INFO);
138 }
139
140 #[test]
141 fn location_helpers_set_line_and_column() {
142 let base = issue(LEVEL_WARNING, "tasks.md", "task warning");
143
144 let with_line_only = with_line(base.clone(), 8);
145 assert_eq!(with_line_only.line, Some(8));
146 assert_eq!(with_line_only.column, None);
147
148 let with_both = with_loc(base, 11, 3);
149 assert_eq!(with_both.line, Some(11));
150 assert_eq!(with_both.column, Some(3));
151 }
152
153 #[test]
154 fn metadata_helper_attaches_json_context() {
155 let base = issue(LEVEL_ERROR, "config.json", "invalid value");
156 let metadata = serde_json::json!({ "expected": "string", "actual": 42 });
157
158 let enriched = with_metadata(base, metadata.clone());
159
160 assert_eq!(enriched.metadata, Some(metadata));
161 }
162
163 #[test]
164 fn format_spec_preserves_non_object_metadata() {
165 let base = with_metadata(
166 error("tasks.md", "bad"),
167 serde_json::Value::String("preexisting".to_string()),
168 );
169 let out = with_format_spec(base, super::super::format_specs::TASKS_TRACKING_V1);
170
171 let Some(meta) = out.metadata.as_ref().and_then(|m| m.as_object()) else {
172 panic!("expected metadata object");
173 };
174 assert_eq!(
175 meta.get("original_metadata").and_then(|v| v.as_str()),
176 Some("preexisting")
177 );
178 assert_eq!(
179 meta.get("validator_id").and_then(|v| v.as_str()),
180 Some("ito.tasks-tracking.v1")
181 );
182 }
183
184 #[test]
185 fn format_spec_is_idempotent_for_message_suffix() {
186 let base = error("specs", "no deltas");
187 let out1 = with_format_spec(base, super::super::format_specs::DELTA_SPECS_V1);
188 let out2 = with_format_spec(out1.clone(), super::super::format_specs::DELTA_SPECS_V1);
189 assert_eq!(out1.message, out2.message);
190 assert!(out2.message.contains("ito.delta-specs.v1"));
191 }
192}