nodus 0.8.0

Local-first CLI for managing project-scoped agent packages.
Documentation
use std::cell::RefCell;
use std::io::{self, Write};
use std::rc::Rc;

use anstream::{AutoStream, ColorChoice};
use anstyle::{AnsiColor, Style};
use anyhow::Error;

use crate::execution::PreviewChange;

const LABEL_WIDTH: usize = 12;

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ColorMode {
    #[default]
    Auto,
    #[cfg(test)]
    Always,
    Never,
}

impl ColorMode {
    fn choice(self) -> ColorChoice {
        match self {
            Self::Auto => ColorChoice::Auto,
            #[cfg(test)]
            Self::Always => ColorChoice::Always,
            Self::Never => ColorChoice::Never,
        }
    }
}

pub struct Reporter {
    result: ReportStream,
    diagnostic: ReportStream,
}

struct ReportStream {
    writer: Rc<RefCell<Box<dyn Write>>>,
    color_enabled: bool,
}

impl ReportStream {
    fn new(writer: impl Write + 'static, color_enabled: bool) -> Self {
        Self {
            writer: Rc::new(RefCell::new(Box::new(writer))),
            color_enabled,
        }
    }

    fn shared(writer: Rc<RefCell<Box<dyn Write>>>, color_enabled: bool) -> Self {
        Self {
            writer,
            color_enabled,
        }
    }

    fn write_line(&self, line: &str) -> anyhow::Result<()> {
        let mut writer = self.writer.borrow_mut();
        writeln!(writer, "{line}").map_err(Into::into)
    }
}

impl Reporter {
    pub fn stderr() -> Self {
        let stream = AutoStream::new(io::stderr().lock(), ColorMode::Auto.choice());
        let color_enabled = !matches!(stream.current_choice(), ColorChoice::Never);
        Self {
            result: ReportStream::new(stream, color_enabled),
            diagnostic: ReportStream::new(
                AutoStream::new(io::stderr().lock(), ColorMode::Auto.choice()),
                color_enabled,
            ),
        }
    }

    pub fn stdio() -> Self {
        let result = AutoStream::new(io::stdout().lock(), ColorMode::Auto.choice());
        let diagnostic = AutoStream::new(io::stderr().lock(), ColorMode::Auto.choice());
        let result_color_enabled = !matches!(result.current_choice(), ColorChoice::Never);
        let diagnostic_color_enabled = !matches!(diagnostic.current_choice(), ColorChoice::Never);
        Self {
            result: ReportStream::new(result, result_color_enabled),
            diagnostic: ReportStream::new(diagnostic, diagnostic_color_enabled),
        }
    }

    pub fn sink(_mode: ColorMode, writer: impl Write + 'static) -> Self {
        let color_enabled = {
            #[cfg(test)]
            {
                matches!(_mode, ColorMode::Always)
            }
            #[cfg(not(test))]
            {
                false
            }
        };
        let writer = Rc::new(RefCell::new(Box::new(writer) as Box<dyn Write>));
        Self {
            result: ReportStream::shared(Rc::clone(&writer), color_enabled),
            diagnostic: ReportStream::shared(writer, color_enabled),
        }
    }

    #[cfg(test)]
    pub fn sink_split(
        mode: ColorMode,
        result_writer: impl Write + 'static,
        diagnostic_writer: impl Write + 'static,
    ) -> Self {
        let color_enabled = matches!(mode, ColorMode::Always);
        Self {
            result: ReportStream::new(result_writer, color_enabled),
            diagnostic: ReportStream::new(diagnostic_writer, color_enabled),
        }
    }

    pub fn silent() -> Self {
        Self::sink(ColorMode::Never, io::sink())
    }

    pub fn status(&self, label: &str, message: impl std::fmt::Display) -> anyhow::Result<()> {
        let padded = format!("{label:>LABEL_WIDTH$}");
        self.write_diagnostic_line(&format!(
            "{} {message}",
            self.styled_diagnostic(&padded, Self::status_style()),
        ))
    }

    pub fn finish(&self, message: impl std::fmt::Display) -> anyhow::Result<()> {
        let padded = format!("{:>LABEL_WIDTH$}", "Finished");
        self.write_result_line(&format!(
            "{} {message}",
            self.styled_result(&padded, Self::finish_style()),
        ))
    }

    pub fn warning(&self, message: impl std::fmt::Display) -> anyhow::Result<()> {
        self.write_diagnostic_line(&format!(
            "{} {message}",
            self.styled_diagnostic("warning:", Self::warning_style()),
        ))
    }

    pub fn note(&self, message: impl std::fmt::Display) -> anyhow::Result<()> {
        self.write_diagnostic_line(&format!(
            "{} {message}",
            self.styled_diagnostic("note:", Self::note_style()),
        ))
    }

    pub fn line(&self, message: impl std::fmt::Display) -> anyhow::Result<()> {
        self.write_result_line(&message.to_string())
    }

    pub fn preview(&self, change: &PreviewChange) -> anyhow::Result<()> {
        self.note(change.describe())
    }

