chuggin 0.12.5

A terminal harness for persistent projects and continuous local model refinement
//! Presentation-only command summaries. Never changes tool results or saved logs.
use std::{
    collections::VecDeque,
    fs::File,
    io::Read,
    path::{Path, PathBuf},
    time::Instant,
};

#[derive(Default, Clone, Copy)]
struct Counts {
    passed: u64,
    failed: u64,
    skipped: u64,
}
impl Counts {
    fn total(self) -> u64 {
        self.passed + self.failed + self.skipped
    }
}
pub struct CommandOutput {
    pub command: String,
    pub path: PathBuf,
    file: Option<File>,
    partial: Vec<u8>,
    pub completed: Option<bool>,
    drained: bool,
    started: Instant,
    elapsed: Option<u64>,
    raw: VecDeque<String>,
    raw_bytes: usize,
    omitted: u64,
    lines: u64,
    failures: Vec<String>,
    counts: Counts,
    live: Counts,
    announced: u64,
    known: bool,
    total_known: bool,
}
impl CommandOutput {
    pub fn new(command: String, path: &Path) -> Self {
        Self {
            command,
            path: path.into(),
            file: File::open(path).ok(),
            partial: Vec::new(),
            completed: None,
            drained: false,
            started: Instant::now(),
            elapsed: None,
            raw: VecDeque::new(),
            raw_bytes: 0,
            omitted: 0,
            lines: 0,
            failures: Vec::new(),
            counts: Counts::default(),
            live: Counts::default(),
            announced: 0,
            known: false,
            total_known: false,
        }
    }
    pub fn finish(&mut self, passed: bool) {
        self.completed = Some(passed);
        self.elapsed = Some(self.started.elapsed().as_secs());
    }
    pub fn poll(&mut self, budget: usize) -> usize {
        if self.drained {
            return 0;
        }
        let Some(file) = &mut self.file else {
            return 0;
        };
        let mut bytes = vec![0; budget];
        let n = match file.read(&mut bytes) {
            Ok(n) => n,
            Err(_) => return 0,
        };
        self.partial.extend_from_slice(&bytes[..n]);
        while let Some(end) = self.partial.iter().position(|b| *b == b'\n') {
            let row: Vec<_> = self.partial.drain(..=end).collect();
            self.line(&String::from_utf8_lossy(&row));
        }
        if self.partial.len() > 16000 {
            let row = std::mem::take(&mut self.partial);
            self.line(&String::from_utf8_lossy(&row));
        }
        if n < budget && self.completed.is_some() {
            let row = std::mem::take(&mut self.partial);
            if !row.is_empty() {
                self.line(&String::from_utf8_lossy(&row));
            }
            self.drained = true;
            self.file = None;
        }
        n
    }
    fn line(&mut self, text: &str) {
        let line = crate::ui::clean(text).trim_end().to_owned();
        self.lines += 1;
        self.observe(&line);
        let lower = line.to_lowercase();
        if self.failures.len() < 3
            && (lower.starts_with("error")
                || lower.starts_with("failed ")
                || lower.contains(" ... failed")
                || lower.starts_with("not ok ")
                || lower.contains("has been running for over"))
        {
            self.failures.push(crate::project::excerpt(&line, 220));
        }
        let line = crate::project::excerpt(&line, 4000);
        self.raw_bytes += line.len();
        self.raw.push_back(line);
        while self.raw_bytes > 20000 || self.raw.len() > 500 {
            if let Some(old) = self.raw.pop_front() {
                self.raw_bytes -= old.len();
                self.omitted += 1;
            }
        }
    }
    fn observe(&mut self, line: &str) {
        let t = line.trim();
        if let Some(n) = t
            .strip_prefix("running ")
            .and_then(|s| s.strip_suffix(" tests").or_else(|| s.strip_suffix(" test")))
            .and_then(|s| s.parse().ok())
        {
            self.announced = n;
            self.total_known = true;
            self.live = Counts::default();
            self.known = true;
        } else if t.starts_with("test result:") {
            self.total_known = true;
            let c = counts(t);
            self.counts.passed += c.passed;
            self.counts.failed += c.failed;
            self.counts.skipped += c.skipped;
            self.live = Counts::default();
            self.announced = 0;
            self.known = true;
        } else if t.starts_with("test ") {
            if t.ends_with(" ... ok") {
                self.live.passed += 1;
                self.known = true;
            } else if t.ends_with(" ... FAILED") {
                self.live.failed += 1;
                self.known = true;
            } else if t.contains(" ... ignored") {
                self.live.skipped += 1;
                self.known = true;
            }
        } else if (t.starts_with('=')
            && (t.contains(" passed") || t.contains(" failed") || t.contains(" skipped")))
            || t.starts_with("Tests:")
            || t.starts_with("Tests ")
        {
            let c = counts(t);
            if c.total() > 0 {
                self.total_known = true;
                self.counts = c;
                self.live = Counts::default();
                self.announced = 0;
                self.known = true;
            }
        } else if t.starts_with("# pass ")
            || t.starts_with("# fail ")
            || t.starts_with("# skipped ")
            || t.starts_with("# tests ")
        {
            let words: Vec<_> = t.split_whitespace().collect();
            if let Some(n) = words.get(2).and_then(|n| n.parse().ok()) {
                match words[1] {
                    "pass" => self.live.passed = n,
                    "fail" => self.live.failed = n,
                    "skipped" => self.live.skipped = n,
                    "tests" => {
                        self.announced = n;
                        self.total_known = true;
                    }
                    _ => {}
                }
                self.known = true;
            }
        }
    }
    pub fn summary(&self) -> String {
        let elapsed = self
            .elapsed
            .unwrap_or_else(|| self.started.elapsed().as_secs());
        let status = match self.completed {
            Some(true) => "✓",
            Some(false) => "×",
            None => "·",
        };
        let totals = if self.known {
            let p = self.counts.passed + self.live.passed;
            let f = self.counts.failed + self.live.failed;
            let skipped = self.counts.skipped + self.live.skipped;
            let total = self.counts.total() + self.announced.max(self.live.total());
            let mut parts = vec![if self.total_known {
                if total == 0 {
                    "No tests executed".into()
                } else {
                    format!("{p}/{total} tests passed")
                }
            } else {
                format!("{p} tests passed · total not reported")
            }];
            if f > 0 {
                parts.push(format!("{f} failed"));
            }
            if skipped > 0 {
                parts.push(format!("{skipped} skipped"));
            }
            if self.completed.is_none() {
                parts.push("running".into());
            } else if self.completed == Some(false) {
                parts.push("command failed".into());
            }
            if self.completed.is_some() && !self.drained {
                parts.push("reading remaining output".into());
            }
            parts.join(" · ")
        } else {
            format!(
                "{} · {} output lines",
                match self.completed {
                    Some(true) => "Command succeeded",
                    Some(false) => "Command failed",
                    None => "Running",
                },
                self.lines
            )
        };
        format!("{status} {totals} · {elapsed}s")
    }
    pub fn display(&self, expanded: bool) -> Vec<String> {
        let compact = crate::ui::clean(&self.command)
            .split_whitespace()
            .collect::<Vec<_>>()
            .join(" ");
        let mut title = compact.chars().take(120).collect::<String>();
        if compact.chars().count() > 120 {
            title.push('…');
        }
        let mut lines = vec![
            format!("{} $ {}", if expanded { "▾" } else { "▸" }, title),
            format!("  {}", self.summary()),
        ];
        if expanded {
            if compact.chars().count() > 120 || self.command.contains('\n') {
                lines.push(format!(
                    "  Command: {}",
                    crate::project::excerpt(&crate::ui::clean(&self.command), 12000)
                ));
            }
            if self.omitted > 0 {
                lines.push(format!(
                    "  … {} earlier lines omitted from this view",
                    self.omitted
                ));
            }
            for line in &self.raw {
                lines.push(format!("  │ {line}"));
            }
            if !self.partial.is_empty() {
                lines.push(format!(
                    "  │ {}",
                    crate::ui::clean(&String::from_utf8_lossy(&self.partial))
                ));
            }
            lines.push(format!("  Full log: {}", self.path.display()));
        } else {
            for failure in &self.failures {
                lines.push(format!("  {failure}"));
            }
        }
        lines
    }
}
fn counts(text: &str) -> Counts {
    let mut result = Counts::default();
    let words: Vec<_> = text.split_whitespace().collect();
    for pair in words.windows(2) {
        if let Ok(n) = pair[0]
            .trim_matches(|c: char| !c.is_ascii_digit())
            .parse::<u64>()
        {
            match pair[1].trim_matches(|c: char| !c.is_ascii_alphabetic()) {
                "passed" => result.passed = n,
                "failed" => result.failed = n,
                "ignored" | "skipped" => result.skipped = n,
                _ => {}
            }
        }
    }
    result
}

