Skip to main content

kaish_tool_api/
issue.rs

1//! Validation issues and formatting.
2
3use std::fmt;
4
5/// Severity level for validation issues.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum Severity {
8    /// Errors prevent execution.
9    Error,
10    /// Warnings are advisory but allow execution.
11    Warning,
12}
13
14impl fmt::Display for Severity {
15    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
16        match self {
17            Severity::Error => write!(f, "error"),
18            Severity::Warning => write!(f, "warning"),
19        }
20    }
21}
22
23/// Categorizes validation issues for filtering and tooling.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum IssueCode {
26    /// Command not found in registry or user tools.
27    UndefinedCommand,
28    /// Required parameter not provided.
29    MissingRequiredArg,
30    /// Flag not defined in tool schema.
31    UnknownFlag,
32    /// Argument type doesn't match schema.
33    InvalidArgType,
34    /// seq increment is zero (infinite loop).
35    SeqZeroIncrement,
36    /// Regex pattern is invalid.
37    InvalidRegex,
38    /// break/continue outside of a loop.
39    BreakOutsideLoop,
40    /// return outside of a function.
41    ReturnOutsideFunction,
42    /// Variable may be undefined.
43    PossiblyUndefinedVariable,
44    /// Bare scalar variable in for loop (no word splitting in kaish).
45    ForLoopScalarVar,
46    /// scatter without gather — parallel results would be lost.
47    ScatterWithoutGather,
48    /// Field access on `$?` (e.g. `${?.data}`, `${?.ok}`) was removed.
49    /// `$?` is the POSIX exit code; use `kaish-last` for structured data.
50    LastResultFieldAccess,
51    /// diff was given other than two file operands.
52    DiffNeedsTwoFiles,
53    /// sed expression is syntactically invalid.
54    InvalidSedExpr,
55    /// jq filter expression is syntactically invalid.
56    InvalidJqFilter,
57}
58
59impl IssueCode {
60    /// Returns a short code string for the issue.
61    ///
62    /// Code numbers are stable identifiers, not contiguous. E010 and
63    /// W003/W004/W005 remain retired. E006 (InvalidSedExpr), E007
64    /// (InvalidJqFilter), and E011 (DiffNeedsTwoFiles) were wired up with
65    /// real emitters in 2026-06-14.
66    pub fn code(&self) -> &'static str {
67        match self {
68            IssueCode::UndefinedCommand => "E001",
69            IssueCode::MissingRequiredArg => "E002",
70            IssueCode::UnknownFlag => "W001",
71            IssueCode::InvalidArgType => "E003",
72            IssueCode::SeqZeroIncrement => "E004",
73            IssueCode::InvalidRegex => "E005",
74            IssueCode::InvalidSedExpr => "E006",
75            IssueCode::InvalidJqFilter => "E007",
76            IssueCode::BreakOutsideLoop => "E008",
77            IssueCode::ReturnOutsideFunction => "E009",
78            // E010 retired — never emitted
79            IssueCode::PossiblyUndefinedVariable => "W002",
80            IssueCode::DiffNeedsTwoFiles => "E011",
81            IssueCode::ForLoopScalarVar => "E012",
82            IssueCode::ScatterWithoutGather => "E014",
83            IssueCode::LastResultFieldAccess => "E015",
84        }
85    }
86
87    /// Default severity for this issue code.
88    pub fn default_severity(&self) -> Severity {
89        match self {
90            // These are hard errors that will definitely fail at runtime
91            IssueCode::SeqZeroIncrement
92            | IssueCode::InvalidRegex
93            | IssueCode::InvalidSedExpr
94            | IssueCode::InvalidJqFilter
95            | IssueCode::DiffNeedsTwoFiles
96            | IssueCode::BreakOutsideLoop
97            | IssueCode::ReturnOutsideFunction
98            | IssueCode::ForLoopScalarVar
99            | IssueCode::ScatterWithoutGather
100            | IssueCode::LastResultFieldAccess => Severity::Error,
101
102            // These are warnings because context matters:
103            // - MissingRequiredArg: might be provided by pipeline stdin or environment
104            // - InvalidArgType: shell coerces types at runtime
105            // - UndefinedCommand: might be script in PATH or external tool
106            IssueCode::MissingRequiredArg
107            | IssueCode::InvalidArgType
108            | IssueCode::UndefinedCommand
109            | IssueCode::UnknownFlag
110            | IssueCode::PossiblyUndefinedVariable => Severity::Warning,
111        }
112    }
113}
114
115impl fmt::Display for IssueCode {
116    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117        write!(f, "{}", self.code())
118    }
119}
120
121/// Source location span.
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
123pub struct Span {
124    /// Start byte offset in source.
125    pub start: usize,
126    /// End byte offset in source.
127    pub end: usize,
128}
129
130impl Span {
131    /// Create a new span.
132    pub fn new(start: usize, end: usize) -> Self {
133        Self { start, end }
134    }
135
136    /// Convert byte offset to line:column.
137    ///
138    /// Returns (line, column) where both are 1-indexed.
139    pub fn to_line_col(&self, source: &str) -> (usize, usize) {
140        let mut line = 1;
141        let mut col = 1;
142
143        for (i, ch) in source.char_indices() {
144            if i >= self.start {
145                break;
146            }
147            if ch == '\n' {
148                line += 1;
149                col = 1;
150            } else {
151                col += 1;
152            }
153        }
154
155        (line, col)
156    }
157
158    /// Format span as "line:col" string.
159    pub fn format_location(&self, source: &str) -> String {
160        let (line, col) = self.to_line_col(source);
161        format!("{}:{}", line, col)
162    }
163}
164
165/// A validation issue found in the script.
166#[derive(Debug, Clone)]
167#[non_exhaustive]
168pub struct ValidationIssue {
169    /// Severity level.
170    pub severity: Severity,
171    /// Issue category code.
172    pub code: IssueCode,
173    /// Human-readable message.
174    pub message: String,
175    /// Optional source location.
176    pub span: Option<Span>,
177    /// Optional suggestion for fixing the issue.
178    pub suggestion: Option<String>,
179}
180
181impl ValidationIssue {
182    /// Create a new validation error.
183    pub fn error(code: IssueCode, message: impl Into<String>) -> Self {
184        Self {
185            severity: Severity::Error,
186            code,
187            message: message.into(),
188            span: None,
189            suggestion: None,
190        }
191    }
192
193    /// Create a new validation warning.
194    pub fn warning(code: IssueCode, message: impl Into<String>) -> Self {
195        Self {
196            severity: Severity::Warning,
197            code,
198            message: message.into(),
199            span: None,
200            suggestion: None,
201        }
202    }
203
204    /// Add a span to this issue.
205    pub fn with_span(mut self, span: Span) -> Self {
206        self.span = Some(span);
207        self
208    }
209
210    /// Add a suggestion to this issue.
211    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
212        self.suggestion = Some(suggestion.into());
213        self
214    }
215
216    /// Format the issue for display.
217    ///
218    /// With source provided, includes line:column information and source context.
219    pub fn format(&self, source: &str) -> String {
220        let mut result = String::new();
221
222        // Location prefix if we have a span
223        if let Some(span) = &self.span {
224            let loc = span.format_location(source);
225            result.push_str(&format!("{}: ", loc));
226        }
227
228        // Severity and code
229        result.push_str(&format!("{} [{}]: {}", self.severity, self.code, self.message));
230
231        // Suggestion if available
232        if let Some(suggestion) = &self.suggestion {
233            result.push_str(&format!("\n  → {}", suggestion));
234        }
235
236        // Source context if we have a span
237        if let Some(span) = &self.span
238            && let Some(line_content) = get_line_at_offset(source, span.start) {
239                result.push_str(&format!("\n  | {}", line_content));
240            }
241
242        result
243    }
244}
245
246impl fmt::Display for ValidationIssue {
247    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
248        write!(f, "{} [{}]: {}", self.severity, self.code, self.message)
249    }
250}
251
252/// Get the line containing a byte offset.
253fn get_line_at_offset(source: &str, offset: usize) -> Option<&str> {
254    if offset >= source.len() {
255        return None;
256    }
257
258    let start = source[..offset].rfind('\n').map_or(0, |i| i + 1);
259    let end = source[offset..]
260        .find('\n')
261        .map_or(source.len(), |i| offset + i);
262
263    Some(&source[start..end])
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn span_to_line_col_single_line() {
272        let source = "echo hello world";
273        let span = Span::new(5, 10);
274        assert_eq!(span.to_line_col(source), (1, 6));
275    }
276
277    #[test]
278    fn span_to_line_col_multi_line() {
279        let source = "line one\nline two\nline three";
280        // "line" on line 3 starts at offset 18
281        let span = Span::new(18, 22);
282        assert_eq!(span.to_line_col(source), (3, 1));
283    }
284
285    #[test]
286    fn span_format_location() {
287        let source = "first\nsecond\nthird";
288        let span = Span::new(6, 12); // "second"
289        assert_eq!(span.format_location(source), "2:1");
290    }
291
292    #[test]
293    fn issue_formatting() {
294        let issue = ValidationIssue::error(IssueCode::UndefinedCommand, "command 'foo' not found")
295            .with_span(Span::new(0, 3))
296            .with_suggestion("did you mean 'for'?");
297
298        let source = "foo bar";
299        let formatted = issue.format(source);
300
301        assert!(formatted.contains("1:1"));
302        assert!(formatted.contains("error"));
303        assert!(formatted.contains("E001"));
304        assert!(formatted.contains("command 'foo' not found"));
305        assert!(formatted.contains("did you mean 'for'?"));
306    }
307
308    #[test]
309    fn get_line_at_offset_works() {
310        let source = "line one\nline two\nline three";
311        assert_eq!(get_line_at_offset(source, 0), Some("line one"));
312        assert_eq!(get_line_at_offset(source, 9), Some("line two"));
313        assert_eq!(get_line_at_offset(source, 18), Some("line three"));
314    }
315}