sherpack_engine/
suggestions.rs

1//! Fuzzy matching and context-aware suggestions for template errors
2//!
3//! This module provides intelligent suggestions when template errors occur,
4//! using Levenshtein distance for fuzzy matching and context extraction
5//! to help users fix common mistakes.
6
7use serde_json::Value as JsonValue;
8
9/// Maximum Levenshtein distance to consider for suggestions
10const MAX_SUGGESTION_DISTANCE: usize = 3;
11
12/// All registered filters in the engine
13pub const AVAILABLE_FILTERS: &[&str] = &[
14    // Custom Sherpack filters
15    "toyaml",
16    "tojson",
17    "tojson_pretty",
18    "b64encode",
19    "b64decode",
20    "quote",
21    "squote",
22    "nindent",
23    "indent",
24    "required",
25    "empty",
26    "haskey",
27    "keys",
28    "merge",
29    "sha256",
30    "trunc",
31    "trimprefix",
32    "trimsuffix",
33    "snakecase",
34    "kebabcase",
35    "tostrings", // Convert list elements to strings
36    // Built-in MiniJinja filters
37    "default",
38    "upper",
39    "lower",
40    "title",
41    "capitalize",
42    "replace",
43    "trim",
44    "join",
45    "first",
46    "last",
47    "length",
48    "reverse",
49    "sort",
50    "unique",
51    "map",
52    "select",
53    "reject",
54    "selectattr",
55    "rejectattr",
56    "batch",
57    "slice",
58    "dictsort",
59    "items",
60    "attr",
61    "int",
62    "float",
63    "abs",
64    "round",
65    "string",
66    "list",
67    "bool",
68    "safe",
69    "escape",
70    "e",
71    "urlencode",
72];
73
74/// All registered functions in the engine
75pub const AVAILABLE_FUNCTIONS: &[&str] = &[
76    // Custom Sherpack functions
77    "fail",
78    "dict",
79    "list",
80    "get",
81    "coalesce",
82    "ternary",
83    "uuidv4",
84    "tostring",
85    "toint",
86    "tofloat",
87    "now",
88    "printf",
89    "tpl",     // Dynamic template evaluation
90    "tpl_ctx", // Dynamic template with full context
91    "lookup",  // K8s resource lookup (returns empty in template mode)
92    // Built-in MiniJinja globals
93    "range",
94    "lipsum",
95    "cycler",
96    "joiner",
97    "namespace",
98];
99
100/// Top-level context variables always available in templates
101pub const CONTEXT_VARIABLES: &[&str] = &["values", "release", "pack", "capabilities", "template"];
102
103/// Suggestion result with confidence scoring
104#[derive(Debug, Clone)]
105pub struct Suggestion {
106    /// The suggested correction
107    pub text: String,
108    /// Levenshtein distance (lower = better match)
109    pub distance: usize,
110    /// Category of suggestion
111    pub category: SuggestionCategory,
112}
113
114#[derive(Debug, Clone, Copy, PartialEq)]
115pub enum SuggestionCategory {
116    Variable,
117    Filter,
118    Function,
119    Property,
120}
121
122/// Calculate Levenshtein distance between two strings
123pub fn levenshtein(a: &str, b: &str) -> usize {
124    strsim::levenshtein(a, b)
125}
126
127/// Find closest matches from a list of candidates
128pub fn find_closest_matches(
129    input: &str,
130    candidates: &[&str],
131    max_results: usize,
132    category: SuggestionCategory,
133) -> Vec<Suggestion> {
134    let mut suggestions: Vec<Suggestion> = candidates
135        .iter()
136        .filter_map(|&candidate| {
137            let distance = levenshtein(input, candidate);
138            if distance <= MAX_SUGGESTION_DISTANCE && distance > 0 {
139                Some(Suggestion {
140                    text: candidate.to_string(),
141                    distance,
142                    category,
143                })
144            } else {
145                None
146            }
147        })
148        .collect();
149
150    // Sort by distance (best matches first)
151    suggestions.sort_by_key(|s| s.distance);
152    suggestions.truncate(max_results);
153    suggestions
154}
155
156/// Suggest corrections for an undefined variable
157pub fn suggest_undefined_variable(
158    variable_name: &str,
159    available_variables: &[String],
160) -> Option<String> {
161    // First check for common typo: "value" instead of "values"
162    if variable_name == "value" {
163        return Some(
164            "Did you mean `values`? The values object is accessed as `values.key`".to_string(),
165        );
166    }
167
168    // Check top-level context variables
169    let context_match = find_closest_matches(
170        variable_name,
171        CONTEXT_VARIABLES,
172        1,
173        SuggestionCategory::Variable,
174    );
175
176    if let Some(suggestion) = context_match.first() {
177        return Some(format!("Did you mean `{}`?", suggestion.text));
178    }
179
180    // Check available values
181    let candidates: Vec<&str> = available_variables.iter().map(|s| s.as_str()).collect();
182
183    let value_match =
184        find_closest_matches(variable_name, &candidates, 3, SuggestionCategory::Variable);
185
186    if !value_match.is_empty() {
187        let suggestions: Vec<String> = value_match
188            .iter()
189            .map(|s| format!("`{}`", s.text))
190            .collect();
191        Some(format!("Did you mean {}?", suggestions.join(" or ")))
192    } else {
193        None
194    }
195}
196
197/// Suggest corrections for an unknown filter
198pub fn suggest_unknown_filter(filter_name: &str) -> Option<String> {
199    let matches = find_closest_matches(
200        filter_name,
201        AVAILABLE_FILTERS,
202        3,
203        SuggestionCategory::Filter,
204    );
205
206    if !matches.is_empty() {
207        let suggestions: Vec<String> = matches.iter().map(|s| format!("`{}`", s.text)).collect();
208        Some(format!(
209            "Did you mean {}? Common filters: toyaml, tojson, b64encode, quote, default, indent",
210            suggestions.join(" or ")
211        ))
212    } else {
213        Some(format!(
214            "Unknown filter `{}`. Common filters: toyaml, tojson, b64encode, quote, default, indent, nindent",
215            filter_name
216        ))
217    }
218}
219
220/// Suggest corrections for an unknown function
221pub fn suggest_unknown_function(func_name: &str) -> Option<String> {
222    let matches = find_closest_matches(
223        func_name,
224        AVAILABLE_FUNCTIONS,
225        3,
226        SuggestionCategory::Function,
227    );
228
229    if !matches.is_empty() {
230        let suggestions: Vec<String> = matches.iter().map(|s| format!("`{}`", s.text)).collect();
231        Some(format!("Did you mean {}?", suggestions.join(" or ")))
232    } else {
233        Some(format!(
234            "Unknown function `{}`. Available functions: {}",
235            func_name,
236            AVAILABLE_FUNCTIONS.join(", ")
237        ))
238    }
239}
240
241/// Extract available keys from a JSON value at a given path
242pub fn extract_available_keys(values: &JsonValue, path: &str) -> Vec<String> {
243    let parts: Vec<&str> = path.split('.').filter(|s| !s.is_empty()).collect();
244
245    let mut current = values;
246    for part in &parts {
247        match current.get(part) {
248            Some(v) => current = v,
249            None => return vec![],
250        }
251    }
252
253    match current {
254        JsonValue::Object(map) => map.keys().cloned().collect(),
255        _ => vec![],
256    }
257}
258
259/// Suggest available properties when accessing undefined key
260pub fn suggest_available_properties(
261    parent_path: &str,
262    attempted_key: &str,
263    values: &JsonValue,
264) -> Option<String> {
265    let available = extract_available_keys(values, parent_path);
266
267    if available.is_empty() {
268        return None;
269    }
270
271    // Try fuzzy match first
272    let candidates: Vec<&str> = available.iter().map(|s| s.as_str()).collect();
273    let matches = find_closest_matches(attempted_key, &candidates, 3, SuggestionCategory::Property);
274
275    if !matches.is_empty() {
276        let suggestions: Vec<String> = matches
277            .iter()
278            .map(|s| format!("`{}.{}`", parent_path, s.text))
279            .collect();
280        Some(format!(
281            "Did you mean {}? Available: {}",
282            suggestions.join(" or "),
283            available.join(", ")
284        ))
285    } else {
286        Some(format!(
287            "Key `{}` not found in `{}`. Available keys: {}",
288            attempted_key,
289            parent_path,
290            available.join(", ")
291        ))
292    }
293}
294
295/// Generate a type-specific hint for iteration errors
296pub fn suggest_iteration_fix(type_name: &str) -> String {
297    match type_name {
298        "object" | "map" => {
299            "Objects require `| dictsort` to iterate: `{% for key, value in obj | dictsort %}`"
300                .to_string()
301        }
302        "string" => {
303            "Strings iterate character by character. Did you mean to split it first?".to_string()
304        }
305        "null" | "none" => {
306            "Value is null/undefined. Check that it exists or use `| default([])` for empty list"
307                .to_string()
308        }
309        _ => format!(
310            "Value of type `{}` is not iterable. Use a list or add `| dictsort` for objects",
311            type_name
312        ),
313    }
314}
315
316/// Extract variable name from error message
317pub fn extract_variable_name(msg: &str) -> Option<String> {
318    // Pattern: "undefined variable `foo`" or "variable 'foo' is undefined"
319    let patterns = [("`", "`"), ("'", "'"), ("\"", "\"")];
320
321    for (start, end) in patterns {
322        if let Some(start_idx) = msg.find(start) {
323            let rest = &msg[start_idx + start.len()..];
324            if let Some(end_idx) = rest.find(end) {
325                return Some(rest[..end_idx].to_string());
326            }
327        }
328    }
329    None
330}
331
332/// Extract filter name from error message
333pub fn extract_filter_name(msg: &str) -> Option<String> {
334    extract_variable_name(msg)
335}
336
337/// Extract function name from error message
338pub fn extract_function_name(msg: &str) -> Option<String> {
339    extract_variable_name(msg)
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn test_levenshtein_distance() {
348        assert_eq!(levenshtein("value", "values"), 1);
349        assert_eq!(levenshtein("toyml", "toyaml"), 1);
350        assert_eq!(levenshtein("b64encode", "b64encode"), 0);
351        // strsim calculates actual Levenshtein distance = 7
352        assert_eq!(levenshtein("something", "completely"), 7);
353    }
354
355    #[test]
356    fn test_find_closest_matches() {
357        let matches =
358            find_closest_matches("toyml", AVAILABLE_FILTERS, 3, SuggestionCategory::Filter);
359        assert!(!matches.is_empty());
360        assert_eq!(matches[0].text, "toyaml");
361        assert_eq!(matches[0].distance, 1);
362    }
363
364    #[test]
365    fn test_suggest_undefined_variable_typo() {
366        let suggestion = suggest_undefined_variable("value", &[]);
367        assert!(suggestion.is_some());
368        assert!(suggestion.unwrap().contains("values"));
369    }
370
371    #[test]
372    fn test_suggest_unknown_filter() {
373        let suggestion = suggest_unknown_filter("toyml");
374        assert!(suggestion.is_some());
375        assert!(suggestion.unwrap().contains("toyaml"));
376    }
377
378    #[test]
379    fn test_extract_available_keys() {
380        let values = serde_json::json!({
381            "image": {
382                "repository": "nginx",
383                "tag": "latest"
384            },
385            "replicas": 3
386        });
387
388        let keys = extract_available_keys(&values, "image");
389        assert!(keys.contains(&"repository".to_string()));
390        assert!(keys.contains(&"tag".to_string()));
391    }
392
393    #[test]
394    fn test_extract_variable_name() {
395        assert_eq!(
396            extract_variable_name("undefined variable `foo`"),
397            Some("foo".to_string())
398        );
399        assert_eq!(
400            extract_variable_name("variable 'bar' is undefined"),
401            Some("bar".to_string())
402        );
403    }
404}