use std::collections::{BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::{Map as JsonMap, Value as Json};
use sha2::Digest;
use crate::codes::Code;
use crate::engine::Events;
use crate::error::Error as Fail;
use crate::project::Project;
use crate::run::per_row::{RowDest, deliver_row};
use crate::run_results::Released;
use crate::runs::BindingRuns;
pub const HELD: &str = "held.json";
pub const SCHEMA_VERSION: &str = "dre/held/v1";
pub const RELEASED: &str = "released.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Held {
pub schema_version: String,
pub run_id: String,
pub entries: Vec<HeldEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeldEntry {
pub id: String,
pub output: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
pub profile: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default)]
pub options: JsonMap<String, Json>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attach: Vec<HeldFile>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rate: Option<crate::rate::Rate>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<(String, String)>,
pub files: Vec<HeldFile>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeldFile {
pub path: PathBuf,
pub sha256: String,
}
pub fn sha256_file(p: &Path) -> std::io::Result<String> {
let bytes = std::fs::read(p)?;
Ok(sha2::Sha256::digest(&bytes)
.iter()
.map(|b| format!("{b:02x}"))
.collect())
}
pub fn held_file(path: &Path) -> HeldFile {
HeldFile {
path: path.to_path_buf(),
sha256: sha256_file(path).unwrap_or_default(),
}
}
pub fn write_held(run_dir: &Path, run_id: &str, entries: &[HeldEntry]) -> std::io::Result<()> {
let held = Held {
schema_version: SCHEMA_VERSION.into(),
run_id: run_id.to_string(),
entries: entries.to_vec(),
};
crate::fs::write_atomic(
&run_dir.join(HELD),
serde_json::to_string_pretty(&held)?.as_bytes(),
)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ReleasedFile {
release_of: String,
released: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ReleaseOptions {
pub report: String,
pub binding: String,
pub run_id: String,
pub only_keys: Option<Vec<String>>,
pub exclude_keys: Option<Vec<String>>,
pub by: String,
pub rate: Option<crate::rate::Rate>,
pub cancel: crate::engine::CancelToken,
}
#[derive(Debug, Clone, Default)]
pub struct ReleaseOutcome {
pub release_id: String,
pub released: Vec<Released>,
pub already: usize,
}
impl ReleaseOutcome {
pub fn failed(&self) -> bool {
self.released
.iter()
.any(|r| r.status != "delivered" && r.status != "not_delivered")
}
}
pub fn release(project: &Project, opts: &ReleaseOptions, events: &Events) -> Result<ReleaseOutcome, Fail> {
let store = crate::engine::RunStore::new(&project.target_dir);
let runs = BindingRuns::new(&store.run_dir(&opts.report, &opts.binding));
let run_dir = runs.run_dir(&opts.run_id);
let held_path = run_dir.join(HELD);
if !run_dir.is_dir() {
return Err(Fail::new(
Code::ReleaseFailed,
format!(
"{} [{}] has no run `{}` in the target path (it may have been pruned: keep it with `keep_runs` for the review window; `dre history` lists the runs)",
opts.report, opts.binding, opts.run_id
),
));
}
let held: Held = match std::fs::read_to_string(&held_path) {
Ok(t) => {
serde_json::from_str(&t).map_err(|e| format!("{} is malformed: {e}", held_path.display()))?
}
Err(_) => {
return Err(Fail::new(
Code::ReleaseFailed,
format!(
"run `{}` has no held deliveries (no {HELD} in its folder)",
opts.run_id
),
));
}
};
let mut done: BTreeSet<String> = BTreeSet::new();
for id in runs.list() {
if let Ok(t) = std::fs::read_to_string(runs.run_dir(&id).join(RELEASED))
&& let Ok(r) = serde_json::from_str::<ReleasedFile>(&t)
&& r.release_of == opts.run_id
{
done.extend(r.released);
}
}
let started = chrono::Utc::now();
let release_id = crate::runs::new_run_id(started);
let _guard = runs.lock(&release_id, started).map_err(|e| {
let why = match e {
crate::runs::LockError::Held(h) => format!("{} holds its lock", h.describe()),
crate::runs::LockError::Unreadable(p) => format!("its lock file {} is unreadable", p.display()),
crate::runs::LockError::Io(e) => format!("its lock can't be taken: {e}"),
};
Fail::new(
Code::RunInProgress,
format!(
"{} [{}] is busy: {why}; release when it finishes",
opts.report, opts.binding
),
)
})?;
let release_dir = runs.run_dir(&release_id);
std::fs::create_dir_all(&release_dir).map_err(|e| e.to_string())?;
let mut outcome = ReleaseOutcome {
release_id: release_id.clone(),
..ReleaseOutcome::default()
};
let chosen = |e: &HeldEntry| match &e.key {
Some(k) => {
opts.only_keys.as_ref().is_none_or(|ks| ks.contains(k))
&& opts.exclude_keys.as_ref().is_none_or(|ks| !ks.contains(k))
}
None => opts.only_keys.is_none(),
};
let log = events.plugin_log();
let mut plugins: HashMap<String, dre_protocol::host::PluginProcess> = HashMap::new();
let pacer = crate::rate::Pacer::default();
for e in held.entries.iter().filter(|e| chosen(e)) {
if done.contains(&e.id) {
outcome.already += 1;
continue;
}
let mask = if project.mask_row_values {
let mut values: JsonMap<String, Json> = e.options.clone();
if let Some(p) = &e.path {
values.insert("$path".into(), Json::from(p.clone()));
}
crate::for_each::RowMask::new(&values, &[])
} else {
crate::for_each::RowMask::default()
};
let mut r = Released {
id: e.id.clone(),
output: e.output.clone(),
key: e.key.clone(),
profile: e.profile.clone(),
status: String::new(),
location: None,
error: None,
};
let changed: Vec<String> = e
.files
.iter()
.chain(&e.attach)
.filter(|f| sha256_file(&f.path).ok().as_deref() != Some(f.sha256.as_str()))
.map(|f| f.path.display().to_string())
.collect();
if !changed.is_empty() {
r.status = "refused".into();
r.error = Some(format!(
"refused: {} changed (or is gone) since the run rendered it; re-run the report to render it again",
changed.join(", ")
));
events.warn(&format!(" {}: {}", e.id, r.error.as_deref().unwrap_or_default()));
outcome.released.push(r);
continue;
}
let dest = RowDest {
idx: 0,
profile: e.profile.clone(),
path: e.path.clone(),
options: e.options.clone(),
hold: false,
rate: None,
attach: e.attach.iter().map(|f| f.path.clone()).collect(),
attach_missing: false,
};
if let Some(rate) = opts.rate.or(e.rate)
&& !pacer.wait(&e.profile, &rate, &opts.cancel)
{
break;
}
if opts.cancel.is_cancelled() {
break;
}
let files: Vec<PathBuf> = e.files.iter().map(|f| f.path.clone()).collect();
match deliver_row(
project,
&log,
&dest,
&files,
e.message.as_ref(),
&mut plugins,
&project.root,
) {
Ok(Some(loc)) => {
r.status = "delivered".into();
r.location = Some(mask.apply(&loc));
done.insert(e.id.clone());
write_released(&release_dir, &opts.run_id, &done)?;
}
Ok(None) => r.status = "not_delivered".into(),
Err(err) => {
let msg = mask.apply(&err.message);
r.status = "failed".into();
r.error = Some(msg.clone());
events.warn(&format!(" {}: {msg}", e.id));
}
}
outcome.released.push(r);
}
for (_, p) in plugins {
let _ = p.close();
}
write_released(&release_dir, &opts.run_id, &done)?;
let mut record: Json = std::fs::read_to_string(run_dir.join("run_results.json"))
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_else(|| serde_json::json!({}));
let failed = outcome.failed();
if let Json::Object(m) = &mut record {
m.insert("run_id".into(), Json::from(release_id.clone()));
m.insert(
"status".into(),
Json::from(if failed { "error" } else { "success" }),
);
m.insert(
"started_at".into(),
Json::from(started.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)),
);
m.insert(
"duration_ms".into(),
Json::from((chrono::Utc::now() - started).num_milliseconds().max(0)),
);
m.insert("release_of".into(), Json::from(opts.run_id.clone()));
m.insert("released_by".into(), Json::from(opts.by.clone()));
m.insert(
"released".into(),
serde_json::to_value(&outcome.released).unwrap_or_default(),
);
m.insert("error".into(), Json::Null);
m.insert("error_code".into(), Json::Null);
m.insert("error_kind".into(), Json::Null);
if failed {
m.insert(
"error".into(),
Json::from("one or more held deliveries failed or were refused"),
);
m.insert("error_code".into(), Json::from(Code::ReleaseFailed.to_string()));
}
m.remove("retry_of");
}
crate::fs::write_atomic(
&release_dir.join("run_results.json"),
serde_json::to_string_pretty(&record)
.unwrap_or_default()
.as_bytes(),
)
.map_err(|e| e.to_string())?;
Ok(outcome)
}
fn write_released(release_dir: &Path, run_id: &str, done: &BTreeSet<String>) -> Result<(), Fail> {
let file = ReleasedFile {
release_of: run_id.to_string(),
released: done.iter().cloned().collect(),
};
crate::fs::write_atomic(
&release_dir.join(RELEASED),
serde_json::to_string(&file).unwrap_or_default().as_bytes(),
)
.map_err(|e| Fail::from(e.to_string()))
}