kaish-kernel 0.7.0

Core kernel for kaish: lexer, parser, interpreter, and runtime
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
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
//! Integration tests for pre-execution validation.
//!
//! These tests verify that the validator correctly blocks execution
//! for scripts with Error-level issues, while allowing scripts with
//! only Warning-level issues to execute.

use std::path::PathBuf;

use kaish_kernel::{Kernel, KernelConfig};

/// Helper to create a transient kernel for testing.
async fn make_kernel() -> std::sync::Arc<Kernel> {
    Kernel::transient().expect("should create kernel").into_arc()
}

/// Helper to create a kernel with CWD in the repo (for tests that run external commands).
fn make_repo_kernel() -> std::sync::Arc<Kernel> {
    let config = KernelConfig::repl()
        .with_cwd(PathBuf::from(env!("CARGO_MANIFEST_DIR")));
    Kernel::new(config).expect("should create kernel").into_arc()
}

// ============================================================================
// Tests that verify validation BLOCKS execution (Error-level issues)
// ============================================================================

#[tokio::test]
async fn validation_blocks_break_outside_loop() {
    let kernel = make_kernel().await;
    let result = kernel.execute("break").await;

    assert!(result.is_err(), "break outside loop should fail validation");
    let err = result.unwrap_err().to_string();
    assert!(
        err.contains("loop") || err.contains("validation"),
        "error should mention loop or validation: {}",
        err
    );
}

