crush-cli 0.2.1

Command-line interface for the Crush compression library
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
mod common;

use common::*;
use predicates::prelude::*;

/// T026: Basic file compression test
#[test]
fn test_compress_basic_file() {
    let dir = test_dir();
    // Use larger data to ensure compression reduces size (overhead from header is ~16 bytes)
    let test_data = b"Hello, world! This is a test file for compression. ".repeat(20);
    let input = create_test_file(dir.path(), "test.txt", &test_data);
    let output = dir.path().join("test.txt.crush");

    // Run compress command (files are kept by default)
    crush_cmd()
        .arg("compress")
        .arg(&input)
        .assert()
        .success()
        .stdout(predicate::str::contains("Compressed"));

    // Verify output file exists
    assert_file_exists(&output);

    // Verify output is smaller than input (compression worked)
    assert_compressed(&input, &output);
}

/// T027: File not found error test
#[test]
fn test_compress_file_not_found() {
    crush_cmd()
        .arg("compress")
        .arg("nonexistent.txt")
        .assert()
        .failure()
        .stderr(
            predicate::str::contains("not found")
                .or(predicate::str::contains("No such file"))
                .or(predicate::str::contains("does not exist")),
        );
}

/// T028: Output already exists error test
#[test]
fn test_compress_output_exists() {
    let dir = test_dir();
    let input = create_test_file(dir.path(), "test.txt", b"Test data");
    let _output = dir.path().join("test.txt.crush");

    // Create output file first
    create_test_file(dir.path(), "test.txt.crush", b"existing data");

    // Try to compress - should fail because output exists
    crush_cmd()
        .arg("compress")
        .arg(&input)
        .assert()
        .failure()
        .stderr(
            predicate::str::contains("already exists").or(predicate::str::contains("File exists")),
        );
}

/// T029: Force overwrite test
#[test]
fn test_compress_force_overwrite() {
    let dir = test_dir();
    let input = create_test_file(dir.path(), "test.txt", b"New test data for compression");
    let output = dir.path().join("test.txt.crush");

    // Create existing output file
    let old_content = b"old compressed data";
    create_test_file(dir.path(), "test.txt.crush", old_content);

    // Compress with --force flag
    crush_cmd()
        .arg("compress")
        .arg("--force")
        .arg(&input)
        .assert()
        .success();

    // Verify output exists
    assert_file_exists(&output);

    // Verify output has changed (not the old content)
    let new_content = read_file(&output);
    assert_ne!(
        new_content, old_content,
        "File should have been overwritten"
    );
}

/// T030: Keep input file test
#[test]
fn test_compress_keep_input() {
    let dir = test_dir();
    let input = create_test_file(dir.path(), "test.txt", b"Data to compress");
    let output = dir.path().join("test.txt.crush");

    // Compress (files are kept by default)
    crush_cmd().arg("compress").arg(&input).assert().success();

    // Verify both files exist
    assert_file_exists(&input);
    assert_file_exists(&output);
}

/// T050: Test that compressed file preserves mtime on Windows
#[test]
#[cfg(windows)]
fn test_compress_preserves_mtime_windows() {
    let dir = test_dir();
    let input = create_test_file(dir.path(), "test.txt", b"mtime test data");
    let output = dir.path().join("test.txt.crush");
    let restored_path = dir.path().join("restored.txt"); // Decompress will create this

    // 1. Set a specific, known modification time
    let original_mtime = filetime::FileTime::from_unix_time(1_500_000_000, 0); // A known past date
    filetime::set_file_mtime(&input, original_mtime).unwrap();

    // 2. Run compress command (files are kept by default)
    crush_cmd().arg("compress").arg(&input).assert().success();

    // 3. Run decompress command
    crush_cmd()
        .arg("decompress")
        .arg(&output)
        .arg("-o")
        .arg(&restored_path)
        .assert()
        .success();

    // 4. Get the modification time of the restored file
    let restored_mtime = filetime::FileTime::from_last_modification_time(
        &std::fs::metadata(&restored_path).unwrap(),
    );

    // 5. Assert that the modification times are equal
    assert_eq!(
        original_mtime, restored_mtime,
        "Modification time should be preserved after roundtrip"
    );
}

/// T048: Test mtime preservation on Linux
#[test]
#[cfg(target_os = "linux")]
fn test_compress_preserves_mtime_linux() {
    let dir = test_dir();
    let input = create_test_file(dir.path(), "test.txt", b"mtime test data for Linux");
    let output = dir.path().join("test.txt.crush");
    let restored_path = dir.path().join("restored.txt");

    // Set a specific modification time
    let original_mtime = filetime::FileTime::from_unix_time(1_600_000_000, 0);
    filetime::set_file_mtime(&input, original_mtime).unwrap();

    // Compress
    crush_cmd().arg("compress").arg(&input).assert().success();

    // Decompress
    crush_cmd()
        .arg("decompress")
        .arg(&output)
        .arg("-o")
        .arg(&restored_path)
        .assert()
        .success();

    // Verify mtime is preserved
    let restored_mtime = filetime::FileTime::from_last_modification_time(
        &std::fs::metadata(&restored_path).unwrap(),
    );
    assert_eq!(
        original_mtime, restored_mtime,
        "Modification time should be preserved on Linux"
    );
}

