kaish-kernel 0.16.0

Core kernel for kaish: lexer, parser, interpreter, and runtime
Documentation
//! Kernel-routed `ls` tests.
//!
//! `ls` carries a lot of responsibility (globs, multiple paths, sort flags,
//! recursion, dotfile hiding) and historically its unit tests called
//! `Ls.execute()` directly with hand-built `ToolArgs` — bypassing the
//! kernel's glob pre-expansion and flag canonicalization. That bypass let a
//! real bug ship green: `ls crates/*/Cargo.toml` lists only the first match
//! because the kernel expands the glob into N positionals but `ls` reads only
//! `positional[0]`.
//!
//! These tests drive real command strings through `kernel.execute()` over a
//! `tempfile::tempdir()` root, so they exercise the same path a REPL/MCP user
//! hits. See `common::kernel_at` / `common::run`.

// Test-fixture code: unwrap/expect on known-good setup is the idiom here.
#![allow(clippy::unwrap_used, clippy::expect_used)]
#![cfg(feature = "localfs")]

mod common;

use std::fs;

use common::{kernel_at, run};
use tempfile::tempdir;

/// Write `name` (relative to `dir`) with the given contents, creating parents.
fn touch(dir: &std::path::Path, name: &str, contents: &str) {
    let path = dir.join(name);
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).expect("create parent dirs");
    }
    fs::write(path, contents).expect("write file");
}

// ---------------------------------------------------------------------------
// Baseline behavior (these should pass today — regression guards)
// ---------------------------------------------------------------------------

#[tokio::test]
async fn ls_empty_dir_is_empty() {
    let dir = tempdir().unwrap();
    let kernel = kernel_at(dir.path());
    let (out, code) = run(&kernel, "ls").await;
    assert_eq!(code, 0, "ls on empty dir should succeed");
    assert!(out.is_empty(), "empty dir should list nothing, got: {out:?}");
}

#[tokio::test]
async fn ls_single_file_shows_name() {
    let dir = tempdir().unwrap();
    touch(dir.path(), "solo.txt", "hi");
    let kernel = kernel_at(dir.path());
    let (out, code) = run(&kernel, "ls solo.txt").await;
    assert_eq!(code, 0);
    assert!(out.contains("solo.txt"), "expected filename, got: {out:?}");
}

#[tokio::test]
async fn ls_directory_lists_all_entries() {
    let dir = tempdir().unwrap();
    touch(dir.path(), "a.txt", "");
    touch(dir.path(), "b.txt", "");
    touch(dir.path(), "c.txt", "");
    let kernel = kernel_at(dir.path());
    let (out, code) = run(&kernel, "ls").await;
    assert_eq!(code, 0);
    for name in ["a.txt", "b.txt", "c.txt"] {
        assert!(out.contains(name), "missing {name} in: {out:?}");
    }
}

#[tokio::test]
async fn ls_hides_dotfiles_by_default() {
    let dir = tempdir().unwrap();
    touch(dir.path(), ".hidden", "");
    touch(dir.path(), "visible.txt", "");
    let kernel = kernel_at(dir.path());
    let (out, code) = run(&kernel, "ls").await;
    assert_eq!(code, 0);
    assert!(out.contains("visible.txt"), "got: {out:?}");
    assert!(!out.contains(".hidden"), "dotfile should be hidden: {out:?}");
}

#[tokio::test]
async fn ls_all_flag_shows_dotfiles() {
    let dir = tempdir().unwrap();
    touch(dir.path(), ".hidden", "");
    touch(dir.path(), "visible.txt", "");
    let kernel = kernel_at(dir.path());
    let (out, code) = run(&kernel, "ls -a").await;
    assert_eq!(code, 0);
    assert!(out.contains(".hidden"), "-a should reveal dotfile: {out:?}");
    assert!(out.contains("visible.txt"), "got: {out:?}");
}

#[tokio::test]
async fn ls_nonexistent_path_fails() {
    let dir = tempdir().unwrap();
    let kernel = kernel_at(dir.path());
    let (_out, code) = run(&kernel, "ls does_not_exist").await;
    assert_ne!(code, 0, "ls of a missing path should fail");
}

#[tokio::test]
async fn ls_long_format_includes_file() {
    let dir = tempdir().unwrap();
    touch(dir.path(), "data.txt", "0123456789");
    let kernel = kernel_at(dir.path());
    let (out, code) = run(&kernel, "ls -l data.txt").await;
    assert_eq!(code, 0);
    assert!(out.contains("data.txt"), "long format missing name: {out:?}");
    assert!(out.contains("10"), "long format should show size 10: {out:?}");
}

// ---------------------------------------------------------------------------
// Multiple paths / glob expansion — the kernel-contract cases the old
// direct-`.execute()` unit tests could not see. EXPECTED TO FAIL until `ls`
// iterates all positionals ("ls <glob> only lists the first match under
// kernel pre-expansion").
// ---------------------------------------------------------------------------

