Skip to content

Commit efec997

Browse files
dkijaniaclaude
andcommitted
Add session apply --dry-run
Validates the manifest against an opened session without committing any change. Useful for CI gates that want to catch typos and broken bundle paths before the real apply runs. What gets checked: * Parse — schema is well-formed (deny_unknown_fields, missing required fields, wrong types — same as a real run). * Local source files exist — every `sources` entry in `insert` and every `replacement` in `replace` is resolved against the manifest directory and verified. * Step shape — `insert` with multiple sources but `directory: false` is rejected (since the real run would too). What's intentionally left to the real run: * Glob match counts — `remove` / `replace` patterns are matched against the live data tree, so we can't tell at validate-time whether they'll match zero files. * `read-field` assertions — they depend on the cumulative effect of earlier (unapplied) steps. The session directory is left bit-for-bit unchanged. Tests: * 5 new unit tests in `check_step` covering missing-source, existing-source, multi-source-without-directory-flag, missing-replacement, and metadata-op no-ops. * 2 new integration tests: dry-run on a valid plan leaves every control field and data file untouched; dry-run on a plan with a missing source file surfaces the error. docs/session-manifest.md gains a "Dry runs" section spelling out exactly what is and isn't checked. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9aa199c commit efec997

6 files changed

Lines changed: 303 additions & 12 deletions

File tree

