cfgd-core 0.4.0

Core library for cfgd — shared types, providers, reconciler, state
Documentation
use std::path::Path;
use std::time::Duration;

use super::{Renderer, Writer, role_glyph};
use crate::PathDisplayExt;
use crate::output::{Role, Verbosity, strip_ansi};

/// Inputs to a single Status line. Builders convert to this for rendering.
pub struct StatusFields<'a> {
    pub role: Role,
    pub subject: &'a str,
    pub detail: Option<&'a str>,
    pub duration: Option<Duration>,
    pub target: Option<&'a Path>,
}

impl Renderer {
    /// Top-level status dispatcher. Routes to the topmost open section's
    /// pending-statuses buffer when one exists (so subjects can be
    /// right-padded to a common column at section close); otherwise writes
    /// immediately.
    pub fn render_status(&self, w: &dyn Writer, depth: usize, f: &StatusFields<'_>) {
        // Status(Fail) is shown even at Quiet.
        if self.verbosity == Verbosity::Quiet && f.role != Role::Fail {
            return;
        }
        // Buffer when a section is open AND this status's depth is inside
        // (not equal to) the section's header_depth. The depth==header_depth
        // case happens for re-routed top-level emits via `enforce_top_level_emit`;
        // those should render immediately so the warning shape stays inline.
        let buffered = {
            let mut s = self.state.lock().unwrap_or_else(|e| e.into_inner());
            let mut did_buffer = false;
            if let Some(top) = s.section_stack.last_mut()
                && depth > top.header_depth
            {
                // Inside the section's child region — buffer.
                top.pending_statuses.push(super::section::BufferedStatus {
                    role: f.role,
                    subject: f.subject.to_string(),
                    detail: f.detail.map(|d| d.to_string()),
                    duration: f.duration,
                    target: f.target.map(|p| p.to_path_buf()),
                    depth,
                });
                did_buffer = true;
            }
            did_buffer
        };
        if buffered {
            // Header emission must still happen so the section's header
            // appears before any of its children. This is idempotent — only
            // the first call writes anything.
            self.flush_pending_section_headers(w);
            return;
        }
        self.render_status_immediate(w, depth, f);
        self.mark_top_level_blank_if_at_root();
    }

