use std::time::{Duration, Instant};
pub(super) const COALESCE_WINDOW: Duration = Duration::from_millis(50);
#[derive(Debug, Clone, PartialEq)]
pub(super) struct PartialToolArgs {
pub value: Option<serde_json::Value>,
pub raw_partial: Option<String>,
}
impl PartialToolArgs {
#[allow(dead_code)]
pub(super) fn is_empty(&self) -> bool {
self.value.is_none() && self.raw_partial.is_none()
}
}
pub(super) fn project_partial(bytes: &str) -> PartialToolArgs {
let trimmed = bytes.trim_start();
if trimmed.is_empty() {
return PartialToolArgs {
value: None,
raw_partial: None,
};
}
if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) {
return PartialToolArgs {
value: Some(value),
raw_partial: None,
};
}
if let Some(closed) = close_dangling_json(trimmed) {
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&closed) {
return PartialToolArgs {
value: Some(value),
raw_partial: None,
};
}
}
PartialToolArgs {
value: None,
raw_partial: Some(bytes.to_string()),
}
}
fn close_dangling_json(text: &str) -> Option<String> {
#[derive(Clone, Copy, Eq, PartialEq)]
enum State {
Outside,
InString,
InNumber,
InIdent,
}
let mut state = State::Outside;
let mut escape = false;
let mut stack: Vec<char> = Vec::new();
let mut clean_stack: Vec<char> = Vec::new();
let mut clean_end: usize = 0;
let bytes = text.as_bytes();
let len = bytes.len();
let mut i = 0;
while i < len {
let ch = bytes[i] as char;
match state {
State::InString => {
if escape {
escape = false;
} else if ch == '\\' {
escape = true;
} else if ch == '"' {
state = State::Outside;
clean_end = i + 1;
clean_stack = stack.clone();
}
i += 1;
}
State::InNumber => {
if ch.is_ascii_digit()
|| ch == '.'
|| ch == 'e'
|| ch == 'E'
|| ch == '+'
|| ch == '-'
{
i += 1;
} else {
state = State::Outside;
clean_end = i;
clean_stack = stack.clone();
}
}
State::InIdent => {
if ch.is_ascii_alphabetic() {
i += 1;
} else {
state = State::Outside;
let start = clean_end;
let token = &text[start..i].trim_start();
if matches!(*token, "true" | "false" | "null") {
clean_end = i;
clean_stack = stack.clone();
}
}
}
State::Outside => {
match ch {
'"' => {
state = State::InString;
i += 1;
}
'{' | '[' => {
stack.push(ch);
i += 1;
clean_end = i;
clean_stack = stack.clone();
}
'}' => {
if stack.pop() != Some('{') {
return None;
}
i += 1;
clean_end = i;
clean_stack = stack.clone();
}
']' => {
if stack.pop() != Some('[') {
return None;
}
i += 1;
clean_end = i;
clean_stack = stack.clone();
}
',' | ':' => {
i += 1;
}
' ' | '\t' | '\n' | '\r' => {
i += 1;
}
'-' => {
state = State::InNumber;
i += 1;
}
c if c.is_ascii_digit() => {
state = State::InNumber;
i += 1;
}
c if c.is_ascii_alphabetic() => {
state = State::InIdent;
i += 1;
}
_ => return None,
}
}
}
}
if state == State::InNumber {
clean_end = len;
clean_stack = stack.clone();
} else if state == State::InIdent {
let token = text[clean_end..].trim_start();
if matches!(token, "true" | "false" | "null") {
clean_end = len;
clean_stack = stack.clone();
}
}
if clean_end == 0 {
return None;
}
let mut closed = text[..clean_end].to_string();
while let Some(open) = clean_stack.pop() {
closed.push(if open == '{' { '}' } else { ']' });
}
Some(closed)
}
pub(super) struct DeltaCoalescer {
last_emit_at: Option<Instant>,
}
impl DeltaCoalescer {
pub(super) fn new() -> Self {
Self { last_emit_at: None }
}
pub(super) fn should_emit(&mut self, now: Instant) -> bool {
match self.last_emit_at {
None => {
self.last_emit_at = Some(now);
true
}
Some(last) if now.saturating_duration_since(last) >= COALESCE_WINDOW => {
self.last_emit_at = Some(now);
true
}
_ => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strict_parse_returns_value() {
let parsed = project_partial(r#"{"path":"README.md"}"#);
assert_eq!(parsed.value, Some(serde_json::json!({"path": "README.md"})));
assert!(parsed.raw_partial.is_none());
}
#[test]
fn unterminated_string_falls_back_to_raw() {
let parsed = project_partial(r#"{"path":"fo"#);
assert!(parsed.value.is_none());
assert_eq!(parsed.raw_partial.as_deref(), Some(r#"{"path":"fo"#));
}
#[test]
fn unbalanced_object_recovers_to_value() {
let parsed = project_partial(r#"{"path":"README.md""#);
assert_eq!(parsed.value, Some(serde_json::json!({"path": "README.md"})));
}
#[test]
fn unbalanced_array_recovers_to_value() {
let parsed = project_partial(r#"{"items":[1, 2"#);
assert_eq!(parsed.value, Some(serde_json::json!({"items": [1, 2]})));
}
#[test]
fn empty_input_emits_nothing() {
let parsed = project_partial("");
assert!(parsed.is_empty());
}
#[test]
fn whitespace_only_emits_nothing() {
let parsed = project_partial(" \n\t ");
assert!(parsed.is_empty());
}
#[test]
fn coalescer_emits_first_then_throttles() {
let mut c = DeltaCoalescer::new();
let t0 = Instant::now();
assert!(c.should_emit(t0), "first delta must always emit");
assert!(
!c.should_emit(t0 + Duration::from_millis(10)),
"deltas inside the coalesce window must be dropped"
);
assert!(
!c.should_emit(t0 + Duration::from_millis(40)),
"deltas inside the coalesce window must be dropped"
);
assert!(
c.should_emit(t0 + Duration::from_millis(60)),
"deltas past the coalesce window must emit"
);
}
#[test]
fn nested_object_in_array_recovers() {
let parsed = project_partial(r#"{"a": [{"k": "v""#);
assert_eq!(parsed.value, Some(serde_json::json!({"a": [{"k": "v"}]})));
}
#[test]
fn dangling_colon_falls_back_to_raw() {
let parsed = project_partial(r#"{"path":"#);
assert!(parsed.value.is_none());
assert_eq!(parsed.raw_partial.as_deref(), Some(r#"{"path":"#));
}
}