/// T049: Test mtime preservation on macOS
#[test]
#[cfg(target_os = "macos")]
fn test_compress_preserves_mtime_macos() {
    let dir = test_dir();
    let input = create_test_file(dir.path(), "test.txt", b"mtime test data for macOS");
    let output = dir.path().join("test.txt.crush");
    let restored_path = dir.path().join("restored.txt");

    // Set a specific modification time
    let original_mtime = filetime::FileTime::from_unix_time(1_600_000_000, 0);
    filetime::set_file_mtime(&input, original_mtime).unwrap();

    // Compress
    crush_cmd().arg("compress").arg(&input).assert().success();

    // Decompress
    crush_cmd()
        .arg("decompress")
        .arg(&output)
        .arg("-o")
        .arg(&restored_path)
        .assert()
        .success();

    // Verify mtime is preserved
    let restored_mtime = filetime::FileTime::from_last_modification_time(
        &std::fs::metadata(&restored_path).unwrap(),
    );
    assert_eq!(
        original_mtime, restored_mtime,
        "Modification time should be preserved on macOS"
    );
}

/// T051: Test Unix permissions preservation
#[test]
#[cfg(unix)]
fn test_compress_preserves_unix_permissions() {
    use std::os::unix::fs::PermissionsExt;

    let dir = test_dir();
    let input = create_test_file(dir.path(), "test.txt", b"permissions test data");
    let output = dir.path().join("test.txt.crush");
    let restored_path = dir.path().join("restored.txt");

    // Set specific permissions (rwxr-xr-x = 0o755)
    let original_perms = std::fs::Permissions::from_mode(0o755);
    std::fs::set_permissions(&input, original_perms.clone()).unwrap();

    // Compress
    crush_cmd().arg("compress").arg(&input).assert().success();

    // Remove original
    std::fs::remove_file(&input).unwrap();

    // Decompress
    crush_cmd()
        .arg("decompress")
        .arg(&output)
        .arg("-o")
        .arg(&restored_path)
        .assert()
        .success();

    // Verify permissions are preserved
    let restored_perms = std::fs::metadata(&restored_path).unwrap().permissions();
    assert_eq!(
        original_perms.mode() & 0o777,
        restored_perms.mode() & 0o777,
        "Unix permissions should be preserved (expected: {:o}, got: {:o})",
        original_perms.mode() & 0o777,
        restored_perms.mode() & 0o777
    );
}

/// T073: Test that large files show progress (spinner appears for files >1MB)
#[test]
fn test_compress_large_file_progress() {
    let dir = test_dir();
    // Create a file larger than 1MB to trigger progress display
    let large_data = vec![0u8; 2 * 1024 * 1024]; // 2MB
    let input = create_test_file(dir.path(), "large.bin", &large_data);
    let output = dir.path().join("large.bin.crush");

    // Compress large file
    crush_cmd().arg("compress").arg(&input).assert().success();

    // Verify compressed file was created
    assert_file_exists(&output);

    // Note: We can't easily test that the spinner actually appeared since it goes to stderr
    // and is ephemeral. The important thing is that compression succeeds for large files.
}

/// T074: Test that final statistics are displayed after compression
#[test]
fn test_compress_displays_statistics() {
    let dir = test_dir();
    let test_data = b"statistics test data";
    let input = create_test_file(dir.path(), "test.txt", test_data);
    let _output = dir.path().join("test.txt.crush");

    // Compress and capture output
    let assert = crush_cmd().arg("compress").arg(&input).assert().success();

    // Verify statistics are displayed in stdout
    let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();

    // Check for key statistics in the output
    assert!(
        stdout.contains("Compressed"),
        "Should show 'Compressed' message"
    );
    assert!(stdout.contains("test.txt"), "Should show input filename");
    assert!(
        stdout.contains("test.txt.crush"),
        "Should show output filename"
    );
    assert!(stdout.contains("MB/s"), "Should show throughput");

    // Check for size information (either "smaller" or "larger")
    assert!(
        stdout.contains("smaller") || stdout.contains("larger") || stdout.contains("same size"),
        "Should show size comparison"
    );
}

