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    /// A POSIX `test` / `[ … ]` conditional was used. kaish has no such command
58    /// (it would resolve to an external binary that bypasses the VFS); use
59    /// `[[ … ]]`, which the validator checks before runtime.
60    PosixTestCommand,
61}
62
63impl IssueCode {
64    /// Returns a short code string for the issue.
65    ///
66    /// Code numbers are stable identifiers, not contiguous. E010 and
67    /// W003/W004/W005 remain retired. E006 (InvalidSedExpr), E007
68    /// (InvalidJqFilter), and E011 (DiffNeedsTwoFiles) were wired up with
69    /// real emitters in 2026-06-14.
70    pub fn code(&self) -> &'static str {
71        match self {
72            IssueCode::UndefinedCommand => "E001",
73            IssueCode::MissingRequiredArg => "E002",
74            IssueCode::UnknownFlag => "W001",
75            IssueCode::InvalidArgType => "E003",
76            IssueCode::SeqZeroIncrement => "E004",
77            IssueCode::InvalidRegex => "E005",
78            IssueCode::InvalidSedExpr => "E006",
79            IssueCode::InvalidJqFilter => "E007",
80            IssueCode::BreakOutsideLoop => "E008",
81            IssueCode::ReturnOutsideFunction => "E009",
82            // E010 retired — never emitted
83            IssueCode::PossiblyUndefinedVariable => "W002",
84            IssueCode::DiffNeedsTwoFiles => "E011",
85            IssueCode::ForLoopScalarVar => "E012",
86            IssueCode::ScatterWithoutGather => "E014",
87            IssueCode::LastResultFieldAccess => "E015",
88            IssueCode::PosixTestCommand => "W006",
89        }
90    }
91
92    /// Whether a warning carrying this code should be surfaced to the agent
93    /// (appended to the result's stderr) rather than only trace-logged.
94    ///
95    /// Most warnings stay trace-only — `UndefinedCommand` fires on every
96    /// external command (`grep`, `cargo`), so surfacing them all would be
97    /// noise. Opt a code in here only when its guidance is worth interrupting
98    /// for. This is the surfacing seam for the "did-you-mean" guidance pass.
99    pub fn surfaces_to_agent(&self) -> bool {
100        matches!(self, IssueCode::PosixTestCommand)
101    }
102
103    /// Default severity for this issue code.
104    pub fn default_severity(&self) -> Severity {
105        match self {
106            // These are hard errors that will definitely fail at runtime
107            IssueCode::SeqZeroIncrement
108            | IssueCode::InvalidRegex
109            | IssueCode::InvalidSedExpr
110            | IssueCode::InvalidJqFilter
111            | IssueCode::DiffNeedsTwoFiles
112            | IssueCode::BreakOutsideLoop
113            | IssueCode::ReturnOutsideFunction
114            | IssueCode::ForLoopScalarVar
115            | IssueCode::ScatterWithoutGather
116            | IssueCode::LastResultFieldAccess => Severity::Error,
117
118            // These are warnings because context matters:
119            // - MissingRequiredArg: might be provided by pipeline stdin or environment
120            // - InvalidArgType: shell coerces types at runtime
121            // - UndefinedCommand: might be script in PATH or external tool
122            IssueCode::MissingRequiredArg
123            | IssueCode::InvalidArgType
124            | IssueCode::UndefinedCommand
125            | IssueCode::UnknownFlag
126            | IssueCode::PosixTestCommand
127            | IssueCode::PossiblyUndefinedVariable => Severity::Warning,
128        }
129    }
130}
131
132impl fmt::Display for IssueCode {
133    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134        write!(f, "{}", self.code())
135    }
136}
137
138/// Source location span.
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
140pub struct Span {
141    /// Start byte offset in source.
142    pub start: usize,
143    /// End byte offset in source.
144    pub end: usize,
145}
146
147impl Span {
148    /// Create a new span.
149    pub fn new(start: usize, end: usize) -> Self {
150        Self { start, end }
151    }
152
153    /// Convert byte offset to line:column.
154    ///
155    /// Returns (line, column) where both are 1-indexed.
156    pub fn to_line_col(&self, source: &str) -> (usize, usize) {
157        let mut line = 1;
158        let mut col = 1;
159
160        for (i, ch) in source.char_indices() {
161            if i >= self.start {
162                break;
163            }
164            if ch == '\n' {
165                line += 1;
166                col = 1;
167            } else {
168                col += 1;
169            }
170        }
171
172        (line, col)
173    }
174
175    /// Format span as "line:col" string.
176    pub fn format_location(&self, source: &str) -> String {
177        let (line, col) = self.to_line_col(source);
178        format!("{}:{}", line, col)
179    }
180}
181
182/// A validation issue found in the script.
183#[derive(Debug, Clone)]
184#[non_exhaustive]
185pub struct ValidationIssue {
186    /// Severity level.
187    pub severity: Severity,
188    /// Issue category code.
189    pub code: IssueCode,
190    /// Human-readable message.
191    pub message: String,
192    /// Optional source location.
193    pub span: Option<Span>,
194    /// Optional suggestion for fixing the issue.
195    pub suggestion: Option<String>,
196}
197
198impl ValidationIssue {
199    /// Create a new validation error.
200    pub fn error(code: IssueCode, message: impl Into<String>) -> Self {
201        Self {
202            severity: Severity::Error,
203            code,
204            message: message.into(),
205            span: None,
206            suggestion: None,
207        }
208    }
209
210    /// Create a new validation warning.
211    pub fn warning(code: IssueCode, message: impl Into<String>) -> Self {
212        Self {
213            severity: Severity::Warning,
214            code,
215            message: message.into(),
216            span: None,
217            suggestion: None,
218        }
219    }
220
221    /// Add a span to this issue.
222    pub fn with_span(mut self, span: Span) -> Self {
223        self.span = Some(span);
224        self
225    }
226
227    /// Add a suggestion to this issue.
228    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
229        self.suggestion = Some(suggestion.into());
230        self
231    }
232
233    /// Format the issue for display.
234    ///
235    /// With source provided, includes line:column information and source context.
236    pub fn format(&self, source: &str) -> String {
237        let mut result = String::new();
238
239        // Location prefix if we have a span
240        if let Some(span) = &self.span {
241            let loc = span.format_location(source);
242            result.push_str(&format!("{}: ", loc));
243        }
244
245        // Severity and code
246        result.push_str(&format!("{} [{}]: {}", self.severity, self.code, self.message));
247
248        // Suggestion if available
249        if let Some(suggestion) = &self.suggestion {
250            result.push_str(&format!("\n  → {}", suggestion));
251        }
252
253        // Source context if we have a span
254        if let Some(span) = &self.span
255            && let Some(line_content) = get_line_at_offset(source, span.start) {
256                result.push_str(&format!("\n  | {}", line_content));
257            }
258
259        result
260    }
261}
262
263impl fmt::Display for ValidationIssue {
264    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
265        write!(f, "{} [{}]: {}", self.severity, self.code, self.message)
266    }
267}
268
269/// Get the line containing a byte offset.
270fn get_line_at_offset(source: &str, offset: usize) -> Option<&str> {
271    if offset >= source.len() {
272        return None;
273    }
274
275    let start = source[..offset].rfind('\n').map_or(0, |i| i + 1);
276    let end = source[offset..]
277        .find('\n')
278        .map_or(source.len(), |i| offset + i);
279
280    Some(&source[start..end])
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn span_to_line_col_single_line() {
289        let source = "echo hello world";
290        let span = Span::new(5, 10);
291        assert_eq!(span.to_line_col(source), (1, 6));
292    }
293
294    #[test]
295    fn span_to_line_col_multi_line() {
296        let source = "line one\nline two\nline three";
297        // "line" on line 3 starts at offset 18
298        let span = Span::new(18, 22);
299        assert_eq!(span.to_line_col(source), (3, 1));
300    }
301
302    #[test]
303    fn span_format_location() {
304        let source = "first\nsecond\nthird";
305        let span = Span::new(6, 12); // "second"
306        assert_eq!(span.format_location(source), "2:1");
307    }
308
309    #[test]
310    fn issue_formatting() {
311        let issue = ValidationIssue::error(IssueCode::UndefinedCommand, "command 'foo' not found")
312            .with_span(Span::new(0, 3))
313            .with_suggestion("did you mean 'for'?");
314
315        let source = "foo bar";
316        let formatted = issue.format(source);
317
318        assert!(formatted.contains("1:1"));
319        assert!(formatted.contains("error"));
320        assert!(formatted.contains("E001"));
321        assert!(formatted.contains("command 'foo' not found"));
322        assert!(formatted.contains("did you mean 'for'?"));
323    }
324
325    #[test]
326    fn get_line_at_offset_works() {
327        let source = "line one\nline two\nline three";
328        assert_eq!(get_line_at_offset(source, 0), Some("line one"));
329        assert_eq!(get_line_at_offset(source, 9), Some("line two"));
330        assert_eq!(get_line_at_offset(source, 18), Some("line three"));
331    }
332}