1use serde_json::{Map, Value};
24
25#[derive(Debug)]
30pub struct TruncationRepair {
31 pub repaired: String,
32 pub changed: bool,
33 pub notes: Vec<&'static str>,
34}
35
36pub fn repair_truncated_json(raw: &str) -> TruncationRepair {
44 if raw.trim().is_empty() {
45 return TruncationRepair {
46 repaired: "{}".into(),
47 changed: true,
48 notes: vec!["empty input -> {}"],
49 };
50 }
51 if is_valid_json(raw) {
52 return TruncationRepair {
53 repaired: raw.into(),
54 changed: false,
55 notes: vec![],
56 };
57 }
58 let mut candidate = raw.to_string();
59 if let Some(closed) = close_likely_json(&candidate) {
60 candidate = closed;
61 }
62 if ends_with_dangling_colon(&candidate) {
63 candidate.push_str(" null");
64 }
65 candidate = strip_trailing_commas(&candidate);
66 if is_valid_json(&candidate) {
67 return TruncationRepair {
68 repaired: candidate,
69 changed: true,
70 notes: vec!["truncation repaired"],
71 };
72 }
73 if let Some(obj) = salvage_json_object_prefix(&candidate) {
75 return TruncationRepair {
76 repaired: obj,
77 changed: true,
78 notes: vec!["salvaged prefix object"],
79 };
80 }
81 if let Some(kv) = salvage_top_level_pairs(&candidate) {
84 return TruncationRepair {
85 repaired: kv,
86 changed: true,
87 notes: vec!["salvaged top-level pairs"],
88 };
89 }
90 TruncationRepair {
91 repaired: "{}".into(),
92 changed: true,
93 notes: vec!["fallback -> {}"],
94 }
95}
96
97fn is_valid_json(s: &str) -> bool {
98 serde_json::from_str::<serde::de::IgnoredAny>(s).is_ok()
99}
100
101fn close_likely_json(raw: &str) -> Option<String> {
105 let mut stack: Vec<u8> = Vec::new();
106 let mut in_string = false;
107 let mut escape = false;
108 for &ch in raw.as_bytes() {
109 if in_string {
110 if escape {
111 escape = false;
112 continue;
113 }
114 match ch {
115 b'\\' => escape = true,
116 b'"' => in_string = false,
117 _ => {}
118 }
119 continue;
120 }
121 match ch {
122 b'"' => in_string = true,
123 b'{' => stack.push(b'}'),
124 b'[' => stack.push(b']'),
125 b'}' | b']' if stack.last() != Some(&ch) => return None,
129 b'}' | b']' => {
130 stack.pop();
131 }
132 _ => {}
133 }
134 }
135 let mut out = String::with_capacity(raw.len() + stack.len() + 1);
136 out.push_str(raw);
137 if in_string {
138 out.push('"');
139 }
140 while let Some(c) = stack.pop() {
141 out.push(c as char);
142 }
143 Some(out)
144}
145
146fn ends_with_dangling_colon(s: &str) -> bool {
149 let trimmed_end = s.trim_end();
150 let Some(before_colon) = trimmed_end.strip_suffix(':') else {
151 return false;
152 };
153 before_colon.trim_end().ends_with('"')
154}
155
156fn strip_trailing_commas(s: &str) -> String {
160 let bytes = s.as_bytes();
161 let mut out = Vec::with_capacity(bytes.len());
162 let mut i = 0;
163 while i < bytes.len() {
164 if bytes[i] == b',' {
165 let mut j = i + 1;
166 while j < bytes.len() && bytes[j].is_ascii_whitespace() {
167 j += 1;
168 }
169 if j < bytes.len() && (bytes[j] == b'}' || bytes[j] == b']') {
170 i += 1; continue;
172 }
173 }
174 out.push(bytes[i]);
175 i += 1;
176 }
177 String::from_utf8(out).unwrap_or_else(|_| s.to_string())
179}
180
181fn salvage_json_object_prefix(raw: &str) -> Option<String> {
185 let start = raw.find('{')?;
186 let mut end = raw.len();
187 while end > start {
188 if !raw.is_char_boundary(end) {
189 end -= 1;
190 continue;
191 }
192 let candidate = raw[start..end].trim();
193 if candidate.starts_with('{') {
194 if let Some(closed) = close_likely_json(candidate) {
195 let closed = strip_trailing_commas(&closed);
196 if is_valid_json(&closed) {
197 return Some(closed);
198 }
199 }
200 }
201 end -= 1;
202 }
203 None
204}
205
206fn salvage_top_level_pairs(raw: &str) -> Option<String> {
209 let mut r = raw.trim();
210 if r.is_empty() {
211 return None;
212 }
213 if let Some(pos) = r.find('{') {
214 r = &r[pos + 1..];
215 }
216 let matches = find_pair_literals(r, 12);
217 if matches.is_empty() {
218 return None;
219 }
220 let mut out = Map::new();
221 for (key, lit) in matches {
222 let Ok(v) = serde_json::from_str::<Value>(&lit) else {
223 continue;
224 };
225 out.insert(key, v);
226 }
227 if out.is_empty() {
228 return None;
229 }
230 serde_json::to_string(&Value::Object(out)).ok()
231}
232
233fn find_pair_literals(s: &str, max: usize) -> Vec<(String, String)> {
237 let bytes = s.as_bytes();
238 let mut out = Vec::new();
239 let mut i = 0;
240 while i < bytes.len() && out.len() < max {
241 if bytes[i] != b'"' {
242 i += 1;
243 continue;
244 }
245 match match_pair_at(bytes, i) {
246 Some((key, lit, next)) => {
247 out.push((key, lit));
248 i = next;
249 }
250 None => i += 1,
251 }
252 }
253 out
254}
255
256fn match_pair_at(bytes: &[u8], start: usize) -> Option<(String, String, usize)> {
259 let mut i = start + 1;
260 let key_start = i;
261 while i < bytes.len()
262 && (bytes[i].is_ascii_alphanumeric() || matches!(bytes[i], b'_' | b'-' | b'.'))
263 {
264 i += 1;
265 }
266 if i == key_start || i >= bytes.len() || bytes[i] != b'"' {
267 return None;
268 }
269 let key = String::from_utf8(bytes[key_start..i].to_vec()).ok()?;
270 i += 1;
271 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
272 i += 1;
273 }
274 if i >= bytes.len() || bytes[i] != b':' {
275 return None;
276 }
277 i += 1;
278 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
279 i += 1;
280 }
281 let (lit, next) = match_literal_at(bytes, i)?;
282 Some((key, lit, next))
283}
284
285fn match_literal_at(bytes: &[u8], start: usize) -> Option<(String, usize)> {
287 if start >= bytes.len() {
288 return None;
289 }
290 match bytes[start] {
291 b'"' => {
292 let mut i = start + 1;
293 while i < bytes.len() {
294 match bytes[i] {
295 b'\\' => i += 2,
296 b'"' => {
297 let lit = String::from_utf8(bytes[start..=i].to_vec()).ok()?;
298 return Some((lit, i + 1));
299 }
300 _ => i += 1,
301 }
302 }
303 None
304 }
305 b'-' | b'0'..=b'9' => {
306 let mut i = start;
307 if bytes[i] == b'-' {
308 i += 1;
309 }
310 let int_start = i;
311 while i < bytes.len() && bytes[i].is_ascii_digit() {
312 i += 1;
313 }
314 if i == int_start {
315 return None;
316 }
317 if i < bytes.len() && bytes[i] == b'.' {
318 let frac_start = i + 1;
319 let mut j = frac_start;
320 while j < bytes.len() && bytes[j].is_ascii_digit() {
321 j += 1;
322 }
323 if j > frac_start {
324 i = j;
325 }
326 }
327 let lit = String::from_utf8(bytes[start..i].to_vec()).ok()?;
328 Some((lit, i))
329 }
330 _ => {
331 for word in ["true", "false", "null"] {
332 if bytes[start..].starts_with(word.as_bytes()) {
333 return Some((word.to_string(), start + word.len()));
334 }
335 }
336 None
337 }
338 }
339}
340
341pub const REPAIR_NULL_OPTIONAL_OMITTED: &str = "null_optional_omitted";
344pub const REPAIR_STRINGIFIED_ARRAY: &str = "stringified_array";
345pub const REPAIR_BARE_STRING_TO_ARRAY: &str = "bare_string_to_array";
346pub const REPAIR_EMPTY_OBJECT_TO_ARRAY: &str = "empty_object_to_array";
347pub const REPAIR_MARKDOWN_AUTOLINK_PATH: &str = "markdown_autolink_path";
348pub const REPAIR_SEMANTIC_BOOLEAN: &str = "semantic_boolean_string";
349pub const REPAIR_SEMANTIC_INTEGER: &str = "semantic_integer_string";
350pub const REPAIR_FIELD_ALIAS: &str = "field_alias";
351
352#[derive(Debug, Clone, PartialEq)]
354pub struct ToolInputRepair {
355 pub kind: &'static str,
356 pub path: String,
357 pub before_type: &'static str,
358 pub after_type: &'static str,
359}
360
361#[derive(Debug, Clone, PartialEq)]
363enum Seg {
364 Key(String),
365 Index(usize),
366}
367
368struct Issue {
374 path: String,
375 expected: String,
376 items_type: Option<String>,
377 required: bool,
378 known_field: bool,
379 parent_loc: Option<(Vec<Seg>, String)>,
380}
381
382pub fn repair_tool_input_for_spec(
386 schema: &Value,
387 input: &Value,
388) -> Option<(Value, Vec<ToolInputRepair>)> {
389 if !schema.is_object() || !input.is_object() {
390 return None;
391 }
392 let mut work = input.clone();
393 let mut repairs = collect_field_alias_repairs(schema, &mut work);
394 repairs.extend(collect_path_string_repairs(schema, &mut work, ""));
395 let issues = collect_issues(schema, &work, "", &[], true);
396 if issues.is_empty() && repairs.is_empty() {
397 return None;
398 }
399 for issue in &issues {
400 if let Some(repair) = apply_issue_repair(&mut work, issue) {
401 repairs.push(repair);
402 }
403 }
404 if repairs.is_empty() {
405 return None;
406 }
407 if !collect_issues(schema, &work, "", &[], true).is_empty() {
409 return None;
410 }
411 Some((work, repairs))
412}
413
414fn collect_field_alias_repairs(schema: &Value, value: &mut Value) -> Vec<ToolInputRepair> {
418 let Some(props) = schema.get("properties").and_then(Value::as_object) else {
419 return vec![];
420 };
421 let Some(obj) = value.as_object_mut() else {
422 return vec![];
423 };
424 let aliases = [
425 ("filePath", "path"),
426 ("oldString", "old_string"),
427 ("newString", "new_string"),
428 ("replaceAll", "replace_all"),
429 ("cmd", "command"),
430 ];
431 let mut out = Vec::new();
432 for (from, to) in aliases {
433 if !props.contains_key(to) || obj.contains_key(to) {
434 continue;
435 }
436 let Some(value) = obj.remove(from) else {
437 continue;
438 };
439 let before_type = json_type_name(&value);
440 obj.insert(to.to_string(), value);
441 out.push(ToolInputRepair {
442 kind: REPAIR_FIELD_ALIAS,
443 path: to.to_string(),
444 before_type,
445 after_type: before_type,
446 });
447 }
448 out
449}
450
451fn json_type_name(value: &Value) -> &'static str {
452 match value {
453 Value::Null => "null",
454 Value::Bool(_) => "boolean",
455 Value::Number(_) => "number",
456 Value::String(_) => "string",
457 Value::Array(_) => "array",
458 Value::Object(_) => "object",
459 }
460}
461
462fn navigate_mut<'a>(value: &'a mut Value, segs: &[Seg]) -> Option<&'a mut Value> {
464 let mut cur = value;
465 for seg in segs {
466 cur = match seg {
467 Seg::Key(k) => cur.as_object_mut()?.get_mut(k)?,
468 Seg::Index(i) => cur.as_array_mut()?.get_mut(*i)?,
469 };
470 }
471 Some(cur)
472}
473
474fn collect_path_string_repairs(
477 schema: &Value,
478 value: &mut Value,
479 path: &str,
480) -> Vec<ToolInputRepair> {
481 match schema_type(schema).as_str() {
482 "object" | "" => {
483 let Some(obj) = value.as_object_mut() else {
484 return vec![];
485 };
486 let Some(props) = schema.get("properties").and_then(Value::as_object) else {
487 return vec![];
488 };
489 let mut keys: Vec<&String> = props.keys().collect();
490 keys.sort();
491 let mut out = Vec::new();
492 for key in keys {
493 let child_schema = &props[key.as_str()];
494 if !child_schema.is_object() {
495 continue;
496 }
497 let Some(child_value) = obj.get_mut(key.as_str()) else {
498 continue;
499 };
500 let child_path = join_path(path, key);
501 if schema_type(child_schema) == "string" && is_path_string_field(key) {
502 if let Some(s) = child_value.as_str() {
503 if let Some(fixed) = unwrap_markdown_autolink_path(s) {
504 *child_value = Value::String(fixed);
505 out.push(ToolInputRepair {
506 kind: REPAIR_MARKDOWN_AUTOLINK_PATH,
507 path: child_path,
508 before_type: "string",
509 after_type: "string",
510 });
511 }
512 }
513 continue;
514 }
515 out.extend(collect_path_string_repairs(
516 child_schema,
517 child_value,
518 &child_path,
519 ));
520 }
521 out
522 }
523 "array" => {
524 let Some(arr) = value.as_array_mut() else {
525 return vec![];
526 };
527 let Some(item_schema) = schema.get("items").filter(|s| s.is_object()) else {
528 return vec![];
529 };
530 let mut out = Vec::new();
531 for (i, item) in arr.iter_mut().enumerate() {
532 let item_path = format!("{path}[{i}]");
533 out.extend(collect_path_string_repairs(item_schema, item, &item_path));
534 }
535 out
536 }
537 _ => vec![],
538 }
539}
540
541fn collect_issues(
545 schema: &Value,
546 value: &Value,
547 path: &str,
548 loc: &[Seg],
549 required: bool,
550) -> Vec<Issue> {
551 let mut expected = schema_type(schema);
552 if expected.is_empty() && schema.get("properties").is_some_and(Value::is_object) {
553 expected = "object".into();
554 }
555 let issue_here = || Issue {
556 path: path.to_string(),
557 expected: expected.clone(),
558 items_type: schema_array_items_type(schema),
559 required,
560 known_field: !path.is_empty(),
561 parent_loc: None,
562 };
563 if value.is_null() {
564 if type_allows_null(schema) {
565 return vec![];
566 }
567 return vec![issue_here()];
568 }
569 match expected.as_str() {
570 "" => vec![],
571 "object" => {
572 let Some(obj) = value.as_object() else {
573 return vec![issue_here()];
574 };
575 collect_object_issues(schema, obj, path, loc)
576 }
577 "array" => {
578 let Some(arr) = value.as_array() else {
579 return vec![issue_here()];
580 };
581 let mut out = Vec::new();
582 if let Some(min) = schema_min_items(schema) {
583 if arr.len() < min {
584 out.push(issue_here());
585 }
586 }
587 let Some(item_schema) = schema.get("items").filter(|s| s.is_object()) else {
588 return out;
589 };
590 for (i, item) in arr.iter().enumerate() {
591 let item_path = format!("{path}[{i}]");
592 let mut item_loc = loc.to_vec();
593 item_loc.push(Seg::Index(i));
594 out.extend(collect_issues(
595 item_schema,
596 item,
597 &item_path,
598 &item_loc,
599 true,
600 ));
601 }
602 out
603 }
604 "string" if value.is_string() => vec![],
605 "integer" if is_json_integer(value) => vec![],
606 "number" if value.is_number() => vec![],
607 "boolean" if value.is_boolean() => vec![],
608 "string" | "integer" | "number" | "boolean" => vec![issue_here()],
609 _ => vec![],
610 }
611}
612
613fn collect_object_issues(
614 schema: &Value,
615 obj: &Map<String, Value>,
616 path: &str,
617 loc: &[Seg],
618) -> Vec<Issue> {
619 let props = schema
620 .get("properties")
621 .and_then(Value::as_object)
622 .cloned()
623 .unwrap_or_default();
624 let req = required_set(schema.get("required"));
625 let mut out = Vec::new();
626 let mut keys: Vec<&String> = props.keys().collect();
627 keys.sort();
628 for key in keys {
629 let child_schema = &props[key.as_str()];
630 if !child_schema.is_object() {
631 continue;
632 }
633 let child_path = join_path(path, key);
634 let Some(value) = obj.get(key.as_str()) else {
635 if req.contains(key) {
636 out.push(Issue {
637 path: child_path,
638 expected: schema_type(child_schema),
639 items_type: schema_array_items_type(child_schema),
640 required: true,
641 known_field: true,
642 parent_loc: Some((loc.to_vec(), key.clone())),
643 });
644 }
645 continue;
646 };
647 let mut child_loc = loc.to_vec();
648 child_loc.push(Seg::Key(key.clone()));
649 let mut issues = collect_issues(
650 child_schema,
651 value,
652 &child_path,
653 &child_loc,
654 req.contains(key),
655 );
656 for issue in &mut issues {
657 issue.known_field = true;
658 if issue.parent_loc.is_none() {
659 issue.parent_loc = Some((loc.to_vec(), key.clone()));
660 }
661 }
662 out.extend(issues);
663 }
664 if schema.get("additionalProperties") == Some(&Value::Bool(false)) {
665 for key in obj.keys() {
666 if props.contains_key(key.as_str()) {
667 continue;
668 }
669 out.push(Issue {
670 path: join_path(path, key),
671 expected: "none".into(),
672 items_type: None,
673 required: false,
674 known_field: false,
675 parent_loc: Some((loc.to_vec(), key.clone())),
676 });
677 }
678 }
679 out
680}
681
682fn apply_issue_repair(work: &mut Value, issue: &Issue) -> Option<ToolInputRepair> {
685 if !issue.known_field {
686 return None;
687 }
688 let (parent_loc, key) = issue.parent_loc.as_ref()?;
689 let parent = navigate_mut(work, parent_loc)?.as_object_mut()?;
690 let value = parent.get(key.as_str())?.clone();
691 if value.is_null() && !issue.required {
692 parent.remove(key.as_str());
693 return Some(ToolInputRepair {
694 kind: REPAIR_NULL_OPTIONAL_OMITTED,
695 path: issue.path.clone(),
696 before_type: "null",
697 after_type: "omitted",
698 });
699 }
700 if let Some(s) = value.as_str() {
701 match issue.expected.as_str() {
702 "boolean" => {
703 let parsed = match s {
704 "true" => true,
705 "false" => false,
706 _ => return None,
707 };
708 parent.insert(key.clone(), Value::Bool(parsed));
709 return Some(ToolInputRepair {
710 kind: REPAIR_SEMANTIC_BOOLEAN,
711 path: issue.path.clone(),
712 before_type: "string",
713 after_type: "boolean",
714 });
715 }
716 "integer" => {
717 if !is_decimal_integer_literal(s) {
718 return None;
719 }
720 let n: i64 = s.parse().ok()?;
721 parent.insert(key.clone(), Value::from(n));
722 return Some(ToolInputRepair {
723 kind: REPAIR_SEMANTIC_INTEGER,
724 path: issue.path.clone(),
725 before_type: "string",
726 after_type: "number",
727 });
728 }
729 _ => {}
730 }
731 }
732 if issue.expected != "array" {
733 return None;
734 }
735 match &value {
736 Value::String(s) => {
737 if let Ok(arr) = serde_json::from_str::<Vec<Value>>(s.trim()) {
738 parent.insert(key.clone(), Value::Array(arr));
739 return Some(ToolInputRepair {
740 kind: REPAIR_STRINGIFIED_ARRAY,
741 path: issue.path.clone(),
742 before_type: "string",
743 after_type: "array",
744 });
745 }
746 if issue.items_type.as_deref() != Some("string") {
747 return None;
748 }
749 parent.insert(key.clone(), Value::Array(vec![value]));
750 Some(ToolInputRepair {
751 kind: REPAIR_BARE_STRING_TO_ARRAY,
752 path: issue.path.clone(),
753 before_type: "string",
754 after_type: "array",
755 })
756 }
757 Value::Object(m) if m.is_empty() => {
758 parent.insert(key.clone(), Value::Array(vec![]));
759 Some(ToolInputRepair {
760 kind: REPAIR_EMPTY_OBJECT_TO_ARRAY,
761 path: issue.path.clone(),
762 before_type: "object",
763 after_type: "array",
764 })
765 }
766 _ => None,
767 }
768}
769
770fn is_decimal_integer_literal(s: &str) -> bool {
771 let rest = s.strip_prefix('-').unwrap_or(s);
774 !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit())
775}
776
777fn schema_type(schema: &Value) -> String {
780 match schema.get("type") {
781 Some(Value::String(s)) => s.clone(),
782 Some(Value::Array(items)) => items
783 .iter()
784 .filter_map(Value::as_str)
785 .find(|s| *s != "null")
786 .unwrap_or_default()
787 .to_string(),
788 _ => String::new(),
789 }
790}
791
792fn type_allows_null(schema: &Value) -> bool {
793 match schema.get("type") {
794 Some(Value::String(s)) => s == "null",
795 Some(Value::Array(items)) => items.iter().filter_map(Value::as_str).any(|s| s == "null"),
796 _ => false,
797 }
798}
799
800fn schema_array_items_type(schema: &Value) -> Option<String> {
801 let items = schema.get("items")?;
802 items.is_object().then(|| schema_type(items))
803}
804
805fn schema_min_items(schema: &Value) -> Option<usize> {
806 let v = schema.get("minItems")?.as_f64()?;
807 (v >= 0.0 && v.trunc() == v).then_some(v as usize)
808}
809
810fn is_json_integer(value: &Value) -> bool {
811 if value.is_i64() || value.is_u64() {
812 return true;
813 }
814 value.as_f64().is_some_and(|f| f.trunc() == f)
815}
816
817fn required_set(required: Option<&Value>) -> Vec<String> {
818 required
819 .and_then(Value::as_array)
820 .map(|arr| {
821 arr.iter()
822 .filter_map(Value::as_str)
823 .map(str::to_string)
824 .collect()
825 })
826 .unwrap_or_default()
827}
828
829fn is_path_string_field(key: &str) -> bool {
830 matches!(key.trim(), "file_path" | "path" | "cwd" | "directory")
831}
832
833fn unwrap_markdown_autolink_path(value: &str) -> Option<String> {
838 let trimmed = value.trim();
839 let start = trimmed.find('[')?;
840 let prefix = &trimmed[..start];
841 if !prefix.is_empty() && !prefix.ends_with('/') && !prefix.ends_with('\\') {
842 return None;
843 }
844 let rest = &trimmed[start..];
845 let end_text = rest.find(']')?;
846 if end_text <= 1
847 || end_text + 1 >= rest.len()
848 || rest.as_bytes()[end_text + 1] != b'('
849 || !rest.ends_with(')')
850 {
851 return None;
852 }
853 let text = &rest[1..end_text];
854 let url = &rest[end_text + 2..rest.len() - 1];
855 let target = strip_http_protocol(url)?;
856 let replacement = format!("{prefix}{text}");
857 let normalized_target = target.trim();
858 if normalized_target != text.trim() && normalized_target != replacement.trim() {
859 return None;
860 }
861 if replacement == value {
862 return None;
863 }
864 Some(replacement)
865}
866
867fn strip_http_protocol(value: &str) -> Option<&str> {
868 let value = value.trim();
869 for prefix in ["http://", "https://"] {
870 if let Some(rest) = value.strip_prefix(prefix) {
871 return Some(rest.trim());
872 }
873 }
874 None
875}
876
877fn join_path(parent: &str, key: &str) -> String {
878 if parent.is_empty() {
879 key.to_string()
880 } else {
881 format!("{parent}.{key}")
882 }
883}
884
885pub fn validate_against_schema(schema: &Value, input: &Value) -> Result<(), String> {
900 let (Some(_), Some(obj)) = (schema.as_object(), input.as_object()) else {
901 return Ok(());
902 };
903 for field in required_set(schema.get("required")) {
904 let prop = schema
905 .get("properties")
906 .and_then(Value::as_object)
907 .and_then(|p| p.get(&field));
908 let allows_null = prop.is_some_and(type_allows_null);
909 match obj.get(&field) {
910 None => return Err(format!("missing required field `{field}`")),
911 Some(Value::Null) if !allows_null => {
912 return Err(format!("required field `{field}` must not be null"))
913 }
914 _ => {}
915 }
916 }
917 let Some(props) = schema.get("properties").and_then(Value::as_object) else {
918 return Ok(());
919 };
920 for (key, value) in obj {
921 let Some(prop) = props.get(key) else { continue };
922 let expected = schema_type(prop);
923 if expected.is_empty() || (value.is_null() && type_allows_null(prop)) {
924 continue;
925 }
926 if !json_value_matches_type(value, &expected) {
927 return Err(format!(
928 "field `{key}` should be {expected}, got {}",
929 json_type_name(value)
930 ));
931 }
932 }
933 Ok(())
934}
935
936fn json_value_matches_type(value: &Value, expected: &str) -> bool {
939 match expected {
940 "string" => value.is_string(),
941 "boolean" => value.is_boolean(),
942 "integer" => is_json_integer(value),
943 "number" => value.is_number(),
944 "array" => value.is_array(),
945 "object" => value.is_object(),
946 "null" => value.is_null(),
947 _ => true,
948 }
949}
950
951pub fn example_for_schema(schema: &Value) -> Value {
956 let Some(props) = schema.get("properties").and_then(Value::as_object) else {
957 return Value::Object(Map::new());
958 };
959 let required = required_set(schema.get("required"));
960 let keys: Vec<&String> = if required.is_empty() {
961 props.keys().collect()
962 } else {
963 required.iter().filter(|k| props.contains_key(*k)).collect()
964 };
965 let mut out = Map::new();
966 for key in keys {
967 if let Some(prop) = props.get(key) {
968 out.insert(key.clone(), placeholder_for_schema(prop));
969 }
970 }
971 Value::Object(out)
972}
973
974fn placeholder_for_schema(prop: &Value) -> Value {
975 match schema_type(prop).as_str() {
976 "string" => Value::String("<string>".into()),
977 "integer" | "number" => Value::from(0),
978 "boolean" => Value::Bool(false),
979 "array" => {
980 let item = schema_array_items_type(prop)
981 .map(|t| placeholder_for_schema(&serde_json::json!({ "type": t })))
982 .unwrap_or(Value::String("<item>".into()));
983 Value::Array(vec![item])
984 }
985 "object" => Value::Object(Map::new()),
986 _ => Value::String("<value>".into()),
987 }
988}
989
990#[cfg(test)]
991mod tests {
992 use super::*;
993 use serde_json::json;
994
995 fn validate_schema() -> Value {
1000 json!({
1001 "type": "object",
1002 "properties": {
1003 "path": { "type": "string" },
1004 "limit": { "type": "integer" },
1005 "flag": { "type": "boolean" },
1006 },
1007 "required": ["path"],
1008 "additionalProperties": false
1009 })
1010 }
1011
1012 #[test]
1013 fn validate_accepts_well_formed_input() {
1014 assert!(validate_against_schema(&validate_schema(), &json!({"path": "a", "limit": 3})).is_ok());
1015 }
1016
1017 #[test]
1018 fn validate_flags_missing_required() {
1019 let err = validate_against_schema(&validate_schema(), &json!({"limit": 3})).unwrap_err();
1020 assert!(err.contains("path"), "{err}");
1021 }
1022
1023 #[test]
1024 fn validate_flags_wrong_type() {
1025 let err =
1026 validate_against_schema(&validate_schema(), &json!({"path": "a", "limit": "nope"}))
1027 .unwrap_err();
1028 assert!(err.contains("limit") && err.contains("integer"), "{err}");
1029 }
1030
1031 #[test]
1032 fn validate_flags_null_required() {
1033 let err = validate_against_schema(&validate_schema(), &json!({"path": null})).unwrap_err();
1034 assert!(err.contains("path") && err.contains("null"), "{err}");
1035 }
1036
1037 #[test]
1038 fn example_uses_required_fields_with_typed_placeholders() {
1039 let ex = example_for_schema(&validate_schema());
1040 assert_eq!(ex["path"], json!("<string>"));
1041 assert!(ex.get("limit").is_none());
1043 }
1044
1045 #[test]
1046 fn example_falls_back_to_all_properties_when_none_required() {
1047 let schema = json!({
1048 "type": "object",
1049 "properties": { "a": { "type": "integer" }, "b": { "type": "boolean" } }
1050 });
1051 let ex = example_for_schema(&schema);
1052 assert_eq!(ex["a"], json!(0));
1053 assert_eq!(ex["b"], json!(false));
1054 }
1055
1056 #[test]
1057 fn truncation_closes_truncated_json() {
1058 let res = repair_truncated_json(r#"{"file_path":"README.md","offset":0"#);
1059 assert!(res.changed);
1060 assert_eq!(res.repaired, r#"{"file_path":"README.md","offset":0}"#);
1061 }
1062
1063 #[test]
1064 fn truncation_leaves_valid_json_untouched() {
1065 let res = repair_truncated_json(r#"{"k":"v"}"#);
1066 assert!(!res.changed);
1067 assert_eq!(res.repaired, r#"{"k":"v"}"#);
1068 }
1069
1070 #[test]
1071 fn truncation_salvages_prefix_when_value_missing() {
1072 let res = repair_truncated_json(r#"{"file_path":"README.md","offset":0,"limit":"#);
1073 assert!(res.changed);
1074 assert_eq!(res.repaired, r#"{"file_path":"README.md","offset":0}"#);
1075 }
1076
1077 #[test]
1078 fn truncation_empty_input_becomes_empty_object() {
1079 let res = repair_truncated_json(" ");
1080 assert!(res.changed);
1081 assert_eq!(res.repaired, "{}");
1082 }
1083
1084 #[test]
1085 fn truncation_closes_unterminated_string() {
1086 let res = repair_truncated_json(r#"{"file_path":"READ"#);
1087 assert!(res.changed);
1088 let v: Value = serde_json::from_str(&res.repaired).unwrap();
1089 assert_eq!(v, json!({"file_path": "READ"}));
1090 }
1091
1092 #[test]
1093 fn truncation_strips_trailing_comma() {
1094 let res = repair_truncated_json(r#"{"a":1,}"#);
1095 assert!(res.changed);
1096 let v: Value = serde_json::from_str(&res.repaired).unwrap();
1097 assert_eq!(v, json!({"a": 1}));
1098 }
1099
1100 #[test]
1101 fn truncation_salvages_top_level_pairs_from_garbage() {
1102 let res = repair_truncated_json(r#"{"cmd":"ls","count":3]"#);
1105 assert!(res.changed);
1106 let v: Value = serde_json::from_str(&res.repaired).unwrap();
1107 assert_eq!(v, json!({"cmd": "ls", "count": 3}));
1108 }
1109
1110 #[test]
1111 fn truncation_falls_back_to_empty_object() {
1112 let res = repair_truncated_json("not json at all");
1113 assert!(res.changed);
1114 assert_eq!(res.repaired, "{}");
1115 }
1116
1117 #[test]
1120 fn spec_repairs_safe_field_aliases() {
1121 let schema = json!({
1122 "type": "object",
1123 "properties": {
1124 "path": {"type": "string"},
1125 "old_string": {"type": "string"},
1126 "new_string": {"type": "string"},
1127 "replace_all": {"type": "boolean"}
1128 },
1129 "required": ["path", "old_string", "new_string"],
1130 "additionalProperties": false
1131 });
1132 let input = json!({
1133 "filePath": "src/lib.rs",
1134 "oldString": "before",
1135 "newString": "after",
1136 "replaceAll": "true"
1137 });
1138 let (out, repairs) = repair_tool_input_for_spec(&schema, &input).unwrap();
1139 assert_eq!(out, json!({
1140 "path": "src/lib.rs",
1141 "old_string": "before",
1142 "new_string": "after",
1143 "replace_all": true
1144 }));
1145 assert!(repairs.iter().any(|r| r.kind == REPAIR_FIELD_ALIAS && r.path == "path"));
1146 assert!(repairs.iter().any(|r| r.kind == REPAIR_SEMANTIC_BOOLEAN && r.path == "replace_all"));
1147 }
1148
1149 fn repair_test_schema() -> Value {
1150 json!({
1151 "type": "object",
1152 "properties": {
1153 "prompts": {"type": "array", "items": {"type": "string"}, "minItems": 1},
1154 "ignore": {"type": "array", "items": {"type": "string"}},
1155 "limit": {"type": "integer"},
1156 "content": {"type": "string"}
1157 },
1158 "required": ["prompts"],
1159 "additionalProperties": false
1160 })
1161 }
1162
1163 fn grep_test_schema() -> Value {
1164 json!({
1165 "type": "object",
1166 "properties": {
1167 "pattern": {"type": "string"},
1168 "literal_text": {"type": "boolean"},
1169 "limit": {"type": "integer"}
1170 },
1171 "required": ["pattern"],
1172 "additionalProperties": false
1173 })
1174 }
1175
1176 fn path_test_schema() -> Value {
1177 json!({
1178 "type": "object",
1179 "properties": {
1180 "file_path": {"type": "string"},
1181 "path": {"type": "string"},
1182 "cwd": {"type": "string"},
1183 "content": {"type": "string"}
1184 },
1185 "required": ["content"],
1186 "additionalProperties": false
1187 })
1188 }
1189
1190 #[test]
1191 fn spec_leaves_valid_input_unchanged() {
1192 let input = json!({"prompts": ["a"], "content": "[\"not an arg array\"]"});
1193 assert!(repair_tool_input_for_spec(&repair_test_schema(), &input).is_none());
1194 }
1195
1196 #[test]
1197 fn spec_omits_optional_null() {
1198 let input = json!({"prompts": ["a"], "limit": null});
1199 let (out, repairs) = repair_tool_input_for_spec(&repair_test_schema(), &input).unwrap();
1200 assert_eq!(repairs.len(), 1);
1201 assert_eq!(repairs[0].kind, REPAIR_NULL_OPTIONAL_OMITTED);
1202 assert_eq!(repairs[0].path, "limit");
1203 assert!(out.get("limit").is_none());
1204 }
1205
1206 #[test]
1207 fn spec_does_not_omit_required_null() {
1208 let input = json!({"prompts": null});
1209 assert!(repair_tool_input_for_spec(&repair_test_schema(), &input).is_none());
1210 }
1211
1212 #[test]
1213 fn spec_parses_stringified_array_before_wrapping() {
1214 let input = json!({"prompts": "[\"a\",\"b\"]"});
1215 let (out, repairs) = repair_tool_input_for_spec(&repair_test_schema(), &input).unwrap();
1216 assert_eq!(repairs.len(), 1);
1217 assert_eq!(repairs[0].kind, REPAIR_STRINGIFIED_ARRAY);
1218 assert_eq!(repairs[0].path, "prompts");
1219 assert_eq!(repairs[0].before_type, "string");
1220 assert_eq!(repairs[0].after_type, "array");
1221 assert_eq!(out["prompts"], json!(["a", "b"]));
1222 }
1223
1224 #[test]
1225 fn spec_wraps_bare_string_for_string_array() {
1226 let input = json!({"prompts": "a"});
1227 let (out, repairs) = repair_tool_input_for_spec(&repair_test_schema(), &input).unwrap();
1228 assert_eq!(repairs.len(), 1);
1229 assert_eq!(repairs[0].kind, REPAIR_BARE_STRING_TO_ARRAY);
1230 assert_eq!(out["prompts"], json!(["a"]));
1231 }
1232
1233 #[test]
1234 fn spec_rejects_empty_array_when_min_items_fails() {
1235 let input = json!({"prompts": {}});
1238 assert!(repair_tool_input_for_spec(&repair_test_schema(), &input).is_none());
1239 }
1240
1241 #[test]
1242 fn spec_repairs_empty_object_to_optional_array() {
1243 let input = json!({"prompts": ["a"], "ignore": {}});
1244 let (out, repairs) = repair_tool_input_for_spec(&repair_test_schema(), &input).unwrap();
1245 assert_eq!(repairs.len(), 1);
1246 assert_eq!(repairs[0].kind, REPAIR_EMPTY_OBJECT_TO_ARRAY);
1247 assert_eq!(repairs[0].path, "ignore");
1248 assert_eq!(out["ignore"], json!([]));
1249 }
1250
1251 #[test]
1252 fn spec_leaves_unknown_field_invalid() {
1253 let input = json!({"prompts": "a", "extra": true});
1256 assert!(repair_tool_input_for_spec(&repair_test_schema(), &input).is_none());
1257 }
1258
1259 #[test]
1260 fn spec_coerces_semantic_boolean_string() {
1261 let input = json!({"pattern": "needle", "literal_text": "false"});
1262 let (out, repairs) = repair_tool_input_for_spec(&grep_test_schema(), &input).unwrap();
1263 assert_eq!(repairs.len(), 1);
1264 assert_eq!(repairs[0].kind, REPAIR_SEMANTIC_BOOLEAN);
1265 assert_eq!(repairs[0].path, "literal_text");
1266 assert_eq!(repairs[0].before_type, "string");
1267 assert_eq!(repairs[0].after_type, "boolean");
1268 assert_eq!(out["literal_text"], json!(false));
1269 }
1270
1271 #[test]
1272 fn spec_coerces_semantic_integer_string() {
1273 let input = json!({"pattern": "needle", "limit": "30"});
1274 let (out, repairs) = repair_tool_input_for_spec(&grep_test_schema(), &input).unwrap();
1275 assert_eq!(repairs.len(), 1);
1276 assert_eq!(repairs[0].kind, REPAIR_SEMANTIC_INTEGER);
1277 assert_eq!(repairs[0].path, "limit");
1278 assert_eq!(repairs[0].before_type, "string");
1279 assert_eq!(repairs[0].after_type, "number");
1280 assert_eq!(out["limit"], json!(30));
1281 }
1282
1283 #[test]
1284 fn spec_leaves_invalid_semantic_boolean_string() {
1285 let input = json!({"pattern": "needle", "literal_text": "no"});
1286 assert!(repair_tool_input_for_spec(&grep_test_schema(), &input).is_none());
1287 }
1288
1289 #[test]
1290 fn spec_unwraps_markdown_autolink_file_path() {
1291 let input = json!({"file_path": "[README.md](http://README.md)", "content": "x"});
1292 let (out, repairs) = repair_tool_input_for_spec(&path_test_schema(), &input).unwrap();
1293 assert_eq!(repairs.len(), 1);
1294 assert_eq!(repairs[0].kind, REPAIR_MARKDOWN_AUTOLINK_PATH);
1295 assert_eq!(repairs[0].path, "file_path");
1296 assert_eq!(repairs[0].before_type, "string");
1297 assert_eq!(repairs[0].after_type, "string");
1298 assert_eq!(out["file_path"], json!("README.md"));
1299 }
1300
1301 #[test]
1302 fn spec_unwraps_markdown_autolink_path_with_prefix() {
1303 let input = json!({"path": "sub/[a.txt](http://a.txt)", "content": "x"});
1304 let (out, repairs) = repair_tool_input_for_spec(&path_test_schema(), &input).unwrap();
1305 assert_eq!(repairs.len(), 1);
1306 assert_eq!(repairs[0].kind, REPAIR_MARKDOWN_AUTOLINK_PATH);
1307 assert_eq!(repairs[0].path, "path");
1308 assert_eq!(out["path"], json!("sub/a.txt"));
1309 }
1310
1311 #[test]
1312 fn spec_unwraps_markdown_autolink_cwd() {
1313 let input = json!({"cwd": "[internal](http://internal)", "content": "x"});
1314 let (out, repairs) = repair_tool_input_for_spec(&path_test_schema(), &input).unwrap();
1315 assert_eq!(repairs.len(), 1);
1316 assert_eq!(repairs[0].kind, REPAIR_MARKDOWN_AUTOLINK_PATH);
1317 assert_eq!(repairs[0].path, "cwd");
1318 assert_eq!(out["cwd"], json!("internal"));
1319 }
1320
1321 #[test]
1322 fn spec_does_not_unwrap_markdown_in_content() {
1323 let input = json!({"file_path": "README.md", "content": "[README.md](http://README.md)"});
1324 assert!(repair_tool_input_for_spec(&path_test_schema(), &input).is_none());
1325 }
1326
1327 #[test]
1328 fn spec_does_not_unwrap_normal_markdown_link() {
1329 let input = json!({"file_path": "[click](https://example.com)", "content": "x"});
1330 assert!(repair_tool_input_for_spec(&path_test_schema(), &input).is_none());
1331 }
1332
1333 #[test]
1334 fn spec_repairs_nested_array_path() {
1335 let schema = json!({
1336 "type": "object",
1337 "properties": {
1338 "questions": {
1339 "type": "array",
1340 "items": {
1341 "type": "object",
1342 "properties": {
1343 "options": {"type": "array", "items": {"type": "string"}}
1344 }
1345 }
1346 }
1347 },
1348 "required": ["questions"],
1349 "additionalProperties": false
1350 });
1351 let input = json!({"questions": [{"options": "[\"yes\",\"no\"]"}]});
1352 let (out, repairs) = repair_tool_input_for_spec(&schema, &input).unwrap();
1353 assert_eq!(repairs.len(), 1);
1354 assert_eq!(repairs[0].kind, REPAIR_STRINGIFIED_ARRAY);
1355 assert_eq!(repairs[0].path, "questions[0].options");
1356 assert_eq!(out["questions"][0]["options"], json!(["yes", "no"]));
1357 }
1358}