Radish alpha
r
rad:z3qg5TKmN83afz2fj9z3fQjU8vaYE
Radicle CI adapter for native CI
Radicle
Git
radicle-native-ci src runlog.rs
use std::{
    path::{Path, PathBuf},
    time::{Duration, SystemTime},
};

use html_page::{Element, HtmlPage, Tag};
use time::{macros::format_description, OffsetDateTime};

use radicle_ci_broker::{
    ergo::Oid,
    msg::{RepoId, Request, RunId},
};

use crate::{
    run::RUNSPEC_PATH,
    runspec::{RunSpec, RunSpecError},
};

const CSS: &str = include_str!("native-ci.css");

#[derive(Debug, Default)]
pub struct RunLog {
    filename: PathBuf,
    title: Option<String>,
    request: Option<Request>,
    rid: Option<RepoId>,
    repo_name: Option<String>,
    commit: Option<Oid>,
    branch: Option<String>,
    patch: Option<(Oid, String)>,
    commands: Vec<Command>,
    runspec: Option<RunSpec>,
    runspec_error: Option<String>,
    adapter_run_id: Option<RunId>,
    started: Option<SystemTime>,
    duration: Option<Duration>,
}

impl RunLog {
    pub fn new(filename: &Path) -> Self {
        Self {
            filename: filename.into(),
            ..Default::default()
        }
    }

    pub fn adapter_run_id(&mut self, id: RunId) {
        self.adapter_run_id = Some(id);
    }

    pub fn title(&mut self, title: &str) {
        self.title = Some(title.into());
    }

    pub fn request(&mut self, request: Request) {
        self.request = Some(request);
    }

    pub fn rid(&mut self, rid: RepoId, name: &str) {
        self.rid = Some(rid);
        self.repo_name = Some(name.into());
    }

    pub fn commit(&mut self, commit: Oid) {
        self.commit = Some(commit);
    }

    pub fn branch(&mut self, branch: &str) {
        self.branch = Some(branch.into());
    }

    pub fn patch(&mut self, patch: Oid, title: &str) {
        self.patch = Some((patch, title.into()));
    }

    pub fn all_commands_succeeded(&self) -> bool {
        self.commands.iter().all(|c| c.exit == 0) && self.runspec_error.is_none()
    }

    pub fn runspec(&mut self, runspec: RunSpec) {
        self.runspec = Some(runspec);
    }

    pub fn runspec_error(&mut self, e: &RunSpecError) {
        self.runspec_error = Some(format!("{e}"));
    }

    pub fn started(&mut self, ts: SystemTime) {
        self.started = Some(ts);
    }

    pub fn duration(&mut self, dur: Duration) {
        self.duration = Some(dur);
    }

    // FIXME: refactor this to maybe use a builder pattern?
    #[allow(clippy::too_many_arguments)]
    pub fn runcmd(
        &mut self,
        argv: &[&str],
        cwd: &Path,
        exit: i32,
        stdout: &[u8],
        started: SystemTime,
        ended: SystemTime,
    ) {
        self.commands.push(Command {
            started,
            ended,
            duration: ended.duration_since(started).expect("compute duration"),
            argv: argv.iter().map(|a| a.to_string()).collect(),
            cwd: cwd.into(),
            exit,
            stdout: stdout.to_vec(),
        });
    }

