pub mod formatter;
pub mod types;
pub use formatter::{FormatMode, TokenFormatter};
pub use types::*;
#[derive(Debug)]
pub enum ParseResult<T> {
Full(T),
Degraded(T, Vec<String>),
Passthrough(String),
}
impl<T> ParseResult<T> {
#[allow(dead_code)]
pub fn unwrap(self) -> T {
match self {
ParseResult::Full(data) => data,
ParseResult::Degraded(data, _) => data,
ParseResult::Passthrough(_) => panic!("Called unwrap on Passthrough result"),
}
}
#[allow(dead_code)]
pub fn tier(&self) -> u8 {
match self {
ParseResult::Full(_) => 1,
ParseResult::Degraded(_, _) => 2,
ParseResult::Passthrough(_) => 3,
}
}
#[allow(dead_code)]
pub fn is_ok(&self) -> bool {
!matches!(self, ParseResult::Passthrough(_))
}
#[allow(dead_code)]
pub fn map<U, F>(self, f: F) -> ParseResult<U>
where
F: FnOnce(T) -> U,
{
match self {
ParseResult::Full(data) => ParseResult::Full(f(data)),
ParseResult::Degraded(data, warnings) => ParseResult::Degraded(f(data), warnings),
ParseResult::Passthrough(raw) => ParseResult::Passthrough(raw),
}
}
#[allow(dead_code)]
pub fn warnings(&self) -> Vec<String> {
match self {
ParseResult::Degraded(_, warnings) => warnings.clone(),
_ => vec![],
}
}
}
pub trait OutputParser: Sized {
type Output;
fn parse(input: &str) -> ParseResult<Self::Output>;
#[allow(dead_code)]
fn parse_with_tier(input: &str, max_tier: u8) -> ParseResult<Self::Output> {
let result = Self::parse(input);
if result.tier() > max_tier {
return ParseResult::Passthrough(truncate_passthrough(input));
}
result
}
}
pub fn truncate_passthrough(output: &str) -> String {
let max_chars = crate::core::config::limits().passthrough_max_chars;
truncate_output(output, max_chars)
}
pub fn truncate_output(output: &str, max_chars: usize) -> String {
let chars: Vec<char> = output.chars().collect();
if chars.len() <= max_chars {
return output.to_string();
}
let truncated: String = chars[..max_chars].iter().collect();
format!(
"{}\n\n[RTK:PASSTHROUGH] Output truncated ({} chars → {} chars)",
truncated,
chars.len(),
max_chars
)
}
pub fn emit_degradation_warning(tool: &str, reason: &str) {
eprintln!("[RTK:DEGRADED] {} parser: {}", tool, reason);
}
pub fn emit_passthrough_warning(tool: &str, reason: &str) {
eprintln!("[RTK:PASSTHROUGH] {} parser: {}", tool, reason);
}
pub fn extract_json_object(input: &str) -> Option<&str> {
let start_pos = if let Some(pos) = input.find("\"numTotalTests\"") {
input[..pos].rfind('{').unwrap_or(0)
} else {
let mut found_start = None;
for (idx, line) in input.lines().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with('{') {
found_start = Some(
input[..]
.lines()
.take(idx)
.map(|l| l.len() + 1)
.sum::<usize>(),
);
break;
}
}
found_start?
};
let mut depth = 0;
let mut in_string = false;
let mut escape_next = false;
let chars: Vec<char> = input[start_pos..].chars().collect();
for (i, &ch) in chars.iter().enumerate() {
if escape_next {
escape_next = false;
continue;
}
match ch {
'\\' if in_string => escape_next = true,
'"' => in_string = !in_string,
'{' if !in_string => depth += 1,
'}' if !in_string => {
depth -= 1;
if depth == 0 {
// Found matching closing brace
let end_pos = start_pos + i + 1; // +1 to include the `}`
return Some(&input[start_pos..end_pos]);
}
}
_ => {}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_result_tier() {
let full: ParseResult<i32> = ParseResult::Full(42);
assert_eq!(full.tier(), 1);
assert!(full.is_ok());
let degraded: ParseResult<i32> = ParseResult::Degraded(42, vec!["warning".to_string()]);
assert_eq!(degraded.tier(), 2);
assert!(degraded.is_ok());
assert_eq!(degraded.warnings().len(), 1);
let passthrough: ParseResult<i32> = ParseResult::Passthrough("raw".to_string());
assert_eq!(passthrough.tier(), 3);
assert!(!passthrough.is_ok());
}
#[test]
fn test_parse_result_map() {
let full: ParseResult<i32> = ParseResult::Full(42);
let mapped = full.map(|x| x * 2);
assert_eq!(mapped.tier(), 1);
assert_eq!(mapped.unwrap(), 84);
let degraded: ParseResult<i32> = ParseResult::Degraded(42, vec!["warn".to_string()]);
let mapped = degraded.map(|x| x * 2);
assert_eq!(mapped.tier(), 2);
assert_eq!(mapped.warnings().len(), 1);
assert_eq!(mapped.unwrap(), 84);
}
#[test]
fn test_truncate_output() {
let short = "hello";
assert_eq!(truncate_output(short, 10), "hello");
let long = "a".repeat(1000);
let truncated = truncate_output(&long, 100);
assert!(truncated.contains("[RTK:PASSTHROUGH]"));
assert!(truncated.contains("1000 chars → 100 chars"));
}
#[test]
fn test_truncate_output_multibyte() {
// Thai text: each char is 3 bytes
let thai = "สวัสดีครับ".repeat(100);
// Try truncating at a byte offset that might land mid-character
let result = truncate_output(&thai, 50);
assert!(result.contains("[RTK:PASSTHROUGH]"));
// Should be valid UTF-8 (no panic)
let _ = result.len();
}
#[test]
fn test_truncate_output_emoji() {
let emoji = "🎉".repeat(200);
let result = truncate_output(&emoji, 100);
assert!(result.contains("[RTK:PASSTHROUGH]"));
}
#[test]
fn test_extract_json_object_clean() {
let input = r#"{"numTotalTests": 13, "numPassedTests": 13}"#;
let extracted = extract_json_object(input);
assert_eq!(extracted, Some(input));
}
#[test]
fn test_extract_json_object_with_pnpm_prefix() {
let input = r#"
Scope: all 6 workspace projects
WARN deprecated inflight@1.0.6: This module is not supported
{"numTotalTests": 13, "numPassedTests": 13, "numFailedTests": 0}
"#;
let extracted = extract_json_object(input).expect("Should extract JSON");
assert!(extracted.contains("numTotalTests"));
assert!(extracted.starts_with('{'));
assert!(extracted.ends_with('}'));
}
#[test]
fn test_extract_json_object_with_dotenv_prefix() {
let input = r#"[dotenv] Loading environment variables from .env
[dotenv] Injected 5 variables
{"numTotalTests": 5, "testResults": [{"name": "test.js"}]}
"#;
let extracted = extract_json_object(input).expect("Should extract JSON");
assert!(extracted.contains("numTotalTests"));
assert!(extracted.contains("testResults"));
}
#[test]
fn test_extract_json_object_nested_braces() {
let input = r#"prefix text
{"numTotalTests": 2, "testResults": [{"name": "test", "data": {"nested": true}}]}
"#;
let extracted = extract_json_object(input).expect("Should extract JSON");
assert!(extracted.contains("\"nested\": true"));
assert!(extracted.starts_with('{'));
assert!(extracted.ends_with('}'));
}
#[test]
fn test_extract_json_object_no_json() {
let input = "Just plain text with no JSON";
let extracted = extract_json_object(input);
assert_eq!(extracted, None);
}
#[test]
fn test_extract_json_object_string_with_braces() {
let input = r#"{"numTotalTests": 1, "message": "test {should} not confuse parser"}"#;
let extracted = extract_json_object(input).expect("Should extract JSON");
assert!(extracted.contains("test {should} not confuse parser"));
assert_eq!(extracted, input);
}
}