    /// Actually emit a Status line, without buffering. Used by the immediate
    /// path AND by `flush_pending_statuses` when a section closes.
    pub(crate) fn render_status_immediate(
        &self,
        w: &dyn Writer,
        depth: usize,
        f: &StatusFields<'_>,
    ) {
        if self.verbosity == Verbosity::Quiet && f.role != Role::Fail {
            return;
        }
        self.flush_pending_section_headers(w);

        let (icon_opt, style) = role_glyph(&self.theme, f.role);
        let mut line = String::new();
        if let Some(icon) = icon_opt {
            line.push_str(&style.apply_to(icon).to_string());
            line.push(' ');
        }
        line.push_str(&style.apply_to(f.subject).to_string());

        // Field order: subject — detail (target). Detail comes first (with
        // em-dash glue), then target in parens. Duration trails last as its
        // own (Ns) parens block.
        if let Some(detail) = f.detail {
            line.push_str("");
            // Sanitize at the renderer boundary: detail may carry external
            // tool stderr or `format!("{e}")` content with embedded ANSI
            // escapes. A stray `\x1b[0m` would prematurely terminate the
            // role styling above; foreign color escapes would paint
            // subsequent terminal output until the next reset.
            line.push_str(&strip_ansi(detail));
        }
        if let Some(target) = f.target {
            let dim = self.theme.muted.apply_to(format!(" ({})", target.posix()));
            line.push_str(&dim.to_string());
        }
        if let Some(d) = f.duration {
            let secs = d.as_secs_f64();
            let dim = self.theme.muted.apply_to(format!(" ({:.1}s)", secs));
            line.push_str(&dim.to_string());
        }
        self.write_line(w, depth, &line);
    }
}

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

    use super::super::StringSink;
    use super::*;
    use crate::output::Theme;
    use crate::output::strip_ansi;

    fn capture() -> (Renderer, StringSink, Arc<Mutex<String>>) {
        let buf = Arc::new(Mutex::new(String::new()));
        let sink = StringSink(buf.clone());
        let r = Renderer::new(Theme::default(), Verbosity::Normal);
        (r, sink, buf)
    }

    #[test]
    fn ok_status_renders_check_glyph() {
        let (r, sink, buf) = capture();
        r.render_status(
            &sink,
            0,
            &StatusFields {
                role: Role::Ok,
                subject: "done",
                detail: None,
                duration: None,
                target: None,
            },
        );
        let out = strip_ansi(&buf.lock().unwrap());
        assert!(out.contains("✓ done"), "got: {out:?}");
    }

    #[test]
    fn info_role_has_no_icon() {
        let (r, sink, buf) = capture();
        r.render_status(
            &sink,
            0,
            &StatusFields {
                role: Role::Info,
                subject: "note",
                detail: None,
                duration: None,
                target: None,
            },
        );
        let out = strip_ansi(&buf.lock().unwrap());
        assert_eq!(out.trim_end(), "note");
    }

    #[test]
    fn detail_appended_with_em_dash() {
        let (r, sink, buf) = capture();
        r.render_status(
            &sink,
            0,
            &StatusFields {
                role: Role::Fail,
                subject: "/tmp/foo",
                detail: Some("permission denied"),
                duration: None,
                target: None,
            },
        );
        let out = strip_ansi(&buf.lock().unwrap());
        assert!(
            out.contains("✗ /tmp/foo — permission denied"),
            "got: {out:?}"
        );
    }

    #[test]
    fn duration_trailed_in_parens() {
        let (r, sink, buf) = capture();
        r.render_status(
            &sink,
            0,
            &StatusFields {
                role: Role::Ok,
                subject: "done",
                detail: None,
                duration: Some(std::time::Duration::from_millis(1234)),
                target: None,
            },
        );
        let out = strip_ansi(&buf.lock().unwrap());
        assert!(out.contains("(1.2s)"), "got: {out:?}");
    }

    #[test]
    fn fail_shown_even_at_quiet() {
        let buf = Arc::new(Mutex::new(String::new()));
        let sink = StringSink(buf.clone());
        let r = Renderer::new(Theme::default(), Verbosity::Quiet);
        r.render_status(
            &sink,
            0,
            &StatusFields {
                role: Role::Fail,
                subject: "boom",
                detail: None,
                duration: None,
                target: None,
            },
        );
        let out = strip_ansi(&buf.lock().unwrap());
        assert!(
            out.contains("boom"),
            "Fail must render at Quiet; got: {out:?}"
        );
    }

    #[test]
    fn ok_suppressed_at_quiet() {
        let buf = Arc::new(Mutex::new(String::new()));
        let sink = StringSink(buf.clone());
        let r = Renderer::new(Theme::default(), Verbosity::Quiet);
        r.render_status(
            &sink,
            0,
            &StatusFields {
                role: Role::Ok,
                subject: "done",
                detail: None,
                duration: None,
                target: None,
            },
        );
        assert!(buf.lock().unwrap().is_empty());
    }

    #[test]
    fn detail_strips_ansi_to_prevent_terminal_paint() {
        let (r, sink, buf) = capture();
        let detail = "upstream: \x1b[31mred\x1b[0m text \x1b[1mbold\x1b[0m";
        r.render_status(
            &sink,
            0,
            &StatusFields {
                role: Role::Fail,
                subject: "sync failed",
                detail: Some(detail),
                duration: None,
                target: None,
            },
        );
        let raw = buf.lock().unwrap().clone();
        let visible = strip_ansi(&raw);
        assert!(
            visible.contains("sync failed — upstream: red text bold"),
            "visible composition mismatch; got: {visible:?}"
        );
        // The renderer's own SGR styles the Fail glyph + subject (bold red),
        // so a blanket `!raw.contains("\\x1b[")` is too strict. Pick a SGR
        // code the renderer would never emit for Fail (foreground red `31`)
        // to prove the detail's escapes were sanitized away.
        assert!(
            !raw.contains("\x1b[31m"),
            "detail's red SGR must be stripped before push_str; got raw: {raw:?}"
        );
        // And the stray `\x1b[0m` mid-detail must not survive — it would
        // otherwise close the renderer's subject styling prematurely.
        let detail_segment = raw.rsplit("").next().unwrap_or("");
        assert!(
            !detail_segment.contains('\u{1b}'),
            "detail segment must contain no ANSI escapes; got: {detail_segment:?}"
        );
    }
}