    pub fn write(&self) -> Result<(), RunLogError> {
        let title = self.title.as_ref().ok_or(RunLogError::Missing("title"))?;
        let rid = self.rid.as_ref().ok_or(RunLogError::Missing("rid"))?;
        let repo_name = self
            .repo_name
            .as_ref()
            .ok_or(RunLogError::Missing("repo_name"))?;
        let commit = self.commit.as_ref().ok_or(RunLogError::Missing("commit"))?;

        let mut doc = HtmlPage::default();

        doc.push_to_head(Element::new(Tag::Title).with_text(title));
        doc.push_to_head(Element::new(Tag::Style).with_text(CSS));
        doc.push_to_body(Element::new(Tag::H1).with_text(title));

        eprintln!("adapter run id: {:?}", self.adapter_run_id);
        let ul = Element::new(Tag::Ul)
            .with_child(
                Element::new(Tag::Li)
                    .with_text("Adapter run ID: ")
                    .with_child(if let Some(id) = &self.adapter_run_id {
                        Element::new(Tag::Span)
                            .with_class("run_id")
                            .with_text(id.as_str())
                    } else {
                        Element::new(Tag::Span).with_class("run_id")
                    }),
            )
            .with_child(
                Element::new(Tag::Li)
                    .with_text("Repository id: ")
                    .with_child(Element::new(Tag::Code).with_text(rid.to_string()))
                    .with_text(" ")
                    .with_text(repo_name),
            )
            .with_child(
                Element::new(Tag::Li)
                    .with_text("Commit: ")
                    .with_child(Element::new(Tag::Code).with_text(commit.to_string())),
            )
            .with_child(if let Some(branch) = &self.branch {
                Element::new(Tag::Li)
                    .with_text("Branch: ")
                    .with_child(Element::new(Tag::Code).with_text(branch))
            } else if let Some((patch, title)) = &self.patch {
                Element::new(Tag::Li)
                    .with_text("Patch: ")
                    .with_child(Element::new(Tag::Code).with_text(patch.to_string()))
                    .with_text(" ")
                    .with_text(title)
            } else {
                Element::new(Tag::Span)
            })
            .with_child({
                let ts = if let Some(ts) = &self.started {
                    Element::new(Tag::Span).with_text(timestamp(ts))
                } else {
                    Element::new(Tag::Span)
                };
                Element::new(Tag::Li).with_text("Started: ").with_child(ts)
            })
            .with_child({
                let dur = if let Some(dur) = &self.duration {
                    Element::new(Tag::Span).with_text(format!("{:1}", dur.as_secs_f64()))
                } else {
                    Element::new(Tag::Span)
                };
                Element::new(Tag::Li)
                    .with_text("Duration: ")
                    .with_child(dur)
                    .with_text(" s")
            })
            .with_child(
                Element::new(Tag::Li)
                    .with_text("Result: ")
                    .with_child(Element::new(Tag::B).with_text(self.result())),
            );
        doc.push_to_body(ul);

        let mut toc = ToC::default();
        let mut body = Element::new(Tag::Div);

        body.push_child(
            toc.push(
                Element::new(Tag::Span)
                    .with_child(Element::new(Tag::Code).with_text("Request message")),
            ),
        );
        let req = serde_json::to_string_pretty(&self.request).unwrap();
        let req = Element::new(Tag::Pre).with_text(&req);
        body.push_child(req);

        if let Some(e) = &self.runspec_error {
            let error = Element::new(Tag::P)
                .with_text("Failed to load .radicle/native.yaml: ")
                .with_child(Element::new(Tag::Code).with_text(e));
            body.push_child(error);
        }

        if let Some(runspec) = &self.runspec {
            let text = serde_norway::to_string(&runspec).map_err(RunLogError::RunSpec)?;
            body.push_child(toc.push(Element::new(Tag::Span).with_child(
                Element::new(Tag::Code).with_child(Element::new(Tag::Code).with_text(RUNSPEC_PATH)),
            )));
            body.push_child(
                Element::new(Tag::Blockquote).with_child(Element::new(Tag::Pre).with_text(&text)),
            );
        }

        for cmd in self.commands.iter() {
            cmd.format(&mut body, &mut toc);
        }

        doc.push_to_body(toc.as_html());
        doc.push_to_body(body);

        let html = format!("{doc}");
        std::fs::write(&self.filename, html.as_bytes())
            .map_err(|e| RunLogError::Write(self.filename.clone(), e))?;

        Ok(())
    }

