termesh-tasks 0.1.0

Internal component of a terminal-native, agent-first IDE.
Documentation
use std::path::{Path, PathBuf};

use serde_json::Value;
use termesh_core::{Problem, ProblemSeverity};

use crate::{DecodedTaskOutput, TaskOutputDecoder};

pub struct CargoOutputDecoder {
    cwd: PathBuf,
    pending: Vec<u8>,
}

impl CargoOutputDecoder {
    pub fn new(cwd: PathBuf) -> Self {
        Self { cwd, pending: Vec::new() }
    }

    fn decode_line(&self, line: &[u8]) -> DecodedTaskOutput {
        let Ok(text) = std::str::from_utf8(line) else {
            return DecodedTaskOutput { display: line.to_vec(), problems: Vec::new() };
        };
        let json_text = text.strip_suffix('\n').unwrap_or(text);
        let json_text = json_text.strip_suffix('\r').unwrap_or(json_text);
        let Ok(value) = serde_json::from_str::<Value>(json_text) else {
            return fallback_line(&self.cwd, line);
        };
        match value.get("reason").and_then(Value::as_str) {
            Some("compiler-message") => compiler_message(&self.cwd, &value),
            Some("compiler-artifact" | "build-script-executed" | "build-finished") => {
                DecodedTaskOutput::default()
            }
            _ => DecodedTaskOutput { display: line.to_vec(), problems: Vec::new() },
        }
    }
}

impl TaskOutputDecoder for CargoOutputDecoder {
    fn push(&mut self, bytes: &[u8]) -> DecodedTaskOutput {
        self.pending.extend_from_slice(bytes);
        let mut decoded = DecodedTaskOutput::default();
        while let Some(end) = self.pending.iter().position(|byte| *byte == b'\n') {
            let line: Vec<u8> = self.pending.drain(..=end).collect();
            merge(&mut decoded, self.decode_line(&line));
        }
        decoded
    }

    fn finish(&mut self) -> DecodedTaskOutput {
        if self.pending.is_empty() {
            return DecodedTaskOutput::default();
        }
        let line = std::mem::take(&mut self.pending);
        self.decode_line(&line)
    }
}

fn compiler_message(cwd: &Path, value: &Value) -> DecodedTaskOutput {
    let Some(message) = value.get("message") else { return DecodedTaskOutput::default() };
    let display = message
        .get("rendered")
        .and_then(Value::as_str)
        .map(str::as_bytes)
        .unwrap_or_default()
        .to_vec();
    let problem = message
        .get("spans")
        .and_then(Value::as_array)
        .and_then(|spans| {
            spans.iter().find(|span| span.get("is_primary") == Some(&Value::Bool(true)))
        })
        .and_then(|span| problem_from_span(cwd, message, span));
    DecodedTaskOutput { display, problems: problem.into_iter().collect() }
}

fn problem_from_span(cwd: &Path, message: &Value, span: &Value) -> Option<Problem> {
    let raw_path = span.get("file_name")?.as_str()?;
    let path = PathBuf::from(raw_path);
    let path = if path.is_absolute() { path } else { cwd.join(path) };
    let level = message.get("level").and_then(Value::as_str).unwrap_or("warning");
    Some(Problem {
        path,
        line: usize::try_from(span.get("line_start")?.as_u64()?).ok()?,
        column: usize::try_from(span.get("column_start")?.as_u64()?).ok()?,
        severity: if level == "error" { ProblemSeverity::Error } else { ProblemSeverity::Warning },
        message: message.get("message")?.as_str()?.to_owned(),
    })
}

fn fallback_line(cwd: &Path, line: &[u8]) -> DecodedTaskOutput {
    let plain = strip_ansi(&String::from_utf8_lossy(line));
    let problems = panic_problem(cwd, &plain).into_iter().collect();
    DecodedTaskOutput { display: line.to_vec(), problems }
}

fn panic_problem(cwd: &Path, line: &str) -> Option<Problem> {
    let location = line.split("panicked at ").nth(1)?.split_whitespace().next()?;
    let location = location
        .trim_matches(|character| matches!(character, '\'' | '"' | ','))
        .trim_end_matches(':');
    let mut parts = location.rsplitn(3, ':');
    let column = parts.next()?.trim_end_matches(':').parse().ok()?;
    let line_number = parts.next()?.parse().ok()?;
    let raw_path = parts.next()?;
    let path = PathBuf::from(raw_path);
    Some(Problem {
        path: if path.is_absolute() { path } else { cwd.join(path) },
        line: line_number,
        column,
        severity: ProblemSeverity::Error,
        message: line.trim().to_owned(),
    })
}

fn strip_ansi(input: &str) -> String {
    let mut output = String::new();
    let mut chars = input.chars().peekable();
    while let Some(character) = chars.next() {
        if character == '\u{1b}' && chars.peek() == Some(&'[') {
            chars.next();
            for next in chars.by_ref() {
                if ('@'..='~').contains(&next) {
                    break;
                }
            }
        } else {
            output.push(character);
        }
    }
    output
}

fn merge(target: &mut DecodedTaskOutput, source: DecodedTaskOutput) {
    target.display.extend(source.display);
    target.problems.extend(source.problems);
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn compiler_json_split_across_chunks_becomes_rendered_output_and_problem() {
        let json = r#"{"reason":"compiler-message","message":{"rendered":"error[E0425]\n","level":"error","message":"cannot find value","spans":[{"file_name":"src/lib.rs","line_start":12,"column_start":5,"is_primary":true}]}}
"#;
        let mut decoder = CargoOutputDecoder::new(PathBuf::from("/p"));
        let split = json.len() / 2;
        assert!(decoder.push(&json.as_bytes()[..split]).display.is_empty());
        let output = decoder.push(&json.as_bytes()[split..]);
        assert_eq!(output.display, b"error[E0425]\n");
        assert_eq!((output.problems[0].line, output.problems[0].column), (12, 5));
    }

    #[test]
    fn artifacts_are_hidden_but_test_output_passes_through() {
        let mut decoder = CargoOutputDecoder::new(PathBuf::from("/p"));
        assert!(decoder.push(b"{\"reason\":\"compiler-artifact\"}\n").display.is_empty());
        assert_eq!(decoder.push(b"test result: ok\n").display, b"test result: ok\n");
    }

    #[test]
    fn ansi_panic_location_becomes_a_problem_without_changing_display() {
        let mut decoder = CargoOutputDecoder::new(PathBuf::from("/p"));
        let line = b"\x1b[31mthread 'case' panicked at src/lib.rs:12:5:\x1b[0m\n";
        let output = decoder.push(line);
        assert_eq!(output.display, line);
        assert_eq!(output.problems[0].path, Path::new("/p/src/lib.rs"));
        assert_eq!((output.problems[0].line, output.problems[0].column), (12, 5));
    }

    #[test]
    fn incomplete_non_json_line_is_flushed_verbatim() {
        let mut decoder = CargoOutputDecoder::new(PathBuf::from("/p"));
        assert!(decoder.push(b"partial").display.is_empty());
        assert_eq!(decoder.finish().display, b"partial");
    }
}