/// T087: Test verbose mode shows plugin selection
#[test]
fn test_compress_verbose_plugin_selection() {
    let dir = test_dir();
    let test_data = b"verbose test data for plugin selection";
    let input = create_test_file(dir.path(), "test.txt", test_data);

    // Compress with -v flag (verbose flag comes before subcommand)
    let assert = crush_cmd()
        .arg("-v")
        .arg("compress")
        .arg(&input)
        .assert()
        .success();

    // Verify verbose output contains plugin selection info
    let stderr = String::from_utf8(assert.get_output().stderr.clone()).unwrap();

    // Should mention plugin selection or plugin name
    assert!(
        stderr.contains("plugin") || stderr.contains("deflate") || stderr.contains("selected"),
        "Verbose output should show plugin selection information"
    );
}

/// T088: Test verbose mode shows performance metrics
#[test]
fn test_compress_verbose_performance_metrics() {
    let dir = test_dir();
    let test_data = b"verbose test data for performance metrics";
    let input = create_test_file(dir.path(), "test.txt", test_data);

    // Compress with -v flag (verbose flag comes before subcommand)
    let assert = crush_cmd()
        .arg("-v")
        .arg("compress")
        .arg(&input)
        .assert()
        .success();

    // Verify verbose output contains performance info
    let stderr = String::from_utf8(assert.get_output().stderr.clone()).unwrap();

    // Should mention throughput or performance metrics
    assert!(
        stderr.contains("throughput") || stderr.contains("MB/s") || stderr.contains("performance"),
        "Verbose output should show performance metrics"
    );
}

/// T089: Test debug level output with -v
#[test]
fn test_compress_verbose_debug_level() {
    let dir = test_dir();
    let test_data = b"debug level verbose test data";
    let input = create_test_file(dir.path(), "test.txt", test_data);

    // Compress with -v flag (debug level - verbose flag comes before subcommand)
    let assert = crush_cmd()
        .arg("-v")
        .arg("compress")
        .arg(&input)
        .assert()
        .success();

    // Verify stderr has some diagnostic output
    let stderr = String::from_utf8(assert.get_output().stderr.clone()).unwrap();

    // Debug level should produce some output to stderr
    assert!(
        !stderr.is_empty(),
        "Debug level (-v) should produce diagnostic output"
    );
}

/// T090: Test trace level output with -vv
#[test]
fn test_compress_verbose_trace_level() {
    let dir = test_dir();
    let test_data = b"trace level verbose test data";
    let input = create_test_file(dir.path(), "test.txt", test_data);

    // Compress with -vv flag (trace level - verbose flag comes before subcommand)
    let assert = crush_cmd()
        .arg("-vv")
        .arg("compress")
        .arg(&input)
        .assert()
        .success();

    // Verify stderr has detailed trace output
    let stderr = String::from_utf8(assert.get_output().stderr.clone()).unwrap();

    // Trace level should produce more detailed output than debug
    assert!(
        !stderr.is_empty(),
        "Trace level (-vv) should produce detailed diagnostic output"
    );

    // Trace level might include more details like file paths, operations, etc.
    assert!(
        stderr.len() > 20,
        "Trace level output should be reasonably detailed"
    );
}

/// T075: Test Ctrl+C cleanup (partial file cleanup on interrupt)
#[test]
fn test_compress_interrupt_cleanup() {
    use std::process::{Command, Stdio};
    use std::thread;
    use std::time::Duration;

    let dir = test_dir();
    // File size does not matter much — we kill before compression can complete.
    // A 50 MB file is large enough to require reading from disk (> 5 ms on any
    // storage device), giving us a reliable window to send the kill signal.
    let large_data = vec![0u8; 50 * 1024 * 1024]; // 50 MB
    let input = create_test_file(dir.path(), "interrupt_test.bin", &large_data);
    let _output = dir.path().join("interrupt_test.bin.crush");

    // Get the path to the crush binary
    #[allow(deprecated)]
    let crush_path = assert_cmd::cargo::cargo_bin("crush");

    // Start compression in a separate process
    let mut child = Command::new(&crush_path)
        .arg("compress")
        .arg(&input)
        .current_dir(dir.path())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .spawn()
        .expect("Failed to start compress process");

    // Kill almost immediately — Windows process startup (DLL loading, Rust runtime
    // init) takes at minimum 20-100 ms, so a 5 ms window reliably arrives before
    // the process has read the input file, regardless of compression speed.
    thread::sleep(Duration::from_millis(5));

    // Kill the process (simulating Ctrl+C)
    child.kill().expect("Failed to kill process");
    let status = child.wait().expect("Failed to wait for process");

    // Verify the process was interrupted
    // On Windows, killed processes return exit code 1
    // On Unix, they typically return 128 + SIGKILL (137) or similar
    assert!(
        !status.success(),
        "Process should not exit successfully after being killed"
    );

    // Note: Cleanup of partial files is best-effort. The important thing is that
    // the process can be interrupted without hanging.
}