Skip to main content

opal/
display.rs

1use crate::execution_plan::{ExecutableJob, ExecutionPlan};
2use crate::model::{
3    CachePolicySpec, CacheSpec, DependencySourceSpec, EnvironmentActionSpec, JobDependencySpec,
4    JobSpec,
5};
6use crate::pipeline::{JobStatus, JobSummary, RuleWhen, VolumeMount};
7use ascii_tree::{Tree, write_tree};
8use humantime::format_duration;
9use owo_colors::OwoColorize;
10use std::collections::{HashMap, HashSet};
11use std::path::{Path, PathBuf};
12
13#[derive(Clone, Copy)]
14pub struct DisplayFormatter {
15    use_color: bool,
16}
17
18impl DisplayFormatter {
19    pub fn new(use_color: bool) -> Self {
20        Self { use_color }
21    }
22
23    pub fn colorize<'a, F>(&self, text: &'a str, styler: F) -> String
24    where
25        F: FnOnce(&'a str) -> String,
26    {
27        if self.use_color {
28            styler(text)
29        } else {
30            text.to_string()
31        }
32    }
33
34    pub fn bold_green(&self, text: &str) -> String {
35        self.colorize(text, |t| format!("{}", t.bold().green()))
36    }
37
38    pub fn bold_red(&self, text: &str) -> String {
39        self.colorize(text, |t| format!("{}", t.bold().red()))
40    }
41
42    pub fn bold_yellow(&self, text: &str) -> String {
43        self.colorize(text, |t| format!("{}", t.bold().yellow()))
44    }
45
46    pub fn bold_white(&self, text: &str) -> String {
47        self.colorize(text, |t| format!("{}", t.bold().white()))
48    }
49
50    pub fn bold_cyan(&self, text: &str) -> String {
51        self.colorize(text, |t| format!("{}", t.bold().cyan()))
52    }
53
54    pub fn bold_blue(&self, text: &str) -> String {
55        self.colorize(text, |t| format!("{}", t.bold().blue()))
56    }
57
58    pub fn bold_magenta(&self, text: &str) -> String {
59        self.colorize(text, |t| format!("{}", t.bold().magenta()))
60    }
61
62    pub fn stage_header(&self, name: &str) -> String {
63        let prefix = self.bold_blue("╭────────");
64        let stage_text = format!("stage {name}");
65        let stage = self.bold_magenta(&stage_text);
66        let suffix = self.bold_blue("────────╮");
67        format!("{} {} {}", prefix, stage, suffix)
68    }
69
70    pub fn logs_header(&self) -> String {
71        self.bold_cyan("    logs:")
72    }
73
74    pub fn format_needs(&self, job: &JobSpec) -> Option<String> {
75        if job.needs.is_empty() {
76            return None;
77        }
78
79        let entries: Vec<String> = job
80            .needs
81            .iter()
82            .map(|need| match &need.source {
83                DependencySourceSpec::Local => {
84                    if need.needs_artifacts {
85                        format!("{} (artifacts)", need.job)
86                    } else {
87                        need.job.clone()
88                    }
89                }
90                DependencySourceSpec::External(ext) => {
91                    let mut label = format!("{}::{}", ext.project, need.job);
92                    if need.needs_artifacts {
93                        label.push_str(" (external artifacts)");
94                    } else {
95                        label.push_str(" (external)");
96                    }
97                    label
98                }
99            })
100            .collect();
101
102        Some(entries.join(", "))
103    }
104
105    pub fn format_paths(&self, paths: &[PathBuf]) -> Option<String> {
106        if paths.is_empty() {
107            return None;
108        }
109
110        Some(
111            paths
112                .iter()
113                .map(|path| path.display().to_string())
114                .collect::<Vec<_>>()
115                .join(", "),
116        )
117    }
118
119    pub fn format_mounts(&self, mounts: &[VolumeMount]) -> String {
120        let label = self.bold_cyan("    artifact mounts:");
121        if mounts.is_empty() {
122            return format!("{} none", label);
123        }
124
125        let desc = mounts
126            .iter()
127            .map(|mount| mount.container.display().to_string())
128            .collect::<Vec<_>>()
129            .join(", ");
130
131        format!("{} {}", label, desc)
132    }
133}
134
135pub fn indent_block(block: &str, prefix: &str) -> String {
136    let mut buf = String::new();
137    for (idx, line) in block.lines().enumerate() {
138        if idx > 0 {
139            buf.push('\n');
140        }
141        buf.push_str(prefix);
142        buf.push_str(line);
143    }
144    buf
145}
146
147pub fn print_line(line: impl AsRef<str>) {
148    println!("{}", line.as_ref());
149}
150
151pub fn print_blank_line() {
152    println!();
153}
154
155pub fn print_prefixed_line(prefix: &str, line: &str) {
156    println!("{} {}", prefix, line);
157}
158
159pub fn print_pipeline_summary<F>(
160    display: &DisplayFormatter,
161    plan: &ExecutionPlan,
162    summaries: &[JobSummary],
163    session_dir: &Path,
164    mut emit_line: F,
165) where
166    F: FnMut(String),
167{
168    emit_line(String::new());
169    let header = display.bold_blue("╭─ pipeline summary");
170    emit_line(header);
171
172    if summaries.is_empty() {
173        emit_line("  no jobs were executed".to_string());
174        emit_line(format!("  session data: {}", session_dir.display()));
175        return;
176    }
177
178    let mut ordered = summaries.to_vec();
179    ordered.sort_by_key(|entry| {
180        plan.order_index
181            .get(&entry.name)
182            .copied()
183            .unwrap_or(usize::MAX)
184    });
185
186    let mut success = 0usize;
187    let mut failed = 0usize;
188    let mut skipped = 0usize;
189    let mut allowed_failures = 0usize;
190
191    for entry in &ordered {
192        match &entry.status {
193            JobStatus::Success => success += 1,
194            JobStatus::Failed(_) => {
195                if entry.allow_failure {
196                    allowed_failures += 1;
197                } else {
198                    failed += 1;
199                }
200            }
201            JobStatus::Skipped(_) => skipped += 1,
202        }
203        let icon = match &entry.status {
204            JobStatus::Success => display.bold_green("✓"),
205            JobStatus::Failed(_) if entry.allow_failure => display.bold_yellow("!"),
206            JobStatus::Failed(_) => display.bold_red("✗"),
207            JobStatus::Skipped(_) => display.bold_yellow("•"),
208        };
209        let mut line = format!(
210            "  {} {} (stage {}, log {})",
211            icon, entry.name, entry.stage_name, entry.log_hash
212        );
213        match &entry.status {
214            JobStatus::Success => line.push_str(&format!(" – {:.2}s", entry.duration)),
215            JobStatus::Failed(msg) => {
216                if entry.allow_failure {
217                    line.push_str(&format!(
218                        " – {:.2}s failed (allowed): {}",
219                        entry.duration, msg
220                    ));
221                } else {
222                    line.push_str(&format!(" – {:.2}s failed: {}", entry.duration, msg));
223                }
224            }
225            JobStatus::Skipped(msg) => line.push_str(&format!(" – {}", msg)),
226        }
227        if let Some(log_path) = &entry.log_path {
228            line.push_str(&format!(" [log: {}]", log_path.display()));
229        }
230        if let Some(env) = &entry.environment {
231            line.push_str(&format!(" (env: {}", env.name));
232            if let Some(url) = &env.url {
233                line.push_str(&format!(", url: {}", url));
234            }
235            line.push(')');
236        }
237        emit_line(line);
238    }
239
240    let mut summary_line = format!(
241        "  results: {} ok / {} failed / {} skipped",
242        success, failed, skipped
243    );
244    if allowed_failures > 0 {
245        summary_line.push_str(&format!(" / {} allowed", allowed_failures));
246    }
247    emit_line(summary_line);
248    emit_line(format!("  session data: {}", session_dir.display()));
249}
250
251pub fn print_pipeline_plan<F>(display: &DisplayFormatter, plan: &ExecutionPlan, mut emit_line: F)
252where
253    F: FnMut(String),
254{
255    let mut emit = |line: String| {
256        if line.is_empty() {
257            emit_line(String::new());
258        } else {
259            emit_line(format!("  {line}"));
260        }
261    };
262    emit(String::new());
263    let header = display.bold_blue("pipeline plan");
264    emit(header);
265    if plan.ordered.is_empty() {
266        emit("no jobs scheduled for this context".to_string());
267        return;
268    }
269
270    let mut current_stage: Option<String> = None;
271    for job_name in &plan.ordered {
272        let Some(planned) = plan.nodes.get(job_name) else {
273            continue;
274        };
275        if current_stage.as_deref() != Some(planned.instance.stage_name.as_str()) {
276            current_stage = Some(planned.instance.stage_name.clone());
277            emit(String::new());
278            let stage_label = display.bold_cyan("stage:");
279            let stage_name = display.bold_magenta(planned.instance.stage_name.as_str());
280            emit(format!("{stage_label} {stage_name}"));
281        }
282        emit_plan_job(display, plan, planned, &mut emit);
283    }
284}
285
286pub fn collect_pipeline_plan(display: &DisplayFormatter, plan: &ExecutionPlan) -> Vec<String> {
287    let mut lines = Vec::new();
288    print_pipeline_plan(display, plan, |line| lines.push(line));
289    while matches!(lines.first(), Some(existing) if existing.trim().is_empty()) {
290        lines.remove(0);
291    }
292    lines
293}
294
295fn emit_plan_job<F>(
296    display: &DisplayFormatter,
297    plan: &ExecutionPlan,
298    planned: &ExecutableJob,
299    emit_line: &mut F,
300) where
301    F: FnMut(String),
302{
303    let job_label = display.bold_green("  job:");
304    let job_name = display.bold_white(planned.instance.job.name.as_str());
305    emit_line(format!("{job_label} {job_name}"));
306
307    if let Some(meta) = plan_job_meta(planned) {
308        emit_line(format!("{} {}", display.bold_cyan("    info:"), meta));
309    }
310    if let Some(image) = planned.instance.job.image.as_ref() {
311        emit_line(format!(
312            "{} {}",
313            display.bold_cyan("    image:"),
314            format_image_spec(image)
315        ));
316    }
317
318    emit_section(
319        display,
320        "depends on",
321        &plan_dependency_lines(planned),
322        emit_line,
323    );
324    emit_section(
325        display,
326        "needs",
327        &plan_needs_lines(&planned.instance.job),
328        emit_line,
329    );
330    emit_section(
331        display,
332        "artifact downloads",
333        &plan_dependencies_list(&planned.instance.job),
334        emit_line,
335    );
336
337    if let Some(artifact_line) = format_artifacts_metadata(display, planned) {
338        emit_line(format!(
339            "{} {}",
340            display.bold_cyan("    artifacts:"),
341            artifact_line
342        ));
343    }
344    emit_section(
345        display,
346        "caches",
347        &plan_cache_lines(&planned.instance.job),
348        emit_line,
349    );
350    emit_section(
351        display,
352        "services",
353        &plan_service_lines(&planned.instance.job),
354        emit_line,
355    );
356    emit_section(
357        display,
358        "tags",
359        &plan_tag_lines(&planned.instance.job),
360        emit_line,
361    );
362
363    if let Some(tree_lines) = plan_relationship_tree_lines(plan, planned) {
364        emit_line(String::new());
365        let header = display.bold_cyan("    relationships graph");
366        emit_line(format!("{header}:"));
367        for line in tree_lines {
368            emit_line(format!("    {line}"));
369        }
370    }
371
372    if let Some(env_line) = format_environment(planned) {
373        emit_line(format!(
374            "{} {}",
375            display.bold_cyan("    environment:"),
376            env_line
377        ));
378    }
379
380    if let Some(timeout) = planned.instance.timeout {
381        emit_line(format!(
382            "{} {}",
383            display.bold_cyan("    timeout:"),
384            format_duration(timeout)
385        ));
386    }
387
388    if let Some(group) = &planned.instance.resource_group {
389        emit_line(format!(
390            "{} {}",
391            display.bold_cyan("    resource group:"),
392            group
393        ));
394    }
395}
396
397fn plan_job_meta(planned: &ExecutableJob) -> Option<String> {
398    let mut parts = Vec::new();
399    parts.push(format!(
400        "when {}",
401        describe_rule_when(planned.instance.rule.when)
402    ));
403    if planned.instance.rule.allow_failure {
404        parts.push("allow failure".to_string());
405    }
406    if let Some(delay) = planned.instance.rule.start_in {
407        parts.push(format!("start after {}", format_duration(delay)));
408    }
409    if planned.instance.rule.when == RuleWhen::Manual {
410        if planned.instance.rule.manual_auto_run {
411            parts.push("auto-run manual".to_string());
412        } else {
413            parts.push("requires trigger".to_string());
414        }
415        if let Some(reason) = &planned.instance.rule.manual_reason
416            && !reason.is_empty()
417        {
418            parts.push(format!("reason: {}", reason));
419        }
420    }
421    if planned.instance.job.retry.max > 0 {
422        parts.push(format!("retries {}", planned.instance.job.retry.max));
423    }
424    if planned.instance.job.interruptible {
425        parts.push("interruptible".to_string());
426    }
427    if parts.is_empty() {
428        None
429    } else {
430        Some(parts.join(" • "))
431    }
432}
433
434fn describe_rule_when(when: RuleWhen) -> &'static str {
435    match when {
436        RuleWhen::OnSuccess => "on_success",
437        RuleWhen::Manual => "manual",
438        RuleWhen::Delayed => "delayed",
439        RuleWhen::Never => "never",
440        RuleWhen::Always => "always",
441        RuleWhen::OnFailure => "on_failure",
442    }
443}
444
445fn format_environment(planned: &ExecutableJob) -> Option<String> {
446    let env = planned.instance.job.environment.as_ref()?;
447    let mut parts = Vec::new();
448    parts.push(env.name.clone());
449    if let Some(url) = &env.url {
450        parts.push(url.clone());
451    }
452    let mut extra = Vec::new();
453    if let Some(on_stop) = &env.on_stop {
454        extra.push(format!("on_stop: {}", on_stop));
455    }
456    if let Some(duration) = env.auto_stop_in {
457        extra.push(format!("auto_stop {}", format_duration(duration)));
458    }
459    match env.action {
460        EnvironmentActionSpec::Start => {}
461        EnvironmentActionSpec::Prepare => extra.push("prepare".to_string()),
462        EnvironmentActionSpec::Stop => extra.push("stop".to_string()),
463        EnvironmentActionSpec::Verify => extra.push("verify".to_string()),
464        EnvironmentActionSpec::Access => extra.push("access".to_string()),
465    }
466    if !extra.is_empty() {
467        parts.push(extra.join(", "));
468    }
469    Some(parts.join(" – "))
470}
471
472fn format_artifacts_metadata(
473    display: &DisplayFormatter,
474    planned: &ExecutableJob,
475) -> Option<String> {
476    let artifacts = &planned.instance.job.artifacts;
477    let mut parts = Vec::new();
478    if let Some(name) = &artifacts.name {
479        parts.push(format!("name {name}"));
480    }
481    if let Some(paths) = display.format_paths(&artifacts.paths) {
482        parts.push(paths);
483    }
484    if let Some(expire_in) = artifacts.expire_in {
485        parts.push(format!("expire_in {}", format_duration(expire_in)));
486    }
487    if let Some(dotenv) = &artifacts.report_dotenv {
488        parts.push(format!("reports:dotenv {}", dotenv.display()));
489    }
490    if parts.is_empty() {
491        None
492    } else {
493        Some(parts.join(" – "))
494    }
495}
496
497pub fn format_image_spec(image: &crate::model::ImageSpec) -> String {
498    let mut extra = Vec::new();
499    if let Some(platform) = &image.docker_platform {
500        extra.push(format!("platform: {}", platform));
501    }
502    if let Some(user) = &image.docker_user {
503        extra.push(format!("user: {}", user));
504    }
505    if !image.entrypoint.is_empty() {
506        extra.push(format!("entrypoint: [{}]", image.entrypoint.join(", ")));
507    }
508    if extra.is_empty() {
509        image.name.clone()
510    } else {
511        format!("{} ({})", image.name, extra.join(", "))
512    }
513}
514
515fn plan_dependency_lines(planned: &ExecutableJob) -> Vec<String> {
516    if planned.instance.dependencies.is_empty() {
517        return vec!["stage ordering".to_string()];
518    }
519    let needs_map = planned
520        .instance
521        .job
522        .needs
523        .iter()
524        .map(|need| (need.job.clone(), need.needs_artifacts))
525        .collect::<HashMap<String, bool>>();
526    planned
527        .instance
528        .dependencies
529        .iter()
530        .map(|name| {
531            if let Some(artifacts) = needs_map.get(name) {
532                if *artifacts {
533                    format!("• {name} (needs + artifacts)")
534                } else {
535                    format!("• {name} (needs)")
536                }
537            } else {
538                format!("• {name} (previous stage)")
539            }
540        })
541        .collect()
542}
543
544fn plan_needs_lines(job: &JobSpec) -> Vec<String> {
545    if job.needs.is_empty() {
546        return Vec::new();
547    }
548    let dependency_set: HashSet<&str> = job.dependencies.iter().map(|s| s.as_str()).collect();
549    job.needs
550        .iter()
551        .map(|need| format_need_line(need, dependency_set.contains(need.job.as_str())))
552        .collect()
553}
554
555fn format_need_line(need: &JobDependencySpec, has_dependency: bool) -> String {
556    let mut tags = Vec::new();
557    match &need.source {
558        DependencySourceSpec::Local => tags.push("local".to_string()),
559        DependencySourceSpec::External(ext) => tags.push(format!("external {}", ext.project)),
560    }
561    if need.needs_artifacts {
562        tags.push("artifacts".to_string());
563    }
564    if need.optional {
565        tags.push("optional".to_string());
566    }
567    if has_dependency {
568        tags.push("downloads via dependencies".to_string());
569    }
570    if let Some(filters) = &need.parallel
571        && !filters.is_empty()
572    {
573        tags.push("matrix filter".to_string());
574    }
575    if tags.is_empty() {
576        format!("• {}", need.job)
577    } else {
578        format!("• {} ({})", need.job, tags.join(", "))
579    }
580}
581
582fn plan_dependencies_list(job: &JobSpec) -> Vec<String> {
583    if job.dependencies.is_empty() {
584        return Vec::new();
585    }
586    let needs_set: HashSet<&str> = job.needs.iter().map(|need| need.job.as_str()).collect();
587    job.dependencies
588        .iter()
589        .map(|dep| {
590            if needs_set.contains(dep.as_str()) {
591                format!("• {dep} (from needs)")
592            } else {
593                format!("• {dep}")
594            }
595        })
596        .collect()
597}
598
599fn plan_cache_lines(job: &JobSpec) -> Vec<String> {
600    if job.cache.is_empty() {
601        return Vec::new();
602    }
603    job.cache.iter().map(format_cache_line).collect()
604}
605
606fn format_cache_line(cache: &CacheSpec) -> String {
607    let policy = cache_policy_label(cache.policy);
608    let paths = if cache.paths.is_empty() {
609        "no paths specified".to_string()
610    } else {
611        cache
612            .paths
613            .iter()
614            .map(|p| p.display().to_string())
615            .collect::<Vec<_>>()
616            .join(", ")
617    };
618    format!("• key {} ({policy}) – paths: {paths}", cache.key.describe())
619}
620
621fn emit_section<F>(display: &DisplayFormatter, title: &str, lines: &[String], emit_line: &mut F)
622where
623    F: FnMut(String),
624{
625    if lines.is_empty() {
626        return;
627    }
628    let header_label = format!("    {title}:");
629    let header = display.bold_cyan(&header_label);
630    emit_line(format!("{header} {}", lines[0]));
631    for line in lines.iter().skip(1) {
632        emit_line(format!("        {line}"));
633    }
634}
635
636fn plan_service_lines(job: &crate::model::JobSpec) -> Vec<String> {
637    job.services
638        .iter()
639        .map(|service| {
640            let mut parts = vec![service.image.clone()];
641            if !service.aliases.is_empty() {
642                parts.push(format!("alias {}", service.aliases.join(",")));
643            }
644            if let Some(platform) = &service.docker_platform {
645                parts.push(format!("platform {platform}"));
646            }
647            if let Some(user) = &service.docker_user {
648                parts.push(format!("user {user}"));
649            }
650            if !service.entrypoint.is_empty() {
651                parts.push(format!("entrypoint [{}]", service.entrypoint.join(", ")));
652            }
653            if !service.command.is_empty() {
654                parts.push(format!("command [{}]", service.command.join(", ")));
655            }
656            if !service.variables.is_empty() {
657                let mut vars = service.variables.keys().cloned().collect::<Vec<_>>();
658                vars.sort();
659                parts.push(format!("variables {}", vars.join(", ")));
660            }
661            format!("• {}", parts.join(" – "))
662        })
663        .collect()
664}
665
666fn plan_tag_lines(job: &crate::model::JobSpec) -> Vec<String> {
667    if job.tags.is_empty() {
668        Vec::new()
669    } else {
670        vec![format!("• {}", job.tags.join(", "))]
671    }
672}
673
674fn plan_relationship_tree_lines(
675    plan: &ExecutionPlan,
676    planned: &ExecutableJob,
677) -> Option<Vec<String>> {
678    let tree = build_relationship_tree(plan, planned)?;
679    let mut buffer = String::new();
680    write_tree(&mut buffer, &tree).ok()?;
681    Some(buffer.lines().map(|line| line.to_string()).collect())
682}
683
684fn build_relationship_tree(plan: &ExecutionPlan, planned: &ExecutableJob) -> Option<Tree> {
685    let mut sections = Vec::new();
686    let dependency_nodes = dependency_tree_nodes(plan, planned);
687    if !dependency_nodes.is_empty() {
688        sections.push(Tree::Node("depends on".to_string(), dependency_nodes));
689    } else {
690        sections.push(Tree::Leaf(vec![
691            "depends on previous stage ordering".to_string(),
692        ]));
693    }
694
695    let need_nodes = need_tree_nodes(plan, planned);
696    if !need_nodes.is_empty() {
697        sections.push(Tree::Node("needs".to_string(), need_nodes));
698    }
699
700    let artifact_nodes = artifact_tree_nodes(&planned.instance.job);
701    if !artifact_nodes.is_empty() {
702        sections.push(Tree::Node("artifacts".to_string(), artifact_nodes));
703    }
704
705    let cache_nodes = cache_tree_nodes(&planned.instance.job);
706    if !cache_nodes.is_empty() {
707        sections.push(Tree::Node("caches".to_string(), cache_nodes));
708    }
709
710    if sections.is_empty() {
711        None
712    } else {
713        Some(Tree::Node(
714            format!("{} relationships", planned.instance.job.name),
715            sections,
716        ))
717    }
718}
719
720fn dependency_tree_nodes(plan: &ExecutionPlan, planned: &ExecutableJob) -> Vec<Tree> {
721    planned
722        .instance
723        .dependencies
724        .iter()
725        .map(|dep| build_dependency_tree_node(plan, planned, dep))
726        .collect()
727}
728
729fn build_dependency_tree_node(
730    plan: &ExecutionPlan,
731    planned: &ExecutableJob,
732    dep_name: &str,
733) -> Tree {
734    let mut children = Vec::new();
735    let need = planned
736        .instance
737        .job
738        .needs
739        .iter()
740        .find(|need| need.job == dep_name);
741    if let Some(need) = need {
742        children.push(tree_leaf(format!(
743            "from need ({})",
744            describe_need_source(need)
745        )));
746        children.push(tree_leaf(format!(
747            "artifacts requested: {}",
748            yes_no(need.needs_artifacts)
749        )));
750        children.push(tree_leaf(format!("optional: {}", yes_no(need.optional))));
751    } else {
752        children.push(tree_leaf("from stage order".to_string()));
753    }
754
755    let mounts = dependency_mounts(plan, dep_name);
756    if !mounts.is_empty() {
757        children.push(Tree::Node(
758            "mounts".to_string(),
759            mounts.into_iter().map(tree_leaf).collect(),
760        ));
761    }
762
763    Tree::Node(dep_name.to_string(), children)
764}
765
766fn dependency_mounts(plan: &ExecutionPlan, dep_name: &str) -> Vec<String> {
767    plan.nodes
768        .get(dep_name)
769        .map(|dep| {
770            dep.instance
771                .job
772                .artifacts
773                .paths
774                .iter()
775                .map(|path| path.display().to_string())
776                .collect::<Vec<_>>()
777        })
778        .unwrap_or_default()
779}
780
781fn need_tree_nodes(plan: &ExecutionPlan, planned: &ExecutableJob) -> Vec<Tree> {
782    planned
783        .instance
784        .job
785        .needs
786        .iter()
787        .map(|need| build_need_tree_node(plan, planned, need))
788        .collect()
789}
790
791fn build_need_tree_node(
792    plan: &ExecutionPlan,
793    planned: &ExecutableJob,
794    need: &JobDependencySpec,
795) -> Tree {
796    let mut children = Vec::new();
797    children.push(tree_leaf(format!("source: {}", describe_need_source(need))));
798    children.push(tree_leaf(format!(
799        "artifacts requested: {}",
800        yes_no(need.needs_artifacts)
801    )));
802    children.push(tree_leaf(format!("optional: {}", yes_no(need.optional))));
803
804    if let Some(filters) = &need.parallel {
805        let filter_nodes = filters
806            .iter()
807            .enumerate()
808            .map(|(idx, filter)| {
809                if filter.is_empty() {
810                    tree_leaf(format!("variant {}", idx + 1))
811                } else {
812                    let desc = filter
813                        .iter()
814                        .map(|(key, value)| format!("{key}={value}"))
815                        .collect::<Vec<_>>()
816                        .join(", ");
817                    tree_leaf(desc)
818                }
819            })
820            .collect();
821        children.push(Tree::Node("matrix filters".to_string(), filter_nodes));
822    }
823
824    let downloads = planned
825        .instance
826        .dependencies
827        .iter()
828        .any(|dep| dep == &need.job);
829    children.push(tree_leaf(format!(
830        "downloaded via dependencies: {}",
831        yes_no(downloads)
832    )));
833    if downloads && need.needs_artifacts {
834        let mounts = dependency_mounts(plan, &need.job);
835        if mounts.is_empty() {
836            children.push(tree_leaf(
837                "mounts available after upstream success".to_string(),
838            ));
839        } else {
840            children.push(Tree::Node(
841                "mounts".to_string(),
842                mounts.into_iter().map(tree_leaf).collect(),
843            ));
844        }
845    }
846
847    Tree::Node(need.job.clone(), children)
848}
849
850fn artifact_tree_nodes(job: &JobSpec) -> Vec<Tree> {
851    job.artifacts
852        .paths
853        .iter()
854        .map(|path| tree_leaf(path.display().to_string()))
855        .collect()
856}
857
858fn cache_tree_nodes(job: &JobSpec) -> Vec<Tree> {
859    job.cache
860        .iter()
861        .map(|cache| {
862            let mut children = vec![tree_leaf(format!(
863                "policy: {}",
864                cache_policy_label(cache.policy)
865            ))];
866            if cache.paths.is_empty() {
867                children.push(tree_leaf("paths: (none)".to_string()));
868            } else {
869                children.push(Tree::Node(
870                    "paths".to_string(),
871                    cache
872                        .paths
873                        .iter()
874                        .map(|path| tree_leaf(path.display().to_string()))
875                        .collect(),
876                ));
877            }
878            Tree::Node(format!("key {}", cache.key.describe()), children)
879        })
880        .collect()
881}
882
883fn describe_need_source(need: &JobDependencySpec) -> String {
884    match &need.source {
885        DependencySourceSpec::Local => "local".to_string(),
886        DependencySourceSpec::External(ext) => format!("external {}", ext.project),
887    }
888}
889
890fn cache_policy_label(policy: CachePolicySpec) -> &'static str {
891    match policy {
892        CachePolicySpec::Pull => "pull",
893        CachePolicySpec::Push => "push",
894        CachePolicySpec::PullPush => "pull-push",
895    }
896}
897
898fn tree_leaf(text: String) -> Tree {
899    Tree::Leaf(vec![text])
900}
901
902fn yes_no(value: bool) -> &'static str {
903    if value { "yes" } else { "no" }
904}