use crate::config::{ConnectorSpec, DlqSpec, OnBatchErrorSpec, PipelineConfig};
use crate::dlq_replay::reader::{DlqDecryptor, DlqReaderSource, SourceOverride};
use crate::error::{CliError, CliResult};
use crate::expand::{ExpandedNode, NodeRole, expand};
use faucet_core::{DeliveryMode, DlqReason, UnwrappedEnvelope};
use serde_json::json;
use std::path::{Path, PathBuf};
pub fn validate_reason(reason: Option<&str>) -> CliResult<Option<String>> {
match reason {
None => Ok(None),
Some(r) => {
if DlqReason::from_serde_str(r).is_some() {
Ok(Some(r.to_owned()))
} else {
let allowed = DlqReason::ALL
.iter()
.map(|r| r.as_str())
.collect::<Vec<_>>()
.join(", ");
Err(CliError::Config(format!(
"unknown --reason '{r}'; expected one of: {allowed}"
)))
}
}
}
}
pub fn default_failed_dlq_path(from_files: &[PathBuf]) -> PathBuf {
match from_files {
[only] => {
let stem = only.file_stem().and_then(|s| s.to_str()).unwrap_or("dlq");
let parent = only.parent().unwrap_or_else(|| Path::new("."));
parent.join(format!("{stem}.replay-failed.jsonl"))
}
_ => {
let parent = from_files
.first()
.and_then(|p| p.parent())
.unwrap_or_else(|| Path::new("."));
parent.join("replay-failed.jsonl")
}
}
}
pub fn failed_dlq_spec(path: &Path, original: Option<&DlqSpec>) -> DlqSpec {
let sink = ConnectorSpec {
kind: "jsonl".to_string(),
config: json!({ "path": path.to_string_lossy() }),
transforms: None,
inherit_transforms: true,
status: None,
tags: Vec::new(),
};
match original {
Some(o) => DlqSpec {
sink,
on_batch_error: o.on_batch_error,
max_failures_per_page: o.max_failures_per_page,
max_failures_total: o.max_failures_total,
include_original_payload: o.include_original_payload,
},
None => DlqSpec {
sink,
on_batch_error: OnBatchErrorSpec::default(),
max_failures_per_page: None,
max_failures_total: None,
include_original_payload: true,
},
}
}
pub fn select_replay_node(nodes: Vec<ExpandedNode>, row: Option<&str>) -> CliResult<ExpandedNode> {
let roots = || nodes.iter().filter(|n| matches!(n.role, NodeRole::Root));
let chosen = match row {
Some(id) => nodes.iter().position(|n| n.id == id).ok_or_else(|| {
CliError::Config(format!(
"row '{id}' not found in config (roots: {})",
roots()
.map(|n| n.id.as_str())
.collect::<Vec<_>>()
.join(", ")
))
})?,
None => nodes
.iter()
.position(|n| matches!(n.role, NodeRole::Root))
.ok_or_else(|| CliError::Config("config has no root pipeline to replay".into()))?,
};
let node = &nodes[chosen];
if !matches!(node.role, NodeRole::Root) {
return Err(CliError::Config(format!(
"row '{}' is a child node; only root pipelines can be replayed directly",
node.id
)));
}
Ok(nodes.into_iter().nth(chosen).expect("index in range"))
}
pub fn build_replay_node(
cfg: &PipelineConfig,
from_files: Vec<PathBuf>,
reason: Option<String>,
failed_dlq: &Path,
row: Option<&str>,
decryptor: DlqDecryptor,
) -> CliResult<ExpandedNode> {
if from_files.iter().any(|f| same_file(f, failed_dlq)) {
return Err(CliError::Config(format!(
"replay-failed DLQ '{}' is one of the source files — replayed failures would re-feed \
the source; pass a different --failed-dlq",
failed_dlq.display()
)));
}
let nodes = expand(cfg)?;
let original_dlq = nodes
.iter()
.find(|n| matches!(n.role, NodeRole::Root))
.and_then(|n| n.dlq.clone());
let mut node = select_replay_node(nodes, row)?;
let reader = DlqReaderSource::new(from_files, reason, decryptor);
node.source_override = Some(SourceOverride::new(Box::new(reader)));
node.state = None;
node.delivery = DeliveryMode::AtLeastOnce;
node.dlq = Some(failed_dlq_spec(failed_dlq, original_dlq.as_ref()));
Ok(node)
}
pub fn dlq_encryption_value(dlq: Option<&DlqSpec>) -> Option<&serde_json::Value> {
let dlq = dlq?;
if dlq.sink.kind != "jsonl" {
return None;
}
dlq.sink.config.get("encryption")
}
fn same_file(a: &Path, b: &Path) -> bool {
match (a.canonicalize(), b.canonicalize()) {
(Ok(ca), Ok(cb)) => ca == cb,
_ => a == b,
}
}
pub fn envelope_selected(
env: &UnwrappedEnvelope,
reason: Option<&str>,
before_ms: Option<i64>,
) -> bool {
let reason_ok = match reason {
None => true,
Some(want) => env.reason.as_deref() == Some(want),
};
let age_ok = match before_ms {
None => true,
Some(cutoff) => env.ts_ms.is_some_and(|ts| ts < cutoff),
};
reason_ok && age_ok
}
pub fn discard_keep_line(
line: &str,
dec: &DlqDecryptor,
reason: Option<&str>,
before_ms: Option<i64>,
) -> bool {
use crate::dlq_replay::reader::{LineOutcome, classify_line_with};
match classify_line_with(line, dec) {
LineOutcome::Envelope(env) => !envelope_selected(&env, reason, before_ms),
_ => true,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn env(reason: Option<&str>, ts_ms: Option<i64>) -> UnwrappedEnvelope {
UnwrappedEnvelope {
payload: json!({}),
reason: reason.map(str::to_owned),
error_kind: None,
error_message: None,
record_index: None,
pipeline: None,
row: None,
sink: None,
ts_ms,
}
}
#[test]
fn validate_reason_accepts_known_and_rejects_unknown() {
assert_eq!(validate_reason(None).unwrap(), None);
assert_eq!(
validate_reason(Some("quality")).unwrap().as_deref(),
Some("quality")
);
assert_eq!(
validate_reason(Some("schema_drift")).unwrap().as_deref(),
Some("schema_drift")
);
let err = validate_reason(Some("sink_error")).unwrap_err();
assert!(format!("{err}").contains("unknown --reason"));
}
#[test]
fn default_failed_dlq_path_single_file() {
let p = default_failed_dlq_path(&[PathBuf::from("/data/dlq.jsonl")]);
assert_eq!(p, PathBuf::from("/data/dlq.replay-failed.jsonl"));
}
#[test]
fn default_failed_dlq_path_multi_file() {
let p = default_failed_dlq_path(&[
PathBuf::from("/data/a.jsonl"),
PathBuf::from("/data/b.jsonl"),
]);
assert_eq!(p, PathBuf::from("/data/replay-failed.jsonl"));
}
#[test]
fn failed_dlq_spec_inherits_budgets() {
let orig = DlqSpec {
sink: ConnectorSpec {
kind: "jsonl".into(),
config: json!({"path": "orig.jsonl"}),
transforms: None,
inherit_transforms: true,
status: None,
tags: Vec::new(),
},
on_batch_error: OnBatchErrorSpec::DlqAll,
max_failures_per_page: Some(5),
max_failures_total: Some(50),
include_original_payload: true,
};
let spec = failed_dlq_spec(Path::new("failed.jsonl"), Some(&orig));
assert_eq!(spec.sink.kind, "jsonl");
assert_eq!(spec.sink.config["path"], "failed.jsonl");
assert_eq!(spec.on_batch_error, OnBatchErrorSpec::DlqAll);
assert_eq!(spec.max_failures_per_page, Some(5));
}
#[test]
fn failed_dlq_spec_defaults_without_original() {
let spec = failed_dlq_spec(Path::new("f.jsonl"), None);
assert_eq!(spec.on_batch_error, OnBatchErrorSpec::default());
assert_eq!(spec.max_failures_per_page, None);
assert!(spec.include_original_payload);
}
#[test]
fn envelope_selected_reason_and_age() {
let e = env(Some("quality"), Some(1000));
assert!(envelope_selected(&e, None, None));
assert!(envelope_selected(&e, Some("quality"), None));
assert!(!envelope_selected(&e, Some("contract"), None));
assert!(envelope_selected(&e, Some("quality"), Some(2000))); assert!(!envelope_selected(&e, Some("quality"), Some(500))); let no_ts = env(Some("quality"), None);
assert!(!envelope_selected(&no_ts, None, Some(2000)));
}
#[test]
fn discard_keep_line_only_touches_matching_envelopes() {
let matching = json!({
"payload": {"id": 1}, "reason": "quality", "ts_ms": 100,
"error": {"kind": "QualityFailure", "message": "x"}
})
.to_string();
assert!(!discard_keep_line(
&matching,
&DlqDecryptor::default(),
Some("quality"),
None
));
assert!(discard_keep_line(
&matching,
&DlqDecryptor::default(),
Some("contract"),
None
));
assert!(discard_keep_line(
r#"{"a":1}"#,
&DlqDecryptor::default(),
None,
None
));
assert!(discard_keep_line("", &DlqDecryptor::default(), None, None));
assert!(discard_keep_line(
"not json",
&DlqDecryptor::default(),
Some("quality"),
None
));
}
}