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 rule_id: None,
36 metadata: None,
37 }
38}
39
40pub fn error(path: impl AsRef<str>, message: impl Into<String>) -> ValidationIssue {
44 issue(LEVEL_ERROR, path, message)
45}
46
47pub fn warning(path: impl AsRef<str>, message: impl Into<String>) -> ValidationIssue {
52 issue(LEVEL_WARNING, path, message)
53}
54
55pub fn info(path: impl AsRef<str>, message: impl Into<String>) -> ValidationIssue {
60 issue(LEVEL_INFO, path, message)
61}
62
63pub fn with_line(mut i: ValidationIssue, line: u32) -> ValidationIssue {
67 i.line = Some(line);
68 i
69}
70
71pub fn with_loc(mut i: ValidationIssue, line: u32, column: u32) -> ValidationIssue {
75 i.line = Some(line);
76 i.column = Some(column);
77 i
78}
79
80pub fn with_metadata(mut i: ValidationIssue, metadata: serde_json::Value) -> ValidationIssue {
85 i.metadata = Some(metadata);
86 i
87}
88
89pub fn with_rule_id(mut i: ValidationIssue, rule_id: impl Into<String>) -> ValidationIssue {
91 i.rule_id = Some(rule_id.into());
92 i
93}
94
95pub(crate) fn with_format_spec(mut i: ValidationIssue, spec: FormatSpecRef) -> ValidationIssue {
97 let mut obj = match i.metadata.take() {
98 Some(serde_json::Value::Object(map)) => map,
99 Some(other) => {
100 let mut map = serde_json::Map::new();
101 map.insert("original_metadata".to_string(), other);
102 map
103 }
104 None => serde_json::Map::new(),
105 };
106 obj.insert(
107 "validator_id".to_string(),
108 serde_json::Value::String(spec.validator_id.to_string()),
109 );
110 obj.insert(
111 "spec_path".to_string(),
112 serde_json::Value::String(spec.spec_path.to_string()),
113 );
114 if let Some(rule_id) = i.rule_id.as_ref() {
115 obj.insert(
116 "rule_id".to_string(),
117 serde_json::Value::String(rule_id.clone()),
118 );
119 }
120 i.metadata = Some(serde_json::Value::Object(obj));
121
122 if !i.message.contains(spec.validator_id) {
123 i.message = format!(
124 "{} (validator: {})",
125 i.message.trim_end(),
126 spec.validator_id
127 );
128 }
129 i
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135
136 #[test]
137 fn constructors_set_expected_fields() {
138 let err = error("spec.md", "missing requirement");
139 let warn = warning("spec.md", "brief purpose");
140 let info_issue = info("spec.md", "looks good");
141
142 assert_eq!(err.level, LEVEL_ERROR);
143 assert_eq!(err.path, "spec.md");
144 assert_eq!(err.message, "missing requirement");
145 assert_eq!(err.line, None);
146 assert_eq!(err.column, None);
147 assert_eq!(err.metadata, None);
148
149 assert_eq!(warn.level, LEVEL_WARNING);
150 assert_eq!(info_issue.level, LEVEL_INFO);
151 }
152
153 #[test]
154 fn location_helpers_set_line_and_column() {
155 let base = issue(LEVEL_WARNING, "tasks.md", "task warning");
156
157 let with_line_only = with_line(base.clone(), 8);
158 assert_eq!(with_line_only.line, Some(8));
159 assert_eq!(with_line_only.column, None);
160
161 let with_both = with_loc(base, 11, 3);
162 assert_eq!(with_both.line, Some(11));
163 assert_eq!(with_both.column, Some(3));
164 }
165
166 #[test]
167 fn metadata_helper_attaches_json_context() {
168 let base = issue(LEVEL_ERROR, "config.json", "invalid value");
169 let metadata = serde_json::json!({ "expected": "string", "actual": 42 });
170
171 let enriched = with_metadata(base, metadata.clone());
172
173 assert_eq!(enriched.metadata, Some(metadata));
174 }
175
176 #[test]
177 fn rule_id_helper_marks_issue_and_is_reflected_in_metadata() {
178 let base = with_rule_id(error("spec.md", "invalid scenario"), "scenario_grammar");
179 let out = with_format_spec(base, super::super::format_specs::DELTA_SPECS_V1);
180
181 assert_eq!(out.rule_id.as_deref(), Some("scenario_grammar"));
182 let Some(meta) = out.metadata.as_ref().and_then(|m| m.as_object()) else {
183 panic!("expected metadata object");
184 };
185 assert_eq!(
186 meta.get("rule_id").and_then(|value| value.as_str()),
187 Some("scenario_grammar")
188 );
189 }
190
191 #[test]
192 fn format_spec_preserves_non_object_metadata() {
193 let base = with_metadata(
194 error("tasks.md", "bad"),
195 serde_json::Value::String("preexisting".to_string()),
196 );
197 let out = with_format_spec(base, super::super::format_specs::TASKS_TRACKING_V1);
198
199 let Some(meta) = out.metadata.as_ref().and_then(|m| m.as_object()) else {
200 panic!("expected metadata object");
201 };
202 assert_eq!(
203 meta.get("original_metadata").and_then(|v| v.as_str()),
204 Some("preexisting")
205 );
206 assert_eq!(
207 meta.get("validator_id").and_then(|v| v.as_str()),
208 Some("ito.tasks-tracking.v1")
209 );
210 }
211
212 #[test]
213 fn format_spec_is_idempotent_for_message_suffix() {
214 let base = error("specs", "no deltas");
215 let out1 = with_format_spec(base, super::super::format_specs::DELTA_SPECS_V1);
216 let out2 = with_format_spec(out1.clone(), super::super::format_specs::DELTA_SPECS_V1);
217 assert_eq!(out1.message, out2.message);
218 assert!(out2.message.contains("ito.delta-specs.v1"));
219 }
220}