#[cfg(test)]
mod tests {
    use super::*;
    fn output(log: &str, passed: bool) -> CommandOutput {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("check.log");
        std::fs::write(&path, log).unwrap();
        let mut output = CommandOutput::new("check".into(), &path);
        output.finish(passed);
        while !output.drained {
            output.poll(1024);
        }
        output
    }
    #[test]
    fn rust_suites_aggregate_without_double_counting_and_failures_stay_visible() {
        let mut log = "running 150 tests\n".to_owned();
        for i in 0..145 {
            log.push_str(&format!("test example_{i} ... ok\n"));
        }
        for i in 145..150 {
            log.push_str(&format!("test example_{i} ... FAILED\n"));
        }
        log.push_str("test result: FAILED. 145 passed; 5 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.0s\nrunning 0 tests\ntest result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.0s\n");
        let out = output(&log, false);
        assert!(out.summary().contains("145/150 tests passed · 5 failed"));
        let collapsed = out.display(false).join("\n");
        assert!(!collapsed.contains("example_0 "));
        assert!(collapsed.contains("example_145 ... FAILED"));
        assert!(out.display(false).len() <= 5);
        assert!(out.display(true).join("\n").contains("example_0 ... ok"));
    }
    #[test]
    fn recognizes_pytest_jest_and_tap_without_inventing_generic_counts() {
        for log in [
            "===== 145 passed, 5 failed in 1.20s =====\n",
            "Tests: 5 failed, 145 passed, 150 total\n",
            "# tests 150\n# pass 145\n# fail 5\n",
        ] {
            assert!(
                output(log, false)
                    .summary()
                    .contains("145/150 tests passed"),
                "{log}"
            );
        }
        assert!(
            output("download completed\n", true)
                .summary()
                .contains("Command succeeded")
        );
        assert!(
            !output("145 products passed inspection\n", true)
                .summary()
                .contains("tests passed")
        );
        assert!(
            output("test only_visible_test ... ok\n", true)
                .summary()
                .contains("total not reported")
        );
    }
    #[test]
    fn large_logs_continue_draining_after_completion_without_losing_final_totals() {
        let log = format!(
            "{}\ntest result: ok. 145 passed; 0 failed; 5 ignored; 0 measured; 0 filtered out; finished in 1s\n",
            "diagnostic detail\n".repeat(10000)
        );
        let out = output(&log, true);
        assert!(out.summary().contains("145/150 tests passed · 5 skipped"));
        assert!(out.raw_bytes <= 20000);
        assert!(out.omitted > 0);
        assert!(
            out.display(true)
                .join("\n")
                .contains("earlier lines omitted")
        );
    }
}