Skip to content

Commit 05f0201

Browse files
committed
fix: preserve runtime config validation compatibility
1 parent d58197c commit 05f0201

1 file changed

Lines changed: 103 additions & 26 deletions

File tree

rust/crates/runtime/src/config_validate.rs

Lines changed: 103 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ enum FieldType {
9292
Bool,
9393
Object,
9494
StringArray,
95+
HookArray,
96+
RulesImport,
9597
Number,
9698
}
9799

@@ -102,6 +104,8 @@ impl FieldType {
102104
Self::Bool => "a boolean",
103105
Self::Object => "an object",
104106
Self::StringArray => "an array of strings",
107+
Self::RulesImport => "a string or an array of strings",
108+
Self::HookArray => "an array of strings or hook objects",
105109
Self::Number => "a number",
106110
}
107111
}
@@ -114,6 +118,13 @@ impl FieldType {
114118
Self::StringArray => value
115119
.as_array()
116120
.is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some())),
121+
Self::HookArray => true,
122+
Self::RulesImport => {
123+
value.as_str().is_some()
124+
|| value
125+
.as_array()
126+
.is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some()))
127+
}
117128
Self::Number => value.as_i64().is_some(),
118129
}
119130
}
@@ -201,6 +212,10 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[
201212
name: "provider",
202213
expected: FieldType::Object,
203214
},
215+
FieldSpec {
216+
name: "rulesImport",
217+
expected: FieldType::RulesImport,
218+
},
204219
FieldSpec {
205220
name: "subagentModel",
206221
expected: FieldType::String,
@@ -210,15 +225,15 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[
210225
const HOOKS_FIELDS: &[FieldSpec] = &[
211226
FieldSpec {
212227
name: "PreToolUse",
213-
expected: FieldType::StringArray,
228+
expected: FieldType::HookArray,
214229
},
215230
FieldSpec {
216231
name: "PostToolUse",
217-
expected: FieldType::StringArray,
232+
expected: FieldType::HookArray,
218233
},
219234
FieldSpec {
220235
name: "PostToolUseFailure",
221-
expected: FieldType::StringArray,
236+
expected: FieldType::HookArray,
222237
},
223238
];
224239

@@ -410,9 +425,8 @@ fn validate_object_keys(
410425
} else if DEPRECATED_FIELDS.iter().any(|d| d.name == key) {
411426
// Deprecated key — handled separately, not an unknown-key error.
412427
} else {
413-
// Unknown key.
414428
let suggestion = suggest_field(key, &known_names);
415-
result.errors.push(ConfigDiagnostic {
429+
result.warnings.push(ConfigDiagnostic {
416430
path: path_display.to_string(),
417431
field: field_path,
418432
line: find_key_line(source, key),
@@ -591,10 +605,11 @@ mod tests {
591605
let result = validate_config_file(object, source, &test_path());
592606

593607
// then
594-
assert_eq!(result.errors.len(), 1);
595-
assert_eq!(result.errors[0].field, "unknownField");
608+
assert!(result.errors.is_empty());
609+
assert_eq!(result.warnings.len(), 1);
610+
assert_eq!(result.warnings[0].field, "unknownField");
596611
assert!(matches!(
597-
result.errors[0].kind,
612+
result.warnings[0].kind,
598613
DiagnosticKind::UnknownKey { .. }
599614
));
600615
}
@@ -674,9 +689,10 @@ mod tests {
674689
let result = validate_config_file(object, source, &test_path());
675690

676691
// then
677-
assert_eq!(result.errors.len(), 1);
678-
assert_eq!(result.errors[0].line, Some(3));
679-
assert_eq!(result.errors[0].field, "badKey");
692+
assert!(result.errors.is_empty());
693+
assert_eq!(result.warnings.len(), 1);
694+
assert_eq!(result.warnings[0].line, Some(3));
695+
assert_eq!(result.warnings[0].field, "badKey");
680696
}
681697

682698
#[test]
@@ -697,16 +713,72 @@ mod tests {
697713
#[test]
698714
fn validates_nested_hooks_keys() {
699715
// given
700-
let source = r#"{"hooks": {"PreToolUse": ["cmd"], "BadHook": ["x"]}}"#;
716+
let source = r#"{"hooks": {"PreToolUse": [{"hooks":[{"type":"command","command":"cmd"}]}], "BadHook": ["x"]}}"#;
701717
let parsed = JsonValue::parse(source).expect("valid json");
702718
let object = parsed.as_object().expect("object");
703719

704720
// when
705721
let result = validate_config_file(object, source, &test_path());
706722

707723
// then
724+
assert!(result.errors.is_empty());
725+
assert_eq!(
726+
result.warnings.len(),
727+
1,
728+
"expected only the unknown key warning, got {:?}",
729+
result.warnings
730+
);
731+
assert_eq!(result.warnings[0].field, "hooks.BadHook");
732+
}
733+
734+
#[test]
735+
fn validates_object_style_hook_entries() {
736+
let source = r#"{"hooks":{"PreToolUse":["legacy",{"matcher":"Bash","hooks":[{"type":"command","command":"echo ok"}]}]}}"#;
737+
let parsed = JsonValue::parse(source).expect("valid json");
738+
let object = parsed.as_object().expect("object");
739+
740+
let result = validate_config_file(object, source, &test_path());
741+
742+
assert!(result.errors.is_empty(), "{:?}", result.errors);
743+
}
744+
745+
#[test]
746+
fn allows_wrong_hook_entry_types_for_partial_runtime_validation_441() {
747+
let source = r#"{"hooks":{"PreToolUse":[42]}}"#;
748+
let parsed = JsonValue::parse(source).expect("valid json");
749+
let object = parsed.as_object().expect("object");
750+
751+
let result = validate_config_file(object, source, &test_path());
752+
753+
assert!(result.errors.is_empty(), "{:?}", result.errors);
754+
}
755+
756+
#[test]
757+
fn validates_rules_import_string_and_array_forms() {
758+
for source in [
759+
r#"{"rulesImport":"auto"}"#,
760+
r#"{"rulesImport":"none"}"#,
761+
r#"{"rulesImport":["cursor","copilot"]}"#,
762+
] {
763+
let parsed = JsonValue::parse(source).expect("valid json");
764+
let object = parsed.as_object().expect("object");
765+
766+
let result = validate_config_file(object, source, &test_path());
767+
768+
assert!(result.errors.is_empty(), "{source}: {:?}", result.errors);
769+
}
770+
}
771+
772+
#[test]
773+
fn rejects_rules_import_wrong_type() {
774+
let source = r#"{"rulesImport":42}"#;
775+
let parsed = JsonValue::parse(source).expect("valid json");
776+
let object = parsed.as_object().expect("object");
777+
778+
let result = validate_config_file(object, source, &test_path());
779+
708780
assert_eq!(result.errors.len(), 1);
709-
assert_eq!(result.errors[0].field, "hooks.BadHook");
781+
assert_eq!(result.errors[0].field, "rulesImport");
710782
}
711783

712784
#[test]
@@ -720,8 +792,9 @@ mod tests {
720792
let result = validate_config_file(object, source, &test_path());
721793

722794
// then
723-
assert_eq!(result.errors.len(), 1);
724-
assert_eq!(result.errors[0].field, "permissions.denyAll");
795+
assert!(result.errors.is_empty());
796+
assert_eq!(result.warnings.len(), 1);
797+
assert_eq!(result.warnings[0].field, "permissions.denyAll");
725798
}
726799

727800
#[test]
@@ -735,8 +808,9 @@ mod tests {
735808
let result = validate_config_file(object, source, &test_path());
736809

737810
// then
738-
assert_eq!(result.errors.len(), 1);
739-
assert_eq!(result.errors[0].field, "sandbox.containerMode");
811+
assert!(result.errors.is_empty());
812+
assert_eq!(result.warnings.len(), 1);
813+
assert_eq!(result.warnings[0].field, "sandbox.containerMode");
740814
}
741815

742816
#[test]
@@ -750,8 +824,9 @@ mod tests {
750824
let result = validate_config_file(object, source, &test_path());
751825

752826
// then
753-
assert_eq!(result.errors.len(), 1);
754-
assert_eq!(result.errors[0].field, "plugins.autoUpdate");
827+
assert!(result.errors.is_empty());
828+
assert_eq!(result.warnings.len(), 1);
829+
assert_eq!(result.warnings[0].field, "plugins.autoUpdate");
755830
}
756831

757832
#[test]
@@ -765,16 +840,17 @@ mod tests {
765840
let result = validate_config_file(object, source, &test_path());
766841

767842
// then
768-
assert_eq!(result.errors.len(), 1);
769-
assert_eq!(result.errors[0].field, "oauth.secret");
843+
assert!(result.errors.is_empty());
844+
assert_eq!(result.warnings.len(), 1);
845+
assert_eq!(result.warnings[0].field, "oauth.secret");
770846
}
771847

772848
#[test]
773849
fn valid_config_produces_no_diagnostics() {
774850
// given
775851
let source = r#"{
776852
"model": "opus",
777-
"hooks": {"PreToolUse": ["guard"]},
853+
"hooks": {"PreToolUse": [{"hooks":[{"type":"command","command":"guard"}]}]},
778854
"permissions": {"defaultMode": "plan", "allow": ["Read"]},
779855
"mcpServers": {},
780856
"sandbox": {"enabled": false}
@@ -801,8 +877,9 @@ mod tests {
801877
let result = validate_config_file(object, source, &test_path());
802878

803879
// then
804-
assert_eq!(result.errors.len(), 1);
805-
match &result.errors[0].kind {
880+
assert!(result.errors.is_empty());
881+
assert_eq!(result.warnings.len(), 1);
882+
match &result.warnings[0].kind {
806883
DiagnosticKind::UnknownKey {
807884
suggestion: Some(s),
808885
} => assert_eq!(s, "model"),
@@ -813,7 +890,7 @@ mod tests {
813890
#[test]
814891
fn format_diagnostics_includes_all_entries() {
815892
// given
816-
let source = r#"{"permissionMode": "plan", "badKey": 1}"#;
893+
let source = r#"{"model": 42, "badKey": 1}"#;
817894
let parsed = JsonValue::parse(source).expect("valid json");
818895
let object = parsed.as_object().expect("object");
819896
let result = validate_config_file(object, source, &test_path());
@@ -825,7 +902,7 @@ mod tests {
825902
assert!(output.contains("warning:"));
826903
assert!(output.contains("error:"));
827904
assert!(output.contains("badKey"));
828-
assert!(output.contains("permissionMode"));
905+
assert!(output.contains("model"));
829906
}
830907

831908
#[test]

0 commit comments

Comments
 (0)