#[tokio::test]
async fn validation_blocks_dollar_question_field_access() {
    let kernel = make_kernel().await;
    let result = kernel.execute(r#"echo "${?.data}""#).await;

    assert!(
        result.is_err(),
        "${{?.data}} should fail validation (use kaish-last instead)"
    );
    let err = result.unwrap_err().to_string();
    assert!(
        err.contains("kaish-last"),
        "error should suggest kaish-last: {}",
        err
    );
}

#[tokio::test]
async fn validation_blocks_dollar_question_code_field() {
    let kernel = make_kernel().await;
    // Any non-empty field path on $? is removed — including the ones that
    // used to be valid (.code, .ok). $? alone is still POSIX-shaped.
    let result = kernel.execute(r#"echo "${?.code}""#).await;
    assert!(result.is_err(), "${{?.code}} should fail validation");
}

#[tokio::test]
async fn validation_blocks_continue_outside_loop() {
    let kernel = make_kernel().await;
    let result = kernel.execute("continue").await;

    assert!(result.is_err(), "continue outside loop should fail validation");
}

#[tokio::test]
async fn validation_blocks_return_outside_function() {
    let kernel = make_kernel().await;
    let result = kernel.execute("return").await;

    assert!(result.is_err(), "return outside function should fail validation");
}

#[tokio::test]
async fn validation_blocks_invalid_regex() {
    let kernel = make_kernel().await;
    // Unclosed bracket is invalid regex
    let result = kernel.execute("grep '[' /dev/null").await;

    assert!(result.is_err(), "invalid regex should fail validation");
    let err = result.unwrap_err().to_string();
    assert!(
        err.contains("regex") || err.contains("validation"),
        "error should mention regex or validation: {}",
        err
    );
}

#[tokio::test]
async fn validation_blocks_seq_zero_increment() {
    let kernel = make_kernel().await;
    // seq FIRST INCREMENT LAST with increment=0 would loop forever
    let result = kernel.execute("seq 1 0 10").await;

    assert!(result.is_err(), "seq with zero increment should fail validation");
    let err = result.unwrap_err().to_string();
    assert!(
        err.contains("zero") || err.contains("increment") || err.contains("validation"),
        "error should mention zero/increment or validation: {}",
        err
    );
}

#[tokio::test]
async fn validation_blocks_bare_var_in_for_loop() {
    let kernel = make_kernel().await;
    // `for i in $VAR` is always wrong in kaish - no implicit word splitting
    let result = kernel.execute(r#"
        ITEMS="a b c"
        for i in $ITEMS; do
            echo $i
        done
    "#).await;

    assert!(result.is_err(), "bare variable in for loop should fail validation");
    let err = result.unwrap_err().to_string();
    assert!(
        err.contains("word splitting") || err.contains("iterate once") || err.contains("E012"),
        "error should mention word splitting or iterate once: {}",
        err
    );
}

#[tokio::test]
async fn validation_allows_split_in_for_loop() {
    let kernel = make_kernel().await;
    // `for i in $(split "$VAR")` is the correct way
    let result = kernel.execute(r#"
        ITEMS="a b c"
        for i in $(split "$ITEMS"); do
            echo $i
        done
    "#).await;

    assert!(result.is_ok(), "split in for loop should pass validation");
    let exec = result.unwrap();
    assert!(exec.ok(), "split in for loop should execute successfully");
    assert!(exec.text_out().contains("a") && exec.text_out().contains("b") && exec.text_out().contains("c"));
}

#[tokio::test]
async fn validation_allows_seq_in_for_loop() {
    let kernel = make_kernel().await;
    // `for i in $(seq 1 3)` works because seq returns a JSON array
    let result = kernel.execute(r#"
        for i in $(seq 1 3); do
            echo $i
        done
    "#).await;

    assert!(result.is_ok(), "seq in for loop should pass validation");
    let exec = result.unwrap();
    assert!(exec.ok(), "seq in for loop should execute successfully");
}

// ============================================================================
// Tests that verify validation ALLOWS execution (Warning-level issues only)
// ============================================================================

#[tokio::test]
async fn validation_allows_unknown_command_with_warning() {
    let kernel = make_kernel().await;
    // Unknown command is a warning, not error
    // It will fail at runtime, not validation
    let result = kernel.execute("nonexistent_command_xyz").await;

    // Should get past validation but may fail at runtime
    // The key test is that it doesn't fail with "validation failed"
    match result {
        Ok(exec_result) => {
            // Got past validation, runtime failure is OK
            assert!(!exec_result.ok(), "unknown command should fail at runtime");
        }
        Err(e) => {
            let err = e.to_string();
            // Should NOT fail due to validation
            assert!(
                !err.contains("validation failed"),
                "unknown command should be warning not error: {}",
                err
            );
        }
    }
}

#[tokio::test]
async fn validation_allows_undefined_variable_with_warning() {
    let kernel = make_kernel().await;
    // Undefined variable is a warning
    let result = kernel.execute("echo $UNDEFINED_VARIABLE_XYZ").await;

    // Validation should NOT reject this — undefined vars are warnings, not errors.
    // The runtime may produce a failure ExecResult or propagate an Err for the
    // undefined variable, but that's separate from validation.
    match result {
        Ok(_exec_result) => {
            // Reached execution — validation allowed it. Runtime behavior for
            // undefined vars is a separate concern (should expand to empty, but
            // currently produces a runtime error).
        }
        Err(e) => {
            let err = e.to_string();
            assert!(
                !err.contains("validation failed"),
                "undefined variable should be warning: {}",
                err
            );
        }
    }
}

// ============================================================================
// Tests for skip_validation flag
// ============================================================================

#[tokio::test]
async fn skip_validation_allows_break_outside_loop() {
    use kaish_kernel::KernelConfig;

    let config = KernelConfig::transient().with_skip_validation(true);
    let kernel = Kernel::new(config).expect("should create kernel");

    // With validation skipped, break outside loop passes validation
    // Runtime behavior: break at top level may be ignored or cause an error
    let result = kernel.execute("break").await;

    match result {
        Ok(_) => {
            // Got past validation - this is the key assertion
            // Runtime may succeed (break ignored) or fail, either is acceptable
        }
        Err(e) => {
            let err = e.to_string();
            // Should NOT say "validation failed" since we skipped it
            assert!(
                !err.contains("validation failed"),
                "should not fail validation when skipped: {}",
                err
            );
        }
    }
}

// ============================================================================
// Tests that valid scripts pass validation
// ============================================================================

#[tokio::test]
async fn validation_passes_for_valid_script() {
    let kernel = make_kernel().await;

    // A completely valid script
    let result = kernel.execute(r#"
        x=1
        echo $x
    "#).await;

    assert!(result.is_ok(), "valid script should pass validation");
    let exec = result.unwrap();
    assert!(exec.ok(), "valid script should execute successfully");
}

#[tokio::test]
async fn validation_passes_for_loop_with_break() {
    let kernel = make_kernel().await;

    // break inside a loop is valid
    let result = kernel.execute(r#"
        for i in 1 2 3; do
            if [[ $i == 2 ]]; then
                break
            fi
            echo $i
        done
    "#).await;

    assert!(result.is_ok(), "break inside loop should pass validation");
    let exec = result.unwrap();
    assert!(exec.ok(), "loop with break should execute successfully");
}

#[tokio::test]
async fn validation_passes_for_valid_grep() {
    let kernel = make_kernel().await;

    // Valid regex pattern
    let result = kernel.execute("echo 'hello world' | grep 'hello'").await;

    assert!(result.is_ok(), "valid grep should pass validation");
    let exec = result.unwrap();
    assert!(exec.ok(), "valid grep should execute successfully");
}

#[tokio::test]
async fn validation_passes_for_valid_seq() {
    let kernel = make_kernel().await;

    // Non-zero increment is valid
    let result = kernel.execute("seq 1 2 10").await;

    assert!(result.is_ok(), "valid seq should pass validation");
    let exec = result.unwrap();
    assert!(exec.ok(), "valid seq should execute successfully");
    assert!(exec.text_out().contains("1") && exec.text_out().contains("9"));
}

// ============================================================================
// Tests for bare glob expansion (globs now parse and expand at runtime)
// ============================================================================

#[tokio::test]
async fn validation_accepts_glob_pattern_ls_star() {
    let kernel = make_kernel().await;
    // `ls *.txt` now parses and expands at runtime.
    // In an empty temp dir, it returns "no matches" error at runtime (not parse error).
    let result = kernel.execute("ls *.txt").await;

    // Parse succeeds, runtime may fail with "no matches"
    match result {
        Ok(exec) => {
            // If there happen to be .txt files, it succeeds
            assert!(exec.code == 0 || exec.code == 1);
        }
        Err(e) => {
            let err = e.to_string();
            assert!(err.contains("no matches"), "expected no-matches error: {}", err);
        }
    }
}

#[tokio::test]
async fn validation_allows_glob_pattern_ls_quoted() {
    let kernel = make_kernel().await;
    // ls now accepts glob patterns natively
    let result = kernel.execute("ls \"*.txt\"").await;

    assert!(result.is_ok(), "ls should accept glob patterns: {:?}", result.unwrap_err());
}

#[tokio::test]
async fn validation_bare_glob_rm_bak_parses() {
    let kernel = make_kernel().await;
    // rm *.bak now parses; runtime fails with "no matches" in empty dir
    let result = kernel.execute("rm *.bak").await;
    match result {
        Ok(_) => {} // might succeed if files exist
        Err(e) => assert!(e.to_string().contains("no matches"), "expected no-matches: {}", e),
    }
}

#[tokio::test]
async fn validation_bare_glob_question_parses() {
    let kernel = make_kernel().await;
    // file?.log now parses as a GlobPattern; fails at runtime with no matches
    let result = kernel.execute("cat file?.log").await;
    match result {
        Ok(_) => {}
        Err(e) => assert!(e.to_string().contains("no matches"), "expected no-matches: {}", e),
    }
}

#[tokio::test]
async fn validation_bare_glob_with_path_parses() {
    let kernel = make_kernel().await;
    // src/*.rs now parses as GlobPattern
    let result = kernel.execute("cp src/*.rs dest/").await;
    match result {
        Ok(_) => {}
        Err(e) => assert!(e.to_string().contains("no matches"), "expected no-matches: {}", e),
    }
}

#[tokio::test]
async fn validation_bare_glob_bracket_parses() {
    let kernel = make_kernel().await;
    // [abc].txt now parses and expands
    let result = kernel.execute("echo [abc].txt").await;
    match result {
        Ok(_) => {}
        Err(e) => assert!(e.to_string().contains("no matches"), "expected no-matches: {}", e),
    }
}

#[tokio::test]
async fn validation_allows_glob_builtin() {
    let kernel = make_kernel().await;
    // The `glob` builtin correctly takes pattern arguments
    let result = kernel.execute("glob \"*.txt\"").await;

    // Should pass validation (runtime may return empty list)
    assert!(result.is_ok(), "glob builtin should pass validation");
}

#[tokio::test]
async fn validation_allows_grep_pattern() {
    let kernel = make_kernel().await;
    // grep takes regex patterns, not file globs
    let result = kernel.execute("echo 'func_test' | grep 'func.*test'").await;

    assert!(result.is_ok(), "grep pattern should pass validation");
    let exec = result.unwrap();
    assert!(exec.ok(), "grep should execute successfully");
}

#[tokio::test]
async fn validation_allows_find_pattern() {
    // Use repo-scoped kernel so `find .` walks the crate dir, not $HOME
    let kernel = make_repo_kernel();
    let result = kernel.execute("find . -name \"*.rs\" -maxdepth 2").await;

    // Should pass validation (find handles its own patterns)
    assert!(result.is_ok(), "find with pattern should pass validation");
}

#[tokio::test]
async fn validation_allows_quoted_glob_pattern() {
    let kernel = make_kernel().await;
    // Quoted patterns are passed literally to commands
    let result = kernel.execute("echo \"*.txt\"").await;

    assert!(result.is_ok(), "quoted pattern should pass validation");
    let exec = result.unwrap();
    assert!(exec.ok(), "quoted pattern should execute");
    assert!(exec.text_out().contains("*.txt"), "pattern should be literal");
}

#[tokio::test]
async fn validation_allows_correct_glob_usage() {
    let kernel = make_kernel().await;
    // Correct way: use glob builtin and iterate results
    let result = kernel.execute(r#"
        for f in $(glob "*.nonexistent"); do
            echo $f
        done
    "#).await;

    assert!(result.is_ok(), "correct glob usage should pass validation");
}

// ============================================================================
// Additional glob pattern edge cases
// ============================================================================

#[tokio::test]
async fn validation_allows_glob_double_star_in_cat() {
    let kernel = make_kernel().await;
    // cat now accepts glob patterns natively
    let result = kernel.execute("cat \"**/*.rs\"").await;

    assert!(result.is_ok(), "cat should accept glob patterns: {:?}", result.unwrap_err());
}

#[tokio::test]
async fn validation_quoted_glob_in_mv() {
    let kernel = make_kernel().await;
    // Quoted glob stays as a literal string — no expansion
    let result = kernel.execute("mv \"*.old\" backup/").await;
    // mv may fail for other reasons (missing files), but it should parse ok
    assert!(result.is_ok() || result.is_err(), "should parse without error");
}

#[tokio::test]
async fn validation_allows_glob_in_head() {
    let kernel = make_kernel().await;
    // head now accepts glob patterns natively
    let result = kernel.execute("head \"config*.yaml\"").await;

    assert!(result.is_ok(), "head should accept glob patterns: {:?}", result.unwrap_err());
}

#[tokio::test]
async fn validation_allows_glob_in_tail() {
    let kernel = make_kernel().await;
    // tail now accepts glob patterns natively
    let result = kernel.execute("tail \"log?.txt\"").await;

    assert!(result.is_ok(), "tail should accept glob patterns: {:?}", result.unwrap_err());
}

#[tokio::test]
async fn validation_allows_glob_character_class_in_cat() {
    let kernel = make_kernel().await;
    // cat now accepts glob patterns natively
    let result = kernel.execute("cat \"file[a-z].txt\"").await;

    assert!(result.is_ok(), "cat should accept glob patterns: {:?}", result.unwrap_err());
}

#[tokio::test]
async fn validation_allows_sed_pattern() {
    let kernel = make_kernel().await;
    // sed patterns look like globs but are regex
    let result = kernel.execute("echo 'test' | sed 's/*.txt/replaced/'").await;

    assert!(result.is_ok(), "sed pattern should pass validation");
}

#[tokio::test]
async fn validation_allows_awk_pattern() {
    let kernel = make_kernel().await;
    // awk patterns
    let result = kernel.execute("echo 'test' | awk '/.*\\.txt/ {print}'").await;

    assert!(result.is_ok(), "awk pattern should pass validation");
}

#[tokio::test]
async fn validation_allows_jq_pattern() {
    let kernel = make_kernel().await;
    // jq filter with glob-like syntax
    let result = kernel.execute("echo '{}' | jq '.files[].name'").await;

    assert!(result.is_ok(), "jq filter should pass validation");
}

#[tokio::test]
async fn validation_allows_glob_in_pipeline_first() {
    let kernel = make_kernel().await;
    // cat now accepts glob patterns, so glob in pipeline is valid
    let result = kernel.execute("cat \"*.log\" | grep error").await;

    assert!(result.is_ok(), "cat with glob in pipeline should pass validation: {:?}", result.unwrap_err());
}

#[tokio::test]
async fn validation_allows_glob_in_pipeline_grep() {
    let kernel = make_kernel().await;
    // Pattern in grep (second command) is fine
    let result = kernel.execute("echo 'test.txt' | grep '.*\\.txt'").await;

    assert!(result.is_ok(), "grep pattern in pipeline should pass");
}

#[tokio::test]
async fn validation_allows_glob_with_path_prefix_in_ls() {
    let kernel = make_kernel().await;
    // ls now accepts glob patterns natively
    let result = kernel.execute("ls \"/tmp/*.log\"").await;

    assert!(result.is_ok(), "ls should accept glob patterns: {:?}", result.unwrap_err());
}

#[tokio::test]
async fn validation_allows_literal_asterisk_filename() {
    let kernel = make_kernel().await;
    // A file literally named "star" without extension - not a glob
    let result = kernel.execute("cat \"notes\"").await;

    // Should pass validation (no glob chars, will fail at runtime if file doesn't exist)
    assert!(result.is_ok(), "literal filename should pass validation");
}

#[tokio::test]
async fn validation_allows_printf_pattern() {
    let kernel = make_kernel().await;
    // printf is text output
    let result = kernel.execute("printf '%s\\n' \"*.txt\"").await;

    assert!(result.is_ok(), "printf with pattern should pass validation");
}

// ============================================================================
// Scatter/gather validation tests (E014)
// ============================================================================

#[tokio::test]
async fn validation_blocks_scatter_without_gather() {
    let kernel = make_kernel().await;
    let result = kernel.execute("seq 1 3 | scatter | echo hi").await;

    assert!(result.is_err(), "scatter without gather should fail validation");
    let err = result.unwrap_err().to_string();
    assert!(
        err.contains("gather") || err.contains("E014"),
        "error should mention gather or E014: {}",
        err
    );
}

#[tokio::test]
async fn validation_allows_scatter_with_gather() {
    let kernel = make_kernel().await;
    // seq | scatter | echo | gather should pass validation
    let result = kernel.execute("seq 1 3 | scatter | echo \"hi\" | gather").await;

    assert!(result.is_ok(), "scatter with gather should pass validation: {:?}", result.err());
}

// ============================================================================
// Scatter/gather explicit splitting pipeline tests
// ============================================================================

#[tokio::test]
async fn scatter_seq_structured_data() {
    let kernel = make_kernel().await;
    // seq produces structured JSON array, scatter consumes it
    let result = kernel.execute(r#"seq 1 3 | scatter | echo "$ITEM" | gather"#).await;

    assert!(result.is_ok(), "seq | scatter | gather should pass: {:?}", result.err());
    let exec = result.unwrap();
    assert!(exec.ok(), "pipeline should succeed: {}", exec.err);
    assert!(exec.text_out().contains("1"), "should contain 1: {}", exec.text_out());
    assert!(exec.text_out().contains("2"), "should contain 2: {}", exec.text_out());
    assert!(exec.text_out().contains("3"), "should contain 3: {}", exec.text_out());
}

#[tokio::test]
async fn scatter_split_structured_data() {
    let kernel = make_kernel().await;
    // split produces structured JSON array, scatter consumes it
    let result = kernel.execute(r#"split "a,b,c" "," | scatter as=X | echo "got $X" | gather"#).await;

    assert!(result.is_ok(), "split | scatter | gather should pass: {:?}", result.err());
    let exec = result.unwrap();
    assert!(exec.ok(), "pipeline should succeed: {}", exec.err);
    assert!(exec.text_out().contains("got a"), "should contain 'got a': {}", exec.text_out());
    assert!(exec.text_out().contains("got b"), "should contain 'got b': {}", exec.text_out());
    assert!(exec.text_out().contains("got c"), "should contain 'got c': {}", exec.text_out());
}

#[tokio::test]
async fn scatter_split_stdin_pipe() {
    let kernel = make_kernel().await;
    // echo | split | scatter — split reads from stdin
    let result = kernel.execute(r#"echo "x,y,z" | split "," | scatter as=V | echo "got $V" | gather"#).await;

    assert!(result.is_ok(), "echo | split | scatter should pass: {:?}", result.err());
    let exec = result.unwrap();
    assert!(exec.ok(), "pipeline should succeed: {}", exec.err);
    assert!(exec.text_out().contains("got x"), "should contain 'got x': {}", exec.text_out());
    assert!(exec.text_out().contains("got y"), "should contain 'got y': {}", exec.text_out());
    assert!(exec.text_out().contains("got z"), "should contain 'got z': {}", exec.text_out());
}

#[tokio::test]
async fn scatter_single_item() {
    let kernel = make_kernel().await;
    // Single-line text (no splitting needed)
    let result = kernel.execute(r#"echo "hello" | scatter | echo "$ITEM" | gather"#).await;

    assert!(result.is_ok(), "single item scatter should pass: {:?}", result.err());
    let exec = result.unwrap();
    assert!(exec.ok(), "pipeline should succeed: {}", exec.err);
    assert!(exec.text_out().contains("hello"), "should contain 'hello': {}", exec.text_out());
}

#[tokio::test]
async fn scatter_empty_input() {
    let kernel = make_kernel().await;
    // Empty input to scatter should succeed with no output
    let result = kernel.execute(r#"split "" "," | scatter | echo "$ITEM" | gather"#).await;

    assert!(result.is_ok(), "empty scatter should pass: {:?}", result.err());
    let exec = result.unwrap();
    assert!(exec.ok(), "pipeline should succeed: {}", exec.err);
}

// ============================================================================
// Span tracking for issues raised inside heredoc bodies.
//
// Pre-fix the validator emitted None spans for issues found inside heredoc
// interpolations — `format(source)` produced a bare message with no location.
// With the SpannedPart flow added by the heredoc work, body-internal issues
// now carry byte offsets in the original source, and `format(source)`
// renders `line:col [code]: msg\n  | source-line`.
// ============================================================================

#[tokio::test]
async fn validation_issue_in_heredoc_body_carries_span() {
    use std::collections::HashMap;
    use kaish_kernel::parser::parse;
    use kaish_kernel::tools::{register_builtins, ToolRegistry};
    use kaish_kernel::validator::Validator;

    let source = "cat <<EOF\n${UNDEFINED_VAR}\nEOF";
    let program = parse(source).expect("source parses");

    let mut registry = ToolRegistry::new();
    register_builtins(&mut registry);
    let user_tools = HashMap::new();
    let validator = Validator::new(&registry, &user_tools);
    let issues = validator.validate(&program);

    // The body references UNDEFINED_VAR — validator should warn about it
    // and the warning should now carry a span (was always None pre-fix).
    let undef = issues
        .iter()
        .find(|i| i.message.contains("UNDEFINED_VAR"))
        .expect("should warn about UNDEFINED_VAR");
    let span = undef.span.expect("span must be populated for body-internal issue");

    // "cat <<EOF\n" is 10 bytes; "${UNDEFINED_VAR}" lives on line 2 col 1.
    let (line, col) = span.to_line_col(source);
    assert_eq!(line, 2, "body-internal issue should report line 2");
    assert_eq!(col, 1, "issue should start at column 1 of body line");

    // format(source) must render line:col + the offending source line.
    let rendered = undef.format(source);
    assert!(rendered.starts_with("2:1"), "rendered should start with line:col, got: {rendered}");
    assert!(
        rendered.contains("UNDEFINED_VAR"),
        "rendered should mention the variable: {rendered}",
    );
    assert!(
        rendered.contains("${UNDEFINED_VAR}"),
        "rendered should include the source-line caret showing the offending line: {rendered}",
    );
}

#[tokio::test]
async fn validation_issue_in_double_quoted_string_still_works() {
    // Sibling check: spanless interpolation (regular double-quoted strings)
    // still produces issues, just without spans. This pins the asymmetry —
    // universal spanning is a follow-up refactor.
    use std::collections::HashMap;
    use kaish_kernel::parser::parse;
    use kaish_kernel::tools::{register_builtins, ToolRegistry};
    use kaish_kernel::validator::Validator;

    let source = r#"echo "value is ${UNDEFINED_VAR_TWO}""#;
    let program = parse(source).expect("source parses");

    let mut registry = ToolRegistry::new();
    register_builtins(&mut registry);
    let user_tools = HashMap::new();
    let validator = Validator::new(&registry, &user_tools);
    let issues = validator.validate(&program);

    let undef = issues
        .iter()
        .find(|i| i.message.contains("UNDEFINED_VAR_TWO"))
        .expect("should still warn about double-quoted-string undefs");
    // Spanless path — span is None until universal spanning lands.
    assert!(
        undef.span.is_none(),
        "double-quoted strings remain spanless until follow-up refactor",
    );
}

#[tokio::test]
async fn validation_issue_in_heredoc_body_full_rendering_snapshot() {
    // Snapshot the full ValidationIssue::format(source) output for a
    // body-internal warning. Locks the user-visible diagnostic layout —
    // the `line:col [code]: msg\n  | source-line` shape. If the format
    // changes (e.g., adding colours, switching to ariadne), this test
    // will alert and the snapshot can be reviewed with `cargo insta`.
    use std::collections::HashMap;
    use kaish_kernel::parser::parse;
    use kaish_kernel::tools::{register_builtins, ToolRegistry};
    use kaish_kernel::validator::Validator;

    let source = "cat <<EOF\n${STILL_UNDEFINED}\nEOF";
    let program = parse(source).expect("source parses");
    let mut registry = ToolRegistry::new();
    register_builtins(&mut registry);
    let user_tools = HashMap::new();
    let validator = Validator::new(&registry, &user_tools);
    let issues = validator.validate(&program);

    let undef = issues
        .iter()
        .find(|i| i.message.contains("STILL_UNDEFINED"))
        .expect("expected undefined-variable warning");

    insta::assert_snapshot!(undef.format(source));
}