dev-prune 1.7.0

Universal, lockfile-safe workspace pruner and background dependency cleaner
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
// Copyright 2026 VKrishna04
// SPDX-License-Identifier: Apache-2.0

use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;

/// The binary under test, with unattended integration installs switched off.
///
/// Without this, running the test suite installs a scheduled task and repoints the
/// developer's global `core.hooksPath` — the suite must never touch the machine it
/// runs on.
fn devp() -> Command {
    let mut cmd = Command::new(env!("CARGO_BIN_EXE_dev-prune"));
    cmd.env("DEV_PRUNE_NO_AUTO_SETUP", "1");
    // A fresh config dir makes the release check "due"; tests stay off the network.
    cmd.env("DEV_PRUNE_OFFLINE", "1");
    // The floor, not the choice: a case that cares about registry state sets its own
    // `DEV_PRUNE_CONFIG_DIR` after this and wins. What this stops is the case that does
    // not care and therefore writes to the developer's real registry — which is how six
    // temporary fixtures ended up permanently registered on the author's machine, listed
    // as `Path missing` forever, with nothing anywhere reporting a fault.
    cmd.env("DEV_PRUNE_CONFIG_DIR", scratch_config_dir());
    cmd
}

/// A throwaway config directory under `target/`, shared by every case in this file that
/// does not name one of its own.
///
/// `CARGO_TARGET_TMPDIR` rather than `std::env::temp_dir()`: it is inside the build
/// directory, so `cargo clean` removes it and a test run leaves nothing behind anywhere
/// else on the machine.
fn scratch_config_dir() -> PathBuf {
    let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("integration-scratch-config");
    std::fs::create_dir_all(&dir).expect("create scratch config dir");
    dir
}

#[test]
fn test_cli_help() {
    let output = devp()
        .arg("--help")
        .output()
        .expect("Failed to execute dev-prune --help");

    assert!(output.status.success());
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("Universal, lockfile-safe workspace pruner"));
}

#[test]
fn test_cli_version_audit() {
    let output = devp()
        .arg("-V")
        .output()
        .expect("Failed to execute dev-prune -V");

    assert!(output.status.success());
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("dev-prune (devp) v"));
    assert!(stdout.contains("Target OS:"));
    assert!(stdout.contains("PATH Audit:"));
}

#[test]
fn test_cli_init_and_status() {
    // Not the OS temp directory, for once. A scan skips any repository under a directory
    // named `tmp` — correctly, since that is where scratch clones live — and on Linux the
    // OS temp directory *is* `/tmp`, so a fixture there was skipped and `init` had nothing
    // to register. macOS (`/var/folders/...`) and Windows (`...\AppData\Local\Temp`) do
    // not match the rule, which is why this only ever failed on one of the three.
    let base = Path::new(env!("CARGO_MANIFEST_DIR")).join("target/init-fixtures");
    fs::create_dir_all(&base).unwrap();
    let tmp = TempDir::new_in(&base).unwrap();
    let repo_dir = tmp.path().join("my-test-repo");
    fs::create_dir_all(&repo_dir).unwrap();

    // Initialize mock git repo
    Command::new("git")
        .args(["init"])
        .current_dir(&repo_dir)
        .output()
        .unwrap();

    // Run init command with --dry-run targeting the temp repo dir
    let output = devp()
        .env("DEV_PRUNE_CONFIG_DIR", tmp.path().join("config"))
        .args(["--dry-run", "init", repo_dir.to_str().unwrap()])
        .output()
        .expect("Failed to run init");

    if !output.status.success() {
        panic!(
            "CLI failed with exit code {:?}\nstdout: {}\nstderr: {}",
            output.status.code(),
            String::from_utf8_lossy(&output.stdout),
            String::from_utf8_lossy(&output.stderr)
        );
    }
    let stdout = String::from_utf8_lossy(&output.stdout);
    // `--dry-run` reports what it *would* do. The old wording said "Registered:" and
    // then wrote nothing, which is the one thing a dry run must not claim.
    assert!(stdout.contains("Would register:"), "{stdout}");
    assert!(stdout.contains("Nothing was written"), "{stdout}");
    assert!(
        !tmp.path().join("config").join("registry.json").exists(),
        "a dry run must not create the registry"
    );

    // Without the flag it registers for real, and says so.
    let real = devp()
        .env("DEV_PRUNE_CONFIG_DIR", tmp.path().join("config"))
        .env("DEV_PRUNE_NO_AUTO_SETUP", "1")
        .args(["init", repo_dir.to_str().unwrap()])
        .output()
        .expect("Failed to run init");
    assert!(real.status.success());
    let stdout = String::from_utf8_lossy(&real.stdout);
    assert!(stdout.contains("Registered:"), "{stdout}");
    assert!(tmp.path().join("config").join("registry.json").exists());
}