docs/session-manifest.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,42 @@ deb-toolkit session apply /tmp/session /elsewhere/variant-bundle/plan.json
255255
(cd /tmp && deb-toolkit session apply /tmp/session ~/variant-bundle/plan.json)
256256
```
257257

258+
## Dry runs
259+
260+
Pass `--dry-run` to `session apply` to validate the manifest against
261+
an opened session **without committing any change**:
262+
263+
```bash
264+
deb-toolkit session apply /tmp/session ./plan.json --dry-run
265+
```
266+
267+
What `--dry-run` checks:
268+
269+
- **Parse** — schema is well-formed (unknown ops, missing required
270+
fields, wrong types all fail before the first step runs).
271+
- **Local files exist** — every `sources` entry in `insert` and every
272+
`replacement` in `replace` is resolved against the manifest
273+
directory and verified to point at an existing file.
274+
- **Step shape**`insert` with `directory: false` and multiple
275+
sources is rejected (since the corresponding real run would fail).
276+
277+
What `--dry-run` does **not** check:
278+
279+
- **Glob match counts.** `remove` and `replace` patterns are evaluated
280+
against the live data tree, so we can't tell at dry-run time
281+
whether a pattern will match zero files (which would fail) without
282+
scanning the session.
283+
- **`read-field` assertions.** Their value depends on the control
284+
file's current state, which earlier (unapplied) steps would have
285+
changed.
286+
- **Permission / disk-space issues** at write time.
287+
288+
So `--dry-run` is a CI gate against the manifest itself — it catches
289+
the typos and broken bundle paths early, but doesn't promise a clean
290+
real run.
291+
292+
The session directory is left bit-for-bit unchanged.
293+
258294
## Failure semantics
259295

260296
Steps run sequentially. On the first failure, `apply` returns the error

src/cli.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,12 @@ pub struct SessionApplyArgs {
155155
pub session_dir: String,
156156
/// Path to the JSON manifest describing the steps to apply
157157
pub manifest: String,
158+
/// Validate the manifest and log what would happen, but don't
159+
/// mutate the session. Local source files referenced by `insert`
160+
/// and `replace` are checked for existence; pattern matches and
161+
/// control-field assertions are deferred to a real run.
162+
#[arg(long = "dry-run", default_value_t = false)]
163+
pub dry_run: bool,
158164
}
159165

160166
#[derive(Subcommand, Debug)]

src/main.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ fn dispatch_session(cmd: SessionCommand) -> Result<()> {
145145
.ok()
146146
.and_then(|p| p.parent().map(|p| p.to_path_buf()))
147147
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
148-
session::apply(&session, &plan, &manifest_dir)
148+
session::apply(&session, &plan, &manifest_dir, args.dry_run)
149149
}
150150
}
151151
}

src/session/apply.rs

Lines changed: 175 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -234,31 +234,117 @@ impl Plan {
234234
/// bundle portable. For programmatically-constructed plans, pass the
235235
/// directory the source files live in (often the process cwd).
236236
///
237+
/// When `dry_run` is true, no session mutations are performed: each
238+
/// step's intent is logged, local source files referenced by `insert`
239+
/// and `replace` are verified to exist, and parse-level errors still
240+
/// surface — but the control file and data tree under `<session>/` are
241+
/// left untouched. Useful for CI gates that want to validate a manifest
242+
/// against an opened session without committing to the change.
243+
///
237244
/// Errors abort the apply: see [module-level docs](self) for the
238245
/// failure-semantics rationale.
239-
pub fn apply(session: &Session, plan: &Plan, manifest_dir: &Path) -> Result<()> {
246+
pub fn apply(session: &Session, plan: &Plan, manifest_dir: &Path, dry_run: bool) -> Result<()> {
240247
if let Some(desc) = &plan.description {
241248
log::info!("Plan: {}", desc);
242249
}
243-
log::info!("=== Applying {} step(s) ===", plan.steps.len());
250+
if dry_run {
251+
log::info!(
252+
"=== DRY RUN: would apply {} step(s), no changes will be made ===",
253+
plan.steps.len()
254+
);
255+
} else {
256+
log::info!("=== Applying {} step(s) ===", plan.steps.len());
257+
}
244258

245259
for (idx, step) in plan.steps.iter().enumerate() {
246260
let step_num = idx + 1;
247261
log::info!("[{}/{}] {}", step_num, plan.steps.len(), step.summary());
248-
apply_step(session, step, manifest_dir).with_context(|| {
249-
format!(
250-
"Step {} of {} failed ({})",
251-
step_num,
252-
plan.steps.len(),
253-
step.summary()
254-
)
255-
})?;
262+
if dry_run {
263+
check_step(step, manifest_dir).with_context(|| {
264+
format!(
265+
"Step {} of {} would fail ({})",
266+
step_num,
267+
plan.steps.len(),
268+
step.summary()
269+
)
270+
})?;
271+
} else {
272+
apply_step(session, step, manifest_dir).with_context(|| {
273+
format!(
274+
"Step {} of {} failed ({})",
275+
step_num,
276+
plan.steps.len(),
277+
step.summary()
278+
)
279+
})?;
280+
}
256281
}
257282

258-
log::info!("✓ All {} step(s) applied", plan.steps.len());
283+
if dry_run {
284+
log::info!(
285+
"✓ Dry run complete: {} step(s) validated, no changes applied",
286+
plan.steps.len()
287+
);
288+
} else {
289+
log::info!("✓ All {} step(s) applied", plan.steps.len());
290+
}
259291
Ok(())
260292
}
261293

294+
/// Dry-run validation for a single step. Only checks what can be
295+
/// verified without mutating the session: that local source files
296+
/// referenced by `insert` / `replace` exist on disk, and that step
297+
/// fields are individually well-formed. Does **not** verify that
298+
/// `remove` / `move` patterns will match anything, since glob
299+
/// matching requires looking at the live data tree.
300+
fn check_step(step: &Step, manifest_dir: &Path) -> Result<()> {
301+
match step {
302+
Step::Insert {
303+
sources, directory, ..
304+
} => {
305+
if sources.is_empty() {
306+
return Err(anyhow!("insert: `sources` is empty"));
307+
}
308+
if !*directory && sources.len() > 1 {
309+
return Err(anyhow!(
310+
"insert: {} sources but `directory` is false — must be true to use multiple sources",
311+
sources.len()
312+
));
313+
}
314+
for s in sources {
315+
let p = resolve_local(manifest_dir, s);
316+
if !p.exists() {
317+
return Err(anyhow!(
318+
"insert: source file does not exist: {}",
319+
p.display()
320+
));
321+
}
322+
}
323+
Ok(())
324+
}
325+
Step::Replace { replacement, .. } => {
326+
let p = resolve_local(manifest_dir, replacement);
327+
if !p.exists() {
328+
return Err(anyhow!(
329+
"replace: replacement file does not exist: {}",
330+
p.display()
331+
));
332+
}
333+
Ok(())
334+
}
335+
// The remaining ops are pure metadata mutations or glob-driven
336+
// operations whose outcome depends on session state. They have
337+
// nothing to validate at dry-run time beyond what the schema
338+
// already enforced at parse time.
339+
Step::RenamePackage { .. }
340+
| Step::ReplaceSuite { .. }
341+
| Step::Reversion { .. }
342+
| Step::Remove { .. }
343+
| Step::Move { .. }
344+
| Step::ReadField { .. } => Ok(()),
345+
}
346+
}
347+
262348
impl Step {
263349
/// A short one-line label for the step, used in log lines and error
264350
/// context so the user can pinpoint which step failed without
@@ -454,4 +540,82 @@ mod tests {
454540
let r = resolve_local(Path::new("/manifests"), "data/foo.tar.gz");
455541
assert_eq!(r, PathBuf::from("/manifests/data/foo.tar.gz"));
456542
}
543+
544+
// --- check_step (dry-run validation) ---------------------------------
545+
546+
#[test]
547+
fn check_step_insert_rejects_missing_source() {
548+
let tmp = tempfile::tempdir().unwrap();
549+
let step = Step::Insert {
550+
dest: "/x".into(),
551+
sources: vec!["./missing.bin".into()],
552+
directory: false,
553+
};
554+
let err = check_step(&step, tmp.path()).unwrap_err();
555+
assert!(err.to_string().contains("does not exist"), "{}", err);
556+
}
557+
558+
#[test]
559+
fn check_step_insert_accepts_existing_source() {
560+
let tmp = tempfile::tempdir().unwrap();
561+
std::fs::write(tmp.path().join("there.bin"), b"x").unwrap();
562+
let step = Step::Insert {
563+
dest: "/x".into(),
564+
sources: vec!["./there.bin".into()],
565+
directory: false,
566+
};
567+
check_step(&step, tmp.path()).unwrap();
568+
}
569+
570+
#[test]
571+
fn check_step_insert_rejects_multi_source_without_directory_flag() {
572+
let tmp = tempfile::tempdir().unwrap();
573+
std::fs::write(tmp.path().join("a"), b"a").unwrap();
574+
std::fs::write(tmp.path().join("b"), b"b").unwrap();
575+
let step = Step::Insert {
576+
dest: "/x".into(),
577+
sources: vec!["./a".into(), "./b".into()],
578+
directory: false,
579+
};
580+
let err = check_step(&step, tmp.path()).unwrap_err();
581+
assert!(err.to_string().contains("directory"), "{}", err);
582+
}
583+
584+
#[test]
585+
fn check_step_replace_rejects_missing_replacement() {
586+
let tmp = tempfile::tempdir().unwrap();
587+
let step = Step::Replace {
588+
pattern: "/x".into(),
589+
replacement: "./missing".into(),
590+
};
591+
let err = check_step(&step, tmp.path()).unwrap_err();
592+
assert!(err.to_string().contains("does not exist"), "{}", err);
593+
}
594+
595+
#[test]
596+
fn check_step_metadata_ops_have_nothing_to_check() {
597+
// None of these touch the filesystem at validate-time.
598+
check_step(
599+
&Step::RenamePackage {
600+
new_name: "x".into(),
601+
},
602+
Path::new("/"),
603+
)
604+
.unwrap();
605+
check_step(
606+
&Step::Reversion {
607+
new_version: "1.0".into(),
608+
update_deps: false,
609+
},
610+
Path::new("/"),
611+
)
612+
.unwrap();
613+
check_step(
614+
&Step::Remove {
615+
pattern: "/x/*".into(),
616+
},
617+
Path::new("/"),
618+
)
619+
.unwrap();
620+
}
457621
}

tests/common/mod.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,15 @@ impl Toolkit {
192192
manifest.as_os_str(),
193193
])
194194
}
195+
196+
pub fn session_apply_dry_run(&self, session_dir: &Path, manifest: &Path) -> CmdOutput {
197+
self.session([
198+
OsStr::new("apply"),
199+
OsStr::new("--dry-run"),
200+
session_dir.as_os_str(),
201+
manifest.as_os_str(),
202+
])
203+
}
195204
}
196205

197206
impl Default for Toolkit {

tests/session_apply.rs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,3 +185,79 @@ fn apply_rejects_unknown_op() {
185185

186186
tk.session_apply(&session, &plan).assert_failure();
187187
}
188+
189+
#[test]
190+
fn apply_dry_run_leaves_session_untouched() {
191+
skip_unless!("dpkg-deb");
192+
193+
let tk = Toolkit::new();
194+
let tmp = tempfile::tempdir().unwrap();
195+
196+
let input = DebFixture::new("example-app")
197+
.version("1.0.0")
198+
.file("/var/lib/example/data.bin", b"ORIGINAL\n".to_vec())
199+
.build(&tmp.path().join("input.deb"));
200+
201+
// Bundle with a valid plan that, if actually applied, would change
202+
// every field we check afterwards.
203+
let bundle = tmp.path().join("bundle");
204+
std::fs::create_dir_all(&bundle).unwrap();
205+
std::fs::write(bundle.join("new_data.bin"), b"NEW\n").unwrap();
206+
std::fs::write(
207+
bundle.join("plan.json"),
208+
r#"{ "steps": [
209+
{ "op": "rename-package", "new_name": "would-be-renamed" },
210+
{ "op": "reversion", "new_version": "9.9.9" },
211+
{ "op": "insert", "dest": "/var/lib/example/data.bin",
212+
"sources": ["./new_data.bin"] }
213+
] }"#,
214+
)
215+
.unwrap();
216+
217+
let session = tmp.path().join("session");
218+
tk.session_open(&input, &session).assert_success();
219+
220+
tk.session_apply_dry_run(&session, &bundle.join("plan.json"))
221+
.assert_success();
222+
223+
// Control fields untouched.
224+
let pkg = tk.session_read_field(&session, "Package").assert_success();
225+
assert_eq!(pkg.stdout_trim(), "example-app");
226+
let ver = tk.session_read_field(&session, "Version").assert_success();
227+
assert_eq!(ver.stdout_trim(), "1.0.0");
228+
229+
// Data file untouched.
230+
let data = std::fs::read(session.join("data/var/lib/example/data.bin")).unwrap();
231+
assert_eq!(data, b"ORIGINAL\n");
232+
}
233+
234+
#[test]
235+
fn apply_dry_run_catches_missing_source_file() {
236+
skip_unless!("dpkg-deb");
237+
238+
let tk = Toolkit::new();
239+
let tmp = tempfile::tempdir().unwrap();
240+
241+
let input = DebFixture::new("example-app")
242+
.file("/usr/share/example/x", b"x\n".to_vec())
243+
.build(&tmp.path().join("input.deb"));
244+
245+
// The plan references ./does-not-exist.bin — dry-run should
246+
// surface this before the user runs the real apply.
247+
let plan = tmp.path().join("plan.json");
248+
std::fs::write(
249+
&plan,
250+
r#"{ "steps": [
251+
{ "op": "insert", "dest": "/var/lib/example/data.bin",
252+
"sources": ["./does-not-exist.bin"] }
253+
] }"#,
254+
)
255+
.unwrap();
256+
257+
let session = tmp.path().join("session");
258+
tk.session_open(&input, &session).assert_success();
259+
260+
tk.session_apply_dry_run(&session, &plan)
261+
.assert_failure()
262+
.stderr_contains("does not exist");
263+
}

0 commit comments

Comments
 (0)