bashkit 0.5.0

Awesomely fast virtual sandbox with bash and file system
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
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
//! Spec test runner for Bashkit compatibility testing
//!
//! Test file format (.test.sh):
//! ```
//! ### test_name
//! # Description of what this tests
//! echo hello
//! ### expect
//! hello
//! ### end
//! ```
//!
//! Multiple tests per file supported. Tests are run against Bashkit
//! and optionally compared against real bash.

use bashkit::Bash;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::process::Command;

/// A single test case parsed from a .test.sh file
#[derive(Debug, Clone)]
pub struct SpecTest {
    pub name: String,
    pub description: String,
    pub script: String,
    pub expected_stdout: String,
    pub expected_exit_code: Option<i32>,
    pub skip: bool,
    pub skip_reason: Option<String>,
    /// If true, run test with tokio paused time for deterministic timing
    pub paused_time: bool,
    /// If true, this test has known differences from real bash behavior.
    /// Test still runs against expected output, but excluded from bash comparison.
    pub bash_diff: bool,
    pub bash_diff_reason: Option<String>,
}

/// Result of running a spec test
#[derive(Debug)]
pub struct TestResult {
    pub name: String,
    pub passed: bool,
    pub bashkit_stdout: String,
    pub bashkit_exit_code: i32,
    pub expected_stdout: String,
    pub expected_exit_code: Option<i32>,
    pub real_bash_stdout: Option<String>,
    pub real_bash_exit_code: Option<i32>,
    pub error: Option<String>,
}

/// Parse test cases from a .test.sh file
pub fn parse_spec_file(content: &str) -> Vec<SpecTest> {
    let mut tests = Vec::new();
    let mut current_test: Option<SpecTest> = None;
    let mut in_script = false;
    let mut in_expect = false;
    let mut script_lines = Vec::new();
    let mut expect_lines = Vec::new();

    for line in content.lines() {
        if let Some(directive) = line.strip_prefix("### ") {
            let directive = directive.trim();

            if directive == "expect" {
                in_script = false;
                in_expect = true;
            } else if directive == "end" {
                // Finalize current test
                if let Some(mut test) = current_test.take() {
                    test.script = script_lines.join("\n");
                    test.expected_stdout = expect_lines.join("\n");
                    if !test.expected_stdout.is_empty() {
                        test.expected_stdout.push('\n');
                    }
                    tests.push(test);
                }
                script_lines.clear();
                expect_lines.clear();
                in_script = false;
                in_expect = false;
            } else if let Some(code_str) = directive.strip_prefix("exit_code:") {
                if let Some(ref mut test) = current_test
                    && let Ok(code) = code_str.trim().parse()
                {
                    test.expected_exit_code = Some(code);
                }
            } else if let Some(reason) = directive.strip_prefix("skip:") {
                if let Some(ref mut test) = current_test {
                    test.skip = true;
                    test.skip_reason = Some(reason.trim().to_string());
                }
            } else if directive == "skip" {
                if let Some(ref mut test) = current_test {
                    test.skip = true;
                }
            } else if directive == "paused_time" {
                if let Some(ref mut test) = current_test {
                    test.paused_time = true;
                }
            } else if let Some(reason) = directive.strip_prefix("bash_diff:") {
                if let Some(ref mut test) = current_test {
                    test.bash_diff = true;
                    test.bash_diff_reason = Some(reason.trim().to_string());
                }
            } else if directive == "bash_diff" {
                if let Some(ref mut test) = current_test {
                    test.bash_diff = true;
                }
            } else {
                // New test name
                if let Some(mut test) = current_test.take() {
                    test.script = script_lines.join("\n");
                    test.expected_stdout = expect_lines.join("\n");
                    if !test.expected_stdout.is_empty() {
                        test.expected_stdout.push('\n');
                    }
                    tests.push(test);
                }
                script_lines.clear();
                expect_lines.clear();

                current_test = Some(SpecTest {
                    name: directive.to_string(),
                    description: String::new(),
                    script: String::new(),
                    expected_stdout: String::new(),
                    expected_exit_code: None,
                    skip: false,
                    skip_reason: None,
                    paused_time: false,
                    bash_diff: false,
                    bash_diff_reason: None,
                });
                in_script = true;
                in_expect = false;
            }
        } else if let Some(comment) = line.strip_prefix("# ") {
            if in_script && script_lines.is_empty() {
                // Description comment at start of script
                if let Some(ref mut test) = current_test {
                    if test.description.is_empty() {
                        test.description = comment.to_string();
                    } else {
                        script_lines.push(line.to_string());
                    }
                }
            } else if in_script {
                script_lines.push(line.to_string());
            }
        } else if in_script {
            script_lines.push(line.to_string());
        } else if in_expect {
            expect_lines.push(line.to_string());
        }
    }

    // Handle case where file doesn't end with ### end
    if let Some(mut test) = current_test.take() {
        test.script = script_lines.join("\n");
        test.expected_stdout = expect_lines.join("\n");
        if !test.expected_stdout.is_empty() && !test.expected_stdout.ends_with('\n') {
            test.expected_stdout.push('\n');
        }
        tests.push(test);
    }

    tests
}