#[test]
fn test_cli_link_unlink_undo() {
    let tmp = TempDir::new().unwrap();
    let repo_dir = tmp.path().join("link-repo");
    fs::create_dir_all(&repo_dir).unwrap();

    // Initialize git repo
    Command::new("git")
        .args(["init"])
        .current_dir(&repo_dir)
        .output()
        .unwrap();

    let config_dir = tmp.path().join("config");

    // 1. Link repository
    let link_out = devp()
        .env("DEV_PRUNE_CONFIG_DIR", &config_dir)
        .args(["link", repo_dir.to_str().unwrap()])
        .output()
        .expect("Failed link");
    assert!(link_out.status.success());
    let stdout = String::from_utf8_lossy(&link_out.stdout);
    assert!(stdout.contains("Linked:"));

    // 2. Undo action directly
    let undo_out = devp()
        .env("DEV_PRUNE_CONFIG_DIR", &config_dir)
        .args(["undo"])
        .output()
        .expect("Failed undo");
    assert!(undo_out.status.success());
    let stdout_undo = String::from_utf8_lossy(&undo_out.stdout);
    assert!(
        stdout_undo.contains("Unregistered 1 repository."),
        "{stdout_undo}"
    );

    // 3. Link again & then Unlink
    devp()
        .env("DEV_PRUNE_CONFIG_DIR", &config_dir)
        .args(["link", repo_dir.to_str().unwrap()])
        .output()
        .unwrap();

    let unlink_out = devp()
        .env("DEV_PRUNE_CONFIG_DIR", &config_dir)
        .args(["unlink", repo_dir.to_str().unwrap()])
        .output()
        .expect("Failed unlink");
    assert!(unlink_out.status.success());
    let stdout_unlink = String::from_utf8_lossy(&unlink_out.stdout);
    assert!(stdout_unlink.contains("Unlinked:"));
}

#[test]
fn test_cli_config_get_set_show() {
    let tmp = TempDir::new().unwrap();
    let config_dir = tmp.path().join("config");

    // 1. Get idle_days
    let get_out = devp()
        .env("DEV_PRUNE_CONFIG_DIR", &config_dir)
        .args(["config", "get", "idle_days"])
        .output()
        .unwrap();
    assert!(get_out.status.success());
    assert!(String::from_utf8_lossy(&get_out.stdout).contains("idle_days"));

    // 2. Set idle_days to 30
    let set_out = devp()
        .env("DEV_PRUNE_CONFIG_DIR", &config_dir)
        .args(["config", "set", "idle_days", "30"])
        .output()
        .unwrap();
    assert!(set_out.status.success());
    assert!(String::from_utf8_lossy(&set_out.stdout).contains("idle_days = 30"));

    // 3. Show global config
    let show_out = devp()
        .env("DEV_PRUNE_CONFIG_DIR", &config_dir)
        .args(["config", "show"])
        .output()
        .unwrap();
    assert!(show_out.status.success());
    assert!(String::from_utf8_lossy(&show_out.stdout).contains("Global Configuration"));
}

#[test]
fn test_cli_run_dry_run_across_ecosystems() {
    let tmp = TempDir::new().unwrap();
    let config_dir = tmp.path().join("config");

    // Create npm repo
    let npm_repo = tmp.path().join("npm-repo");
    fs::create_dir_all(&npm_repo).unwrap();
    Command::new("git")
        .args(["init"])
        .current_dir(&npm_repo)
        .output()
        .unwrap();
    fs::write(npm_repo.join("package.json"), "{}").unwrap();
    fs::write(npm_repo.join("package-lock.json"), "{}").unwrap();
    let node_modules = npm_repo.join("node_modules");
    fs::create_dir(&node_modules).unwrap();
    fs::write(node_modules.join("dummy.js"), "content").unwrap();

    // Link npm repo
    devp()
        .env("DEV_PRUNE_CONFIG_DIR", &config_dir)
        .args(["link", npm_repo.to_str().unwrap()])
        .output()
        .unwrap();

    // Run --dry-run --force across registered repos
    let run_out = devp()
        .env("DEV_PRUNE_CONFIG_DIR", &config_dir)
        .args(["--dry-run", "--force", "-y", "run"])
        .output()
        .unwrap();

    assert!(run_out.status.success());
    let stdout = String::from_utf8_lossy(&run_out.stdout);
    assert!(stdout.contains("DRY RUN"));
}

