ambient-ci 0.14.0

A continuous integration engine
Documentation
use std::{
    io::Write,
    path::{Path, PathBuf},
};

use clap::{Parser, ValueEnum};

use super::{AmbientError, Config, Leaf};
use ambient_ci::{
    project::{ProjectError, State},
    runlog::{RunLog, RunLogError, SynthLog},
};

/// Show run log.
///
/// Output can be the raw run log from the VM, a JSON Lines log with
/// everything that Ambient did durig the CI run (including outside
/// the VM) or an HTML version of that.
#[derive(Debug, Parser)]
pub struct Log {
    /// Output the console log, not the run log. Output format is always "raw".
    #[clap(conflicts_with = "format", long)]
    console: bool,

    /// What format should log be?
    #[clap(long, default_value = "raw")]
    format: Format,

    /// Which project log should be written out?
    #[clap(conflicts_with = "filename")]
    project: Option<String>,

    /// Read run log from this file.
    #[clap(long)]
    filename: Option<PathBuf>,

    /// Write log to this file, not stdout.
    #[clap(long)]
    output: Option<PathBuf>,
}

impl Log {
    fn state(&self, config: &Config, project: &str) -> Result<State, LogError> {
        let statedir = config.state();
        if !statedir.exists() {
            return Err(LogError::NoStateDir(project.to_string(), statedir.into()))?;
        }
        State::from_file(statedir, project).map_err(LogError::Project)
    }

    fn read(&self, filename: &Path) -> Result<Vec<u8>, LogError> {
        std::fs::read(filename).map_err(|err| LogError::Read(filename.to_path_buf(), err))
    }

    fn write(&self, data: &[u8]) -> Result<(), LogError> {
        if let Some(output) = &self.output {
            std::fs::write(output, data)
                .map_err(|err| LogError::Write(output.to_path_buf(), err))?;
        } else {
            std::io::stdout()
                .write_all(data)
                .map_err(LogError::WriteStdout)?;
        }
        Ok(())
    }
}

impl Leaf for Log {
    fn run(&self, config: &Config, _runlog: &mut RunLog) -> Result<(), AmbientError> {
        match (self.format, self.console, &self.project, &self.filename) {
            (Format::Raw, false, Some(project), None) => {
                // project run log, raw
                let state = self.state(config, project)?;
                let data = self.read(&state.raw_log_filename())?;
                self.write(&data)?;
            }
            (Format::Raw, false, None, Some(filename)) => {
                // named file run log, raw
                let data = self.read(filename)?;
                self.write(&data)?;
            }
            (Format::Raw, true, Some(project), None) => {
                // project console log, raw
                let state = self.state(config, project)?;
                let data = self.read(&state.console_log_filename())?;
                self.write(&data)?;
            }
            (Format::Raw, true, None, Some(filename)) => {
                // named file console log, raw
                let data = self.read(filename)?;
                self.write(&data)?;
            }
            (Format::Json, false, Some(project), None) => {
                // project run log, JSON
                let state = self.state(config, project)?;
                let data = self.read(&state.raw_log_filename())?;
                self.write(&data)?;
            }
            (Format::Json, false, None, Some(filename)) => {
                // name file run log, JSON
                let data = self.read(filename)?;
                self.write(&data)?;
            }
            (Format::Html, false, Some(project), None) => {
                // project run log, HTML
                let state = self.state(config, project)?;
                let data = self.read(&state.run_log_filename())?;
                let runlog = RunLog::parse_jsonl(data)
                    .map_err(|err| LogError::LoadJson(state.run_log_filename(), err))?;
                let mut synth = SynthLog::new(runlog.msgs());
                let mut data = self.read(&state.console_log_filename())?;
                data.retain(|byte| *byte != b'\r' && *byte != b'\x1b');
                synth.set_console_log(data);
                self.write(synth.to_html().to_string().as_bytes())?;
            }

            (_, _, None, None)
            | (_, _, Some(_), Some(_))
            | (Format::Json, true, _, _)
            | (Format::Html, true, _, _)
            | (Format::Html, false, None, Some(_)) => {
                Err(LogError::Usage)?;
            }
        }

        Ok(())
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum Format {
    Raw,
    Json,
    Html,
}

#[derive(Debug, thiserror::Error)]
pub enum LogError {
    #[error("state directory for project {0} does not exist: {1}")]
    NoStateDir(String, PathBuf),

    #[error(transparent)]
    Project(#[from] ProjectError),

    #[error("failed to load Ambient JSON run log file {0}")]
    LoadJson(PathBuf, #[source] RunLogError),

    #[error("failed to read log file {0}")]
    Read(PathBuf, #[source] std::io::Error),

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

    #[error("failed to write output to stdout")]
    WriteStdout(#[source] std::io::Error),

    #[error(
        "the combination of output format, console log, project, and file names is not acceptable"
    )]
    Usage,
}