diff --git a/shell/aliases b/shell/aliases index b96c8e3..f2eef9b 100644 --- a/shell/aliases +++ b/shell/aliases @@ -268,15 +268,99 @@ delete_terms() { set_posts_statuses() { wp post update "$(wp post list --post_type="${1}" --post_status="${2}" --format=ids)" --post_status="${3}" } +_project_open_root() { + printf '%s\n' "${PROJECT_OPEN_ROOT:-${HOME}/Code}" +} + +_project_open_scan() { + local root="${1:-$(_project_open_root)}" + local dir + find "${root}" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | while IFS= read -r dir; do + if [[ -e "${dir}/.git" || -d "${dir}/bedrock" || -d "${dir}/site" ]]; then + printf '%s\n' "${dir}" + else + _project_open_scan "${dir}" + fi + done +} + +_project_open_cache_file() { + printf '%s\n' "${XDG_CACHE_HOME:-${HOME}/.cache}/project_open/projects" +} + +_project_open_build_cache() { + local cache stamp dir tmp now last ttl root + root="$(_project_open_root)" + [[ -d "${root}" ]] || return 1 + cache="$(_project_open_cache_file)" + stamp="${cache}.ts" + ttl="${PROJECT_OPEN_CACHE_TTL:-3}" + case "${ttl}" in '' | *[!0-9]*) ttl=3 ;; esac + now="$(date +%s)" + last="$(cat "${stamp}" 2>/dev/null || echo 0)" + case "${last}" in '' | *[!0-9]*) last=0 ;; esac + [[ -f "${cache}" ]] && (( now - last < ttl )) && return 0 + + dir="${cache%/*}" + mkdir -p "${dir}" 2>/dev/null || return 1 + tmp="$(mktemp "${dir}/projects.XXXXXX")" || return 1 + if _project_open_scan >"${tmp}" && mv -f "${tmp}" "${cache}"; then + printf '%s\n' "${now}" >"${stamp}" + return 0 + fi + rm -f "${tmp}" + return 1 +} + +_project_open_read_cache() { + local cache line + cache="$(_project_open_cache_file)" + [[ -f "${cache}" ]] || return 0 + while IFS= read -r line || [[ -n "${line}" ]]; do + printf '%s\n' "${line}" + done < "${cache}" +} + +po_refresh() { + local cache + cache="$(_project_open_cache_file)" + rm -f "${cache}" "${cache}.ts" + _project_open_build_cache +} + +_project_open_resolve() { + local name="${1}" cache_only="${2}" cache line tmp + [[ -n "${name}" ]] || return 1 + cache="$(_project_open_cache_file)" + if [[ -f "${cache}" ]]; then + while IFS= read -r line || [[ -n "${line}" ]]; do + [[ "${line##*/}" == "${name}" ]] && { printf '%s\n' "${line}"; return 0; } + done < "${cache}" + fi + [[ -n "${cache_only}" ]] && return 1 + tmp="$(mktemp "${TMPDIR:-/tmp}/project_open_scan.XXXXXX")" || return 1 + _project_open_scan >"${tmp}" + # shellcheck disable=SC2094 + while IFS= read -r line || [[ -n "${line}" ]]; do + [[ "${line##*/}" == "${name}" ]] && { printf '%s\n' "${line}"; rm -f "${tmp}"; return 0; } + done < "${tmp}" + rm -f "${tmp}" + return 1 +} + project_open() { - WP_PATH="${HOME}/Code/wordpress" + CODE_PATH="$(_project_open_root)" if [ -z "${1}" ]; then - NEW_PATH="${WP_PATH}" + NEW_PATH="${CODE_PATH}" tmux setw automatic-rename else - NEW_PATH="${WP_PATH}/${1}" + NEW_PATH="$(_project_open_resolve "${1}")" + if [ -z "${NEW_PATH}" ]; then + printf 'No project named %s under %s\n' "${1}" "${CODE_PATH}" >&2 + return 1 + fi tmux rename-window "${1}" fi @@ -298,9 +382,8 @@ project_open() { return fi - # Find only themes using Composer. - THEMES=("${NEW_PATH}/web/app/themes/"*/composer.json) - THEME_PATH="${THEMES[1]//composer.json/}" + THEME_PATH="$(find "${NEW_PATH}/web/app/themes" -mindepth 2 -maxdepth 2 -type f -name composer.json 2>/dev/null | LC_ALL=C sort | head -n1)" + THEME_PATH="${THEME_PATH%composer.json}" if [ -z "${THEME_PATH}" ]; then echo "No theme available" >&2 return 1 @@ -311,29 +394,18 @@ project_open() { alias po='project_open' _project_open_projects() { - local wp_path="${HOME}/Code/wordpress" - local project_dir - - [[ -d "${wp_path}" ]] || return 0 - - for project_dir in "${wp_path}"/*; do - [[ -d "${project_dir}" ]] || continue - [[ -d "${project_dir}/bedrock" || -d "${project_dir}/site" ]] || continue - printf '%s\n' "${project_dir##*/}" + local proj + _project_open_read_cache | while IFS= read -r proj; do + printf '%s\n' "${proj##*/}" done } _project_open_targets() { - local wp_path="${HOME}/Code/wordpress" - local project="${1}" - local project_path="${wp_path}/${project}" - local target_dir - - [[ -n "${project}" ]] || return 0 - [[ -d "${project_path}" ]] || return 0 - - for target_dir in "${project_path}"/*; do - [[ -d "${target_dir}" ]] || continue + local name="${1}" project_path target_dir + [[ -n "${name}" ]] || return 0 + project_path="$(_project_open_resolve "${name}" cache_only)" || return 0 + [[ -n "${project_path}" ]] || return 0 + find "${project_path}" -mindepth 1 -maxdepth 1 -type d -not -name '.*' 2>/dev/null | while IFS= read -r target_dir; do printf '%s\n' "${target_dir##*/}" done } @@ -343,8 +415,6 @@ _project_open_complete_bash() { current="${COMP_WORDS[COMP_CWORD]}" COMPREPLY=() - # while-read + manual prefix match keeps this working on Bash 3.2 (macOS), - # avoids COMP_WORDS[-1] when COMP_CWORD is 0, and preserves names with spaces. if (( COMP_CWORD == 1 )); then while IFS= read -r candidate; do [[ "${candidate}" == "${current}"* ]] && COMPREPLY+=("${candidate}") @@ -366,12 +436,12 @@ _project_open_complete_bash() { if [[ -n "${BASH_VERSION:-}" ]]; then complete -F _project_open_complete_bash project_open complete -F _project_open_complete_bash po + if [[ $- == *i* ]]; then + ( _project_open_build_cache & ) >/dev/null 2>&1 + fi fi if [[ -n "${ZSH_VERSION:-}" ]]; then - # eval is required here because the Zsh-specific ${(@f)...} syntax inside the - # function body is a Bash parse error unless the code is deferred and evaluated - # only when Zsh is active. eval '_project_open_complete_zsh() { local current previous local -a candidates @@ -391,11 +461,8 @@ if [[ -n "${ZSH_VERSION:-}" ]]; then return fi - # Explicitly return when no completion candidate applicable (CURRENT > 3) return } - # Defer registration until compdef exists, so completion still works when this - # file is sourced before compinit. The hook removes itself once registered. _project_open_register_zsh() { (( $+functions[compdef] )) || return 0 compdef _project_open_complete_zsh project_open @@ -407,6 +474,9 @@ if [[ -n "${ZSH_VERSION:-}" ]]; then _project_open_register_zsh else add-zsh-hook precmd _project_open_register_zsh + fi + if [[ -o interactive ]]; then + _project_open_build_cache >/dev/null 2>&1 &! fi' fi diff --git a/tests/project_open_test.sh b/tests/project_open_test.sh new file mode 100644 index 0000000..7450502 --- /dev/null +++ b/tests/project_open_test.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# Standalone tests for project_open discovery/cache/resolution. +# Run: bash tests/project_open_test.sh +# No `set -u`: project_open references ${2} unguarded (matching the original), +# which is fine in a normal shell but would throw under nounset. + +FAILS=0 + +assert_eq() { + local desc="$1" expected="$2" actual="$3" + if [[ "${expected}" == "${actual}" ]]; then + printf 'ok - %s\n' "${desc}" + return 0 + fi + printf 'FAIL - %s\n expected: %q\n actual: %q\n' "${desc}" "${expected}" "${actual}" + FAILS=$((FAILS + 1)) +} + +ROOT="" +setup_fixture() { + # Templates keep mktemp portable: BSD/macOS mktemp needs one, GNU accepts it. + ROOT="$(mktemp -d "${TMPDIR:-/tmp}/project_open_root.XXXXXX")" + export PROJECT_OPEN_ROOT="${ROOT}" + XDG_CACHE_HOME="$(mktemp -d "${TMPDIR:-/tmp}/project_open_cache.XXXXXX")" + export XDG_CACHE_HOME + mkdir -p \ + "${ROOT}/mods/HoldFast/.git" \ + "${ROOT}/mods/HoldFast/lib/vcpkg/.git" \ + "${ROOT}/misc/khuey/.git" \ + "${ROOT}/wordpress/acme/site" \ + "${ROOT}/wordpress/beta/bedrock" \ + "${ROOT}/deep/a/b/proj/.git" +} + +TEST_HOME="" +cleanup() { + [[ -n "${ROOT}" ]] && rm -rf "${ROOT}" + [[ -n "${XDG_CACHE_HOME}" ]] && rm -rf "${XDG_CACHE_HOME}" + [[ -n "${TEST_HOME}" ]] && rm -rf "${TEST_HOME}" +} +trap cleanup EXIT + +# tmux is unavailable / irrelevant in tests; stub it to a no-op. +tmux() { return 0; } + +test_scan() { + local out + out="$(_project_open_scan | sed "s#^${ROOT}/##" | LC_ALL=C sort | tr '\n' ',')" + assert_eq "scan finds outermost projects, excludes submodules" \ + "deep/a/b/proj,misc/khuey,mods/HoldFast,wordpress/acme,wordpress/beta," \ + "${out}" +} + +test_cache() { + local cache + export PROJECT_OPEN_CACHE_TTL=3600 + po_refresh + cache="$(_project_open_cache_file)" + assert_eq "cache file created" "yes" "$([[ -f "${cache}" ]] && echo yes || echo no)" + + # TTL guard: a new project added now must NOT appear via a guarded rebuild. + mkdir -p "${ROOT}/mods/NewMod/.git" + _project_open_build_cache + assert_eq "TTL guard skips rebuild" "no" \ + "$(grep -q '/NewMod$' "${cache}" && echo yes || echo no)" + + # po_refresh bypasses the guard and picks up the new project. + po_refresh + assert_eq "po_refresh rebuilds" "yes" \ + "$(grep -q '/NewMod$' "${cache}" && echo yes || echo no)" + + rm -f "${cache}" "${cache}.ts" + assert_eq "read_cache empty when cache missing" "0" \ + "$(_project_open_read_cache | grep -c '/mods/HoldFast$')" + assert_eq "resolve scans when cache missing" "${ROOT}/mods/HoldFast" \ + "$(_project_open_resolve HoldFast)" + assert_eq "resolve cache_only empty when cache missing" "" \ + "$(_project_open_resolve HoldFast cache_only)" + + po_refresh + rm -f "${cache}" + PROJECT_OPEN_CACHE_TTL=3600 _project_open_build_cache + assert_eq "rebuilds when cache gone but stamp fresh" "yes" \ + "$([[ -f "${cache}" ]] && echo yes || echo no)" + + rm -f "${cache}" "${cache}.ts" + PROJECT_OPEN_ROOT="${ROOT}/missing" _project_open_build_cache + assert_eq "no cache built when root missing" "no" \ + "$([[ -f "${cache}" ]] && echo yes || echo no)" +} + +test_resolve_and_completion() { + assert_eq "resolve HoldFast to path" "${ROOT}/mods/HoldFast" \ + "$(_project_open_resolve HoldFast)" + assert_eq "resolve deep project" "${ROOT}/deep/a/b/proj" \ + "$(_project_open_resolve proj)" + assert_eq "resolve unknown is empty" "" "$(_project_open_resolve nope)" + + # test_cache (run earlier in main) added mods/NewMod to the fixture, so it + # appears here too. sort makes order irrelevant. + po_refresh + assert_eq "projects lists basenames" "HoldFast,NewMod,acme,beta,khuey,proj," \ + "$(_project_open_projects | LC_ALL=C sort | tr '\n' ',')" + + assert_eq "targets lists subdirs" "lib" "$(_project_open_targets HoldFast)" + assert_eq "targets of childless project is empty" "" \ + "$(_project_open_targets khuey)" + assert_eq "targets of unknown project is empty" "" \ + "$(_project_open_targets nope)" +} + +test_project_open() { + po_refresh + assert_eq "no-arg cds to Code root" "${ROOT}" \ + "$(cd / && project_open >/dev/null 2>&1; pwd)" + assert_eq "name cds into plain project" "${ROOT}/mods/HoldFast" \ + "$(cd / && project_open HoldFast >/dev/null 2>&1; pwd)" + assert_eq "descends into bedrock" "${ROOT}/wordpress/beta/bedrock" \ + "$(cd / && project_open beta >/dev/null 2>&1; pwd)" + assert_eq "descends into site" "${ROOT}/wordpress/acme/site" \ + "$(cd / && project_open acme >/dev/null 2>&1; pwd)" + assert_eq "subdir target cds into subdir" "${ROOT}/mods/HoldFast/lib" \ + "$(cd / && project_open HoldFast lib >/dev/null 2>&1; pwd)" + + local rc + ( cd / && project_open nope >/dev/null 2>&1 ); rc=$? + assert_eq "unknown project returns 1" "1" "${rc}" +} + +main() { + setup_fixture + local dotfiles + dotfiles="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + # shell/aliases sources "${HOME}/.dotfiles/shell/os.sh", so point HOME at a + # temp dir whose .dotfiles symlinks to this repo. That lets the test run + # from any checkout path, not only ~/.dotfiles. + TEST_HOME="$(mktemp -d "${TMPDIR:-/tmp}/project_open_home.XXXXXX")" + ln -s "${dotfiles}" "${TEST_HOME}/.dotfiles" + export HOME="${TEST_HOME}" + # shellcheck source=/dev/null + source "${HOME}/.dotfiles/shell/aliases" + + test_scan + test_cache + test_resolve_and_completion + test_project_open + + exit $(( FAILS > 0 ? 1 : 0 )) +} + +main "$@"