From ce6ed282fca3f841f5adf31220ca031e26475faf Mon Sep 17 00:00:00 2001 From: benbenbang Date: Wed, 9 Sep 2026 20:16:57 +0800 Subject: [PATCH 1/3] feat(shell)!: add bounded shell-local directory history Use 100-entry browser-style history in Bash, Zsh, Nushell, and PowerShell. Integration regeneration and reload are required after upgrading. Refs #36 --- README.md | 19 ++- src/shell.rs | 255 ++++++++++++++++++++-------------- tests/external_integration.rs | 8 +- 3 files changed, 168 insertions(+), 114 deletions(-) diff --git a/README.md b/README.md index e6daecc..f76d46e 100644 --- a/README.md +++ b/README.md @@ -104,12 +104,23 @@ cd external-ssd # a configured mount cd top # top level of the current Git repository cd origin # main worktree; "og" is also enabled by default cd ^^^ # three directories up -cd vvv # three levels back down along the remembered route +cd vvv # go back three directory-history entries ``` -Downward navigation is browser-style: moving up remembers the directory you left, -and the down ticker walks back toward it one level at a time. A different successful -directory change clears that route. Existing directories always win over tickers. +The down ticker uses browser-style directory history: every successful `cd` change +remembers the directory you left, including unrelated paths, aliases, mounts, raw +paths, and zoxide jumps. `cd v` goes back one entry; `cd vvv` goes back three. +Moving up with `cd ^^^` records each traversed parent so `cd vvv` retraces the move. +History keeps the latest 100 departures in the current shell session and resets +when you reload the integration. Existing directories always win over tickers. + +Going back consumes entries without adding the current directory. An empty or +insufficient history reports `cj: directory history exhausted`; a missing history +destination reports the shell's directory error. Both leave history and the +current directory unchanged. Failed commands and moves to the current directory +also preserve history. Native escapes such as `builtin cd` or `Set-Location` bypass +history tracking. Regenerate and reload your integration after upgrading to use +this behavior. Resolver flags make the choice explicit: diff --git a/src/shell.rs b/src/shell.rs index f398808..c03ac53 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -39,7 +39,7 @@ fn render_posix( let wrapper = format!( r#"_cj_navigate_up={navigate_up} _cj_navigate_down={navigate_down} -unset _cj_down_route +_cj_history=() function _cj_is_repeated() {{ local value="$1" ticker="$2" @@ -51,16 +51,50 @@ function _cj_is_repeated() {{ return 0 }} +function _cj_record_move() {{ + local before="$1" after="$2" mode="${{3-other}}" parent physical previous + [[ "$before" != "$after" ]] || return 0 + if typeset -f _cj_key_history_reset >/dev/null; then _cj_key_history_reset; fi + _cj_history+=("$before") + if [[ "$mode" == up ]]; then + # The native cd resolves .. against its logical path, including symlinks. + parent="${{OLDPWD%/*}}" + previous="$before" + [[ -n "$parent" ]] || parent=/ + while [[ "$parent" != / ]]; do + physical="$(\builtin cd -- "$parent" && \builtin pwd -P)" || break + [[ "$physical" != "$after" ]] || break + if [[ "$physical" != "$previous" ]]; then _cj_history+=("$physical"); fi + previous="$physical" + parent="${{parent%/*}}" + [[ -n "$parent" ]] || parent=/ + done + fi + if (( ${{#_cj_history[@]}} > 100 )); then + _cj_history=("${{_cj_history[@]: -100}}") + fi + return 0 +}} + +function _cj_builtin_cd() {{ + local before after result + before="$(\builtin pwd -P)" || return + \{builtin} cd "$@" + result=$? + (( result == 0 )) || return "$result" + after="$(\builtin pwd -P)" || return + _cj_record_move "$before" "$after" +}} + \builtin unalias cd 2>/dev/null || : function cd() {{ local target _cj_status _cj_before _cj_after _cj_nav_arg _cj_nav_mode + local _cj_count _cj_remaining _cj_index _cj_value local -a _cj_args if [[ $# -eq 0 ]]; then - \{builtin} cd - _cj_status=$? - (( _cj_status == 0 )) && unset _cj_down_route - return "$_cj_status" + _cj_builtin_cd + return fi case "${{1-}}" in @@ -70,10 +104,8 @@ function cd() {{ printf '%s\n' 'cj: --raw requires exactly one target' >&2 return 2 fi - \{builtin} cd -- "$1" - _cj_status=$? - (( _cj_status == 0 )) && unset _cj_down_route - return "$_cj_status" + _cj_builtin_cd -- "$1" + return ;; -z|--zoxide) _cj_nav_arg= @@ -84,24 +116,16 @@ function cd() {{ ;; -Z|--no-zoxide) if [[ $# -eq 2 ]]; then - if \{builtin} cd -- "$2" 2>/dev/null; then - unset _cj_down_route - return - fi + if _cj_builtin_cd -- "$2" 2>/dev/null; then return 0; fi _cj_nav_arg="$2" fi ;; -*) - \{builtin} cd "$@" - _cj_status=$? - (( _cj_status == 0 )) && unset _cj_down_route - return "$_cj_status" + _cj_builtin_cd "$@" + return ;; *) - if \{builtin} cd "$@" 2>/dev/null; then - unset _cj_down_route - return - fi + if _cj_builtin_cd "$@" 2>/dev/null; then return 0; fi [[ $# -eq 1 ]] && _cj_nav_arg="$1" ;; esac @@ -110,38 +134,40 @@ function cd() {{ if _cj_is_repeated "${{_cj_nav_arg-}}" "$_cj_navigate_up"; then _cj_nav_mode=up elif _cj_is_repeated "${{_cj_nav_arg-}}" "$_cj_navigate_down"; then - _cj_nav_mode=down + _cj_count=0 + _cj_value="$_cj_nav_arg" + while [[ -n "$_cj_value" ]]; do + _cj_count=$((_cj_count + 1)) + _cj_value="${{_cj_value#"$_cj_navigate_down"}}" + done + _cj_remaining=$((${{#_cj_history[@]}} - _cj_count)) + if (( _cj_remaining < 0 )); then + printf '%s\n' 'cj: directory history exhausted' >&2 + return 2 + fi + _cj_index=$_cj_remaining + [[ -n "${{ZSH_VERSION-}}" ]] && _cj_index=$((_cj_index + 1)) + target="${{_cj_history[_cj_index]}}" + _cj_before="$(\builtin pwd -P)" || return + \{builtin} cd -- "$target" || return + _cj_after="$(\builtin pwd -P)" || return + [[ "$_cj_before" != "$_cj_after" ]] || return 0 + _cj_history=("${{_cj_history[@]:0:_cj_remaining}}") + if typeset -f _cj_key_history_reset >/dev/null; then _cj_key_history_reset; fi + return 0 fi case "${{1-}}" in -z|--zoxide|-Z|--no-zoxide) _cj_args=("$@") ;; *) _cj_args=(-- "$@") ;; esac _cj_before="$(\builtin pwd -P)" || return - target="$(CJ_INTERNAL_DOWN_ROUTE="${{_cj_down_route-}}" \command cj{config} "${{_cj_args[@]}}")" + target="$(\command cj{config} "${{_cj_args[@]}}")" _cj_status=$? (( _cj_status == 0 )) || return "$_cj_status" [[ -n "$target" ]] || return 1 - \{builtin} cd -- "$target" - _cj_status=$? - (( _cj_status == 0 )) || return "$_cj_status" + \{builtin} cd -- "$target" || return _cj_after="$(\builtin pwd -P)" || return - - case "$_cj_nav_mode" in - up) - if [[ "$_cj_after" != "$_cj_before" ]]; then - if [[ -z "${{_cj_down_route-}}" || ( "$_cj_down_route" != "$_cj_before" && "$_cj_down_route" != "$_cj_before"/* ) ]]; then - _cj_down_route="$_cj_before" - fi - fi - ;; - down) - [[ "$_cj_after" == "${{_cj_down_route-}}" ]] && unset _cj_down_route - ;; - *) - unset _cj_down_route - ;; - esac - return 0 + _cj_record_move "$_cj_before" "$_cj_after" "$_cj_nav_mode" }} function _cj_complete_cd() {{ @@ -275,13 +301,31 @@ fn render_nu(config_path: Option<&Path>, settings: &Config) -> Result 0 and (($value | split chars | all {{ |char| $char == $ticker }})) }} +def --env _cj-record-move [before: string, mode: string = 'other', logical: string = ''] {{ + let after = ($env.PWD | path expand) + if $before == $after {{ return }} + mut entries = [$before] + if $mode == 'up' {{ + mut parent = ($logical | path dirname) + mut previous = $before + while $parent != ($parent | path dirname) {{ + let physical = ($parent | path expand) + if $physical == $after {{ break }} + if $physical != $previous {{ $entries = ($entries | append $physical) }} + $previous = $physical + $parent = ($parent | path dirname) + }} + }} + $env.__cj_history = ($env.__cj_history | append $entries | last 100) +}} + def _cj-complete-cd [spans: list] {{ let current = ($spans | last) if (($spans | length) == 2) and ($current in ['-jw' '--jump-worktree']) {{ @@ -306,10 +350,12 @@ export def --env --wrapped __cj_cd [...args: string] {{ let config = {config} let navigate_up = {navigate_up} let navigate_down = {navigate_down} + let logical = $env.PWD + let before = ($env.PWD | path expand) if ($args | is-empty) {{ - cd $nu.home-path - $env.CJ_INTERNAL_DOWN_ROUTE = '' + cd + _cj-record-move $before return }} @@ -322,14 +368,14 @@ export def --env --wrapped __cj_cd [...args: string] {{ error make {{ msg: 'cj: --raw requires exactly one target' }} }} cd $args.1 - $env.CJ_INTERNAL_DOWN_ROUTE = '' + _cj-record-move $before return }} if (($args.0 == '-P') or ($args.0 == '--physical')) {{ if ($args | length) > 2 {{ error make {{ msg: 'cj: --physical accepts at most one target' }} }} if ($args | length) == 1 {{ cd --physical }} else {{ cd --physical $args.1 }} - $env.CJ_INTERNAL_DOWN_ROUTE = '' + _cj-record-move $before return }} @@ -341,28 +387,36 @@ export def --env --wrapped __cj_cd [...args: string] {{ if (($args | length) == 1) and (not ($args.0 | str starts-with '-')) {{ let direct = (try {{ cd $args.0; true }} catch {{ false }}) if $direct {{ - $env.CJ_INTERNAL_DOWN_ROUTE = '' + _cj-record-move $before return }} }} else if (($args | length) == 2) and (($args.0 == '-Z') or ($args.0 == '--no-zoxide')) {{ let direct = (try {{ cd $args.1; true }} catch {{ false }}) if $direct {{ - $env.CJ_INTERNAL_DOWN_ROUTE = '' + _cj-record-move $before return }} }} else if ($args.0 | str starts-with '-') and (not ($args.0 in ['-z' '--zoxide' '-Z' '--no-zoxide'])) {{ if ($args | length) != 1 {{ error make {{ msg: 'cj: Nushell cd accepts one target' }} }} cd $args.0 - $env.CJ_INTERNAL_DOWN_ROUTE = '' + _cj-record-move $before return }} let nav_arg = if (($args | length) == 1) {{ $args.0 }} else if (($args | length) == 2) and (($args.0 == '-Z') or ($args.0 == '--no-zoxide')) {{ $args.1 }} else {{ '' }} - let before = ($env.PWD | path expand) let invoke_args = if ($args.0 in ['-z' '--zoxide' '-Z' '--no-zoxide']) {{ $args }} else {{ ['--'] | append $args }} - let result = with-env {{ CJ_INTERNAL_DOWN_ROUTE: $env.CJ_INTERNAL_DOWN_ROUTE }} {{ - ^cj ...$config ...$invoke_args | complete + if (_cj-is-repeated $nav_arg $navigate_down) {{ + let count = ($nav_arg | str length) + let remaining = (($env.__cj_history | length) - $count) + if $remaining < 0 {{ error make {{ msg: 'cj: directory history exhausted' }} }} + let target = ($env.__cj_history | get $remaining) + cd $target + if ($env.PWD | path expand) != $before {{ + $env.__cj_history = ($env.__cj_history | first $remaining) + }} + return }} + let result = (^cj ...$config ...$invoke_args | complete) if $result.exit_code != 0 {{ if not ($result.stderr | is-empty) {{ print --stderr --no-newline $result.stderr }} error make {{ msg: $'cj exited with status ($result.exit_code)' }} @@ -370,23 +424,8 @@ export def --env --wrapped __cj_cd [...args: string] {{ let target = ($result.stdout | str replace --regex '\r?\n$' '') if ($target | is-empty) {{ error make {{ msg: 'cj returned an empty destination' }} }} cd $target - let after = ($env.PWD | path expand) - - if (_cj-is-repeated $nav_arg $navigate_up) and ($after != $before) {{ - let route_is_below = if ($env.CJ_INTERNAL_DOWN_ROUTE | is-empty) {{ false }} else {{ - try {{ - let relative = ($env.CJ_INTERNAL_DOWN_ROUTE | path relative-to $before) - (($relative | path split | first) != '..') - }} catch {{ false }} - }} - if not $route_is_below {{ - $env.CJ_INTERNAL_DOWN_ROUTE = $before - }} - }} else if (_cj-is-repeated $nav_arg $navigate_down) {{ - if $after == $env.CJ_INTERNAL_DOWN_ROUTE {{ $env.CJ_INTERNAL_DOWN_ROUTE = '' }} - }} else {{ - $env.CJ_INTERNAL_DOWN_ROUTE = '' - }} + let mode = if (_cj-is-repeated $nav_arg $navigate_up) {{ 'up' }} else {{ 'other' }} + _cj-record-move $before $mode $logical }} export alias cd = __cj_cd"# @@ -414,7 +453,7 @@ fn render_powershell( r#"$script:__cj_executable = Get-Command cj -CommandType Application -ErrorAction Stop | Select-Object -First 1 $script:__cj_config = {config} $script:__cj_destinations = @({destinations}) -$global:__cj_down_route = $null +$global:__cj_history = @() $script:__cj_navigate_up = {navigate_up} $script:__cj_navigate_down = {navigate_down} $script:__cj_path_comparison = if ($env:OS -eq 'Windows_NT') {{ [System.StringComparison]::OrdinalIgnoreCase }} else {{ [System.StringComparison]::Ordinal }} @@ -425,12 +464,20 @@ function script:Test-CjRepeated {{ return $Value.Replace($Ticker, '').Length -eq 0 }} -function script:Test-CjDescendant {{ - param([string]$Root, [string]$Candidate) - if ([string]::IsNullOrEmpty($Root) -or [string]::IsNullOrEmpty($Candidate)) {{ return $false }} - $relative = [System.IO.Path]::GetRelativePath($Root, $Candidate) - $parentPrefix = '..' + [System.IO.Path]::DirectorySeparatorChar - return -not [System.IO.Path]::IsPathRooted($relative) -and $relative -ne '..' -and -not $relative.StartsWith($parentPrefix, $script:__cj_path_comparison) +function script:Add-CjHistory {{ + param([string]$Before, [string]$Mode = 'other') + $after = (Microsoft.PowerShell.Management\Get-Location).ProviderPath + if ($after.Equals($Before, $script:__cj_path_comparison)) {{ return }} + if (Get-Command Reset-CjKeyHistory -CommandType Function -ErrorAction SilentlyContinue) {{ Reset-CjKeyHistory }} + $entries = @($Before) + if ($Mode -eq 'up') {{ + $parent = [System.IO.Path]::GetDirectoryName($Before) + while (-not [string]::IsNullOrEmpty($parent) -and -not $parent.Equals($after, $script:__cj_path_comparison)) {{ + $entries += $parent + $parent = [System.IO.Path]::GetDirectoryName($parent) + }} + }} + $global:__cj_history = @(($global:__cj_history + $entries) | Select-Object -Last 100) }} function script:ConvertTo-CjCompletionText {{ @@ -474,9 +521,10 @@ function script:Complete-CjCdArgument {{ Remove-Item Alias:cd -Force -ErrorAction SilentlyContinue function global:cd {{ $cjArgs = @($args) + $before = (Microsoft.PowerShell.Management\Get-Location).ProviderPath if ($cjArgs.Count -eq 0) {{ Microsoft.PowerShell.Management\Set-Location -LiteralPath $HOME -ErrorAction Stop - $global:__cj_down_route = $null + Add-CjHistory $before return }} $first = [string]$cjArgs[0] @@ -488,60 +536,55 @@ function global:cd {{ if (($first -ceq '-r') -or ($first -ceq '--raw')) {{ if ($cjArgs.Count -ne 2) {{ throw 'cj: --raw requires exactly one target' }} Microsoft.PowerShell.Management\Set-Location -LiteralPath $cjArgs[1] -ErrorAction Stop - $global:__cj_down_route = $null + Add-CjHistory $before return }} if (($cjArgs.Count -eq 1) -and (Test-Path -LiteralPath $cjArgs[0] -PathType Container)) {{ Microsoft.PowerShell.Management\Set-Location -LiteralPath $cjArgs[0] -ErrorAction Stop - $global:__cj_down_route = $null + Add-CjHistory $before return }} if (($cjArgs.Count -eq 1) -and ($first -ceq '-')) {{ Microsoft.PowerShell.Management\Set-Location -Path '-' -ErrorAction Stop - $global:__cj_down_route = $null + Add-CjHistory $before return }} if (($first -ceq '-Path') -and ($cjArgs.Count -eq 2)) {{ Microsoft.PowerShell.Management\Set-Location -Path $cjArgs[1] -ErrorAction Stop - $global:__cj_down_route = $null + Add-CjHistory $before return }} if (($first -ceq '-LiteralPath') -and ($cjArgs.Count -eq 2)) {{ Microsoft.PowerShell.Management\Set-Location -LiteralPath $cjArgs[1] -ErrorAction Stop - $global:__cj_down_route = $null + Add-CjHistory $before return }} if ($first.StartsWith('-') -and -not (($first -ceq '-z') -or ($first -ceq '--zoxide') -or ($first -ceq '-Z') -or ($first -ceq '--no-zoxide'))) {{ throw "cj: unsupported PowerShell cd option: $first" }} $navArg = if ($cjArgs.Count -eq 1) {{ $first }} elseif (($cjArgs.Count -eq 2) -and (($first -ceq '-Z') -or ($first -ceq '--no-zoxide'))) {{ [string]$cjArgs[1] }} else {{ '' }} - $before = (Microsoft.PowerShell.Management\Get-Location).ProviderPath [string[]]$invokeArgs = if (($first -ceq '-z') -or ($first -ceq '--zoxide') -or ($first -ceq '-Z') -or ($first -ceq '--no-zoxide')) {{ $cjArgs }} else {{ @('--') + $cjArgs }} - $hadRoute = Test-Path Env:CJ_INTERNAL_DOWN_ROUTE - $oldRoute = $env:CJ_INTERNAL_DOWN_ROUTE + if (Test-CjRepeated $navArg $script:__cj_navigate_down) {{ + $remaining = $global:__cj_history.Count - $navArg.Length + if ($remaining -lt 0) {{ throw 'cj: directory history exhausted' }} + $target = $global:__cj_history[$remaining] + Microsoft.PowerShell.Management\Set-Location -LiteralPath $target -ErrorAction Stop + $after = (Microsoft.PowerShell.Management\Get-Location).ProviderPath + if (-not $after.Equals($before, $script:__cj_path_comparison)) {{ + $global:__cj_history = @($global:__cj_history | Select-Object -First $remaining) + if (Get-Command Reset-CjKeyHistory -CommandType Function -ErrorAction SilentlyContinue) {{ Reset-CjKeyHistory }} + }} + return + }} $configArgs = $script:__cj_config $executable = $script:__cj_executable.Path - try {{ - $env:CJ_INTERNAL_DOWN_ROUTE = if ($null -eq $global:__cj_down_route) {{ '' }} else {{ $global:__cj_down_route }} - $target = @(& $executable @configArgs @invokeArgs) - $status = $LASTEXITCODE - }} finally {{ - if ($hadRoute) {{ $env:CJ_INTERNAL_DOWN_ROUTE = $oldRoute }} else {{ Remove-Item Env:CJ_INTERNAL_DOWN_ROUTE -ErrorAction SilentlyContinue }} - }} + $target = @(& $executable @configArgs @invokeArgs) + $status = $LASTEXITCODE if ($status -ne 0) {{ throw "cj exited with status $status" }} if ($target.Count -ne 1 -or [string]::IsNullOrEmpty($target[0])) {{ throw 'cj returned an invalid destination' }} Microsoft.PowerShell.Management\Set-Location -LiteralPath $target[0] -ErrorAction Stop - $after = (Microsoft.PowerShell.Management\Get-Location).ProviderPath - - if ((Test-CjRepeated $navArg $script:__cj_navigate_up) -and -not $after.Equals($before, $script:__cj_path_comparison)) {{ - if (($null -eq $global:__cj_down_route) -or -not (Test-CjDescendant $before $global:__cj_down_route)) {{ - $global:__cj_down_route = $before - }} - }} elseif (Test-CjRepeated $navArg $script:__cj_navigate_down) {{ - if (($null -ne $global:__cj_down_route) -and $after.Equals($global:__cj_down_route, $script:__cj_path_comparison)) {{ $global:__cj_down_route = $null }} - }} else {{ - $global:__cj_down_route = $null - }} + $mode = if (Test-CjRepeated $navArg $script:__cj_navigate_up) {{ 'up' }} else {{ 'other' }} + Add-CjHistory $before $mode }} if (Test-Path Function:TabExpansion2) {{ @@ -651,7 +694,7 @@ mod tests { assert!(output.contains("-Z|--no-zoxide")); assert!(output.contains("\\builtin unalias cd")); assert!(output.contains("function cd()")); - assert!(output.contains("CJ_INTERNAL_DOWN_ROUTE")); + assert!(output.contains("_cj_history")); } #[test] diff --git a/tests/external_integration.rs b/tests/external_integration.rs index 681aebd..486f25e 100644 --- a/tests/external_integration.rs +++ b/tests/external_integration.rs @@ -493,7 +493,7 @@ fn generated_wrappers_jump_and_propagate_failures() { let mut success = shell_command( shell, &init.stdout, - "eval \"$1\"; cd -z \"$2\" \"$3\"; printf '%s\\n' \"$PWD\"", + "eval \"$1\"; before=$PWD; cd -z \"$2\" \"$3\"; printf '%s\\n' \"$PWD\"; cd v; [[ $PWD == \"$before\" ]]", ); success .current_dir(&fixture.cwd) @@ -605,15 +605,15 @@ fn generated_wrappers_navigate_down_with_custom_tickers() { let no_history = no_history.output().expect("run available shell"); assert_eq!(no_history.status.code(), Some(2)); assert!(no_history.stdout.is_empty()); - assert_eq!(no_history.stderr, b"cj: no remembered downward route\n"); + assert_eq!(no_history.stderr, b"cj: directory history exhausted\n"); let clear_script = "eval \"$1\"; cd uu; cd other; builtin cd ..; cd d"; let mut cleared = shell_with_setup(shell, &init.stdout, clear_script); cleared.current_dir(&leaf).env("PATH", &path); let cleared = cleared.output().expect("run available shell"); - assert_eq!(cleared.status.code(), Some(2)); + assert_success(&cleared); assert!(cleared.stdout.is_empty()); - assert_eq!(cleared.stderr, b"cj: no remembered downward route\n"); + assert!(cleared.stderr.is_empty()); let shadow_script = "eval \"$1\"; cd uu; cd d; printf '%s\\n' \"$PWD\""; let mut shadowed = shell_with_setup(shell, &init.stdout, shadow_script); From ab3b8ef9fd8f3c6a2bc2e99a83983612035e1f27 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Wed, 9 Sep 2026 20:18:00 +0800 Subject: [PATCH 2/3] test(shell): cover browser directory history across shells Add cross-shell coverage for atomic history exhaustion and successful, failed, and no-op navigation. Verify behavior with aliases, mounts, zoxide, and logical symlinks. Refs #36 --- tests/browser_history.rs | 288 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100644 tests/browser_history.rs diff --git a/tests/browser_history.rs b/tests/browser_history.rs new file mode 100644 index 0000000..58a0218 --- /dev/null +++ b/tests/browser_history.rs @@ -0,0 +1,288 @@ +mod support; + +use std::env; +use std::fs; +use std::path::Path; +use std::process::Command; + +use support::{TempDir, assert_success, cj}; + +/// Exercise generated integrations in real shells, including CI's Windows PowerShell. +fn exercise(label: &str, posix: &str, nu: &str, pwsh: &str, expected: &[&str]) { + let temp = TempDir::new(&format!("{label} ' spaced path")); + for directory in ["a/b/c", "other", "home", "d"] { + fs::create_dir_all(temp.path().join(directory)).unwrap(); + } + let config = temp.path().join("history.toml"); + let alias = temp + .path() + .join("other") + .to_string_lossy() + .replace('\\', "\\\\") + .replace('"', "\\\""); + fs::write( + &config, + format!("[behavior]\ndefault = \"builtin\"\n[aliases]\nelsewhere = \"{alias}\"\n[mounts]\nportable = {{ path = \"{alias}\" }}\n"), + ) + .unwrap(); + let mut paths = vec![ + Path::new(env!("CARGO_BIN_EXE_cj")) + .parent() + .unwrap() + .to_path_buf(), + ]; + paths.extend(env::split_paths(&env::var_os("PATH").unwrap_or_default())); + let path = env::join_paths(paths).unwrap(); + let mut exercised = 0; + for shell in ["bash", "zsh", "nu", "pwsh"] { + if cfg!(windows) && matches!(shell, "bash" | "zsh") { + continue; + } + let available = Command::new(shell) + .arg("--version") + .output() + .is_ok_and(|output| output.status.success()); + assert!( + available + || (env::var_os("CJ_REQUIRE_SHELLS").is_none() + && !(shell == "pwsh" && env::var_os("CJ_REQUIRE_POWERSHELL").is_some())), + "required shell unavailable: {shell}" + ); + if !available { + continue; + } + fs::create_dir_all(temp.path().join("other")).unwrap(); + let source = cj(temp.path(), temp.path()) + .arg("-C") + .arg(&config) + .args(["init", shell]) + .output() + .unwrap(); + assert_success(&source); + let file = temp.path().join(if shell == "nu" { + "init.nu" + } else if shell == "pwsh" { + "init.ps1" + } else { + "init.sh" + }); + fs::write(&file, source.stdout).unwrap(); + let mut command = Command::new(shell); + match shell { + "bash" | "zsh" => { + command.args(if shell == "bash" { + &["--noprofile", "--norc", "-c"][..] + } else { + &["-f", "-c"][..] + }); + command.arg(format!("set -e; . \"$CJ_SOURCE\"; snapshot() {{ printf '%s\\n' \"$PWD\"; printf '%s\\n' \"${{#_cj_history[@]}}\"; }}; {posix}")); + } + "nu" => { + let quoted = file.to_string_lossy(); + command.args(["--no-config-file", "-c"]).arg(format!("use r#'{quoted}'# *; hide cd; def snapshot [] {{ print $env.PWD; print ($env.__cj_history | length) }}; {nu}")); + } + _ => { + command.args(["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"]).arg(format!("$ErrorActionPreference = 'Stop'; . $env:CJ_SOURCE; function snapshot {{ (Get-Location).ProviderPath; $global:__cj_history.Count }}; {pwsh}")); + } + } + let output = command + .current_dir(temp.path().join("a/b/c")) + .env("PATH", &path) + .env("CJ_SOURCE", &file) + .env("CJ_ROOT", temp.path()) + .env("HOME", temp.path().join("home")) + .env("USERPROFILE", temp.path().join("home")) + .output() + .unwrap(); + assert_success(&output); + let stdout = String::from_utf8(output.stdout).unwrap(); + let actual: Vec<_> = stdout.lines().collect(); + let expected: Vec<_> = expected + .iter() + .map(|line| { + if let Some(relative) = line.strip_prefix("@/") { + if relative.is_empty() { + temp.path().to_string_lossy().into_owned() + } else { + temp.path().join(relative).to_string_lossy().into_owned() + } + } else { + line.to_string() + } + }) + .collect(); + assert_eq!( + actual, + expected, + "{shell}: {}", + String::from_utf8_lossy(&output.stderr) + ); + exercised += 1; + } + assert!(exercised > 0, "no supported shell available"); +} + +#[test] +fn up_records_each_parent_and_back_consumes_without_push() { + exercise( + "history-parents", + "cd '^^^'; snapshot; cd v; snapshot; cd vv; snapshot", + "__cj_cd '^^^'; snapshot; __cj_cd v; snapshot; __cj_cd vv; snapshot", + "cd '^^^'; snapshot; cd v; snapshot; cd vv; snapshot", + &["@/", "3", "@/a", "2", "@/a/b/c", "0"], + ); +} + +#[test] +fn direct_raw_alias_home_and_previous_directory_moves_are_recorded() { + exercise( + "history-all-moves", + "cd \"$CJ_ROOT/other\"; cd -r \"$CJ_ROOT/d\"; cd elsewhere; cd >/dev/null; cd - >/dev/null; snapshot; cd vvv; snapshot; cd vv; snapshot", + "__cj_cd ($env.CJ_ROOT | path join other); __cj_cd -r ($env.CJ_ROOT | path join d); __cj_cd elsewhere; __cj_cd; __cj_cd -; snapshot; __cj_cd vvv; snapshot; __cj_cd vv; snapshot", + "cd (Join-Path $env:CJ_ROOT other); cd -r (Join-Path $env:CJ_ROOT d); cd elsewhere; cd; cd -; snapshot; cd vvv; snapshot; cd vv; snapshot", + &["@/other", "5", "@/d", "2", "@/a/b/c", "0"], + ); +} + +#[test] +fn failures_noops_and_native_escape_preserve_history() { + exercise( + "history-preservation", + "cd \"$CJ_ROOT/other\"; cd .; cd -r .; cd missing 2>/dev/null && exit 1; cd vv 2>/dev/null && exit 1; builtin cd \"$CJ_ROOT/d\"; snapshot; cd v; snapshot", + "__cj_cd ($env.CJ_ROOT | path join other); __cj_cd .; __cj_cd -r .; try { __cj_cd missing }; try { __cj_cd vv }; cd ($env.CJ_ROOT | path join d); snapshot; __cj_cd v; snapshot", + "cd (Join-Path $env:CJ_ROOT other); cd .; cd -r .; try { cd missing } catch {}; try { cd vv } catch {}; Microsoft.PowerShell.Management\\Set-Location -LiteralPath (Join-Path $env:CJ_ROOT d); snapshot; cd v; snapshot", + &["@/d", "1", "@/a/b/c", "0"], + ); +} + +#[test] +fn failed_history_destination_is_atomic() { + exercise( + "history-stale", + "cd \"$CJ_ROOT/other\"; cd \"$CJ_ROOT/d\"; rmdir \"$CJ_ROOT/other\"; cd v 2>/dev/null && exit 1; snapshot; cd vv; snapshot", + "__cj_cd ($env.CJ_ROOT | path join other); __cj_cd ($env.CJ_ROOT | path join d); rm ($env.CJ_ROOT | path join other); try { __cj_cd v }; snapshot; __cj_cd vv; snapshot", + "cd (Join-Path $env:CJ_ROOT other); cd (Join-Path $env:CJ_ROOT d); Remove-Item -LiteralPath (Join-Path $env:CJ_ROOT other); try { cd v } catch {}; snapshot; cd vv; snapshot", + &["@/d", "2", "@/a/b/c", "0"], + ); +} + +#[test] +fn stack_is_bounded_to_the_latest_hundred_departures() { + exercise( + "history-bound", + "for i in {1..51}; do cd \"$CJ_ROOT/other\"; cd \"$CJ_ROOT/d\"; done; snapshot; printf '%s\\n' \"${_cj_history[@]:0:1}\"", + "for i in 1..51 { __cj_cd ($env.CJ_ROOT | path join other); __cj_cd ($env.CJ_ROOT | path join d) }; snapshot; print ($env.__cj_history | first)", + "foreach ($i in 1..51) { cd (Join-Path $env:CJ_ROOT other); cd (Join-Path $env:CJ_ROOT d) }; snapshot; $global:__cj_history[0]", + &["@/d", "100", "@/d"], + ); +} + +#[test] +fn history_noop_and_empty_history_do_not_change_directory_or_stack() { + exercise( + "history-noop", + "cd v 2>/dev/null && exit 1; snapshot; cd \"$CJ_ROOT/other\"; builtin cd \"$CJ_ROOT/a/b/c\"; cd v; snapshot", + "try { __cj_cd v }; snapshot; __cj_cd ($env.CJ_ROOT | path join other); cd ($env.CJ_ROOT | path join a b c); __cj_cd v; snapshot", + "try { cd v } catch {}; snapshot; cd (Join-Path $env:CJ_ROOT other); Microsoft.PowerShell.Management\\Set-Location -LiteralPath (Join-Path $env:CJ_ROOT a/b/c); cd v; snapshot", + &["@/a/b/c", "0", "@/a/b/c", "1"], + ); +} + +#[cfg(all(unix, not(target_os = "macos")))] +#[test] +fn posix_history_preserves_non_utf8_directory_names() { + use std::os::unix::ffi::{OsStrExt, OsStringExt}; + let temp = TempDir::new("history-bytes"); + let directory = temp + .path() + .join(std::ffi::OsString::from_vec(b"raw-\xff directory".to_vec())); + fs::create_dir(&directory).unwrap(); + for shell in ["bash", "zsh"] { + let source = cj(temp.path(), temp.path()) + .args(["init", shell]) + .output() + .unwrap(); + assert_success(&source); + let output = Command::new(shell) + .args([ + "-c", + "set -e; eval \"$1\"; cd -r \"$2\"; cd -r \"$3\"; cd v; printf '%s' \"$PWD\"", + "_", + ]) + .arg(String::from_utf8(source.stdout).unwrap()) + .arg(&directory) + .arg(temp.path()) + .current_dir(temp.path()) + .output(); + let output = match output { + Ok(output) => output, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => panic!("{error}"), + }; + assert_success(&output); + assert_eq!(output.stdout, directory.as_os_str().as_bytes(), "{shell}"); + } +} + +#[test] +fn mount_and_no_zoxide_paths_use_the_same_history() { + exercise( + "history-mount", + "cd portable; snapshot; cd -Z \"$CJ_ROOT/d\"; snapshot; cd -Z vv; snapshot", + "__cj_cd portable; snapshot; __cj_cd -Z ($env.CJ_ROOT | path join d); snapshot; __cj_cd -Z vv; snapshot", + "cd portable; snapshot; cd -Z (Join-Path $env:CJ_ROOT d); snapshot; cd -Z vv; snapshot", + &["@/other", "1", "@/d", "2", "@/a/b/c", "0"], + ); +} + +#[cfg(unix)] +#[test] +fn parent_history_follows_native_logical_symlink_traversal() { + let temp = TempDir::new("history-symlink"); + let physical = temp.path().join("physical/deep/leaf"); + let logical = temp.path().join("logical"); + fs::create_dir_all(&physical).unwrap(); + std::os::unix::fs::symlink(&physical, &logical).unwrap(); + let mut paths = vec![ + Path::new(env!("CARGO_BIN_EXE_cj")) + .parent() + .unwrap() + .to_path_buf(), + ]; + paths.extend(env::split_paths(&env::var_os("PATH").unwrap_or_default())); + let path = env::join_paths(paths).unwrap(); + for shell in ["bash", "zsh", "nu"] { + let source = cj(temp.path(), temp.path()) + .args(["init", shell]) + .output() + .unwrap(); + assert_success(&source); + let output = if shell == "nu" { + let file = temp.path().join("init.nu"); + fs::write(&file, source.stdout).unwrap(); + let script = format!( + "use r#'{}'# *; hide cd; cd r#'{}'#; __cj_cd '^'; if $env.PWD != r#'{}'# or ($env.__cj_history | length) != 1 {{ error make {{ msg: 'incorrect logical parent history' }} }}; __cj_cd v; print -n $env.PWD", + file.display(), + logical.display(), + temp.path().display() + ); + Command::new(shell) + .args(["--no-config-file", "-c", &script]) + .env("PATH", &path) + .output() + } else { + Command::new(shell).args(["-c", "set -e; eval \"$1\"; builtin cd \"$2\"; cd '^'; [[ $PWD == \"$3\" ]]; [[ ${#_cj_history[@]} == 1 ]]; cd v; printf '%s' \"$PWD\"", "_"]).arg(String::from_utf8(source.stdout).unwrap()).arg(&logical).arg(temp.path()).current_dir(temp.path()).env("PATH", &path).output() + }; + let output = match output { + Ok(output) => output, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => panic!("{error}"), + }; + assert_success(&output); + assert_eq!( + String::from_utf8(output.stdout).unwrap(), + physical.to_string_lossy(), + "{shell}" + ); + } +} From ec69c0687bcb85c101cc644c2e66c75d088fc29c Mon Sep 17 00:00:00 2001 From: benbenbang Date: Wed, 9 Sep 2026 20:58:58 +0800 Subject: [PATCH 3/3] test(shell): fix Windows browser history path expectations Construct expected nested paths component by component so tests use native separators on Windows. Production history behavior is unchanged. Refs #36 --- tests/browser_history.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/browser_history.rs b/tests/browser_history.rs index 58a0218..8f1bcfa 100644 --- a/tests/browser_history.rs +++ b/tests/browser_history.rs @@ -104,7 +104,13 @@ fn exercise(label: &str, posix: &str, nu: &str, pwsh: &str, expected: &[&str]) { if relative.is_empty() { temp.path().to_string_lossy().into_owned() } else { - temp.path().join(relative).to_string_lossy().into_owned() + relative + .split('/') + .fold(temp.path().to_path_buf(), |path, component| { + path.join(component) + }) + .to_string_lossy() + .into_owned() } } else { line.to_string()