    pub fn color_enabled(&self) -> bool {
        self.result.color_enabled
    }

    pub fn paint(&self, value: &str, style: Style) -> String {
        self.styled_result(value, style)
    }

    pub fn error(&self, error: &Error) -> anyhow::Result<()> {
        let mut chain = error.chain();
        if let Some(head) = chain.next() {
            self.write_diagnostic_line(&format!(
                "{} {head}",
                self.styled_diagnostic("error:", Self::error_style()),
            ))?;
        }

        let causes = chain.map(|cause| cause.to_string()).collect::<Vec<_>>();
        if !causes.is_empty() {
            self.write_diagnostic_line("Caused by:")?;
            for (index, cause) in causes.iter().enumerate() {
                self.write_diagnostic_line(&format!("  {index}: {cause}"))?;
            }
        }

        Ok(())
    }

    fn write_result_line(&self, line: &str) -> anyhow::Result<()> {
        self.result.write_line(line)
    }

    fn write_diagnostic_line(&self, line: &str) -> anyhow::Result<()> {
        self.diagnostic.write_line(line)
    }

    fn styled_result(&self, value: &str, style: Style) -> String {
        if self.result.color_enabled {
            format!("{style}{value}{style:#}")
        } else {
            value.to_string()
        }
    }

    fn styled_diagnostic(&self, value: &str, style: Style) -> String {
        if self.diagnostic.color_enabled {
            format!("{style}{value}{style:#}")
        } else {
            value.to_string()
        }
    }

    fn status_style() -> Style {
        Style::new().bold().fg_color(Some(AnsiColor::Green.into()))
    }

    fn finish_style() -> Style {
        Self::status_style()
    }

    fn warning_style() -> Style {
        Style::new().bold().fg_color(Some(AnsiColor::Yellow.into()))
    }

    fn note_style() -> Style {
        Style::new().bold().fg_color(Some(AnsiColor::Cyan.into()))
    }

    fn error_style() -> Style {
        Style::new().bold().fg_color(Some(AnsiColor::Red.into()))
    }
}

#[cfg(test)]
mod tests {
    use std::io::{self, Write};
    use std::sync::{Arc, Mutex};

    use super::*;

    #[derive(Clone, Default)]
    struct SharedBuffer(Arc<Mutex<Vec<u8>>>);

    impl SharedBuffer {
        fn contents(&self) -> String {
            String::from_utf8(self.0.lock().unwrap().clone()).unwrap()
        }
    }

    impl Write for SharedBuffer {
        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
            self.0.lock().unwrap().extend_from_slice(buf);
            Ok(buf.len())
        }

        fn flush(&mut self) -> io::Result<()> {
            Ok(())
        }
    }

    #[test]
    fn renders_plain_status_output_when_color_is_disabled() {
        let buffer = SharedBuffer::default();
        let reporter = Reporter::sink(ColorMode::Never, buffer.clone());

        reporter.status("Checking", "project graph").unwrap();

        assert_eq!(buffer.contents(), "    Checking project graph\n");
    }

    #[test]
    fn renders_colored_output_when_color_is_forced() {
        let buffer = SharedBuffer::default();
        let reporter = Reporter::sink(ColorMode::Always, buffer.clone());

        reporter.warning("be careful").unwrap();

        let output = buffer.contents();
        assert!(output.contains("\u{1b}["));
        assert!(output.contains("warning:"));
        assert!(output.contains("be careful"));
    }

    #[test]
    fn renders_finish_and_note_output() {
        let buffer = SharedBuffer::default();
        let reporter = Reporter::sink(ColorMode::Never, buffer.clone());

        reporter.note("using shared checkout").unwrap();
        reporter.finish("1 package in 0.01s").unwrap();

        assert_eq!(
            buffer.contents(),
            "note: using shared checkout\n    Finished 1 package in 0.01s\n"
        );
    }

    #[test]
    fn renders_plain_lines_without_prefixes() {
        let buffer = SharedBuffer::default();
        let reporter = Reporter::sink(ColorMode::Never, buffer.clone());

        reporter.line("hello world").unwrap();

        assert_eq!(buffer.contents(), "hello world\n");
    }

    #[test]
    fn renders_error_chains() {
        let buffer = SharedBuffer::default();
        let reporter = Reporter::sink(ColorMode::Never, buffer.clone());
        let error = anyhow::anyhow!("outer").context("middle").context("inner");

        reporter.error(&error).unwrap();

        assert_eq!(
            buffer.contents(),
            "error: inner\nCaused by:\n  0: middle\n  1: outer\n"
        );
    }

    #[test]
    fn routes_results_and_diagnostics_to_different_writers() {
        let result = SharedBuffer::default();
        let diagnostic = SharedBuffer::default();
        let reporter = Reporter::sink_split(ColorMode::Never, result.clone(), diagnostic.clone());

        reporter.line("payload").unwrap();
        reporter.status("Checking", "project graph").unwrap();
        reporter.finish("done").unwrap();

        assert_eq!(result.contents(), "payload\n    Finished done\n");
        assert_eq!(diagnostic.contents(), "    Checking project graph\n");
    }
}