/// Run a single spec test against Bashkit
pub async fn run_spec_test(test: &SpecTest) -> TestResult {
    run_spec_test_with(test, Bash::new).await
}

/// Run a single spec test with a custom Bash constructor
pub async fn run_spec_test_with(
    test: &SpecTest,
    make_bash: impl Fn() -> Bash + Send + 'static,
) -> TestResult {
    // For timing tests, run in a separate runtime with paused time
    // This enables deterministic time-based testing with auto-advance
    if test.paused_time {
        return run_spec_test_paused_time(test).await;
    }

    let mut bash = make_bash();

    let (bashkit_stdout, bashkit_exit_code, error) = match bash.exec(&test.script).await {
        Ok(result) => (result.stdout, result.exit_code, None),
        Err(e) => (String::new(), 1, Some(e.to_string())),
    };

    let stdout_matches = bashkit_stdout == test.expected_stdout;
    let exit_code_matches = test
        .expected_exit_code
        .map(|expected| bashkit_exit_code == expected)
        .unwrap_or(true);

    let passed = stdout_matches && exit_code_matches && error.is_none();

    TestResult {
        name: test.name.clone(),
        passed,
        bashkit_stdout,
        bashkit_exit_code,
        expected_stdout: test.expected_stdout.clone(),
        expected_exit_code: test.expected_exit_code,
        real_bash_stdout: None,
        real_bash_exit_code: None,
        error,
    }
}

/// Run a spec test with paused time for deterministic timing behavior.
/// Uses spawn_blocking + a separate tokio runtime with start_paused=true.
async fn run_spec_test_paused_time(test: &SpecTest) -> TestResult {
    let script = test.script.clone();
    let expected_stdout = test.expected_stdout.clone();
    let expected_exit_code = test.expected_exit_code;
    let name = test.name.clone();

    // Run in a blocking thread to create a new runtime with paused time
    let (bashkit_stdout, bashkit_exit_code, error) = tokio::task::spawn_blocking(move || {
        // Create a new runtime with paused time and auto-advance
        let rt = tokio::runtime::Builder::new_current_thread()
            .enable_time()
            .start_paused(true)
            .build()
            .expect("Failed to create paused time runtime");

        rt.block_on(async {
            let mut bash = Bash::new();
            match bash.exec(&script).await {
                Ok(result) => (result.stdout, result.exit_code, None),
                Err(e) => (String::new(), 1, Some(e.to_string())),
            }
        })
    })
    .await
    .expect("spawn_blocking failed");

    let stdout_matches = bashkit_stdout == expected_stdout;
    let exit_code_matches = expected_exit_code
        .map(|expected| bashkit_exit_code == expected)
        .unwrap_or(true);

    let passed = stdout_matches && exit_code_matches && error.is_none();

    TestResult {
        name,
        passed,
        bashkit_stdout,
        bashkit_exit_code,
        expected_stdout,
        expected_exit_code,
        real_bash_stdout: None,
        real_bash_exit_code: None,
        error,
    }
}

/// Run a spec test against real bash for comparison
pub fn run_real_bash(script: &str) -> (String, i32) {
    let output = Command::new("bash")
        .arg("-c")
        .arg(script)
        .output()
        .expect("Failed to run bash");

    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
    let exit_code = output.status.code().unwrap_or(1);

    (stdout, exit_code)
}

/// Run spec test with real bash comparison
pub async fn run_spec_test_with_comparison(test: &SpecTest) -> TestResult {
    let mut result = run_spec_test(test).await;

    let (real_stdout, real_exit_code) = run_real_bash(&test.script);
    result.real_bash_stdout = Some(real_stdout);
    result.real_bash_exit_code = Some(real_exit_code);

    result
}

