use crate::commands::{compute_sha256, run_preview};
use oxo_flow_core::config::WorkflowConfig;
use oxo_flow_core::dag::WorkflowDag;
use oxo_flow_core::deep_check::{interpreter_script_candidates, rendered_shell_text};
use oxo_flow_core::executor::checkpoint::{rendered_input_patterns, resolve_pattern_files};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::path::Path;
#[derive(Debug, Default)]
pub(crate) struct SourceLinkReport {
pub linked: Vec<String>,
pub sidecars: Vec<String>,
pub converted: Vec<String>,
pub conflicts: Vec<String>,
pub undeclared: Vec<String>,
pub errors: Vec<String>,
pub skipped_note: Option<String>,
}
impl SourceLinkReport {
pub(crate) fn is_empty(&self) -> bool {
self.linked.is_empty()
&& self.converted.is_empty()
&& self.conflicts.is_empty()
&& self.undeclared.is_empty()
&& self.errors.is_empty()
}
}
const SIDECAR_SUFFIXES: &[&str] = &[
".fai", ".dict", ".gzi", ".csi", ".crai", ".tbi", ".bai", ".amb", ".ann", ".bwt", ".pac",
".sa", ".mmi",
];
pub(crate) fn link_workflow_sources(
config: &WorkflowConfig,
dag: &WorkflowDag,
order: &[String],
workflow_dir: &Path,
workdir: &Path,
wildcard_values: &HashMap<String, String>,
) -> SourceLinkReport {
let mut report = SourceLinkReport::default();
let canon = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
if canon(workflow_dir) == canon(workdir) {
return report; }
if workdir.starts_with(workflow_dir) {
report.skipped_note = Some(format!(
"workdir {} is inside the workflow repo; source linking skipped",
workdir.display()
));
return report;
}
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 (linkable, sidecars, undeclared) = source_link_candidates(
config,
dag,
order,
workflow_dir,
wildcard_values,
&reference_outputs,
);
report.undeclared = undeclared;
for rel in &linkable {
match link_one(rel, workflow_dir, workdir) {
Ok(outcome @ (LinkOutcome::Created | LinkOutcome::Converted)) => {
if sidecars.contains(rel) {
report.sidecars.push(rel.clone());
}
if outcome == LinkOutcome::Created {
report.linked.push(rel.clone());
} else {
report.converted.push(rel.clone());
}
}
Ok(LinkOutcome::Idempotent | LinkOutcome::LeftAlone) => {}
Ok(LinkOutcome::Conflict) => report.conflicts.push(rel.clone()),
Err(e) => report.errors.push(format!("{}: {e}", rel)),
}
}
report
}
#[allow(clippy::type_complexity)]
fn source_link_candidates(
config: &WorkflowConfig,
dag: &WorkflowDag,
order: &[String],
workflow_dir: &Path,
wildcard_values: &HashMap<String, String>,
reference_outputs: &HashSet<String>,
) -> (BTreeSet<String>, BTreeSet<String>, Vec<String>) {
let mut linkable = BTreeSet::new();
let mut rules = Vec::new();
for name in order {
let Some(rule) = config.get_rule(name) else {
continue;
};
if 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;
}
rules.push(rule);
}
for rule in &rules {
for pattern in rendered_input_patterns(rule, wildcard_values) {
if dag.producer_of(&pattern).is_some() || reference_outputs.contains(&pattern) {
continue; }
for file in resolve_pattern_files(&pattern, workflow_dir) {
if let Some(rel) = rel_under(&file, workflow_dir) {
linkable.insert(rel);
}
}
}
let shell = rendered_shell_text(rule, config);
for token in interpreter_script_candidates(&shell) {
if dag.producer_of(&token).is_some() || reference_outputs.contains(&token) {
continue;
}
for file in resolve_pattern_files(&token, workflow_dir) {
if let Some(rel) = rel_under(&file, workflow_dir) {
linkable.insert(rel);
}
}
}
}
let mut sidecars = BTreeSet::new();
for rel in &linkable {
for suffix in SIDECAR_SUFFIXES {
let candidate = format!("{rel}{suffix}");
if linkable.contains(&candidate)
|| sidecars.contains(&candidate)
|| dag.producer_of(&candidate).is_some()
|| reference_outputs.contains(&candidate)
{
continue;
}
if workflow_dir.join(&candidate).is_file() {
sidecars.insert(candidate);
}
}
}
linkable.extend(sidecars.iter().cloned());
let mut undeclared = BTreeSet::new();
for rule in &rules {
let shell = rendered_shell_text(rule, config);
for token in shell.split_whitespace() {
let token = token.trim_matches(|c: char| {
matches!(c, '\'' | '"' | ';' | '&' | ')' | ']')
|| c.is_ascii_punctuation() && !matches!(c, '/' | '.' | '-' | '_' | '+')
});
if !is_path_like_token(token) {
continue;
}
if linkable.contains(token)
|| reference_outputs.contains(token)
|| dag.producer_of(token).is_some()
{
continue;
}
if workflow_dir.join(token).is_file() {
undeclared.insert(format!("{} (rule '{}')", token, rule.name));
}
}
}
(linkable, sidecars, undeclared.into_iter().collect())
}
fn is_path_like_token(token: &str) -> bool {
token.len() > 1
&& token.contains('/')
&& !token.starts_with('-')
&& !token.contains([
'{', '}', '$', '=', '`', '\'', '"', '(', ')', '?', ';', '|', '<', '>', '*',
])
&& !token.starts_with(".oxo-flow")
}
fn rel_under(file: &Path, workflow_dir: &Path) -> Option<String> {
file.strip_prefix(workflow_dir)
.ok()
.map(|p| p.to_string_lossy().replace('\\', "/"))
}
#[derive(Clone, Copy, PartialEq)]
enum LinkOutcome {
Created,
Converted,
Idempotent,
LeftAlone,
Conflict,
}
fn link_one(rel: &str, workflow_dir: &Path, workdir: &Path) -> anyhow::Result<LinkOutcome> {
let repo_path = workflow_dir.join(rel);
let wd_path = workdir.join(rel);
if !repo_path.is_file() {
return Ok(LinkOutcome::LeftAlone);
}
let repo_target = std::fs::canonicalize(&repo_path)?;
if wd_path.is_symlink() {
if std::fs::canonicalize(&wd_path).is_ok_and(|resolved| resolved == repo_target) {
return Ok(LinkOutcome::Idempotent);
}
return Ok(LinkOutcome::LeftAlone);
}
if wd_path.exists() {
if wd_path.is_dir() {
return Ok(LinkOutcome::LeftAlone);
}
let identical = match (compute_sha256(&wd_path), compute_sha256(&repo_target)) {
(Ok(wd_hash), Ok(repo_hash)) => wd_hash == repo_hash,
_ => false,
};
if identical {
std::fs::remove_file(&wd_path)?;
make_symlink(&repo_target, &wd_path)?;
return Ok(LinkOutcome::Converted);
}
return Ok(LinkOutcome::Conflict);
}
make_symlink(&repo_target, &wd_path)?;
Ok(LinkOutcome::Created)
}
fn make_symlink(repo_target: &Path, wd_path: &Path) -> anyhow::Result<()> {
if let Some(parent) = wd_path.parent() {
std::fs::create_dir_all(parent)?;
}
#[cfg(unix)]
{
std::os::unix::fs::symlink(repo_target, wd_path)?;
}
#[cfg(not(unix))]
{
std::fs::symlink(repo_target, wd_path)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(content: &str) -> WorkflowConfig {
WorkflowConfig::parse(content).expect("workflow parses")
}
fn write(path: &Path, content: &str) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, content).unwrap();
}
const WF: &str = r#"
[workflow]
name = "t"
version = "1"
[config]
tools = "scripts"
[[rules]]
name = "report"
input = ["notes/source.txt"]
output = ["out.txt"]
shell = "python {config.tools}/tool.py --in notes/source.txt > out.txt"
"#;
fn setup_repo(root: &Path) -> WorkflowConfig {
write(&root.join("notes/source.txt"), "source-of-truth\n");
write(&root.join("scripts/tool.py"), "print('hi')\n");
parse(WF)
}
#[test]
fn candidates_cover_declared_inputs_and_scripts() {
let tmp = tempfile::tempdir().unwrap();
let config = setup_repo(tmp.path());
let dag = WorkflowDag::from_rules(&config.rules).unwrap();
let order = dag.execution_order().unwrap();
let (linkable, sidecars, undeclared) = source_link_candidates(
&config,
&dag,
&order,
tmp.path(),
&HashMap::new(),
&HashSet::new(),
);
assert_eq!(
linkable.into_iter().collect::<Vec<_>>(),
vec!["notes/source.txt", "scripts/tool.py"]
);
assert!(sidecars.is_empty());
assert!(undeclared.is_empty());
}
#[test]
fn undeclared_shell_reference_is_reported_not_linked() {
let repo = tempfile::tempdir().unwrap();
write(&repo.path().join("extra/pairs.tsv"), "pairs\n");
write(&repo.path().join("notes/source.txt"), "source\n");
write(&repo.path().join("scripts/tool.py"), "print('hi')\n");
let wf = WF.replace(
"--in notes/source.txt > out.txt",
"--in notes/source.txt --pairs extra/pairs.tsv > out.txt",
);
let config = parse(&wf);
let dag = WorkflowDag::from_rules(&config.rules).unwrap();
let order = dag.execution_order().unwrap();
let (linkable, sidecars, undeclared) = source_link_candidates(
&config,
&dag,
&order,
repo.path(),
&HashMap::new(),
&HashSet::new(),
);
assert!(!linkable.contains("extra/pairs.tsv"));
assert!(sidecars.is_empty());
assert_eq!(undeclared, vec!["extra/pairs.tsv (rule 'report')"]);
}
#[test]
fn tool_convention_index_sidecars_are_linked_without_declaration() {
let repo = tempfile::tempdir().unwrap();
let wd = tempfile::tempdir().unwrap();
write(&repo.path().join("reference/ref.fa"), "ACGT\n");
write(&repo.path().join("reference/ref.fa.amb"), "amb\n");
write(&repo.path().join("reference/ref.fa.bwt"), "bwt\n");
write(&repo.path().join("reference/ref.fa.fai"), "fai\n");
write(&repo.path().join("reference/ref.fa.dict"), "dict\n");
write(&repo.path().join("scripts/tool.py"), "print('hi')\n");
let wf = r#"
[workflow]
name = "t"
version = "1"
[[rules]]
name = "align"
input = ["reference/ref.fa"]
output = ["out.txt"]
shell = "python3 scripts/tool.py --ref reference/ref.fa > out.txt"
"#;
let config = parse(wf);
let dag = WorkflowDag::from_rules(&config.rules).unwrap();
let order = dag.execution_order().unwrap();
let report = link_workflow_sources(
&config,
&dag,
&order,
repo.path(),
wd.path(),
&HashMap::new(),
);
assert_eq!(report.linked.len(), 6, "{report:?}");
assert_eq!(
report.sidecars,
vec![
"reference/ref.fa.amb",
"reference/ref.fa.bwt",
"reference/ref.fa.dict",
"reference/ref.fa.fai",
],
"{report:?}"
);
for rel in &report.sidecars {
assert!(wd.path().join(rel).is_symlink(), "{rel} not linked");
}
}
#[test]
fn dag_produced_sidecar_stays_a_generated_intermediate() {
let repo = tempfile::tempdir().unwrap();
let wd = tempfile::tempdir().unwrap();
write(&repo.path().join("notes/source.txt"), "source\n");
write(&repo.path().join("notes/source.txt.fai"), "stale\n");
let wf = r#"
[workflow]
name = "t"
version = "1"
[[rules]]
name = "report"
input = ["notes/source.txt"]
output = ["out.txt"]
shell = "cat notes/source.txt > out.txt"
[[rules]]
name = "index"
input = ["notes/source.txt"]
output = ["notes/source.txt.fai"]
shell = "printf idx > notes/source.txt.fai"
"#;
let config = parse(wf);
let dag = WorkflowDag::from_rules(&config.rules).unwrap();
let order = dag.execution_order().unwrap();
let report = link_workflow_sources(
&config,
&dag,
&order,
repo.path(),
wd.path(),
&HashMap::new(),
);
assert_eq!(report.linked, vec!["notes/source.txt"], "{report:?}");
assert!(report.sidecars.is_empty(), "{report:?}");
}
#[test]
fn linking_creates_symlinks_and_converts_identical_copies() {
let repo = tempfile::tempdir().unwrap();
let wd = tempfile::tempdir().unwrap();
let config = setup_repo(repo.path());
let dag = WorkflowDag::from_rules(&config.rules).unwrap();
let order = dag.execution_order().unwrap();
let report = link_workflow_sources(
&config,
&dag,
&order,
repo.path(),
wd.path(),
&HashMap::new(),
);
assert_eq!(report.linked.len(), 2, "{report:?}");
assert!(report.conflicts.is_empty() && report.errors.is_empty());
assert!(wd.path().join("notes/source.txt").is_symlink());
assert!(wd.path().join("scripts/tool.py").is_symlink());
let again = link_workflow_sources(
&config,
&dag,
&order,
repo.path(),
wd.path(),
&HashMap::new(),
);
assert!(again.is_empty(), "{again:?}");
std::fs::remove_file(wd.path().join("notes/source.txt")).unwrap();
write(&wd.path().join("notes/source.txt"), "source-of-truth\n");
let report = link_workflow_sources(
&config,
&dag,
&order,
repo.path(),
wd.path(),
&HashMap::new(),
);
assert_eq!(report.converted, vec!["notes/source.txt"], "{report:?}");
assert_eq!(
std::fs::read_to_string(wd.path().join("notes/source.txt")).unwrap(),
"source-of-truth\n"
);
std::fs::remove_file(wd.path().join("scripts/tool.py")).unwrap();
write(&wd.path().join("scripts/tool.py"), "print('local edit')\n");
let report = link_workflow_sources(
&config,
&dag,
&order,
repo.path(),
wd.path(),
&HashMap::new(),
);
assert_eq!(report.conflicts, vec!["scripts/tool.py"], "{report:?}");
assert_eq!(
std::fs::read_to_string(wd.path().join("scripts/tool.py")).unwrap(),
"print('local edit')\n"
);
}
#[test]
fn single_directory_mode_is_a_no_op() {
let tmp = tempfile::tempdir().unwrap();
let config = setup_repo(tmp.path());
let dag = WorkflowDag::from_rules(&config.rules).unwrap();
let order = dag.execution_order().unwrap();
let report = link_workflow_sources(
&config,
&dag,
&order,
tmp.path(),
tmp.path(),
&HashMap::new(),
);
assert!(report.is_empty());
}
#[test]
fn workdir_inside_repo_is_skipped() {
let repo = tempfile::tempdir().unwrap();
let config = setup_repo(repo.path());
let dag = WorkflowDag::from_rules(&config.rules).unwrap();
let order = dag.execution_order().unwrap();
let wd = repo.path().join("scratch-run");
std::fs::create_dir_all(&wd).unwrap();
let report =
link_workflow_sources(&config, &dag, &order, repo.path(), &wd, &HashMap::new());
assert!(report.skipped_note.is_some());
assert!(report.linked.is_empty());
}
#[test]
fn path_like_token_filter_rejects_flags_and_placeholders() {
assert!(is_path_like_token("scripts/tool.py"));
assert!(is_path_like_token("config/pairs.tsv"));
assert!(!is_path_like_token("--output"));
assert!(!is_path_like_token("{config.data_dir}"));
assert!(!is_path_like_token("$HOME/data/x.tsv"));
assert!(!is_path_like_token("KEY=value"));
assert!(!is_path_like_token(".oxo-flow/state.json"));
assert!(!is_path_like_token("python"));
}
}