#[test]
fn test_cli_restore_no_lockfiles() {
    let tmp = TempDir::new().unwrap();
    let config_dir = tmp.path().join("config");
    let repo = tmp.path().join("empty-repo");
    fs::create_dir_all(&repo).unwrap();
    Command::new("git")
        .args(["init"])
        .current_dir(&repo)
        .output()
        .unwrap();

    let restore_out = devp()
        .env("DEV_PRUNE_CONFIG_DIR", &config_dir)
        .args(["restore", repo.to_str().unwrap()])
        .output()
        .unwrap();

    // When no lockfile exists, restore prints header and returns error output
    let stdout = String::from_utf8_lossy(&restore_out.stdout);
    let stderr = String::from_utf8_lossy(&restore_out.stderr);
    let combined = format!("{stdout}\n{stderr}");
    assert!(
        combined.contains("dev-prune restore")
            || combined.contains("No matching package manager lockfiles")
    );
}

/// `daemon`, `hook` and `icon` live under `config`, but the tool's own output and its
/// docs call them by their bare names. Both spellings must reach the same handler.
#[test]
fn test_bare_hook_subcommand_routes_to_config_hook() {
    for args in [vec!["hook"], vec!["config", "hook"], vec!["status", "hook"]] {
        let output = devp()
            .args(&args)
            .output()
            .unwrap_or_else(|e| panic!("failed to run {args:?}: {e}"));
        assert!(output.status.success(), "{args:?} exited non-zero");
        let stdout = String::from_utf8_lossy(&output.stdout);
        assert!(
            stdout.contains("dev-prune Git Hooks Status"),
            "{args:?} did not reach the hook status handler:\n{stdout}"
        );
    }
}

#[test]
fn test_bare_daemon_subcommand_routes_to_config_daemon() {
    for args in [vec!["daemon"], vec!["config", "daemon"]] {
        let output = devp()
            .args(&args)
            .output()
            .unwrap_or_else(|e| panic!("failed to run {args:?}: {e}"));
        assert!(output.status.success(), "{args:?} exited non-zero");
        let stdout = String::from_utf8_lossy(&output.stdout);
        assert!(
            stdout.contains("dev-prune daemon status"),
            "{args:?} did not reach the daemon status handler:\n{stdout}"
        );
    }
}

/// `setup --status` reports every integration and changes nothing.
#[test]
fn test_setup_status_reports_without_installing() {
    let tmp = TempDir::new().unwrap();
    let config_dir = tmp.path().join("config");

    let output = devp()
        .env("DEV_PRUNE_CONFIG_DIR", &config_dir)
        .args(["setup", "--status"])
        .output()
        .expect("Failed to run setup --status");

    assert!(output.status.success());
    let stdout = String::from_utf8_lossy(&output.stdout);
    for row in [
        "devp alias:",
        "SKILL.md:",
        "Git hooks:",
        "Background scheduler:",
        "auto_setup",
    ] {
        assert!(stdout.contains(row), "missing `{row}` in:\n{stdout}");
    }
    // Reporting is not installing: nothing may have been exported.
    assert!(
        !config_dir.join("SKILL.md").exists(),
        "`setup --status` must not write anything"
    );
}

/// The opt-out is what keeps dev-prune off machines that did not ask for it — CI
/// images, containers, and this suite. It must hold on the command that installs most.
#[test]
fn test_auto_setup_opt_out_installs_nothing() {
    let tmp = TempDir::new().unwrap();
    let config_dir = tmp.path().join("config");
    let repo = tmp.path().join("repo");
    fs::create_dir_all(&repo).unwrap();
    Command::new("git")
        .args(["init"])
        .current_dir(&repo)
        .output()
        .unwrap();

    // `devp()` sets DEV_PRUNE_NO_AUTO_SETUP.
    let output = devp()
        .env("DEV_PRUNE_CONFIG_DIR", &config_dir)
        .args(["init", repo.to_str().unwrap()])
        .output()
        .expect("Failed to run init");

    assert!(output.status.success());
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        !stdout.contains("Integrations"),
        "init installed integrations despite the opt-out:\n{stdout}"
    );
    assert!(
        !config_dir.join("SKILL.md").exists(),
        "the opt-out must cover the SKILL.md export too"
    );
    // The registry itself is dev-prune's own business and is still written.
    assert!(config_dir.join("registry.json").exists());
}

