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}