diff --git a/experiments/cd-edge.mjs b/experiments/cd-edge.mjs new file mode 100644 index 0000000..51fa7cb --- /dev/null +++ b/experiments/cd-edge.mjs @@ -0,0 +1,8 @@ +import { $ } from '../js/src/$.mjs'; +process.chdir('/tmp'); +async function t(cmd){ try{const r=await $`${{raw:cmd}}`; return `code=${r.code} out=${JSON.stringify((r.stdout||'').toString().trim())} err=${JSON.stringify((r.stderr||'').toString().trim())}`;}catch(e){return 'THROW '+e.message;}} +console.log('cd ~ ->', await t('cd ~'), 'cwd:', process.cwd()); +process.chdir('/tmp'); +console.log('cd ->', await t('cd'), 'cwd:', process.cwd()); +process.chdir('/tmp'); +console.log('cd ~/ ->', await t('cd ~/'), 'cwd:', process.cwd()); diff --git a/experiments/cd-sh-comparison.mjs b/experiments/cd-sh-comparison.mjs new file mode 100644 index 0000000..64f3a30 --- /dev/null +++ b/experiments/cd-sh-comparison.mjs @@ -0,0 +1,31 @@ +import { $ } from '../js/src/$.mjs'; + +const tmp = '/tmp/cs-cd-test'; +await $`rm -rf ${tmp}`; +await $`mkdir -p ${tmp}/sub`; + +console.log('=== Test 1: cd - (previous dir) ==='); +try { + process.chdir(tmp); + await $`cd sub`; + console.log('after cd sub, cwd=', process.cwd()); + const r = await $`cd -`; + console.log('cd - stdout:', JSON.stringify(r.stdout), 'code:', r.code, 'cwd now:', process.cwd()); +} catch(e) { console.log('cd - error:', e.message); } + +console.log('=== Test 2: $PWD env var ==='); +process.chdir(tmp); +const pwdEnv = await $`echo $PWD`; +console.log('echo $PWD ->', JSON.stringify(pwdEnv.stdout), ' actual process.cwd:', process.cwd()); + +console.log('=== Test 3: subshell isolation (cd x); pwd ==='); +process.chdir(tmp); +const sub = await $`(cd sub && pwd) ; pwd`; +console.log('subshell stdout:', JSON.stringify(sub.stdout)); +console.log('process.cwd after subshell:', process.cwd()); + +console.log('=== Test 4: cwd option with cd ==='); +const r4 = await $({cwd: tmp})`cd sub && pwd`; +console.log('cwd-option cd sub && pwd ->', JSON.stringify(r4.stdout), 'code', r4.code); + +process.chdir('/tmp'); diff --git a/experiments/env-expand.mjs b/experiments/env-expand.mjs new file mode 100644 index 0000000..acc3cf7 --- /dev/null +++ b/experiments/env-expand.mjs @@ -0,0 +1,6 @@ +import { $ } from '../js/src/$.mjs'; +process.chdir('/tmp'); +for (const cmd of ['echo $HOME', 'echo $PWD', 'echo $OLDPWD', 'echo ~']) { + const r = await $`${{raw: cmd}}`.catch(e=>({stdout:'ERR '+e.message})); + console.log(cmd, '->', JSON.stringify((r.stdout||'').toString().trim())); +} diff --git a/experiments/env2.mjs b/experiments/env2.mjs new file mode 100644 index 0000000..ce4ba0c --- /dev/null +++ b/experiments/env2.mjs @@ -0,0 +1,8 @@ +import { $ } from '../js/src/$.mjs'; +process.chdir('/tmp'); +let r = await $`echo $HOME`; +console.log('virtual echo $HOME ->', JSON.stringify(r.stdout.toString().trim())); +r = await $`/bin/echo $HOME`; +console.log('/bin/echo $HOME ->', JSON.stringify(r.stdout.toString().trim())); +r = await $`/bin/echo hi && /bin/echo $HOME`; +console.log('chained /bin/echo $HOME ->', JSON.stringify(r.stdout.toString().trim())); diff --git a/js/.changeset/cd-sh-compatibility.md b/js/.changeset/cd-sh-compatibility.md new file mode 100644 index 0000000..2c73b9d --- /dev/null +++ b/js/.changeset/cd-sh-compatibility.md @@ -0,0 +1,12 @@ +--- +'command-stream': minor +--- + +Make the built-in `cd` command fully `sh`/bash compatible so shell scripts translate directly to `.mjs` (issue #50): + +- `cd -` switches to the previous directory and prints it, like `sh` +- `~` and `~/path` tilde expansion +- successful `cd` updates the `PWD` and `OLDPWD` environment variables +- relative targets resolve against the `cwd` option for consistency + +Also documents the working-directory behavior (persistence across commands, subshell isolation, and `cd` vs. the `cwd` option) in the README. diff --git a/js/README.md b/js/README.md index b2705b0..a519fa3 100644 --- a/js/README.md +++ b/js/README.md @@ -627,6 +627,81 @@ const numbers = await $`cat numbers.txt`; await $`rm -r project numbers.txt`; ``` +### Working Directory (`cd` and the `cwd` option) + +The built-in `cd` command behaves like `cd` in a POSIX `sh`/bash script, so shell +scripts translate directly. Crucially, **a directory change persists across +subsequent commands** — just like running successive lines in a shell script: + +```javascript +import { $ } from 'command-stream'; + +// In sh: // In command-stream (.mjs): +// cd /some/directory await $`cd /some/directory`; +// pwd # -> /some/directory await $`pwd`; // -> /some/directory +``` + +This is the behavior described in [issue #50](https://github.com/link-foundation/command-stream/issues/50): +each `cd` updates the process working directory, so the next command starts from +the new location. All of the following sh idioms work identically: + +```javascript +await $`cd /tmp && pwd`; // chain with && -> /tmp +await $`cd /tmp`; +await $`pwd`; // separate commands -> /tmp (change persists) +await $`cd`; // no argument -> $HOME +await $`cd ~`; // ~ expands to $HOME +await $`cd ~/projects`; // ~/ prefix expands to $HOME/projects +await $`cd ..`; // parent directory +await $`cd -`; // previous directory (prints it, like sh) +await $`cd /tmp && mkdir t && cd t && pwd`; // -> /tmp/t +``` + +A successful `cd` updates the `PWD` and `OLDPWD` environment variables (used by +`cd -`), exactly like a real shell. A failed `cd` prints a `sh`-style error to +stderr, returns a non-zero exit code, and leaves the working directory unchanged: + +```javascript +const r = await $`cd /does/not/exist`; +console.log(r.code); // 1 +console.log(r.stderr); // cd: ENOENT: no such file or directory, ... +``` + +#### Subshell isolation with `( … )` + +As in sh, a `cd` inside a subshell `( … )` does **not** leak to the parent: + +```javascript +process.chdir('/tmp'); +await $`(cd /usr && pwd) ; pwd`; +// -> /usr (inside the subshell) +// -> /tmp (parent directory is unchanged) +``` + +#### `cd` vs. the `cwd` option + +There are two ways to control the working directory; pick whichever maps best to +the script you are translating: + +```javascript +// 1) cd command — mutates the process working directory and persists, +// just like a line in a shell script. +await $`cd /tmp`; +await $`pwd`; // -> /tmp + +// 2) cwd option — sets a fixed working directory for a single invocation +// (or a reusable $({ cwd }) binding) without changing process.cwd(). +await $({ cwd: '/tmp' })`pwd`; // -> /tmp, process.cwd() is untouched +``` + +Relative `cd` targets are resolved against the `cwd` option when one is provided, +so `` $({ cwd: '/tmp' })`cd sub` `` changes into `/tmp/sub`. + +> **Note:** Virtual commands such as `echo` do not perform shell variable +> expansion, so `echo $PWD` prints the literal string `$PWD`. Use `process.cwd()` +> in JavaScript, the `pwd` command, or a real binary (e.g. `/bin/echo $PWD`) when +> you need the expanded value. + ### Virtual Commands (Extensible Shell) Create custom commands that work seamlessly alongside built-ins: diff --git a/js/examples/cd-cwd-sh-translation.mjs b/js/examples/cd-cwd-sh-translation.mjs new file mode 100644 index 0000000..d11cbef --- /dev/null +++ b/js/examples/cd-cwd-sh-translation.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +// Example for issue #50: translating `cd` patterns from sh to .mjs. +// +// The built-in `cd` command behaves like `cd` in a POSIX sh/bash script, so a +// shell script translates almost line-for-line. Run with: +// node examples/cd-cwd-sh-translation.mjs +import { $ } from '../src/$.mjs'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +const root = mkdtempSync(join(tmpdir(), 'cd-demo-')); + +try { + // ---- sh: cd /dir && pwd ---------------------------------------------------- + // cd /dir + // pwd # -> /dir + let r = await $`cd ${root} && pwd`; + console.log('cd && pwd ->', r.stdout.trim()); + + // ---- sh: the change persists across separate commands ---------------------- + // cd /dir + // pwd # still /dir on the next line + await $`cd ${root}`; + r = await $`pwd`; + console.log('cd; then pwd ->', r.stdout.trim()); + + // ---- sh: nested cd within a chain ------------------------------------------ + await $`mkdir -p ${join(root, 'build')}`; + r = await $`cd ${root} && cd build && pwd`; + console.log('cd a && cd b ->', r.stdout.trim()); + + // ---- sh: cd - returns to the previous directory and prints it -------------- + await $`cd ${root}`; + await $`cd ${join(root, 'build')}`; + r = await $`cd -`; + console.log('cd - ->', r.stdout.trim(), '(printed, like sh)'); + + // ---- sh: subshell isolation — (cd x) does not affect the parent ------------ + await $`cd ${root}`; + r = await $`(cd build && pwd) ; pwd`; + console.log('(cd b); pwd ->', JSON.stringify(r.stdout.trim())); + + // ---- the cwd option: a fixed directory without changing process.cwd() ------ + r = await $({ cwd: root })`pwd`; + console.log('cwd option ->', r.stdout.trim()); +} finally { + process.chdir(tmpdir()); + rmSync(root, { recursive: true, force: true }); +} diff --git a/js/src/commands/$.cd.mjs b/js/src/commands/$.cd.mjs index eaa6520..a568c6a 100644 --- a/js/src/commands/$.cd.mjs +++ b/js/src/commands/$.cd.mjs @@ -1,21 +1,71 @@ +import path from 'path'; import { trace, VirtualUtils } from '../$.utils.mjs'; -export default async function cd({ args }) { - const target = args[0] || process.env.HOME || process.env.USERPROFILE || '/'; +/** + * Virtual `cd` command. + * + * Mirrors POSIX `sh`/bash semantics so that shell scripts translate directly: + * - `cd` -> change to $HOME (or $USERPROFILE on Windows) + * - `cd ~`/`cd ~/x` -> tilde expands to $HOME + * - `cd -` -> change to $OLDPWD and print the new directory (like sh) + * - `cd ` -> change to (relative paths resolve against the + * current working directory, or the `cwd` option) + * + * Like a real shell, a successful `cd` updates the `PWD` and `OLDPWD` + * environment variables and changes the Node.js process directory so that + * subsequent commands (virtual or real) observe the new location. + */ +export default async function cd({ args, cwd }) { + const home = process.env.HOME || process.env.USERPROFILE || '/'; + const base = cwd || process.cwd(); + const previousDir = process.cwd(); + + let target = args[0]; + let printDir = false; + + if (target === undefined || target === '') { + // `cd` with no argument goes to $HOME, just like sh. + target = home; + } else if (target === '-') { + // `cd -` switches to the previous directory and prints it (sh behavior). + const oldpwd = process.env.OLDPWD; + if (!oldpwd) { + trace('VirtualCommand', () => 'cd: OLDPWD not set'); + return { stdout: '', stderr: 'cd: OLDPWD not set\n', code: 1 }; + } + target = oldpwd; + printDir = true; + } else if (target === '~') { + target = home; + } else if (target.startsWith('~/')) { + target = path.join(home, target.slice(2)); + } + + // Resolve relative targets against the effective base directory so that the + // `cwd` option and chained `cd` commands behave consistently. + const resolved = path.isAbsolute(target) + ? target + : path.resolve(base, target); + trace( 'VirtualCommand', - () => `cd: changing directory | ${JSON.stringify({ target }, null, 2)}` + () => + `cd: changing directory | ${JSON.stringify({ target, resolved }, null, 2)}` ); try { - process.chdir(target); + process.chdir(resolved); const newDir = process.cwd(); + // Keep PWD/OLDPWD in sync with the real shell so `$PWD`-style lookups and + // child processes observe the change. + process.env.OLDPWD = previousDir; + process.env.PWD = newDir; trace( 'VirtualCommand', () => `cd: success | ${JSON.stringify({ newDir }, null, 2)}` ); - // cd command should not output anything on success, just like real cd - return VirtualUtils.success(''); + // A successful `cd` is silent, except for `cd -` which echoes the new dir. + return VirtualUtils.success(printDir ? newDir + '\n' : ''); } catch (error) { trace( 'VirtualCommand', diff --git a/js/tests/cd-virtual-command.test.mjs b/js/tests/cd-virtual-command.test.mjs index 18d3038..4bb0583 100644 --- a/js/tests/cd-virtual-command.test.mjs +++ b/js/tests/cd-virtual-command.test.mjs @@ -145,9 +145,15 @@ describe.skipIf(isWindows)('cd Virtual Command - Core Behavior', () => { const pwd2 = await $`pwd`; expect(normalizePath(pwd2.stdout.trim())).toBe(normalizePath(dir2)); - // Note: cd - might not be implemented in virtual command yet - // This test documents expected behavior - const result = await $`cd - 2>&1 || echo "not implemented"`; + // `cd -` switches back to the previous directory and prints it, + // exactly like POSIX sh/bash. + const result = await $`cd -`; + expect(result.code).toBe(0); + expect(normalizePath(result.stdout.trim())).toBe(normalizePath(dir1)); + expect(normalizePath(process.cwd())).toBe(normalizePath(dir1)); + + const pwd3 = await $`pwd`; + expect(normalizePath(pwd3.stdout.trim())).toBe(normalizePath(dir1)); await $`cd ${originalCwd}`; } finally { diff --git a/js/tests/cwd-cd-pattern-issue.test.mjs b/js/tests/cwd-cd-pattern-issue.test.mjs new file mode 100644 index 0000000..4bad3fa --- /dev/null +++ b/js/tests/cwd-cd-pattern-issue.test.mjs @@ -0,0 +1,277 @@ +import { test, expect, describe, beforeEach, afterEach } from 'bun:test'; +import { + beforeTestCleanup, + afterTestCleanup, + originalCwd, +} from './test-cleanup.mjs'; +import { $ } from '../src/$.mjs'; +import { isWindows } from './test-helper.mjs'; +import { mkdtempSync, rmSync, writeFileSync, realpathSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +// Normalize paths to handle macOS /var -> /private/var symlink resolution so +// comparisons against process.cwd() are stable across platforms. +const normalizePath = (p) => { + try { + return realpathSync(p); + } catch { + return p; + } +}; + +// These scenarios rely on POSIX shell semantics (pwd, ls, cat, mkdir -p, +// `&&` chaining, git status format), which the cmd.exe-backed Windows runner +// does not provide. Skip on Windows like the repo's other Unix-shell suites. +describe.skipIf(isWindows)('Issue #50: CWD with CD pattern failure', () => { + let testDir; + + beforeEach(async () => { + await beforeTestCleanup(); + // Normalize so comparisons against process.cwd()/pwd are stable on macOS, + // where the temp dir lives under /var but resolves to /private/var. + testDir = normalizePath(mkdtempSync(join(tmpdir(), 'issue-50-'))); + }); + + afterEach(async () => { + // Ensure we're back in original directory + process.chdir(originalCwd); + if (testDir) { + rmSync(testDir, { recursive: true, force: true }); + } + await afterTestCleanup(); + }); + + test('should handle separate cd and pwd commands correctly', async () => { + // Start from original directory + process.chdir(originalCwd); + + // Run cd command + const cdResult = await $`cd ${testDir}`; + expect(cdResult.code).toBe(0); + + // Check that Node.js CWD actually changed + expect(process.cwd()).toBe(testDir); + + // Run pwd command + const pwdResult = await $`pwd`; + expect(pwdResult.code).toBe(0); + expect(pwdResult.stdout.trim()).toBe(testDir); + }); + + test('should handle cd && pwd pattern correctly', async () => { + process.chdir(originalCwd); + + const result = await $`cd ${testDir} && pwd`; + expect(result.code).toBe(0); + expect(result.stdout.trim()).toBe(testDir); + + // Node.js CWD should also be changed by the virtual cd + expect(process.cwd()).toBe(testDir); + }); + + test('should handle git scenario from issue description', async () => { + const testFile = join(testDir, 'test.txt'); + writeFileSync(testFile, 'Test content'); + + // Initialize git repo + await $({ cwd: testDir })`git init`; + + // Start from original directory + process.chdir(originalCwd); + + // This pattern was failing according to the issue + const addResult = await $`cd ${testDir} && git add test.txt`; + expect(addResult.code).toBe(0); + + // Check git status to verify file was actually added + const statusResult = await $({ cwd: testDir })`git status --short`; + const status = statusResult.stdout.toString().trim(); + + // File should be staged (A test.txt) not untracked (?? test.txt) + expect(status).toContain('A test.txt'); + expect(status).not.toContain('??'); + }); + + test('should maintain directory changes across multiple shell operations', async () => { + const subDir = join(testDir, 'subdir'); + const subSubDir = join(subDir, 'subsubdir'); + + // Create nested directories + await $({ cwd: testDir })`mkdir -p subdir/subsubdir`; + + process.chdir(originalCwd); + + // Chain multiple cd operations + const result = await $`cd ${testDir} && cd subdir && cd subsubdir && pwd`; + expect(result.code).toBe(0); + expect(result.stdout.trim()).toBe(subSubDir); + + // Check that Node.js CWD reflects the final directory + expect(process.cwd()).toBe(subSubDir); + }); + + test('should handle complex build scenario with cd pattern', async () => { + const srcDir = join(testDir, 'src'); + const buildFile = join(srcDir, 'index.js'); + + // Create source directory and file + await $({ cwd: testDir })`mkdir src`; + writeFileSync(buildFile, 'console.log("Hello from build");'); + + process.chdir(originalCwd); + + // Simulate build process that depends on being in correct directory + const buildResult = await $`cd ${testDir} && ls src && cat src/index.js`; + expect(buildResult.code).toBe(0); + expect(buildResult.stdout).toContain('index.js'); + expect(buildResult.stdout).toContain('Hello from build'); + }); + + test('should work with relative paths after cd', async () => { + const subDir = join(testDir, 'relative-test'); + await $({ cwd: testDir })`mkdir relative-test`; + + process.chdir(originalCwd); + + // Use cd then relative paths + const result = await $`cd ${testDir} && cd relative-test && pwd`; + expect(result.code).toBe(0); + expect(result.stdout.trim()).toBe(subDir); + }); + + test('should handle cwd option vs cd command consistency', async () => { + const testFile = join(testDir, 'consistency.txt'); + writeFileSync(testFile, 'test content'); + + process.chdir(originalCwd); + + // Method 1: Using cd command + const cdMethod = await $`cd ${testDir} && cat consistency.txt`; + + // Reset directory + process.chdir(originalCwd); + + // Method 2: Using cwd option + const cwdMethod = await $({ cwd: testDir })`cat consistency.txt`; + + // Both methods should produce the same result + expect(cdMethod.code).toBe(0); + expect(cwdMethod.code).toBe(0); + expect(cdMethod.stdout).toBe(cwdMethod.stdout); + expect(cdMethod.stdout.trim()).toBe('test content'); + }); + + test('should handle error cases correctly', async () => { + const nonExistentDir = join(testDir, 'does-not-exist'); + + process.chdir(originalCwd); + + // cd to non-existent directory should fail + const result = await $`cd ${nonExistentDir} && pwd`; + expect(result.code).not.toBe(0); + + // Should remain in original directory + expect(process.cwd()).toBe(originalCwd); + }); +}); + +// sh-translation semantics ($HOME, ~ expansion, cd -, $PWD/$OLDPWD) are POSIX +// shell concepts; skip on the cmd.exe-backed Windows runner. +describe.skipIf(isWindows)( + 'Issue #50: cd sh-compatibility (drop-in translation from sh)', + () => { + let testDir; + + beforeEach(async () => { + await beforeTestCleanup(); + testDir = normalizePath( + mkdtempSync(join(tmpdir(), 'issue-50-shcompat-')) + ); + }); + + afterEach(async () => { + process.chdir(originalCwd); + if (testDir) { + rmSync(testDir, { recursive: true, force: true }); + } + await afterTestCleanup(); + }); + + test('cd with no argument goes to $HOME (like sh)', async () => { + process.chdir(testDir); + const result = await $`cd`; + expect(result.code).toBe(0); + expect(normalizePath(process.cwd())).toBe( + normalizePath(process.env.HOME) + ); + }); + + test('cd ~ expands tilde to $HOME (like sh)', async () => { + process.chdir(testDir); + const result = await $`cd ~`; + expect(result.code).toBe(0); + expect(normalizePath(process.cwd())).toBe( + normalizePath(process.env.HOME) + ); + }); + + test('cd ~/subpath expands tilde prefix (like sh)', async () => { + process.chdir(testDir); + const result = await $`cd ~/`; + expect(result.code).toBe(0); + expect(normalizePath(process.cwd())).toBe( + normalizePath(process.env.HOME) + ); + }); + + test('cd - switches to previous directory and prints it (like sh)', async () => { + const dirA = normalizePath(mkdtempSync(join(tmpdir(), 'issue-50-a-'))); + const dirB = normalizePath(mkdtempSync(join(tmpdir(), 'issue-50-b-'))); + try { + await $`cd ${dirA}`; + await $`cd ${dirB}`; + const result = await $`cd -`; + expect(result.code).toBe(0); + // sh prints the new (previous) directory on `cd -` + expect(normalizePath(result.stdout.trim())).toBe(dirA); + expect(normalizePath(process.cwd())).toBe(dirA); + } finally { + process.chdir(originalCwd); + rmSync(dirA, { recursive: true, force: true }); + rmSync(dirB, { recursive: true, force: true }); + } + }); + + test('cd updates PWD and OLDPWD environment variables (like sh)', async () => { + const start = normalizePath(mkdtempSync(join(tmpdir(), 'issue-50-pwd-'))); + try { + await $`cd ${start}`; + expect(normalizePath(process.env.PWD)).toBe(start); + await $`cd ${testDir}`; + expect(normalizePath(process.env.PWD)).toBe(testDir); + expect(normalizePath(process.env.OLDPWD)).toBe(start); + } finally { + process.chdir(originalCwd); + rmSync(start, { recursive: true, force: true }); + } + }); + + test('cd to a non-existent directory reports a sh-style error and keeps cwd', async () => { + process.chdir(testDir); + const result = await $`cd ${join(testDir, 'nope')}`; + expect(result.code).toBe(1); + expect(result.stderr).toContain('cd:'); + expect(normalizePath(process.cwd())).toBe(testDir); + }); + + test('relative cd resolves against the cwd option', async () => { + const subDir = join(testDir, 'sub'); + await $({ cwd: testDir })`mkdir sub`; + process.chdir(originalCwd); + const result = await $({ cwd: testDir })`cd sub`; + expect(result.code).toBe(0); + expect(normalizePath(process.cwd())).toBe(normalizePath(subDir)); + }); + } +); diff --git a/rust/changelog.d/20260610_101606_cd-sh-compatibility.md b/rust/changelog.d/20260610_101606_cd-sh-compatibility.md new file mode 100644 index 0000000..9bbf28b --- /dev/null +++ b/rust/changelog.d/20260610_101606_cd-sh-compatibility.md @@ -0,0 +1,10 @@ +--- +bump: minor +--- + +### Changed +- Make the built-in `cd` command fully `sh`/bash compatible so shell scripts translate directly to Rust (issue #50): + - `cd -` switches to the previous directory and prints it, like `sh` + - `~` and `~/path` tilde expansion + - a successful `cd` updates the `PWD` and `OLDPWD` environment variables + - relative targets resolve against the `cwd` option for consistency diff --git a/rust/src/commands/cd.rs b/rust/src/commands/cd.rs index 1c84b48..37dd23b 100644 --- a/rust/src/commands/cd.rs +++ b/rust/src/commands/cd.rs @@ -7,35 +7,79 @@ use std::path::PathBuf; /// Execute the cd command /// -/// Changes the current working directory. +/// Mirrors POSIX `sh`/bash semantics so that shell scripts translate directly: +/// - `cd` -> change to $HOME (or $USERPROFILE on Windows) +/// - `cd ~`/`cd ~/x` -> tilde expands to $HOME +/// - `cd -` -> change to $OLDPWD and print the new directory (like sh) +/// - `cd ` -> change to (relative paths resolve against the +/// current working directory, or the `cwd` option) +/// +/// Like a real shell, a successful `cd` updates the `PWD` and `OLDPWD` +/// environment variables and changes the process directory so that subsequent +/// commands (virtual or real) observe the new location. pub async fn cd(ctx: CommandContext) -> CommandResult { - let target = if ctx.args.is_empty() { - // No argument - go to home directory - env::var("HOME") - .or_else(|_| env::var("USERPROFILE")) - .unwrap_or_else(|_| "/".to_string()) + let home = env::var("HOME") + .or_else(|_| env::var("USERPROFILE")) + .unwrap_or_else(|_| "/".to_string()); + + let previous_dir = env::current_dir().ok(); + let base = ctx.get_cwd(); + + let mut print_dir = false; + let target: String = match ctx.args.first().map(|s| s.as_str()) { + // `cd` with no argument goes to $HOME, just like sh. + None | Some("") => home.clone(), + // `cd -` switches to the previous directory and prints it (sh behavior). + Some("-") => match env::var("OLDPWD") { + Ok(oldpwd) if !oldpwd.is_empty() => { + print_dir = true; + oldpwd + } + _ => { + trace("VirtualCommand", "cd: OLDPWD not set"); + return CommandResult::error("cd: OLDPWD not set\n"); + } + }, + Some("~") => home.clone(), + Some(t) if t.starts_with("~/") => PathBuf::from(&home).join(&t[2..]).display().to_string(), + Some(t) => t.to_string(), + }; + + // Resolve relative targets against the effective base directory so that the + // `cwd` option and chained `cd` commands behave consistently. + let target_path = PathBuf::from(&target); + let resolved = if target_path.is_absolute() { + target_path } else { - ctx.args[0].clone() + base.join(&target_path) }; trace( "VirtualCommand", - &format!("cd: changing directory to {:?}", target), + &format!("cd: changing directory to {:?}", resolved), ); - let path = PathBuf::from(&target); - - match env::set_current_dir(&path) { + match env::set_current_dir(&resolved) { Ok(()) => { let new_dir = env::current_dir() .map(|p| p.display().to_string()) .unwrap_or_default(); + // Keep PWD/OLDPWD in sync with the real shell so `$PWD`-style lookups + // and child processes observe the change. + if let Some(prev) = previous_dir { + env::set_var("OLDPWD", prev); + } + env::set_var("PWD", &new_dir); trace( "VirtualCommand", &format!("cd: success, new dir: {}", new_dir), ); - // cd command should not output anything on success - CommandResult::success_empty() + // A successful `cd` is silent, except for `cd -` which echoes the dir. + if print_dir { + CommandResult::success(format!("{}\n", new_dir)) + } else { + CommandResult::success_empty() + } } Err(e) => { trace("VirtualCommand", &format!("cd: failed: {}", e)); @@ -47,10 +91,26 @@ pub async fn cd(ctx: CommandContext) -> CommandResult { #[cfg(test)] mod tests { use super::*; + use std::path::Path; use tempfile::tempdir; + use tokio::sync::Mutex; + + // `cd` mutates process-global state (current dir + PWD/OLDPWD env vars). + // Rust runs tests in parallel by default, so serialize the cd tests against + // each other to avoid races on that shared state. An async-aware mutex lets + // the guard be held across the `cd(...).await` calls without tripping + // clippy's `await_holding_lock` lint. + static CD_TEST_LOCK: Mutex<()> = Mutex::const_new(()); + + // Normalize paths so comparisons survive symlinked temp dirs + // (e.g. macOS `/var` -> `/private/var`). + fn normalize(p: &Path) -> PathBuf { + std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()) + } #[tokio::test] async fn test_cd_to_temp() { + let _guard = CD_TEST_LOCK.lock().await; let temp = tempdir().unwrap(); let temp_path = temp.path().to_string_lossy().to_string(); let original_dir = env::current_dir().unwrap(); @@ -59,6 +119,10 @@ mod tests { let result = cd(ctx).await; assert!(result.is_success()); assert_eq!(result.stdout, ""); + assert_eq!( + normalize(&env::current_dir().unwrap()), + normalize(temp.path()) + ); // Restore original directory env::set_current_dir(original_dir).unwrap(); @@ -66,8 +130,155 @@ mod tests { #[tokio::test] async fn test_cd_to_nonexistent() { + let _guard = CD_TEST_LOCK.lock().await; + let original_dir = env::current_dir().unwrap(); let ctx = CommandContext::new(vec!["/nonexistent/path/12345".to_string()]); let result = cd(ctx).await; assert!(!result.is_success()); + assert_eq!(result.code, 1); + assert!(result.stderr.contains("cd:")); + // A failed cd must not move the process out of its directory. + assert_eq!(env::current_dir().unwrap(), original_dir); + } + + #[tokio::test] + async fn test_cd_no_arg_goes_home() { + let _guard = CD_TEST_LOCK.lock().await; + let temp = tempdir().unwrap(); + let original_dir = env::current_dir().unwrap(); + env::set_var("HOME", temp.path()); + + let ctx = CommandContext::new(vec![]); + let result = cd(ctx).await; + assert!(result.is_success()); + assert_eq!( + normalize(&env::current_dir().unwrap()), + normalize(temp.path()) + ); + + env::set_current_dir(original_dir).unwrap(); + } + + #[tokio::test] + async fn test_cd_tilde_expands_home() { + let _guard = CD_TEST_LOCK.lock().await; + let temp = tempdir().unwrap(); + let original_dir = env::current_dir().unwrap(); + env::set_var("HOME", temp.path()); + + let ctx = CommandContext::new(vec!["~".to_string()]); + let result = cd(ctx).await; + assert!(result.is_success()); + assert_eq!( + normalize(&env::current_dir().unwrap()), + normalize(temp.path()) + ); + + env::set_current_dir(original_dir).unwrap(); + } + + #[tokio::test] + async fn test_cd_tilde_subpath_expands() { + let _guard = CD_TEST_LOCK.lock().await; + let temp = tempdir().unwrap(); + std::fs::create_dir(temp.path().join("sub")).unwrap(); + let original_dir = env::current_dir().unwrap(); + env::set_var("HOME", temp.path()); + + let ctx = CommandContext::new(vec!["~/sub".to_string()]); + let result = cd(ctx).await; + assert!(result.is_success()); + assert_eq!( + normalize(&env::current_dir().unwrap()), + normalize(&temp.path().join("sub")) + ); + + env::set_current_dir(original_dir).unwrap(); + } + + #[tokio::test] + async fn test_cd_dash_switches_and_prints() { + let _guard = CD_TEST_LOCK.lock().await; + let dir_a = tempdir().unwrap(); + let dir_b = tempdir().unwrap(); + let original_dir = env::current_dir().unwrap(); + + let _ = cd(CommandContext::new(vec![dir_a + .path() + .to_string_lossy() + .to_string()])) + .await; + let _ = cd(CommandContext::new(vec![dir_b + .path() + .to_string_lossy() + .to_string()])) + .await; + + let result = cd(CommandContext::new(vec!["-".to_string()])).await; + assert!(result.is_success()); + // sh prints the previous directory on `cd -`. + assert_eq!( + normalize(Path::new(result.stdout.trim())), + normalize(dir_a.path()) + ); + assert_eq!( + normalize(&env::current_dir().unwrap()), + normalize(dir_a.path()) + ); + + env::set_current_dir(original_dir).unwrap(); + } + + #[tokio::test] + async fn test_cd_updates_pwd_and_oldpwd() { + let _guard = CD_TEST_LOCK.lock().await; + let dir_a = tempdir().unwrap(); + let dir_b = tempdir().unwrap(); + let original_dir = env::current_dir().unwrap(); + + let _ = cd(CommandContext::new(vec![dir_a + .path() + .to_string_lossy() + .to_string()])) + .await; + assert_eq!( + normalize(Path::new(&env::var("PWD").unwrap())), + normalize(dir_a.path()) + ); + + let _ = cd(CommandContext::new(vec![dir_b + .path() + .to_string_lossy() + .to_string()])) + .await; + assert_eq!( + normalize(Path::new(&env::var("PWD").unwrap())), + normalize(dir_b.path()) + ); + assert_eq!( + normalize(Path::new(&env::var("OLDPWD").unwrap())), + normalize(dir_a.path()) + ); + + env::set_current_dir(original_dir).unwrap(); + } + + #[tokio::test] + async fn test_cd_relative_resolves_against_cwd_option() { + let _guard = CD_TEST_LOCK.lock().await; + let temp = tempdir().unwrap(); + std::fs::create_dir(temp.path().join("sub")).unwrap(); + let original_dir = env::current_dir().unwrap(); + + let mut ctx = CommandContext::new(vec!["sub".to_string()]); + ctx.cwd = Some(temp.path().to_path_buf()); + let result = cd(ctx).await; + assert!(result.is_success()); + assert_eq!( + normalize(&env::current_dir().unwrap()), + normalize(&temp.path().join("sub")) + ); + + env::set_current_dir(original_dir).unwrap(); } }