    fn result(&self) -> &'static str {
        if self.all_commands_succeeded() {
            "SUCCESS"
        } else {
            "FAILURE"
        }
    }
}

#[derive(Debug)]
struct Command {
    started: SystemTime,
    ended: SystemTime,
    duration: Duration,
    argv: Vec<String>,
    cwd: PathBuf,
    exit: i32,
    stdout: Vec<u8>,
}

impl Command {
    fn command(&self) -> String {
        self.argv.as_slice().join(" ")
    }

    fn format(&self, body: &mut Element, toc: &mut ToC) {
        body.push_child(
            toc.push(
                Element::new(Tag::Span)
                    .with_text("Run: ")
                    .with_child(Element::new(Tag::Code).with_text(self.command())),
            ),
        );

        body.push_child(
            Element::new(Tag::Ul)
                .with_child(
                    Element::new(Tag::Li)
                        .with_text(format!("Started: {}", timestamp(&self.started))),
                )
                .with_child(
                    Element::new(Tag::Li).with_text(format!("Ended: {}", timestamp(&self.ended))),
                )
                .with_child(
                    Element::new(Tag::Li)
                        .with_text(format!("Duration: {} s", self.duration.as_secs())),
                ),
        );

        body.push_child(Element::new(Tag::P).with_text("Command arguments:"));
        let mut ul = Element::new(Tag::Ul);
        for arg in self.argv.iter() {
            ul.push_child(
                Element::new(Tag::Li)
                    .with_child(Element::new(Tag::Code).with_text(format!("{arg:?}"))),
            );
        }
        body.push_child(ul);

        body.push_child(
            Element::new(Tag::P)
                .with_text("In directory: ")
                .with_child(Element::new(Tag::Code).with_text(format!("{}", self.cwd.display()))),
        );

        body.push_child(Element::new(Tag::P).with_text(format!("Exit code: {}", self.exit)));

        self.output(body, "Output (stdout and stderr):", &self.stdout);
    }

    fn output(&self, body: &mut Element, stream: &str, data: &[u8]) {
        if !data.is_empty() {
            body.push_child(Element::new(Tag::P).with_text(stream));
            body.push_child(
                Element::new(Tag::Blockquote)
                    .with_child(Element::new(Tag::Pre).with_text(&*String::from_utf8_lossy(data))),
            );
        }
    }
}

fn timestamp(when: &SystemTime) -> String {
    let fmt = format_description!("[year]-[month]-[day] [hour]:[minute]:[second]Z");
    OffsetDateTime::from(*when).format(fmt).ok().unwrap()
}

#[derive(Debug, Default)]
struct ToC {
    counter: usize,
    headings: Vec<(String, Element)>,
}

impl ToC {
    fn push(&mut self, content: Element) -> Element {
        self.counter += 1;
        let anchor = format!("h{}", self.counter);
        self.headings.push((anchor.clone(), content.clone()));
        Element::new(Tag::H2)
            .with_attribute("id", format!("h{}", self.counter))
            .with_child(content)
    }

    fn as_html(&self) -> Element {
        let mut toc =
            Element::new(Tag::Div).with_child(Element::new(Tag::H2).with_text("Table of contents"));
        let mut list = Element::new(Tag::Ul);
        for (anchor, content) in self.headings.iter() {
            let entry = Element::new(Tag::Li).with_child(
                Element::new(Tag::A)
                    .with_attribute("href", format!("#{anchor}"))
                    .with_child(content.clone()),
            );
            list.push_child(entry);
        }
        toc.push_child(list);
        toc
    }
}

#[derive(Debug, thiserror::Error)]
pub enum RunLogError {
    #[error("programming error: missing field for run log {0}")]
    Missing(&'static str),

    #[error("failed to write HTML run log to {0}")]
    Write(PathBuf, #[source] std::io::Error),

    #[error("failed to serialize run spec to YAML")]
    RunSpec(#[source] serde_norway::Error),
}