use crate::{
config::LintConfig,
diagnostics::{Diagnostic, Fix},
rules::{Validator, ValidatorMetadata},
};
use rust_i18n::t;
use std::path::Path;
const RULE_IDS: &[&str] = &["KR-SET-001", "KR-SET-002", "KR-SET-003"];
pub struct KiroSettingsValidator;
impl Validator for KiroSettingsValidator {
fn metadata(&self) -> ValidatorMetadata {
ValidatorMetadata {
name: self.name(),
rule_ids: RULE_IDS,
}
}
fn validate(&self, path: &Path, content: &str, config: &LintConfig) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
let Ok(value) = serde_json::from_str::<serde_json::Value>(content) else {
return diagnostics;
};
if config.is_rule_enabled("KR-SET-001") {
validate_tool_search_enabled(path, content, &value, &mut diagnostics);
}
if config.is_rule_enabled("KR-SET-002") {
validate_tool_search_min_pct(path, content, &value, &mut diagnostics);
}
if config.is_rule_enabled("KR-SET-003") {
validate_tool_search_min_tokens(path, content, &value, &mut diagnostics);
}
diagnostics
}
}
fn validate_tool_search_enabled(
path: &Path,
content: &str,
value: &serde_json::Value,
diagnostics: &mut Vec<Diagnostic>,
) {
let Some(field) = value.get("toolSearch.enabled") else {
return;
};
if field.as_bool().is_none() {
let line = find_key_line(content, "toolSearch.enabled").unwrap_or(1);
let mut diagnostic = Diagnostic::error(
path.to_path_buf(),
line,
0,
"KR-SET-001",
t!("rules.kr_set_001.type_error"),
)
.with_suggestion(t!("rules.kr_set_001.suggestion"));
if let Some(s) = field.as_str()
&& let Some(parsed) = parse_string_as_bool(s)
&& let Some((start, end)) = find_value_span(content, "toolSearch.enabled")
{
diagnostic = diagnostic.with_fix(Fix::replace(
start,
end,
if parsed { "true" } else { "false" }.to_string(),
format!("Remove quotes: \"{s}\" -> {parsed}"),
true,
));
}
diagnostics.push(diagnostic);
}
}
fn parse_string_as_bool(s: &str) -> Option<bool> {
match s.to_lowercase().as_str() {
"true" => Some(true),
"false" => Some(false),
_ => None,
}
}
fn parse_string_as_number(s: &str) -> Option<String> {
let trimmed = s.trim();
if !is_json_nonneg_number(trimmed) {
return None;
}
Some(trimmed.to_string())
}
fn parse_string_as_integer(s: &str) -> Option<String> {
let trimmed = s.trim();
if !is_json_nonneg_integer(trimmed) {
return None;
}
Some(trimmed.to_string())
}
fn is_json_nonneg_number(s: &str) -> bool {
let bytes = s.as_bytes();
if bytes.is_empty() {
return false;
}
let mut i;
match bytes.first() {
Some(b'0') => {
i = 1;
if let Some(b) = bytes.get(i)
&& b.is_ascii_digit()
{
return false; }
}
Some(b) if (b'1'..=b'9').contains(b) => {
i = 1;
while let Some(c) = bytes.get(i)
&& c.is_ascii_digit()
{
i += 1;
}
}
_ => return false, }
if bytes.get(i) == Some(&b'.') {
i += 1;
let frac_start = i;
while let Some(c) = bytes.get(i)
&& c.is_ascii_digit()
{
i += 1;
}
if i == frac_start {
return false; }
}
if let Some(c) = bytes.get(i)
&& (*c == b'e' || *c == b'E')
{
i += 1;
if let Some(s) = bytes.get(i)
&& (*s == b'+' || *s == b'-')
{
i += 1;
}
let exp_start = i;
while let Some(c) = bytes.get(i)
&& c.is_ascii_digit()
{
i += 1;
}
if i == exp_start {
return false; }
}
i == bytes.len()
}
fn is_json_nonneg_integer(s: &str) -> bool {
let bytes = s.as_bytes();
match bytes.first() {
Some(b'0') => bytes.len() == 1,
Some(b) if (b'1'..=b'9').contains(b) => bytes.iter().all(|c| c.is_ascii_digit()),
_ => false,
}
}
fn validate_tool_search_min_pct(
path: &Path,
content: &str,
value: &serde_json::Value,
diagnostics: &mut Vec<Diagnostic>,
) {
let Some(field) = value.get("toolSearch.minPct") else {
return;
};
let line = find_key_line(content, "toolSearch.minPct").unwrap_or(1);
match field.as_f64() {
None => {
let mut diagnostic = Diagnostic::error(
path.to_path_buf(),
line,
0,
"KR-SET-002",
t!("rules.kr_set_002.type_error"),
)
.with_suggestion(t!("rules.kr_set_002.suggestion"));
if let Some(s) = field.as_str()
&& let Some(parsed) = parse_string_as_number(s)
&& let Some((start, end)) = find_value_span(content, "toolSearch.minPct")
{
diagnostic = diagnostic.with_fix(Fix::replace(
start,
end,
parsed.clone(),
format!("Remove quotes: \"{s}\" -> {parsed}"),
true,
));
}
diagnostics.push(diagnostic);
}
Some(n) if n < 0.0 => {
diagnostics.push(
Diagnostic::error(
path.to_path_buf(),
line,
0,
"KR-SET-002",
t!("rules.kr_set_002.negative"),
)
.with_suggestion(t!("rules.kr_set_002.suggestion")),
);
}
Some(n) if n > 100.0 => {
diagnostics.push(
Diagnostic::warning(
path.to_path_buf(),
line,
0,
"KR-SET-002",
t!("rules.kr_set_002.over_100"),
)
.with_suggestion(t!("rules.kr_set_002.suggestion")),
);
}
_ => {}
}
}
fn validate_tool_search_min_tokens(
path: &Path,
content: &str,
value: &serde_json::Value,
diagnostics: &mut Vec<Diagnostic>,
) {
let Some(field) = value.get("toolSearch.minTokens") else {
return;
};
let line = find_key_line(content, "toolSearch.minTokens").unwrap_or(1);
match field.as_f64() {
None => {
let mut diagnostic = Diagnostic::error(
path.to_path_buf(),
line,
0,
"KR-SET-003",
t!("rules.kr_set_003.type_error"),
)
.with_suggestion(t!("rules.kr_set_003.suggestion"));
if let Some(s) = field.as_str()
&& let Some(parsed) = parse_string_as_integer(s)
&& let Some((start, end)) = find_value_span(content, "toolSearch.minTokens")
{
diagnostic = diagnostic.with_fix(Fix::replace(
start,
end,
parsed.clone(),
format!("Remove quotes: \"{s}\" -> {parsed}"),
true,
));
}
diagnostics.push(diagnostic);
}
Some(n) if n < 0.0 => {
diagnostics.push(
Diagnostic::error(
path.to_path_buf(),
line,
0,
"KR-SET-003",
t!("rules.kr_set_003.negative"),
)
.with_suggestion(t!("rules.kr_set_003.suggestion")),
);
}
Some(n) if n.fract() != 0.0 => {
diagnostics.push(
Diagnostic::error(
path.to_path_buf(),
line,
0,
"KR-SET-003",
t!("rules.kr_set_003.not_integer"),
)
.with_suggestion(t!("rules.kr_set_003.suggestion")),
);
}
_ => {}
}
}
fn find_value_span(content: &str, key: &str) -> Option<(usize, usize)> {
debug_assert!(
key.is_ascii() && !key.contains('"') && !key.contains('\\'),
"find_value_span expects ASCII key without quotes or backslashes"
);
let needle = format!("\"{key}\"");
let needle_bytes = needle.as_bytes();
let needle_len = needle_bytes.len();
let bytes = content.as_bytes();
let mut in_string = false;
let mut escape = false;
let mut i = 0;
let mut colon_pos: Option<usize> = None;
while i < bytes.len() {
let b = bytes[i];
if escape {
escape = false;
i += 1;
continue;
}
if b == b'\\' && in_string {
escape = true;
i += 1;
continue;
}
if b == b'"' {
if !in_string
&& i + needle_len <= bytes.len()
&& &bytes[i..i + needle_len] == needle_bytes
{
let mut j = i + needle_len;
while j < bytes.len() && matches!(bytes[j], b' ' | b'\t' | b'\n' | b'\r') {
j += 1;
}
if j < bytes.len() && bytes[j] == b':' {
colon_pos = Some(j);
break;
}
}
in_string = !in_string;
}
i += 1;
}
let colon = colon_pos?;
let mut start = colon + 1;
while start < bytes.len() && matches!(bytes[start], b' ' | b'\t' | b'\n' | b'\r') {
start += 1;
}
if start >= bytes.len() {
return None;
}
match bytes[start] {
b'"' => {
let mut k = start + 1;
let mut esc = false;
while k < bytes.len() {
let b = bytes[k];
if esc {
esc = false;
} else if b == b'\\' {
esc = true;
} else if b == b'"' {
return Some((start, k + 1));
}
k += 1;
}
None }
b't' | b'f' | b'n' => {
for lit in ["true", "false", "null"] {
let lit_bytes = lit.as_bytes();
if start + lit_bytes.len() <= bytes.len()
&& &bytes[start..start + lit_bytes.len()] == lit_bytes
{
return Some((start, start + lit_bytes.len()));
}
}
None
}
b'-' | b'+' | b'0'..=b'9' => {
let mut k = start + 1;
while k < bytes.len()
&& matches!(bytes[k], b'0'..=b'9' | b'.' | b'e' | b'E' | b'+' | b'-')
{
k += 1;
}
Some((start, k))
}
_ => None, }
}
fn find_key_line(content: &str, key: &str) -> Option<usize> {
debug_assert!(
key.is_ascii() && !key.contains('"') && !key.contains('\\'),
"find_key_line expects ASCII key without quotes or backslashes"
);
let needle = format!("\"{key}\"");
let needle_bytes = needle.as_bytes();
let needle_len = needle_bytes.len();
let bytes = content.as_bytes();
let mut in_string = false;
let mut escape = false;
let mut line = 1usize;
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if b == b'\n' {
line += 1;
i += 1;
continue;
}
if escape {
escape = false;
i += 1;
continue;
}
if b == b'\\' && in_string {
escape = true;
i += 1;
continue;
}
if b == b'"' {
if !in_string
&& i + needle_len <= bytes.len()
&& &bytes[i..i + needle_len] == needle_bytes
{
let mut j = i + needle_len;
while j < bytes.len() && matches!(bytes[j], b' ' | b'\t' | b'\n' | b'\r') {
j += 1;
}
if j < bytes.len() && bytes[j] == b':' {
return Some(line);
}
}
in_string = !in_string;
}
i += 1;
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::LintConfig;
use std::path::PathBuf;
fn validate(content: &str) -> Vec<Diagnostic> {
let validator = KiroSettingsValidator;
validator.validate(
&PathBuf::from(".kiro/settings.json"),
content,
&LintConfig::default(),
)
}
fn validate_with_config(content: &str, config: &LintConfig) -> Vec<Diagnostic> {
let validator = KiroSettingsValidator;
validator.validate(&PathBuf::from(".kiro/settings.json"), content, config)
}
#[test]
fn test_kr_set_001_absent_field_is_fine() {
let diagnostics = validate(r#"{"chat.ui": "prose"}"#);
assert!(diagnostics.is_empty());
}
#[test]
fn test_kr_set_001_true_is_fine() {
let diagnostics = validate(r#"{"toolSearch.enabled": true}"#);
let hits: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KR-SET-001")
.collect();
assert!(hits.is_empty());
}
#[test]
fn test_kr_set_001_false_is_fine() {
let diagnostics = validate(r#"{"toolSearch.enabled": false}"#);
let hits: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KR-SET-001")
.collect();
assert!(hits.is_empty());
}
#[test]
fn test_kr_set_001_string_flags() {
let diagnostics = validate(r#"{"toolSearch.enabled": "true"}"#);
let hits: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KR-SET-001")
.collect();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].level, crate::diagnostics::DiagnosticLevel::Error);
}
#[test]
fn test_kr_set_001_number_flags() {
let diagnostics = validate(r#"{"toolSearch.enabled": 1}"#);
let hits: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KR-SET-001")
.collect();
assert_eq!(hits.len(), 1);
}
#[test]
fn test_kr_set_001_null_flags() {
let diagnostics = validate(r#"{"toolSearch.enabled": null}"#);
let hits: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KR-SET-001")
.collect();
assert_eq!(hits.len(), 1);
}
#[test]
fn test_kr_set_002_valid_percentage_is_fine() {
let diagnostics = validate(r#"{"toolSearch.minPct": 5}"#);
let hits: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KR-SET-002")
.collect();
assert!(hits.is_empty());
}
#[test]
fn test_kr_set_002_zero_is_fine() {
let diagnostics = validate(r#"{"toolSearch.minPct": 0}"#);
let hits: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KR-SET-002")
.collect();
assert!(hits.is_empty());
}
#[test]
fn test_kr_set_002_float_is_fine() {
let diagnostics = validate(r#"{"toolSearch.minPct": 2.5}"#);
let hits: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KR-SET-002")
.collect();
assert!(hits.is_empty());
}
#[test]
fn test_kr_set_002_negative_flags() {
let diagnostics = validate(r#"{"toolSearch.minPct": -1}"#);
let hits: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KR-SET-002")
.collect();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].level, crate::diagnostics::DiagnosticLevel::Error);
}
#[test]
fn test_kr_set_002_over_100_warns() {
let diagnostics = validate(r#"{"toolSearch.minPct": 150}"#);
let hits: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KR-SET-002")
.collect();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].level, crate::diagnostics::DiagnosticLevel::Warning);
}
#[test]
fn test_kr_set_002_string_flags() {
let diagnostics = validate(r#"{"toolSearch.minPct": "5"}"#);
let hits: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KR-SET-002")
.collect();
assert_eq!(hits.len(), 1);
}
#[test]
fn test_kr_set_003_valid_is_fine() {
let diagnostics = validate(r#"{"toolSearch.minTokens": 50000}"#);
let hits: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KR-SET-003")
.collect();
assert!(hits.is_empty());
}
#[test]
fn test_kr_set_003_zero_is_fine() {
let diagnostics = validate(r#"{"toolSearch.minTokens": 0}"#);
let hits: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KR-SET-003")
.collect();
assert!(hits.is_empty());
}
#[test]
fn test_kr_set_003_negative_flags() {
let diagnostics = validate(r#"{"toolSearch.minTokens": -10}"#);
let hits: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KR-SET-003")
.collect();
assert_eq!(hits.len(), 1);
}
#[test]
fn test_kr_set_003_fractional_flags() {
let diagnostics = validate(r#"{"toolSearch.minTokens": 100.5}"#);
let hits: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KR-SET-003")
.collect();
assert_eq!(hits.len(), 1);
}
#[test]
fn test_kr_set_003_string_flags() {
let diagnostics = validate(r#"{"toolSearch.minTokens": "50000"}"#);
let hits: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "KR-SET-003")
.collect();
assert_eq!(hits.len(), 1);
}
#[test]
fn test_all_three_rules_fire_on_combined_bad_config() {
let content = r#"{
"toolSearch.enabled": "true",
"toolSearch.minPct": -5,
"toolSearch.minTokens": "lots"
}"#;
let diagnostics = validate(content);
let ids: Vec<&str> = diagnostics.iter().map(|d| d.rule.as_str()).collect();
assert!(ids.contains(&"KR-SET-001"));
assert!(ids.contains(&"KR-SET-002"));
assert!(ids.contains(&"KR-SET-003"));
}
#[test]
fn test_rules_are_independently_disableable() {
let mut config = LintConfig::default();
config.rules_mut().disabled_rules = vec!["KR-SET-002".to_string()];
let content = r#"{
"toolSearch.enabled": "true",
"toolSearch.minPct": -5
}"#;
let diagnostics = validate_with_config(content, &config);
assert!(diagnostics.iter().any(|d| d.rule == "KR-SET-001"));
assert!(!diagnostics.iter().any(|d| d.rule == "KR-SET-002"));
}
#[test]
fn test_malformed_json_is_silent() {
let diagnostics = validate(r#"{"toolSearch.enabled": tr"#);
assert!(diagnostics.is_empty());
}
#[test]
fn test_line_reporting_for_toolsearch_enabled() {
let content = "{\n \"chat.ui\": \"prose\",\n \"toolSearch.enabled\": \"true\"\n}";
let diagnostics = validate(content);
let hit = diagnostics
.iter()
.find(|d| d.rule == "KR-SET-001")
.expect("KR-SET-001 diagnostic");
assert_eq!(hit.line, 3);
}
#[test]
fn test_prefix_typo_does_not_match_scanner() {
let content = "{\n \"toolSearch.enabledX\": true,\n \"toolSearch.enabled\": \"bad\"\n}";
let diagnostics = validate(content);
let hit = diagnostics
.iter()
.find(|d| d.rule == "KR-SET-001")
.expect("KR-SET-001 diagnostic");
assert_eq!(hit.line, 3);
}
#[test]
fn test_does_not_panic_on_non_ascii_content() {
let content = "{\n \"chat.ui\": \"\u{1F525}prose\u{4e2d}\u{6587}\",\n \"toolSearch.enabled\": 42\n}";
let diagnostics = validate(content);
let hit = diagnostics
.iter()
.find(|d| d.rule == "KR-SET-001")
.expect("KR-SET-001 diagnostic");
assert_eq!(hit.line, 3);
}
fn apply_first_fix(content: &str, rule: &str) -> String {
let diagnostics = validate(content);
let diag = diagnostics
.iter()
.find(|d| d.rule == rule)
.unwrap_or_else(|| panic!("expected {rule} diagnostic, got {:?}", diagnostics));
let fix = diag
.fixes
.first()
.unwrap_or_else(|| panic!("{rule} diagnostic had no fix attached: {:?}", diag));
let mut out = content.to_string();
out.replace_range(fix.start_byte..fix.end_byte, &fix.replacement);
out
}
#[test]
fn test_kr_set_001_autofix_string_true_to_bool() {
let content = r#"{"toolSearch.enabled": "true"}"#;
let fixed = apply_first_fix(content, "KR-SET-001");
assert_eq!(fixed, r#"{"toolSearch.enabled": true}"#);
assert!(validate(&fixed).iter().all(|d| d.rule != "KR-SET-001"));
}
#[test]
fn test_kr_set_001_autofix_string_false_to_bool() {
let content = r#"{"toolSearch.enabled": "false"}"#;
let fixed = apply_first_fix(content, "KR-SET-001");
assert_eq!(fixed, r#"{"toolSearch.enabled": false}"#);
}
#[test]
fn test_kr_set_001_autofix_case_insensitive() {
let content = r#"{"toolSearch.enabled": "TRUE"}"#;
let fixed = apply_first_fix(content, "KR-SET-001");
assert_eq!(fixed, r#"{"toolSearch.enabled": true}"#);
}
#[test]
fn test_kr_set_001_no_autofix_for_ambiguous_string() {
let content = r#"{"toolSearch.enabled": "yes"}"#;
let diagnostics = validate(content);
let diag = diagnostics
.iter()
.find(|d| d.rule == "KR-SET-001")
.expect("KR-SET-001");
assert!(
diag.fixes.is_empty(),
"ambiguous string must not get an auto-fix, got {:?}",
diag.fixes
);
}
#[test]
fn test_kr_set_001_no_autofix_for_non_string_type() {
for fixture in [
r#"{"toolSearch.enabled": 1}"#,
r#"{"toolSearch.enabled": 0}"#,
r#"{"toolSearch.enabled": null}"#,
r#"{"toolSearch.enabled": []}"#,
] {
let diagnostics = validate(fixture);
let diag = diagnostics
.iter()
.find(|d| d.rule == "KR-SET-001")
.unwrap_or_else(|| panic!("fixture {fixture} did not flag"));
assert!(
diag.fixes.is_empty(),
"no fix expected for {fixture}, got {:?}",
diag.fixes
);
}
}
#[test]
fn test_kr_set_002_autofix_string_to_number() {
let content = r#"{"toolSearch.minPct": "5"}"#;
let fixed = apply_first_fix(content, "KR-SET-002");
assert_eq!(fixed, r#"{"toolSearch.minPct": 5}"#);
}
#[test]
fn test_kr_set_002_autofix_string_float_to_number() {
let content = r#"{"toolSearch.minPct": "2.5"}"#;
let fixed = apply_first_fix(content, "KR-SET-002");
assert_eq!(fixed, r#"{"toolSearch.minPct": 2.5}"#);
}
#[test]
fn test_kr_set_002_no_autofix_for_non_numeric_string() {
let content = r#"{"toolSearch.minPct": "five"}"#;
let diagnostics = validate(content);
let diag = diagnostics
.iter()
.find(|d| d.rule == "KR-SET-002")
.expect("KR-SET-002");
assert!(diag.fixes.is_empty());
}
#[test]
fn test_kr_set_002_no_autofix_for_negative_number() {
let content = r#"{"toolSearch.minPct": -5}"#;
let diagnostics = validate(content);
let diag = diagnostics
.iter()
.find(|d| d.rule == "KR-SET-002")
.expect("KR-SET-002");
assert!(diag.fixes.is_empty());
}
#[test]
fn test_kr_set_003_autofix_string_to_integer() {
let content = r#"{"toolSearch.minTokens": "50000"}"#;
let fixed = apply_first_fix(content, "KR-SET-003");
assert_eq!(fixed, r#"{"toolSearch.minTokens": 50000}"#);
}
#[test]
fn test_kr_set_003_no_autofix_for_fractional_string() {
let content = r#"{"toolSearch.minTokens": "50000.5"}"#;
let diagnostics = validate(content);
let diag = diagnostics
.iter()
.find(|d| d.rule == "KR-SET-003")
.expect("KR-SET-003");
assert!(diag.fixes.is_empty());
}
#[test]
fn test_autofix_line_reporting_stays_intact() {
let content = "{\n \"chat.ui\": \"prose\",\n \"toolSearch.enabled\": \"true\"\n}";
let diagnostics = validate(content);
let diag = diagnostics
.iter()
.find(|d| d.rule == "KR-SET-001")
.expect("KR-SET-001");
assert_eq!(diag.line, 3);
let fix = diag.fixes.first().expect("fix attached");
assert_eq!(&content[fix.start_byte..fix.end_byte], "\"true\"");
assert_eq!(fix.replacement, "true");
}
#[test]
fn test_find_value_span_string() {
let content = r#"{"k": "v"}"#;
let (s, e) = find_value_span(content, "k").unwrap();
assert_eq!(&content[s..e], r#""v""#);
}
#[test]
fn test_find_value_span_number() {
let content = r#"{"k": 42}"#;
let (s, e) = find_value_span(content, "k").unwrap();
assert_eq!(&content[s..e], "42");
}
#[test]
fn test_find_value_span_bool() {
let content = r#"{"k": true}"#;
let (s, e) = find_value_span(content, "k").unwrap();
assert_eq!(&content[s..e], "true");
}
#[test]
fn test_find_value_span_null() {
let content = r#"{"k": null}"#;
let (s, e) = find_value_span(content, "k").unwrap();
assert_eq!(&content[s..e], "null");
}
#[test]
fn test_find_value_span_object_returns_none() {
let content = r#"{"k": {"nested": 1}}"#;
assert!(find_value_span(content, "k").is_none());
}
#[test]
fn test_find_value_span_missing_key() {
let content = r#"{"k": 1}"#;
assert!(find_value_span(content, "other").is_none());
}
#[test]
fn test_find_value_span_key_inside_string_literal_ignored() {
let content = r#"{"note": "k is an important key", "k": 42}"#;
let (s, e) = find_value_span(content, "k").unwrap();
assert_eq!(&content[s..e], "42");
}
#[test]
fn test_json_number_accepts_valid_forms() {
for v in [
"0", "5", "42", "100", "2.5", "0.5", "1e10", "1E10", "1.5e-3", "1.5E+3",
] {
assert!(is_json_nonneg_number(v), "should accept {v}");
}
}
#[test]
fn test_json_number_rejects_leading_zero() {
for v in ["05", "05.5", "007", "00"] {
assert!(!is_json_nonneg_number(v), "should reject {v}");
}
}
#[test]
fn test_json_number_rejects_negative_and_leading_plus() {
for v in ["-5", "+5", "-0.5", "+100"] {
assert!(!is_json_nonneg_number(v), "should reject {v}");
}
}
#[test]
fn test_json_number_rejects_malformed_fraction_or_exponent() {
for v in [".5", "5.", "5.e3", "5e", "5e+", "5.e", ""] {
assert!(!is_json_nonneg_number(v), "should reject {v}");
}
}
#[test]
fn test_json_integer_accepts_valid_forms() {
for v in ["0", "5", "42", "50000", "9999999"] {
assert!(is_json_nonneg_integer(v), "should accept {v}");
}
}
#[test]
fn test_json_integer_rejects_fractional_and_exponent_and_leading_zero() {
for v in ["5.5", "1e10", "05", "00", "-5", "+5", "", "5."] {
assert!(!is_json_nonneg_integer(v), "should reject {v}");
}
}
#[test]
fn test_parse_string_as_number_declines_leading_zero() {
assert!(parse_string_as_number("050").is_none());
assert!(parse_string_as_number("007.5").is_none());
}
#[test]
fn test_parse_string_as_number_declines_negative() {
assert!(parse_string_as_number("-5").is_none());
assert!(parse_string_as_number("+5").is_none());
}
#[test]
fn test_parse_string_as_integer_declines_fractional_string() {
assert!(parse_string_as_integer("5.5").is_none());
assert!(parse_string_as_integer("1e10").is_none());
}
#[test]
fn test_kr_set_002_no_autofix_for_leading_zero_string() {
let content = r#"{"toolSearch.minPct": "05"}"#;
let diagnostics = validate(content);
let diag = diagnostics
.iter()
.find(|d| d.rule == "KR-SET-002")
.expect("KR-SET-002");
assert!(
diag.fixes.is_empty(),
"leading-zero string must not auto-fix to invalid JSON, got {:?}",
diag.fixes
);
}
#[test]
fn test_kr_set_002_no_autofix_for_negative_string() {
let content = r#"{"toolSearch.minPct": "-5"}"#;
let diagnostics = validate(content);
let diag = diagnostics
.iter()
.find(|d| d.rule == "KR-SET-002")
.expect("KR-SET-002");
assert!(diag.fixes.is_empty());
}
}