/// Load all spec tests from a directory
pub fn load_spec_tests(dir: &Path) -> HashMap<String, Vec<SpecTest>> {
    let mut all_tests = HashMap::new();

    if let Ok(entries) = fs::read_dir(dir) {
        for entry in entries.flatten() {
            let path = entry.path();
            if path.extension().is_some_and(|e| e == "sh")
                && let Ok(content) = fs::read_to_string(&path)
            {
                let file_name = path
                    .file_stem()
                    .unwrap_or_default()
                    .to_string_lossy()
                    .to_string();
                let tests = parse_spec_file(&content);
                if !tests.is_empty() {
                    all_tests.insert(file_name, tests);
                }
            }
        }
    }

    all_tests
}

/// Summary statistics for a test run
#[derive(Debug, Default)]
pub struct TestSummary {
    pub total: usize,
    pub passed: usize,
    pub failed: usize,
    pub skipped: usize,
}

impl TestSummary {
    pub fn add(&mut self, result: &TestResult, was_skipped: bool) {
        self.total += 1;
        if was_skipped {
            self.skipped += 1;
        } else if result.passed {
            self.passed += 1;
        } else {
            self.failed += 1;
        }
    }

    pub fn pass_rate(&self) -> f64 {
        if self.total == 0 {
            0.0
        } else {
            (self.passed as f64 / (self.total - self.skipped) as f64) * 100.0
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_spec_file() {
        let content = r#"
### simple_echo
# Test basic echo
echo hello
### expect
hello
### end

### multi_line
echo one
echo two
### expect
one
two
### end
"#;

        let tests = parse_spec_file(content);
        assert_eq!(tests.len(), 2);

        assert_eq!(tests[0].name, "simple_echo");
        assert_eq!(tests[0].description, "Test basic echo");
        assert_eq!(tests[0].script, "echo hello");
        assert_eq!(tests[0].expected_stdout, "hello\n");

        assert_eq!(tests[1].name, "multi_line");
        assert_eq!(tests[1].script, "echo one\necho two");
        assert_eq!(tests[1].expected_stdout, "one\ntwo\n");
    }

    #[test]
    fn test_parse_with_exit_code() {
        let content = r#"
### exit_test
false
### exit_code: 1
### expect
### end
"#;

        let tests = parse_spec_file(content);
        assert_eq!(tests.len(), 1);
        assert_eq!(tests[0].expected_exit_code, Some(1));
    }

    #[test]
    fn test_parse_with_skip() {
        let content = r#"
### skipped_test
### skip: not implemented yet
echo hello
### expect
hello
### end
"#;

        let tests = parse_spec_file(content);
        assert_eq!(tests.len(), 1);
        assert!(tests[0].skip);
        assert_eq!(
            tests[0].skip_reason,
            Some("not implemented yet".to_string())
        );
    }

    #[tokio::test]
    async fn test_run_simple_spec() {
        let test = SpecTest {
            name: "echo_test".to_string(),
            description: "Test echo".to_string(),
            script: "echo hello".to_string(),
            expected_stdout: "hello\n".to_string(),
            expected_exit_code: None,
            skip: false,
            skip_reason: None,
            paused_time: false,
            bash_diff: false,
            bash_diff_reason: None,
        };

        let result = run_spec_test(&test).await;
        assert!(result.passed, "Test should pass: {:?}", result);
    }

    #[tokio::test]
    async fn test_run_failing_spec() {
        let test = SpecTest {
            name: "fail_test".to_string(),
            description: "Test that should fail".to_string(),
            script: "echo wrong".to_string(),
            expected_stdout: "right\n".to_string(),
            expected_exit_code: None,
            skip: false,
            skip_reason: None,
            paused_time: false,
            bash_diff: false,
            bash_diff_reason: None,
        };

        let result = run_spec_test(&test).await;
        assert!(!result.passed, "Test should fail");
    }

    #[test]
    fn test_parse_with_bash_diff() {
        let content = r#"
### diff_test
### bash_diff: wc output formatting differs
echo "test" | wc -l
### expect
       1
### end
"#;

        let tests = parse_spec_file(content);
        assert_eq!(tests.len(), 1);
        assert!(tests[0].bash_diff);
        assert_eq!(
            tests[0].bash_diff_reason,
            Some("wc output formatting differs".to_string())
        );
        assert!(!tests[0].skip);
    }

    #[test]
    fn test_parse_with_paused_time() {
        let content = r#"
### timing_test
### paused_time
timeout 0.001 sleep 10
echo $?
### expect
124
### end
"#;

        let tests = parse_spec_file(content);
        assert_eq!(tests.len(), 1);
        assert!(tests[0].paused_time);
        assert!(!tests[0].skip);
    }
}