use async_nats::Client;
use kanade_shared::wire::{Command, FinalizeCommand, Shell};
use serde_json::json;
use tracing::{info, warn};
use crate::process::{ExecOutcome, run_command_with_kill};
pub fn collect_result_json(bundles: &[crate::collect::BundleResult]) -> String {
let arr: Vec<_> = bundles
.iter()
.map(|b| {
json!({
"label": b.label,
"key": b.key,
"uploaded": true,
"files": b.files,
})
})
.collect();
json!({ "ok": !arr.is_empty(), "bundles": arr }).to_string()
}
fn powershell_prelude(result_json: &str) -> String {
format!(
"$env:KANADE_COLLECT_RESULT = '{}'\n",
result_json.replace('\'', "''")
)
}
pub async fn run_finalize(
client: &Client,
parent: &Command,
fin: &FinalizeCommand,
result_json: Option<&str>,
) {
if matches!(fin.shell, Shell::Cmd) {
warn!(job = %parent.id, "finalize: cmd shell is not supported (injection risk); skipping hook");
return;
}
let prelude = result_json.map(powershell_prelude).unwrap_or_default();
let script = format!("{prelude}{}", fin.script);
let fin_cmd = Command {
id: format!("{}__finalize", parent.id),
version: parent.version.clone(),
request_id: parent.request_id.clone(),
exec_id: parent.exec_id.clone(),
shell: fin.shell,
script,
script_object: None,
script_object_sha256: None,
timeout_secs: fin.timeout_secs,
jitter_secs: None,
run_as: fin.run_as,
cwd: fin.cwd.clone(),
deadline_at: None,
staleness: parent.staleness.clone(),
emit: None,
check: None,
collect: None,
retry: None,
finalize: None,
};
match run_command_with_kill(client, &fin_cmd, None).await {
Ok(ExecOutcome::Completed { exit_code: 0, .. }) => {
info!(job = %parent.id, "finalize: hook completed");
}
Ok(ExecOutcome::Completed {
exit_code, stderr, ..
}) => {
warn!(job = %parent.id, exit_code, stderr = %stderr, "finalize: hook exited non-zero (ignored)");
}
Ok(ExecOutcome::Killed { .. }) => {
warn!(job = %parent.id, "finalize: hook killed (ignored)");
}
Ok(ExecOutcome::Timeout { .. }) => {
warn!(job = %parent.id, "finalize: hook timed out (ignored)");
}
Err(e) => {
warn!(job = %parent.id, error = %e, "finalize: hook spawn failed (ignored)");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn result_json_marks_uploaded_when_collected() {
let json = collect_result_json(&[
crate::collect::BundleResult {
label: Some("20260101".into()),
key: "pc/job/20260101__ts.zip".into(),
files: vec!["C:/a.png".into(), "C:/b.png".into()],
},
crate::collect::BundleResult {
label: Some("20260102".into()),
key: "pc/job/20260102__ts.zip".into(),
files: vec!["C:/c.png".into()],
},
]);
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["ok"], true);
assert_eq!(v["bundles"].as_array().unwrap().len(), 2);
assert_eq!(v["bundles"][0]["label"], "20260101");
assert_eq!(v["bundles"][0]["key"], "pc/job/20260101__ts.zip");
assert_eq!(v["bundles"][0]["uploaded"], true);
assert_eq!(v["bundles"][0]["files"].as_array().unwrap().len(), 2);
}
#[test]
fn powershell_prelude_doubles_single_quotes() {
let p = powershell_prelude(r#"{"files":["C:/it's here.png"]}"#);
assert!(
p.contains("it''s here"),
"single quote must be doubled: {p}"
);
assert!(p.starts_with("$env:KANADE_COLLECT_RESULT = '"));
assert!(p.ends_with("'\n"));
let inner = p
.trim_start_matches("$env:KANADE_COLLECT_RESULT = '")
.trim_end_matches("'\n");
assert!(!inner.contains('\'') || inner.contains("''"));
}
#[test]
fn result_json_is_empty_when_nothing_collected() {
let json = collect_result_json(&[]);
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["ok"], false);
assert_eq!(v["bundles"].as_array().unwrap().len(), 0);
}
}