use super::*;
pub(crate) fn sensitive_values_of(config: &WorkflowConfig) -> Vec<String> {
config
.config_meta
.iter()
.filter(|(_, def)| def.sensitive)
.filter_map(|(key, _)| config.config.get(key))
.map(oxo_flow_core::config_impact::config_value_string)
.collect()
}
pub(crate) fn adopt_checkpoint_for_workflow(
ck: &mut CheckpointState,
workflow: &Path,
run_log: Option<&SharedRunLog>,
) {
match ck.foreign_workflow(workflow) {
Some(true) => {
diagnostic_narrate(
format_args!(
" {} checkpoint belongs to a different workflow — ignoring its \
completed/failed records so nothing is reused (every rule re-executes)",
"Note:".yellow()
),
run_log,
);
ck.invalidate_reuse_records();
}
Some(false) => {}
None => {
tracing::debug!(
"checkpoint predates workflow-identity tracking; reuse checks stay \
path/mtime/fingerprint based"
);
}
}
}
pub(crate) fn print_truncated_list(label: &str, names: &[String]) -> String {
let shown: Vec<&str> = names.iter().take(3).map(String::as_str).collect();
let extra = names.len().saturating_sub(3);
let suffix = if extra > 0 {
format!(", … (+{extra} more)")
} else {
String::new()
};
format!(" {label} {}{}", shown.join(", "), suffix)
}
#[allow(clippy::too_many_arguments)] pub(crate) fn print_config_change_summary(
report: &ConfigChangeReport,
old_snapshot: &std::collections::BTreeMap<String, String>,
config: &WorkflowConfig,
sensitive_keys: &HashSet<String>,
order: &[String],
completed_in_run: usize,
rerun: bool,
run_log: Option<&SharedRunLog>,
) {
if rerun {
return;
}
if report.is_legacy {
diagnostic_narrate(
format_args!(
" {} checkpoint predates config tracking: recorded a baseline snapshot; \
future config changes will invalidate affected rules automatically",
"Note:".yellow()
),
run_log,
);
return;
}
if report.changed_keys.is_empty()
&& report.added_keys.is_empty()
&& report.removed_keys.is_empty()
&& report.fingerprint_mismatches.is_empty()
&& report.when_flip_invalidated.is_empty()
{
return;
}
let mut lines: Vec<String> = vec!["Config change:".bold().cyan().to_string()];
for key in &report.changed_keys {
if sensitive_keys.contains(key) {
lines.push(format!(" {key}: **** → ****"));
} else {
let old = old_snapshot.get(key).map(String::as_str).unwrap_or("?");
let new = config
.config
.get(key)
.map(config_value_string)
.unwrap_or_else(|| "?".to_string());
lines.push(format!(" {key}: {old} → {new}"));
}
}
for key in &report.added_keys {
lines.push(format!(" {key}: (new key)"));
}
for key in &report.removed_keys {
lines.push(format!(" {key}: (removed)"));
}
if !report.fingerprint_mismatches.is_empty() {
lines.push(print_truncated_list(
"rule definition changed:",
&report.fingerprint_mismatches,
));
}
if !report.when_flip_invalidated.is_empty() {
lines.push(print_truncated_list(
"when-condition flipped:",
&report.when_flip_invalidated,
));
}
if !report.when_gate_exempt.is_empty() {
lines.push(print_truncated_list(
"when-condition unchanged, reused:",
&report.when_gate_exempt,
));
}
let order_set: HashSet<&str> = order.iter().map(String::as_str).collect();
let rerun_this_run = report
.invalidated
.iter()
.filter(|name| order_set.contains(name.as_str()))
.count();
lines.push(format!(
" → invalidated {} ({} directly affected), re-running {}/{} this run, skipping {}",
report.invalidated.len(),
report.directly_affected.len(),
rerun_this_run,
order.len(),
completed_in_run,
));
diagnostic_narrate(format_args!("{}", lines.join("\n")), run_log);
}
pub(crate) fn quote_shell_path(path: &str) -> String {
format!("'{}'", path.replace('\'', "'\\''"))
}
pub(crate) fn substitute_source_placeholder(build_cmd: &str, expanded_source: &str) -> String {
build_cmd.replace("{source}", "e_shell_path(expanded_source))
}
pub(crate) fn known_modules_hint(
module_rules: &std::collections::HashMap<String, Vec<String>>,
) -> String {
if module_rules.is_empty() {
"(none — no [[include]] modules)".to_string()
} else {
module_rules
.keys()
.map(|k| k.as_str())
.collect::<Vec<_>>()
.join(", ")
}
}
pub(crate) fn module_groups_for_rules<'a>(
names: &[&'a str],
module_rules: &'a HashMap<String, Vec<String>>,
) -> Option<Vec<(Option<&'a str>, Vec<&'a str>)>> {
if module_rules.is_empty() {
return None;
}
let module_members: Vec<(&str, &str)> = module_rules
.iter()
.flat_map(|(module, members)| {
members
.iter()
.map(move |member| (member.as_str(), module.as_str()))
})
.collect();
let mut groups: Vec<(Option<&str>, Vec<&str>)> = Vec::new();
let mut group_index: HashMap<Option<&str>, usize> = HashMap::new();
for &name in names {
let group = if let Some((prefix, _)) = name.split_once("::") {
Some(prefix)
} else {
oxo_flow_core::dag::owning_module(name, &module_members)
};
let idx = *group_index.entry(group).or_insert_with(|| {
groups.push((group, Vec::new()));
groups.len() - 1
});
groups[idx].1.push(name);
}
Some(groups)
}
pub(crate) fn run_group_title(group: Option<&str>) -> String {
match group {
Some(module) => oxo_flow_core::stage::module_display(module),
None => "Main workflow".to_string(),
}
}
pub(crate) fn undefined_config_findings(config: &WorkflowConfig) -> Vec<String> {
config
.rules
.iter()
.flat_map(|rule| oxo_flow_core::format::undefined_config_refs(rule, config))
.map(|d| {
format!(
"rule '{}': {} ({})",
d.rule.as_deref().unwrap_or("<unknown>"),
d.message,
d.suggestion.unwrap_or_default()
)
})
.collect()
}
pub(crate) fn missing_source_findings(
config: &WorkflowConfig,
dag: &WorkflowDag,
order: &[String],
workdir: &Path,
workflow_dir: &Path,
wildcard_values: &HashMap<String, String>,
) -> Vec<String> {
let reference_outputs: HashSet<String> = config
.references
.iter()
.map(|r| {
oxo_flow_core::executor::checkpoint::expand_config_in_path(&r.output, wildcard_values)
})
.collect();
let sample_search_dir = config.workflow.sample_pattern.as_ref().and_then(|pattern| {
let expanded =
oxo_flow_core::executor::checkpoint::expand_config_in_path(pattern, wildcard_values);
let path = Path::new(&expanded);
if path.is_absolute() {
path.parent().map(|p| p.to_path_buf())
} else {
let joined = workflow_dir.join(path);
Some(
joined
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| workflow_dir.to_path_buf()),
)
}
});
let when_skipped: HashSet<String> = config
.when_skipped_outputs
.iter()
.map(|recorded| {
oxo_flow_core::executor::checkpoint::expand_config_in_path(recorded, wildcard_values)
})
.collect();
let mut findings = Vec::new();
for name in order {
let Some(rule) = config.get_rule(name) else {
continue;
};
if crate::commands::run_preview::when_condition_false(rule, config, wildcard_values) {
continue;
}
if rule.scatter.as_ref().is_some_and(|scatter| {
if !scatter.values.is_empty() {
return false;
}
match scatter.values_from.as_deref() {
None => true,
Some(values_from) => config
.resolve_config_list(values_from)
.is_some_and(|values| values.is_empty()),
}
}) {
continue;
}
if rule.optional.is_optional() {
continue;
}
let missing = oxo_flow_core::executor::checkpoint::missing_input_patterns(
rule,
workdir,
wildcard_values,
);
for pattern in missing {
if reference_outputs.contains(&pattern) {
continue;
}
if dag.producer_of(&pattern).is_some() {
continue;
}
if when_skipped.contains(&pattern) {
continue;
}
let absolute = Path::new(&pattern).is_absolute();
if !absolute
&& sample_search_dir
.as_ref()
.is_some_and(|dir| dir.join(&pattern).exists())
{
continue;
}
findings.push(format!(
"rule '{name}': input '{pattern}' does not exist and no rule produces it"
));
}
}
findings
}
pub(crate) fn missing_source_lines(
missing_inputs: &HashSet<String>,
config: &WorkflowConfig,
workdir: &Path,
wildcard_values: &HashMap<String, String>,
) -> Vec<String> {
let mut names: Vec<&String> = missing_inputs.iter().collect();
names.sort();
names
.into_iter()
.map(|name| {
let patterns = config
.get_rule(name)
.map(|rule| {
oxo_flow_core::executor::checkpoint::missing_input_patterns(
rule,
workdir,
wildcard_values,
)
})
.unwrap_or_default();
let listing = if patterns.is_empty() {
"(unresolvable)".to_string()
} else {
patterns.join(", ")
};
format!(" {name} — missing: {listing}")
})
.collect()
}