#[tokio::test]
async fn ls_multiple_explicit_files_lists_all() {
    let dir = tempdir().unwrap();
    touch(dir.path(), "first.txt", "");
    touch(dir.path(), "second.txt", "");
    let kernel = kernel_at(dir.path());
    let (out, code) = run(&kernel, "ls first.txt second.txt").await;
    assert_eq!(code, 0, "ls of two files should succeed: {out:?}");
    assert!(out.contains("first.txt"), "missing first.txt: {out:?}");
    assert!(out.contains("second.txt"), "missing second.txt: {out:?}");
}

#[tokio::test]
async fn ls_glob_lists_all_matches() {
    let dir = tempdir().unwrap();
    touch(dir.path(), "one.rs", "");
    touch(dir.path(), "two.rs", "");
    touch(dir.path(), "three.rs", "");
    touch(dir.path(), "ignore.txt", "");
    let kernel = kernel_at(dir.path());
    let (out, code) = run(&kernel, "ls *.rs").await;
    assert_eq!(code, 0, "glob ls should succeed: {out:?}");
    for name in ["one.rs", "two.rs", "three.rs"] {
        assert!(out.contains(name), "glob dropped {name}: {out:?}");
    }
    assert!(!out.contains("ignore.txt"), "glob matched non-.rs: {out:?}");
}

#[tokio::test]
async fn ls_glob_in_subdir_lists_all_matches() {
    let dir = tempdir().unwrap();
    touch(dir.path(), "sub/alpha.txt", "");
    touch(dir.path(), "sub/beta.txt", "");
    let kernel = kernel_at(dir.path());
    let (out, code) = run(&kernel, "ls sub/*.txt").await;
    assert_eq!(code, 0, "subdir glob ls should succeed: {out:?}");
    assert!(out.contains("alpha.txt"), "missing alpha.txt: {out:?}");
    assert!(out.contains("beta.txt"), "missing beta.txt: {out:?}");
}

#[tokio::test]
async fn ls_single_glob_match_works() {
    let dir = tempdir().unwrap();
    touch(dir.path(), "only.rs", "");
    touch(dir.path(), "skip.txt", "");
    let kernel = kernel_at(dir.path());
    let (out, code) = run(&kernel, "ls *.rs").await;
    assert_eq!(code, 0);
    assert!(out.contains("only.rs"), "got: {out:?}");
    assert!(!out.contains("skip.txt"), "got: {out:?}");
}

// ---------------------------------------------------------------------------
// Sort / recursion flags through the real flag-canonicalization path.
// ---------------------------------------------------------------------------

#[tokio::test]
async fn ls_recursive_includes_nested_entries() {
    let dir = tempdir().unwrap();
    touch(dir.path(), "top.txt", "");
    touch(dir.path(), "nested/inner.txt", "");
    let kernel = kernel_at(dir.path());
    let (out, code) = run(&kernel, "ls -R").await;
    assert_eq!(code, 0);
    assert!(out.contains("top.txt"), "missing top-level entry: {out:?}");
    assert!(out.contains("inner.txt"), "recursive missed nested: {out:?}");
}

#[tokio::test]
async fn ls_reverse_sort_orders_descending() {
    let dir = tempdir().unwrap();
    touch(dir.path(), "aaa.txt", "");
    touch(dir.path(), "zzz.txt", "");
    let kernel = kernel_at(dir.path());
    let (out, code) = run(&kernel, "ls -r").await;
    assert_eq!(code, 0);
    let a = out.find("aaa.txt").expect("aaa present");
    let z = out.find("zzz.txt").expect("zzz present");
    assert!(z < a, "reverse sort should put zzz before aaa: {out:?}");
}

// ---------------------------------------------------------------------------
// `ls -R` headers match GNU's operand-as-written convention (#398 follow-up).
// ---------------------------------------------------------------------------

/// A relative directory operand headers every level with itself joined onto
/// the subpath, GNU style: `ls -R sub` headers `sub:` then `sub/inner:`, not
/// the bare `.:` / `inner:` kaish used to print regardless of the operand.
#[tokio::test]
async fn ls_recursive_relative_operand_headers_match_gnu() {
    let dir = tempdir().unwrap();
    touch(dir.path(), "sub/top.txt", "");
    touch(dir.path(), "sub/inner/deep.txt", "");
    let kernel = kernel_at(dir.path());
    let (out, code) = run(&kernel, "ls -R sub").await;
    assert_eq!(code, 0);
    assert!(out.contains("sub:\n"), "top header should be the operand: {out:?}");
    assert!(out.contains("sub/inner:\n"), "nested header should join onto the operand: {out:?}");
}

/// The `.` operand (explicit or the `ls -R` default) headers the root `.:`
/// but joins every subdirectory with a `./` prefix, matching GNU exactly —
/// kaish used to print the bare subdirectory name with no prefix at all.
#[tokio::test]
async fn ls_recursive_dot_operand_prefixes_children_with_dot_slash() {
    let dir = tempdir().unwrap();
    touch(dir.path(), "sub/inner.txt", "");
    let kernel = kernel_at(dir.path());
    let (out, code) = run(&kernel, "ls -R").await;
    assert_eq!(code, 0);
    assert!(out.starts_with(".:\n"), "root header stays bare .: {out:?}");
    assert!(out.contains("./sub:\n"), "child header gets GNU's ./ prefix: {out:?}");
}

