use super::*;
pub(crate) type SharedRunLog = Arc<std::sync::Mutex<Option<crate::logging::RunLogGuard>>>;
pub(crate) fn write_run_log_line(run_log: &SharedRunLog, text: &str) {
let mut slot = run_log
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(log) = slot.as_mut() {
let line = format!("{}\n", text.trim_start());
if let Err(e) = log.write_all(line.as_bytes()) {
tracing::debug!(error = %e, "narrative line could not be written to the run log");
}
}
}
pub(crate) fn narrate_line_to<W: std::io::Write>(sink: &mut W, msg: std::fmt::Arguments<'_>) {
if let Err(e) = writeln!(sink, "{msg}") {
tracing::trace!(error = %e, "console narrate line dropped (stderr unavailable)");
}
}
pub(crate) fn progress_narrate(msg: std::fmt::Arguments<'_>, run_log: &SharedRunLog) {
if !crate::commands::is_quiet() {
narrate_console_line(msg);
}
write_run_log_line(run_log, &strip_ansi(&msg.to_string()));
}
pub(crate) fn narrate_console_line(msg: std::fmt::Arguments<'_>) {
narrate_line_to(&mut std::io::stderr(), msg);
}
pub(crate) fn write_execution_plan<W: std::io::Write>(
sink: &mut W,
order: &[String],
config: &WorkflowConfig,
) -> std::io::Result<()> {
writeln!(
sink,
"{} {} rules in dependency order",
"DAG:".bold().green(),
order.len()
)?;
let names: Vec<&str> = order.iter().map(String::as_str).collect();
if let Some(groups) = module_groups_for_rules(&names, &config.module_rules) {
let mut seen = 0usize;
for (group, members) in &groups {
writeln!(
sink,
" {} — {} rule{}",
run_group_title(*group).bold(),
members.len(),
if members.len() == 1 { "" } else { "s" }
)?;
for (i, rule_name) in members.iter().enumerate() {
writeln!(sink, " {}. {}", seen + i + 1, rule_name)?;
}
seen += members.len();
}
} else {
for (i, rule_name) in order.iter().enumerate() {
writeln!(sink, " {}. {}", i + 1, rule_name)?;
}
}
Ok(())
}
pub(crate) fn diagnostic_narrate(msg: std::fmt::Arguments<'_>, run_log: Option<&SharedRunLog>) {
narrate_console_line(msg);
if let Some(run_log) = run_log {
write_run_log_line(run_log, &strip_ansi(&msg.to_string()));
}
}
pub(crate) fn narrate_source_link_report(
report: &crate::commands::workdir_sources::SourceLinkReport,
run_log: Option<&SharedRunLog>,
) {
if let Some(note) = &report.skipped_note {
diagnostic_narrate(format_args!("workdir source linking: {}", note), run_log);
return;
}
if report.is_empty() {
return;
}
if !report.linked.is_empty() {
diagnostic_narrate(
format_args!(
"workdir source linking: created {} into the workdir (repo stays the single source of truth): {}",
oxo_flow_core::plural(report.linked.len(), "symlink", "symlinks"),
report.linked.join(", ")
),
run_log,
);
}
if !report.sidecars.is_empty() {
diagnostic_narrate(
format_args!(
"workdir source linking: linked {} next to their linked files (bwa/samtools/picard auto-discovery — not spelled out in any shell command): {}",
oxo_flow_core::plural(
report.sidecars.len(),
"tool-convention index sidecar",
"tool-convention index sidecars"
),
report.sidecars.join(", ")
),
run_log,
);
}
if !report.converted.is_empty() {
diagnostic_narrate(
format_args!(
"workdir source linking: replaced {} byte-identical workdir duplicates with {}: {}",
report.converted.len(),
oxo_flow_core::plural(report.converted.len(), "a symlink", "symlinks"),
report.converted.join(", ")
),
run_log,
);
}
for path in &report.conflicts {
diagnostic_narrate(
format_args!(
"{} workdir source linking: '{}' differs from the workflow-repo file — keeping the workdir copy for this run (remove the copy or update the repo to link it)",
"Warning:".yellow(),
path
),
run_log,
);
}
if !report.undeclared.is_empty() {
diagnostic_narrate(
format_args!(
"{} workdir source linking: shell commands reference workflow-repo files that no rule declares as inputs — they were NOT linked (declaring the path as a rule input links it automatically):\n {}",
"Note:".yellow(),
report.undeclared.join("\n ")
),
run_log,
);
}
for err in &report.errors {
diagnostic_narrate(
format_args!("{} workdir source linking: {}", "Warning:".yellow(), err),
run_log,
);
}
}
pub(crate) fn strip_ansi(text: &str) -> String {
let bytes = text.as_bytes();
let mut plain = String::with_capacity(text.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == 0x1b {
if i + 1 < bytes.len() && bytes[i + 1] == b'[' {
i += 2;
while i < bytes.len() && !(0x40..=0x7e).contains(&bytes[i]) {
i += 1;
}
if i < bytes.len() {
i += 1;
}
} else {
i += 1;
}
} else {
let start = i;
while i < bytes.len() && bytes[i] != 0x1b {
i += 1;
}
plain.push_str(&text[start..i]);
}
}
plain
}
pub(crate) fn emit_execution_event(event: oxo_flow_core::executor::ExecutionEvent) {
tracing::info!(target: "execution_event", "{}", event.to_json_log());
}
#[cfg(test)]
mod sigpipe_narrate_tests {
use super::{
WorkflowConfig, diagnostic_narrate, narrate_line_to, progress_narrate, strip_ansi,
write_execution_plan, write_run_log_line,
};
struct DeadSink;
impl std::io::Write for DeadSink {
fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
Err(std::io::Error::from_raw_os_error(32))
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn slot() -> super::SharedRunLog {
std::sync::Arc::new(std::sync::Mutex::new(None))
}
#[test]
fn narrate_line_to_survives_dead_sink() {
let mut sink = DeadSink;
narrate_line_to(&mut sink, format_args!(" ✓ greet (0.1s)"));
narrate_line_to(&mut sink, format_args!("\nexit code: 1"));
narrate_line_to(&mut sink, format_args!(""));
}
#[test]
fn narrate_helpers_survive_dead_console_with_log() {
let run_log = slot();
progress_narrate(format_args!(" ✓ greet (0.1s)"), &run_log);
diagnostic_narrate(format_args!(" ⚠ adoption note"), Some(&run_log));
diagnostic_narrate(format_args!(" ⚠ operator hint"), None);
write_run_log_line(&run_log, "plain line");
}
#[test]
fn execution_plan_groups_module_rules_without_renumbering() {
let mut config = WorkflowConfig::parse(
r#"
[workflow]
name = "plan"
[[rules]]
name = "prepare"
shell = "true"
"#,
)
.unwrap();
config
.module_rules
.insert("qc".to_string(), vec!["qc::align".to_string()]);
let order = vec!["qc::align".to_string(), "prepare".to_string()];
let mut sink = Vec::new();
write_execution_plan(&mut sink, &order, &config).unwrap();
let plain = strip_ansi(&String::from_utf8(sink).unwrap());
assert!(
plain.contains("DAG: 2 rules in dependency order\n"),
"{plain}"
);
assert!(plain.contains("QC — 1 rule\n 1. qc::align\n"), "{plain}");
assert!(
plain.contains("Main workflow — 1 rule\n 2. prepare\n"),
"{plain}"
);
}
#[test]
fn execution_plan_stays_flat_without_module_rules() {
let config = WorkflowConfig::parse(
r#"
[workflow]
name = "plan"
[[rules]]
name = "only"
shell = "true"
"#,
)
.unwrap();
let mut sink = Vec::new();
write_execution_plan(&mut sink, &["only".to_string()], &config).unwrap();
let plain = strip_ansi(&String::from_utf8(sink).unwrap());
assert_eq!(plain, "DAG: 1 rules in dependency order\n 1. only\n");
}
}