Skip to main content

ito_core/validate/
issue.rs

1//! Helpers for constructing validation issues.
2//!
3//! This module provides a fluent API for creating and enriching `ValidationIssue` instances.
4//! It is the primary way to generate issues during validation logic.
5//!
6//! # Usage
7//!
8//! ```no_run
9//! use ito_core::validate::{error, warning, with_loc};
10//!
11//! let err = error("path/to/file", "Something went wrong");
12//! let warn = with_loc(warning("path/to/file", "Check this"), 10, 5);
13//! ```
14
15use super::{
16    LEVEL_ERROR, LEVEL_INFO, LEVEL_WARNING, ValidationIssue, ValidationLevel,
17    format_specs::FormatSpecRef,
18};
19
20/// Construct a [`ValidationIssue`] with a fixed `level`, `path`, and message.
21///
22/// This is the low-level constructor. Prefer using [`error`], [`warning`], or [`info`]
23/// for better readability.
24pub 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
39/// Creates an `ERROR` level issue.
40///
41/// Use this for validation failures that must prevent the operation from succeeding.
42pub fn error(path: impl AsRef<str>, message: impl Into<String>) -> ValidationIssue {
43    issue(LEVEL_ERROR, path, message)
44}
45
46/// Creates a `WARNING` level issue.
47///
48/// Use this for potential problems that should be fixed but do not strictly prevent
49/// the operation from succeeding (unless strict mode is enabled).
50pub fn warning(path: impl AsRef<str>, message: impl Into<String>) -> ValidationIssue {
51    issue(LEVEL_WARNING, path, message)
52}
53
54/// Creates an `INFO` level issue.
55///
56/// Use this for informational messages, successful checks, or context that helps
57/// the user understand the validation state.
58pub fn info(path: impl AsRef<str>, message: impl Into<String>) -> ValidationIssue {
59    issue(LEVEL_INFO, path, message)
60}
61
62/// Attach a 1-based line number to an existing issue.
63///
64/// Use this when the issue can be pinpointed to a specific line.
65pub fn with_line(mut i: ValidationIssue, line: u32) -> ValidationIssue {
66    i.line = Some(line);
67    i
68}
69
70/// Attach a 1-based line + column location to an existing issue.
71///
72/// Use this when precise location information is available.
73pub 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
79/// Attach structured metadata to an existing issue.
80///
81/// Use this to attach extra JSON-serializable context (e.g., "expected" vs "actual" values)
82/// that can be used by machine-readable output formats.
83pub fn with_metadata(mut i: ValidationIssue, metadata: serde_json::Value) -> ValidationIssue {
84    i.metadata = Some(metadata);
85    i
86}
87
88/// Attach a stable validator id and spec path reference.
89pub(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}