use super::Invocation;
use crate::error::{CliError, InterruptContext};
use crate::json::OValue;
use crate::report::ReadError;
use std::cell::RefCell;
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ReportDestination {
Stdout,
File(PathBuf),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ParentCheck {
Checked,
AtWrite,
}
pub const GENERATION_FAILED: &str = "✗ Report generation failed. Verify Management API configuration and a writable, new output path. Existing files are never overwritten.";
pub const NO_REPORT: &str = "no report was written";
pub fn no_report() -> CliError {
CliError::Interrupted(InterruptContext::Context(NO_REPORT.to_owned()))
}
pub fn before_report(error: CliError) -> CliError {
match error {
CliError::Interrupted(_) => no_report(),
other => other,
}
}
fn failed() -> CliError {
CliError::operational("report generation failed")
}
pub fn status_path(path: &Path) -> String {
path.display()
.to_string()
.chars()
.map(|c| {
if crate::report::is_control_or_format(c) {
'_'
} else {
c
}
})
.collect()
}
pub fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |elapsed| {
i64::try_from(elapsed.as_millis()).unwrap_or(i64::MAX)
})
}
pub fn check_destination(
destination: &ReportDestination,
parent: ParentCheck,
) -> Result<Option<PathBuf>, CliError> {
let ReportDestination::File(path) = destination else {
return Ok(None);
};
if !crate::files::names_a_file(path) {
return Err(failed());
}
let path = std::path::absolute(path).map_err(|_| failed())?;
match std::fs::symlink_metadata(&path) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
_ => return Err(failed()),
}
if parent == ParentCheck::Checked {
let parent = path.parent().ok_or_else(failed)?;
if !std::fs::metadata(parent).is_ok_and(|metadata| metadata.is_dir()) {
return Err(failed());
}
}
Ok(Some(path))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Verdict {
pub incomplete: usize,
pub health_unknown: bool,
pub strict: bool,
}
pub fn publish(
invocation: &mut Invocation,
path: Option<&Path>,
html: &str,
verdict: Verdict,
) -> Result<i32, CliError> {
if invocation.output.pending_signal().is_some() {
return Err(no_report());
}
match path {
Some(path) => {
let published = crate::files::publish_new(path, html.as_bytes(), "--output-file")
.map_err(|_| failed())?;
let shown = status_path(path);
invocation.interrupt = Some(InterruptContext::Context(format!(
"the report was saved in {shown}"
)));
if published == crate::files::Published::DirectoryNotSynced {
invocation
.output
.warning(&crate::store::atomic::dir_sync_warning(path));
}
invocation
.output
.status(&format!("Report → {shown} (mode 0600)"));
}
None => {
invocation.output.data(html);
if invocation.output.commit().is_err() {
if invocation.output.pending_signal().is_some() {
return Err(CliError::Interrupted(InterruptContext::OutputIncomplete));
}
return Err(failed());
}
}
}
if verdict.incomplete > 0 {
invocation.output.status(&format!(
"{} source(s) incomplete; review the evidence section.",
verdict.incomplete
));
}
Ok(
if verdict.health_unknown || (verdict.strict && verdict.incomplete > 0) {
crate::error::EXIT_OPERATIONAL
} else {
0
},
)
}
pub fn conclude(
invocation: &mut Invocation,
result: Result<i32, CliError>,
) -> Result<(), CliError> {
match result {
Ok(0) => Ok(()),
Ok(code) => {
let _ = invocation.output.commit();
Err(CliError::Reported(code))
}
Err(CliError::Interrupted(context)) => Err(CliError::Interrupted(context)),
Err(_) => {
invocation.output.discard();
let line = invocation
.output
.paint_err(crate::render::style::Style::Red, GENERATION_FAILED);
invocation.output.write_stderr(&format!(" {line}\n"));
Err(CliError::Reported(crate::error::EXIT_OPERATIONAL))
}
}
}
pub struct SourceBudget {
build: fn() -> scm::Result<scm::RequestOptions>,
current: RefCell<Option<(&'static str, scm::RequestOptions)>>,
}
impl SourceBudget {
pub fn new(build: fn() -> scm::Result<scm::RequestOptions>) -> Self {
Self {
build,
current: RefCell::new(None),
}
}
fn options(&self, source: &'static str) -> Result<scm::RequestOptions, ReadError> {
let mut current = self.current.borrow_mut();
if let Some((name, options)) = current.as_ref()
&& *name == source
{
return Ok(options.clone());
}
let options = (self.build)().map_err(|_| ReadError::Failed)?;
*current = Some((source, options.clone()));
Ok(options)
}
pub async fn read<T, F, Fut>(
&self,
source: &'static str,
mut call: F,
) -> Result<OValue, ReadError>
where
T: serde::Serialize,
F: FnMut(scm::RequestOptions) -> Fut,
Fut: std::future::Future<Output = scm::Result<T>>,
{
let options = self.options(source)?;
match crate::clients::reread(|| call(options.clone())).await {
Ok(answer) => crate::json::to_ordered(&answer).map_err(|_| ReadError::Failed),
Err(error) if error.kind() == scm::ErrorKind::Cancelled => Err(ReadError::Cancelled),
Err(error) => Err(match error.status() {
Some(status) => ReadError::Status(status),
None => ReadError::Failed,
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_budget_per_source() {
use std::sync::atomic::{AtomicUsize, Ordering};
static BUILT: AtomicUsize = AtomicUsize::new(0);
fn build() -> scm::Result<scm::RequestOptions> {
BUILT.fetch_add(1, Ordering::SeqCst);
scm::RequestOptions::with_timeout(std::time::Duration::from_secs(5))
}
let budget = SourceBudget::new(build);
budget.options("a").unwrap();
budget.options("a").unwrap();
assert_eq!(BUILT.load(Ordering::SeqCst), 1);
budget.options("b").unwrap();
budget.options("a").unwrap();
assert_eq!(BUILT.load(Ordering::SeqCst), 3);
}
#[test]
fn destination_checks() {
let dir = crate::test_support::TempDir::new("report-dest");
let file = |path: PathBuf| ReportDestination::File(path);
assert_eq!(
check_destination(&ReportDestination::Stdout, ParentCheck::Checked).unwrap(),
None
);
let new = dir.join("r.html");
assert_eq!(
check_destination(&file(new.clone()), ParentCheck::Checked).unwrap(),
Some(new.clone())
);
std::fs::write(&new, "x").unwrap();
assert!(check_destination(&file(new), ParentCheck::AtWrite).is_err());
assert!(check_destination(&file(dir.join("sub/")), ParentCheck::AtWrite).is_err());
let orphan = dir.join("missing").join("r.html");
assert!(check_destination(&file(orphan.clone()), ParentCheck::Checked).is_err());
assert!(check_destination(&file(orphan), ParentCheck::AtWrite).is_ok());
}
}