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),
}