/// An absolute directory operand headers every level with its full absolute
/// path, the same class of fix as #398 for `glob`/`grep -r`: a header kaish
/// prints should be usable as a path.
#[tokio::test]
async fn ls_recursive_absolute_operand_headers_stay_absolute() {
    let dir = tempdir().unwrap();
    touch(dir.path(), "sub/inner.txt", "");
    let kernel = kernel_at(dir.path());
    let abs = dir.path().join("sub");
    let (out, code) = run(&kernel, &format!("ls -R {}", dir.path().display())).await;
    assert_eq!(code, 0);
    assert!(
        out.contains(&format!("{}:\n", abs.display())),
        "nested header should be the absolute path, not a bare name: {out:?}"
    );
}

/// The `--json` shape is a separate contract from the text header fix above:
/// node names still start from `.` for the walk root and join bare names for
/// children, regardless of how the operand was spelled. Needs a real nested
/// subdirectory — a flat one-level walk collapses to a single un-keyed node,
/// which would pass trivially either way.
#[tokio::test]
async fn ls_recursive_json_shape_is_unaffected_by_operand_spelling() {
    let dir = tempdir().unwrap();
    touch(dir.path(), "sub/inner/deep.txt", "");
    let kernel = kernel_at(dir.path());
    let (out, code) = run(&kernel, "ls -R sub --json").await;
    assert_eq!(code, 0);
    assert!(
        out.contains("\".\":"),
        "the json walk-root key must stay '.', unaffected by the operand: {out:?}"
    );
}

/// `ls -R` with several operands recursed into none of them: the multi-operand
/// arm never looked at the `recursive` flag, so the flag was silently dropped
/// and the command exited 0. A dropped flag is worse than a display
/// difference — the caller asked for a tree and got a list of names.
///
/// Checked against `/bin/ls -R d1 d2` directly, not the `ls` on PATH.
#[tokio::test]
async fn ls_recursive_recurses_into_every_operand() {
    let dir = tempdir().unwrap();
    fs::create_dir_all(dir.path().join("d1/sub")).unwrap();
    fs::create_dir_all(dir.path().join("d2")).unwrap();
    fs::write(dir.path().join("d1/a.txt"), b"").unwrap();
    fs::write(dir.path().join("d1/sub/deep.txt"), b"").unwrap();
    fs::write(dir.path().join("d2/b.txt"), b"").unwrap();
    let kernel = kernel_at(dir.path());

    let (out, code) = run(&kernel, "ls -R d1 d2").await;
    assert_eq!(code, 0, "ls -R should succeed: {out:?}");
    for expected in ["d1:", "d1/sub:", "d2:", "deep.txt", "b.txt"] {
        assert!(
            out.contains(expected),
            "every operand must be recursed: missing {expected:?} in {out:?}"
        );
    }
}

/// The `--json` half of the same fix. Each operand's recursive listing roots
/// its nodes at `.`, so concatenating several collided — the second operand
/// overwrote the first and `--json` reported one operand's contents under the
/// other's name. Silent, and exactly the class of bug the text fix was for.
#[tokio::test]
async fn ls_recursive_multiple_operands_do_not_collide_in_json() {
    let dir = tempdir().unwrap();
    fs::create_dir_all(dir.path().join("d1/sub")).unwrap();
    fs::create_dir_all(dir.path().join("d2")).unwrap();
    fs::write(dir.path().join("d1/a.txt"), b"").unwrap();
    fs::write(dir.path().join("d1/sub/deep.txt"), b"").unwrap();
    fs::write(dir.path().join("d2/b.txt"), b"").unwrap();
    let kernel = kernel_at(dir.path());

    let (out, code) = run(&kernel, "ls -R d1 d2 --json").await;
    assert_eq!(code, 0, "ls -R --json should succeed: {out:?}");
    assert!(out.contains("\"d1\""), "d1 must keep its own group: {out:?}");
    assert!(out.contains("\"d2\""), "d2 must keep its own group: {out:?}");
    assert!(
        out.contains("a.txt") && out.contains("b.txt") && out.contains("deep.txt"),
        "no operand's contents may be lost to a collision: {out:?}"
    );
}

/// The control: one operand still roots at `.`, which is the shape #404
/// deliberately preserved. Fixing the multi-operand collision must not change
/// the single-operand `--json` contract.
#[tokio::test]
async fn ls_recursive_single_operand_json_still_roots_at_dot() {
    let dir = tempdir().unwrap();
    fs::create_dir_all(dir.path().join("d1/sub")).unwrap();
    fs::write(dir.path().join("d1/a.txt"), b"").unwrap();
    let kernel = kernel_at(dir.path());

    let (out, code) = run(&kernel, "ls -R d1 --json").await;
    assert_eq!(code, 0, "ls -R --json should succeed: {out:?}");
    assert!(
        out.contains("\".\""),
        "a single operand still roots at `.`: {out:?}"
    );
}