/// A repository dev-prune refused to examine must never be reported as "nothing to do".
///
/// The analysis pass produces a `config_error` for an unreadable `.devprune.json`, and
/// that result used to be dropped along with every other non-candidate state — so the run
/// ended on "No idle repositories or pruneable bloat directories found." and exit 0 while
/// having quietly skipped the repository the user asked it to handle.
#[test]
fn test_a_repo_with_a_broken_config_is_reported_and_fails_the_run() {
    let tmp = TempDir::new().unwrap();
    let config_dir = tmp.path().join("config");
    let repo = tmp.path().join("broken-repo");
    fs::create_dir_all(&repo).unwrap();
    Command::new("git")
        .args(["init"])
        .current_dir(&repo)
        .output()
        .unwrap();
    let broken = r#"{ "project_name": "api", "override_idle_days": 90, }"#;
    fs::write(repo.join(".devprune.json"), broken).unwrap();

    devp()
        .env("DEV_PRUNE_CONFIG_DIR", &config_dir)
        .args(["link", repo.to_str().unwrap()])
        .output()
        .unwrap();

    let out = devp()
        .env("DEV_PRUNE_CONFIG_DIR", &config_dir)
        .args(["--force", "-y", "run"])
        .output()
        .expect("Failed to run");

    let stdout = String::from_utf8_lossy(&out.stdout);
    assert!(!out.status.success(), "a skipped repo must fail the run");
    assert!(
        !stdout.contains("No idle repositories or pruneable bloat directories found."),
        "the run claimed there was nothing to do:\n{stdout}"
    );
    assert!(stdout.contains("Could Not Be Examined"), "{stdout}");
    assert!(stdout.contains("--update"), "{stdout}");

    // Nothing may have been written over the file the user still has to fix.
    assert_eq!(
        fs::read_to_string(repo.join(".devprune.json")).unwrap(),
        broken
    );
}

/// The same repository in the JSON document: one parseable object, `config_error`
/// carrying the parse failure, a non-zero `summary.errors`, and a non-zero exit.
#[test]
fn test_a_broken_config_is_a_config_error_in_the_json_document() {
    let tmp = TempDir::new().unwrap();
    let config_dir = tmp.path().join("config");
    let repo = tmp.path().join("broken-repo");
    fs::create_dir_all(&repo).unwrap();
    Command::new("git")
        .args(["init"])
        .current_dir(&repo)
        .output()
        .unwrap();
    fs::write(repo.join(".devprune.json"), "{ not json").unwrap();

    devp()
        .env("DEV_PRUNE_CONFIG_DIR", &config_dir)
        .args(["link", repo.to_str().unwrap()])
        .output()
        .unwrap();

    let out = devp()
        .env("DEV_PRUNE_CONFIG_DIR", &config_dir)
        .args(["--force", "-y", "run", "--json"])
        .output()
        .expect("Failed to run");

    assert!(!out.status.success());
    // stdout is the document and nothing else, so a parser never has to strip a banner.
    let doc: serde_json::Value =
        serde_json::from_slice(&out.stdout).expect("stdout was not one JSON document");
    assert_eq!(doc["results"][0]["status"], "config_error");
    assert!(
        doc["results"][0]["message"]
            .as_str()
            .unwrap()
            .contains("Syntax error")
    );
    assert_eq!(doc["summary"]["errors"], 1);
}

/// A mistyped action must fail loudly. Falling through to a status report would print a
/// success-looking message while leaving the daemon uninstalled.
#[test]
fn test_mistyped_toggle_action_is_rejected() {
    let output = devp()
        .args(["config", "daemon", "enabel"])
        .output()
        .expect("Failed to execute dev-prune");

    assert!(!output.status.success());
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(stderr.contains("enabel"), "{stderr}");
}