Skip to main content

zig_core/
dry_run.rs

1//! `zig run --dry-run` — preview what a workflow would do without invoking the agent.
2//!
3//! This module walks the tiers produced by `topological_sort` and renders,
4//! for each step, everything a real run would compute up to the moment of
5//! agent invocation: the resolved prompt, system prompt (including the
6//! `<resources>` / `<memory>` / `<storage>` blocks), the condition outcome,
7//! and the full [`AgentConfig`] snapshot (every AgentBuilder knob the
8//! executor would set plus any command-specific params) that *would* be
9//! applied.
10//!
11//! No side effects: no session log is written, no storage directories are
12//! created, no agent subprocess is launched.
13
14use 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/// Output format for a dry-run plan.
31#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
32pub enum DryRunFormat {
33    /// Human-readable, grouped per tier and step. Default.
34    #[default]
35    Text,
36    /// Pretty-printed JSON — suitable for piping into `jq` or for tooling.
37    /// The field names are part of zig's public contract; see
38    /// `docs/dry-run.md` for the full schema.
39    Json,
40}
41
42/// Inputs `print_plan` needs to build the plan. Mirrors the state
43/// `execute()` has assembled just before it would open a session log.
44pub 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
61/// Build the plan and print it to stdout in the requested format.
62pub 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// ── plan data ────────────────────────────────────────────────────────────────
81
82#[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    /// Snapshot of the AgentBuilder configuration that would drive this
127    /// step at run time. Mirrors every builder knob (provider, model,
128    /// system prompt, root, add_dirs, env, files, auto_approve, json,
129    /// output format, max_turns, timeout, session metadata, …) plus any
130    /// command-specific params (review/plan/pipe/collect/summary).
131    pub agent_config: AgentConfig,
132}
133
134#[derive(Debug, Clone, Serialize)]
135pub struct DryRunCondition {
136    pub expr: Option<String>,
137    /// `"true" | "false" | "unknown" | "none"`.
138    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    /// `"no_resources" | "no_memory" | "no_storage"` when the block is
159    /// suppressed by a `--no-*` flag; otherwise `None`.
160    pub omitted_reason: Option<String>,
161    /// Rendered block text when present. `None` when the block is disabled
162    /// or nothing would be rendered for this step.
163    pub content: Option<String>,
164}
165
166// ── condition tri-state ──────────────────────────────────────────────────────
167
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub(crate) enum CondOutcome {
170    True,
171    False,
172    Unknown(Vec<String>),
173    None,
174}
175
176/// Evaluate a condition with explicit resolvability tracking.
177///
178/// Returns [`CondOutcome::Unknown`] if any referenced variable is absent
179/// from `vars` (and is not a numeric/string literal). Otherwise delegates
180/// to [`crate::run::evaluate_condition`].
181pub(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    // Deduplicate while preserving order of first appearance.
190    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        // Evaluation errors in dry-run are reported as "unknown" with the
203        // expression stashed in `missing` for visibility.
204        Err(_) => CondOutcome::Unknown(Vec::new()),
205    }
206}
207
208// ── plan builder ─────────────────────────────────────────────────────────────
209
210fn build_plan(ctx: &DryRunContext<'_>, tiers: &[Vec<&Step>]) -> Result<DryRunPlan, ZigError> {
211    // An empty dependency-output map — in a dry run, prior-step outputs
212    // don't exist, so `inject_context = true` becomes a no-op and
213    // `${steps.X.Y}` references in the prompt stay as literal placeholders
214    // (substitute_vars leaves unknown `${...}` unchanged).
215    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    // Resources
326    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    // Memory
345    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    // Storage
368    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
407// ── text renderer ────────────────────────────────────────────────────────────
408
409fn 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;