1use std::collections::{HashMap, HashSet};
15use std::path::Path;
16
17use serde::Serialize;
18
19use crate::error::ZigError;
20use crate::memory::MemoryCollector;
21use crate::resources::ResourceCollector;
22use crate::run::{
23 AgentConfig, build_agent_config, evaluate_condition, render_step_prompt,
24 resolve_role_system_prompt,
25};
26use crate::storage::StorageManager;
27use crate::workflow::model::{FailurePolicy, Role, Step, StepCommand, Workflow};
28use crate::workflow::validate::extract_condition_vars;
29
30#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
32pub enum DryRunFormat {
33 #[default]
35 Text,
36 Json,
40}
41
42pub struct DryRunContext<'a> {
45 pub workflow: &'a Workflow,
46 pub workflow_path: &'a Path,
47 pub workflow_dir: &'a Path,
48 pub vars: &'a HashMap<String, String>,
49 pub user_prompt: Option<&'a str>,
50 pub roles: &'a HashMap<String, Role>,
51 pub resources: &'a ResourceCollector<'a>,
52 pub memory: &'a MemoryCollector,
53 pub storage: &'a StorageManager,
54 pub wf_provider: Option<&'a str>,
55 pub wf_model: Option<&'a str>,
56 pub disable_resources: bool,
57 pub disable_memory: bool,
58 pub disable_storage: bool,
59}
60
61pub fn print_plan(
63 ctx: &DryRunContext<'_>,
64 tiers: &[Vec<&Step>],
65 format: DryRunFormat,
66) -> Result<(), ZigError> {
67 let plan = build_plan(ctx, tiers)?;
68 match format {
69 DryRunFormat::Text => print_text(&plan),
70 DryRunFormat::Json => {
71 let json = serde_json::to_string_pretty(&plan).map_err(|e| {
72 ZigError::Execution(format!("failed to serialize dry-run plan as JSON: {e}"))
73 })?;
74 println!("{json}");
75 }
76 }
77 Ok(())
78}
79
80#[derive(Debug, Clone, Serialize)]
83pub struct DryRunPlan {
84 pub workflow: DryRunWorkflow,
85 pub disabled: DryRunDisabled,
86 pub vars: HashMap<String, String>,
87 pub tiers: Vec<DryRunTier>,
88}
89
90#[derive(Debug, Clone, Serialize)]
91pub struct DryRunWorkflow {
92 pub name: String,
93 pub path: String,
94 pub provider: Option<String>,
95 pub model: Option<String>,
96 pub step_count: usize,
97 pub tier_count: usize,
98}
99
100#[derive(Debug, Clone, Serialize)]
101pub struct DryRunDisabled {
102 pub resources: bool,
103 pub memory: bool,
104 pub storage: bool,
105}
106
107#[derive(Debug, Clone, Serialize)]
108pub struct DryRunTier {
109 pub index: usize,
110 pub steps: Vec<DryRunStep>,
111}
112
113#[derive(Debug, Clone, Serialize)]
114pub struct DryRunStep {
115 pub name: String,
116 pub command: String,
117 pub provider: Option<String>,
118 pub model: Option<String>,
119 pub failure: String,
120 pub depends_on: Vec<String>,
121 pub condition: DryRunCondition,
122 pub saves: Vec<DryRunSave>,
123 pub prompt: String,
124 pub system_prompt: Option<String>,
125 pub blocks: DryRunBlocks,
126 pub agent_config: AgentConfig,
132}
133
134#[derive(Debug, Clone, Serialize)]
135pub struct DryRunCondition {
136 pub expr: Option<String>,
137 pub outcome: String,
139 #[serde(skip_serializing_if = "Vec::is_empty")]
140 pub missing: Vec<String>,
141}
142
143#[derive(Debug, Clone, Serialize)]
144pub struct DryRunSave {
145 pub name: String,
146 pub selector: String,
147}
148
149#[derive(Debug, Clone, Serialize)]
150pub struct DryRunBlocks {
151 pub resources: DryRunBlock,
152 pub memory: DryRunBlock,
153 pub storage: DryRunBlock,
154}
155
156#[derive(Debug, Clone, Serialize)]
157pub struct DryRunBlock {
158 pub omitted_reason: Option<String>,
161 pub content: Option<String>,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq)]
169pub(crate) enum CondOutcome {
170 True,
171 False,
172 Unknown(Vec<String>),
173 None,
174}
175
176pub(crate) fn evaluate_with_resolvability(
182 expr: Option<&str>,
183 vars: &HashMap<String, String>,
184) -> CondOutcome {
185 let Some(cond) = expr else {
186 return CondOutcome::None;
187 };
188 let refs = extract_condition_vars(cond);
189 let mut seen: HashSet<String> = HashSet::new();
191 let missing: Vec<String> = refs
192 .into_iter()
193 .filter(|name| !vars.contains_key(name))
194 .filter(|name| seen.insert(name.clone()))
195 .collect();
196 if !missing.is_empty() {
197 return CondOutcome::Unknown(missing);
198 }
199 match evaluate_condition(cond, vars) {
200 Ok(true) => CondOutcome::True,
201 Ok(false) => CondOutcome::False,
202 Err(_) => CondOutcome::Unknown(Vec::new()),
205 }
206}
207
208fn build_plan(ctx: &DryRunContext<'_>, tiers: &[Vec<&Step>]) -> Result<DryRunPlan, ZigError> {
211 let empty_outputs: HashMap<String, String> = HashMap::new();
216
217 let mut plan_tiers = Vec::with_capacity(tiers.len());
218 for (tier_index, tier) in tiers.iter().enumerate() {
219 let mut steps = Vec::with_capacity(tier.len());
220 for step in tier {
221 steps.push(build_step(ctx, step, &empty_outputs)?);
222 }
223 plan_tiers.push(DryRunTier {
224 index: tier_index,
225 steps,
226 });
227 }
228
229 Ok(DryRunPlan {
230 workflow: DryRunWorkflow {
231 name: ctx.workflow.workflow.name.clone(),
232 path: ctx.workflow_path.display().to_string(),
233 provider: ctx.wf_provider.map(String::from),
234 model: ctx.wf_model.map(String::from),
235 step_count: ctx.workflow.steps.len(),
236 tier_count: tiers.len(),
237 },
238 disabled: DryRunDisabled {
239 resources: ctx.disable_resources,
240 memory: ctx.disable_memory,
241 storage: ctx.disable_storage,
242 },
243 vars: ctx.vars.clone(),
244 tiers: plan_tiers,
245 })
246}
247
248fn build_step(
249 ctx: &DryRunContext<'_>,
250 step: &Step,
251 empty_outputs: &HashMap<String, String>,
252) -> Result<DryRunStep, ZigError> {
253 let prompt = render_step_prompt(step, ctx.vars, ctx.user_prompt, empty_outputs);
254
255 let rendered_sp = resolve_role_system_prompt(
256 step,
257 ctx.roles,
258 ctx.resources,
259 ctx.memory,
260 ctx.storage,
261 ctx.vars,
262 ctx.workflow_dir,
263 &ctx.workflow.workflow.name,
264 )?;
265
266 let storage_dirs = ctx.storage.add_dirs_for_step(step.storage.as_deref());
267
268 let agent_config = build_agent_config(
269 step,
270 &prompt,
271 &ctx.workflow.workflow.name,
272 None,
273 rendered_sp.as_deref(),
274 ctx.wf_provider,
275 ctx.wf_model,
276 &storage_dirs,
277 );
278
279 let condition = condition_to_plan(step.condition.as_deref(), ctx.vars);
280
281 let mut saves: Vec<DryRunSave> = step
282 .saves
283 .iter()
284 .map(|(name, selector)| DryRunSave {
285 name: name.clone(),
286 selector: selector.clone(),
287 })
288 .collect();
289 saves.sort_by(|a, b| a.name.cmp(&b.name));
290
291 let blocks = build_blocks(ctx, step)?;
292
293 Ok(DryRunStep {
294 name: step.name.clone(),
295 command: zag_command_label(&step.command).to_string(),
296 provider: step.provider.clone(),
297 model: step.model.clone(),
298 failure: failure_label(step.on_failure.as_ref()).to_string(),
299 depends_on: step.depends_on.clone(),
300 condition,
301 saves,
302 prompt,
303 system_prompt: rendered_sp,
304 blocks,
305 agent_config,
306 })
307}
308
309fn condition_to_plan(expr: Option<&str>, vars: &HashMap<String, String>) -> DryRunCondition {
310 let outcome = evaluate_with_resolvability(expr, vars);
311 let (label, missing) = match outcome {
312 CondOutcome::None => ("none", Vec::new()),
313 CondOutcome::True => ("true", Vec::new()),
314 CondOutcome::False => ("false", Vec::new()),
315 CondOutcome::Unknown(m) => ("unknown", m),
316 };
317 DryRunCondition {
318 expr: expr.map(String::from),
319 outcome: label.to_string(),
320 missing,
321 }
322}
323
324fn build_blocks(ctx: &DryRunContext<'_>, step: &Step) -> Result<DryRunBlocks, ZigError> {
325 let resources = if ctx.disable_resources {
327 DryRunBlock {
328 omitted_reason: Some("no_resources".into()),
329 content: None,
330 }
331 } else {
332 let set = ctx.resources.collect_for_step(&step.resources)?;
333 let rendered = crate::resources::render_system_block(&set);
334 DryRunBlock {
335 omitted_reason: None,
336 content: if rendered.is_empty() {
337 None
338 } else {
339 Some(rendered.trim_end().to_string())
340 },
341 }
342 };
343
344 let memory = if ctx.disable_memory {
346 DryRunBlock {
347 omitted_reason: Some("no_memory".into()),
348 content: None,
349 }
350 } else {
351 let entries = ctx.memory.collect_for_step(step.memory.as_deref())?;
352 let rendered = crate::memory::render_memory_block(
353 &entries,
354 &ctx.workflow.workflow.name,
355 Some(&step.name),
356 );
357 DryRunBlock {
358 omitted_reason: None,
359 content: if rendered.is_empty() {
360 None
361 } else {
362 Some(rendered.trim_end().to_string())
363 },
364 }
365 };
366
367 let storage = if ctx.disable_storage {
369 DryRunBlock {
370 omitted_reason: Some("no_storage".into()),
371 content: None,
372 }
373 } else {
374 let rendered = ctx.storage.render_block(step.storage.as_deref())?;
375 DryRunBlock {
376 omitted_reason: None,
377 content: rendered,
378 }
379 };
380
381 Ok(DryRunBlocks {
382 resources,
383 memory,
384 storage,
385 })
386}
387
388fn zag_command_label(cmd: &Option<StepCommand>) -> &'static str {
389 match cmd {
390 None => "run",
391 Some(StepCommand::Review) => "review",
392 Some(StepCommand::Plan) => "plan",
393 Some(StepCommand::Pipe) => "pipe",
394 Some(StepCommand::Collect) => "collect",
395 Some(StepCommand::Summary) => "summary",
396 }
397}
398
399fn failure_label(policy: Option<&FailurePolicy>) -> &'static str {
400 match policy.unwrap_or(&FailurePolicy::Fail) {
401 FailurePolicy::Fail => "fail",
402 FailurePolicy::Continue => "continue",
403 FailurePolicy::Retry => "retry",
404 }
405}
406
407fn print_text(plan: &DryRunPlan) {
410 let wf = &plan.workflow;
411 println!(
412 "workflow: {name} ({steps} step{step_plural} in {tiers} tier{tier_plural})",
413 name = wf.name,
414 steps = wf.step_count,
415 step_plural = if wf.step_count == 1 { "" } else { "s" },
416 tiers = wf.tier_count,
417 tier_plural = if wf.tier_count == 1 { "" } else { "s" },
418 );
419 println!("path: {}", wf.path);
420 if let Some(ref provider) = wf.provider {
421 println!("provider: {provider}");
422 }
423 if let Some(ref model) = wf.model {
424 println!("model: {model}");
425 }
426 if plan.disabled.resources || plan.disabled.memory || plan.disabled.storage {
427 let mut disabled = Vec::new();
428 if plan.disabled.resources {
429 disabled.push("resources");
430 }
431 if plan.disabled.memory {
432 disabled.push("memory");
433 }
434 if plan.disabled.storage {
435 disabled.push("storage");
436 }
437 println!("disabled: {}", disabled.join(", "));
438 }
439 if !plan.vars.is_empty() {
440 let mut names: Vec<&String> = plan.vars.keys().collect();
441 names.sort();
442 println!("vars:");
443 for name in names {
444 let value = &plan.vars[name];
445 let preview = preview(value, 80);
446 println!(" {name} = {preview}");
447 }
448 }
449 println!();
450
451 for tier in &plan.tiers {
452 println!("=== Tier {} ===", tier.index);
453 for (i, step) in tier.steps.iter().enumerate() {
454 print_step_text(i + 1, step);
455 }
456 }
457}
458
459fn print_step_text(position: usize, step: &DryRunStep) {
460 println!(
461 "[{pos}] step: {name} command: {cmd}{provider}{model}",
462 pos = position,
463 name = step.name,
464 cmd = step.command,
465 provider = step
466 .provider
467 .as_ref()
468 .map(|p| format!(" provider: {p}"))
469 .unwrap_or_default(),
470 model = step
471 .model
472 .as_ref()
473 .map(|m| format!(" model: {m}"))
474 .unwrap_or_default(),
475 );
476 println!(" failure: {}", step.failure);
477 if !step.depends_on.is_empty() {
478 println!(" depends_on: {}", step.depends_on.join(", "));
479 }
480
481 match step.condition.outcome.as_str() {
482 "none" => {
483 println!(" condition: <none>");
484 }
485 "unknown" => {
486 let expr = step.condition.expr.as_deref().unwrap_or("");
487 let missing = if step.condition.missing.is_empty() {
488 String::new()
489 } else {
490 format!(" (missing: {})", step.condition.missing.join(", "))
491 };
492 println!(" condition: \"{expr}\" => unknown{missing}");
493 }
494 outcome => {
495 let expr = step.condition.expr.as_deref().unwrap_or("");
496 println!(" condition: \"{expr}\" => {outcome}");
497 }
498 }
499
500 if !step.saves.is_empty() {
501 let joined = step
502 .saves
503 .iter()
504 .map(|s| format!("{}={}", s.name, s.selector))
505 .collect::<Vec<_>>()
506 .join(", ");
507 println!(" saves: {joined}");
508 }
509
510 println!(" prompt:");
511 print_indented(&step.prompt, " ");
512
513 if let Some(ref sp) = step.system_prompt {
514 println!(" system_prompt:");
515 print_indented(sp, " ");
516 }
517
518 print_block_text("resources", &step.blocks.resources);
519 print_block_text("memory", &step.blocks.memory);
520 print_block_text("storage", &step.blocks.storage);
521
522 println!(" agent config:");
523 print_agent_config_text(&step.agent_config, " ");
524 println!();
525}
526
527fn print_agent_config_text(cfg: &AgentConfig, prefix: &str) {
528 println!("{prefix}command: {}", cfg.command);
529 if let Some(ref p) = cfg.provider {
530 println!("{prefix}provider: {p}");
531 }
532 if let Some(ref m) = cfg.model {
533 println!("{prefix}model: {m}");
534 }
535 if let Some(ref r) = cfg.root {
536 println!("{prefix}root: {r}");
537 }
538 if !cfg.add_dirs.is_empty() {
539 println!("{prefix}add_dirs: {:?}", cfg.add_dirs);
540 }
541 if !cfg.env.is_empty() {
542 let pairs: Vec<String> = cfg.env.iter().map(|(k, v)| format!("{k}={v}")).collect();
543 println!("{prefix}env: [{}]", pairs.join(", "));
544 }
545 if !cfg.files.is_empty() {
546 println!("{prefix}files: {:?}", cfg.files);
547 }
548 if cfg.auto_approve {
549 println!("{prefix}auto_approve: true");
550 }
551 if let Some(ref wt) = cfg.worktree {
552 match wt {
553 None => println!("{prefix}worktree: generated"),
554 Some(name) => println!("{prefix}worktree: {name}"),
555 }
556 }
557 if let Some(ref sb) = cfg.sandbox {
558 println!("{prefix}sandbox: {sb}");
559 }
560 if cfg.json_mode {
561 println!("{prefix}json_mode: true");
562 }
563 if let Some(ref schema) = cfg.json_schema {
564 println!("{prefix}json_schema: {}", preview(schema, 80));
565 }
566 if let Some(ref fmt) = cfg.output_format {
567 println!("{prefix}output_format: {fmt}");
568 }
569 if let Some(turns) = cfg.max_turns {
570 println!("{prefix}max_turns: {turns}");
571 }
572 if let Some(ref t) = cfg.timeout {
573 println!("{prefix}timeout: {t}");
574 }
575 if let Some(ref mcp) = cfg.mcp_config {
576 println!("{prefix}mcp_config: {mcp}");
577 }
578 println!("{prefix}session_name: {}", cfg.session_name);
579 if let Some(ref d) = cfg.description {
580 println!("{prefix}description: {d}");
581 }
582 if !cfg.tags.is_empty() {
583 println!("{prefix}tags: {:?}", cfg.tags);
584 }
585 if cfg.interactive {
586 println!("{prefix}interactive: true");
587 }
588 if let Some(ref params) = cfg.command_params {
589 let j = serde_json::to_string(params).unwrap_or_default();
590 println!("{prefix}command_params: {j}");
591 }
592}
593
594fn print_block_text(label: &str, block: &DryRunBlock) {
595 if let Some(ref reason) = block.omitted_reason {
596 println!(" {label}: (omitted — --{})", reason.replace('_', "-"));
597 return;
598 }
599 match &block.content {
600 None => println!(" {label}: (none)"),
601 Some(content) => {
602 println!(" {label}:");
603 print_indented(content, " ");
604 }
605 }
606}
607
608fn print_indented(content: &str, prefix: &str) {
609 if content.is_empty() {
610 println!("{prefix}");
611 return;
612 }
613 for line in content.lines() {
614 println!("{prefix}{line}");
615 }
616}
617
618fn preview(value: &str, max: usize) -> String {
619 let collapsed: String = value
620 .chars()
621 .map(|c| if c == '\n' { ' ' } else { c })
622 .collect();
623 if collapsed.chars().count() <= max {
624 collapsed
625 } else {
626 let truncated: String = collapsed.chars().take(max).collect();
627 format!("{truncated}…")
628 }
629}
630
631#[cfg(test)]
632#[path = "dry_run_tests.rs"]
633mod tests;