use std::io::Read;
use std::path::{Path, PathBuf};
use serde::Serialize;
use serde_json::Value;
use octl_core::report::{validate_report_payload, ReportValidationError};
use octl_core::{ensure_root, read_manifest_opt, read_node_opt};
use crate::error::CliError;
use crate::output::{self, OutputFormat, OutputSpec};
use crate::run::{from_core, parse_node_id, require_nonempty, run_paths};
const MAX_FROM_FILE_BYTES: u64 = 1024 * 1024;
pub struct Args<'a> {
pub run_id: String,
pub node_id: String,
pub from_file: PathBuf,
pub idempotency_key: Option<String>,
pub dry_run: bool,
pub spec: &'a OutputSpec,
pub warnings: &'a [String],
}
#[derive(Serialize)]
struct ReportPayload<'a> {
run_id: &'a str,
node_id: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
event_seq: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
idempotent_replay: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
dry_run: Option<bool>,
}
pub fn run(args: Args<'_>) -> Result<(), CliError> {
let run_id = args.run_id.clone();
let node_id = parse_node_id(&args.node_id)?;
let idempotency_key = match args.idempotency_key {
Some(k) => Some(require_nonempty(&k, "idempotency-key")?),
None => None,
};
let bytes = read_capped(&args.from_file)?;
let data: Value = serde_json::from_slice(&bytes).map_err(|e| {
CliError::user(
"from_file_invalid_json",
format!("parse {}: {}", args.from_file.display(), e),
)
.with_invalid_value(args.from_file.display().to_string())
})?;
validate_report_payload(&data).map_err(map_report_validation_error)?;
let root = crate::home::root_dir()?;
let paths = run_paths(&root, &run_id)?;
if read_manifest_opt(&paths).map_err(from_core)?.is_none() {
return Err(
CliError::user("run_not_found", format!("no run with id {run_id}"))
.with_invalid_value(&run_id),
);
}
if read_node_opt(&paths, &node_id)
.map_err(from_core)?
.is_none()
{
return Err(CliError::user(
"node_not_found",
format!("no node {node_id} in run {run_id}"),
)
.with_invalid_value(node_id.as_str()));
}
if args.dry_run {
let payload = ReportPayload {
run_id: &run_id,
node_id: node_id.as_str(),
event_seq: None,
idempotent_replay: None,
dry_run: Some(true),
};
return emit(&payload, args.spec, args.warnings);
}
ensure_root(&root).map_err(from_core)?;
let result = octl_core::append_and_apply_event(
&paths,
"node.report",
Some(&node_id),
idempotency_key.as_deref(),
data.clone(),
)
.map_err(from_core)?;
let (event_seq, replayed) = if result.idempotent_replay {
let prior = result
.prior
.expect("an idempotent replay always carries the prior event");
let prior_node = prior.node_id.as_deref().unwrap_or("<none>");
if prior_node != node_id.as_str() {
return Err(CliError::user(
"idempotency_conflict",
format!(
"idempotency-key was previously used for a different --node-id \
(prior: {prior_node}, current: {node_id})"
),
));
}
if prior.data != data {
return Err(CliError::user(
"idempotency_conflict",
"idempotency-key was previously used with a different --from-file payload",
));
}
(prior.seq, true)
} else {
(result.seq, false)
};
let payload = ReportPayload {
run_id: &run_id,
node_id: node_id.as_str(),
event_seq: Some(event_seq),
idempotent_replay: if replayed { Some(true) } else { None },
dry_run: None,
};
emit(&payload, args.spec, args.warnings)
}
fn read_capped(path: &Path) -> Result<Vec<u8>, CliError> {
let mut f = std::fs::File::open(path).map_err(|e| {
CliError::user(
"from_file_unreadable",
format!("open {}: {}", path.display(), e),
)
.with_invalid_value(path.display().to_string())
})?;
let meta = f.metadata().map_err(|e| {
CliError::user(
"from_file_unreadable",
format!("stat {}: {}", path.display(), e),
)
.with_invalid_value(path.display().to_string())
})?;
if !meta.is_file() {
return Err(CliError::user(
"from_file_unreadable",
format!("{} is not a regular file", path.display()),
)
.with_invalid_value(path.display().to_string()));
}
let mut buf = Vec::new();
f.by_ref()
.take(MAX_FROM_FILE_BYTES + 1)
.read_to_end(&mut buf)
.map_err(|e| {
CliError::user(
"from_file_unreadable",
format!("read {}: {}", path.display(), e),
)
.with_invalid_value(path.display().to_string())
})?;
if (buf.len() as u64) > MAX_FROM_FILE_BYTES {
return Err(CliError::user(
"from_file_too_large",
format!("--from-file exceeds maximum of {MAX_FROM_FILE_BYTES} bytes"),
)
.with_invalid_value(path.display().to_string()));
}
Ok(buf)
}
fn map_report_validation_error(err: ReportValidationError) -> CliError {
let mut cli = CliError::user("schema_violation", err.to_string());
if let Some(expected) = err.expected() {
cli = cli.with_expected(expected);
}
cli
}
fn emit(
payload: &ReportPayload<'_>,
spec: &OutputSpec,
warnings: &[String],
) -> Result<(), CliError> {
match spec.format {
OutputFormat::Json | OutputFormat::Jsonl => {
output::emit_envelope(payload, spec, warnings)?;
}
OutputFormat::Text => {
println!("run-id: {}", payload.run_id);
println!("node-id: {}", payload.node_id);
match payload.event_seq {
Some(s) => println!("event_seq: {s}"),
None => println!("event_seq: (assigned on apply)"),
}
if payload.dry_run == Some(true) {
println!("note: --dry-run (no filesystem changes)");
}
if payload.idempotent_replay == Some(true) {
println!("note: returned from idempotency-key cache");
}
output::emit_text_warnings(warnings);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn validate(v: &Value) -> Result<(), CliError> {
validate_report_payload(v).map_err(map_report_validation_error)
}
#[test]
fn valid_payload_passes_through() {
let v = json!({"success": true});
assert!(validate(&v).is_ok());
}
#[test]
fn domain_error_maps_to_schema_violation() {
let v = json!({"summary": "no success field"});
let err = validate(&v).unwrap_err();
assert_eq!(err.code, "schema_violation");
assert_eq!(
err.expected,
Some(json!({"field": "success", "type": "boolean"}))
);
}
#[test]
fn unknown_kind_hint_survives_mapping() {
let v = json!({
"success": true,
"spinoff_proposals": [{"proposed_title": "x", "proposed_kind": "not-a-kind"}],
});
let err = validate(&v).unwrap_err();
assert_eq!(err.code, "schema_violation");
assert!(err.expected.is_some());
}
#[test]
fn error_without_hint_maps_with_no_expected() {
let v = json!({"success": true, "summary": 42});
let err = validate(&v).unwrap_err();
assert_eq!(err.code, "schema_violation");
assert!(err.expected.is_none());
}
}