From 8c7219e9036efe3aab5c13495b364b148e1c0408 Mon Sep 17 00:00:00 2001 From: Ramesh Padmanabhaiah Date: Mon, 6 Jul 2026 19:47:28 -0700 Subject: [PATCH] Expose file section inspection helpers --- lib/bash/file/README.md | 24 ++++++ lib/bash/file/lib_file.sh | 128 ++++++++++++++++++++++++++++++ lib/bash/file/tests/lib_file.bats | 98 +++++++++++++++++++++++ 3 files changed, 250 insertions(+) diff --git a/lib/bash/file/README.md b/lib/bash/file/README.md index db230b2..82b965c 100644 --- a/lib/bash/file/README.md +++ b/lib/bash/file/README.md @@ -8,6 +8,12 @@ Source `lib/bash/std/lib_std.sh` before this library so logging and error helper ## Public API +- `file_section_exists` + Inspect whether a valid marker-delimited block is present without changing + the file. +- `file_section_needs_update` + Inspect whether adding or replacing a marker-delimited block would change the + file. - `update_file_section` Idempotently add, replace, or remove a marker-delimited block inside a file. @@ -22,12 +28,30 @@ update_file_section ~/.bash_profile "# BEGIN APP" "# END APP" \ "alias appctl='app status'" ``` +Use the inspection helpers before dry-run output, backup creation, or other +caller-owned side effects: + +```bash +if file_section_needs_update ~/.bash_profile "# BEGIN APP" "# END APP" \ + "export APP_HOME=/opt/app"; then + cp -p ~/.bash_profile ~/.bash_profile.backup + update_file_section ~/.bash_profile "# BEGIN APP" "# END APP" \ + "export APP_HOME=/opt/app" +fi +``` + ## Behavior Notes - Returns success when the target file does not exist and there is nothing to remove. - Replaces or removes only the first matching marked section when markers already exist. - Treats markers as exact full lines; marker text embedded in longer lines is ignored. - Appends the marked block when markers are not present. +- `file_section_exists` returns `0` when a valid marker pair is present, `1` + when the target file is missing or the section is absent, and `2` when marker + pairs are asymmetric or misordered. +- `file_section_needs_update` returns `0` when an add/update would change the + target file, `1` when the first existing marked section already matches, and + `2` when marker pairs are asymmetric or misordered. ## Tests diff --git a/lib/bash/file/lib_file.sh b/lib/bash/file/lib_file.sh index 6757bef..94a8531 100644 --- a/lib/bash/file/lib_file.sh +++ b/lib/bash/file/lib_file.sh @@ -90,6 +90,134 @@ __file_section_markers_ordered__() { ' "$target_file" } +__file_section_marker_counts__() { + local target_file="$1" beginning_marker="$2" end_marker="$3" + local beginning_count_var="$4" end_count_var="$5" + local section_beginning_marker_count section_end_marker_count + + section_beginning_marker_count=$(grep -cxF -- "$beginning_marker" "$target_file" || true) + section_end_marker_count=$(grep -cxF -- "$end_marker" "$target_file" || true) + + printf -v "$beginning_count_var" '%s' "$section_beginning_marker_count" + printf -v "$end_count_var" '%s' "$section_end_marker_count" + + if ((section_beginning_marker_count != section_end_marker_count)); then + log_error "Asymmetric markers in '$target_file': $section_beginning_marker_count start, $section_end_marker_count end. Manual repair needed." + return 2 + fi + if ((section_beginning_marker_count > 0)) && ! __file_section_markers_ordered__ "$target_file" "$beginning_marker" "$end_marker"; then + log_error "Misordered markers in '$target_file'. Manual repair needed." + return 2 + fi + + return 0 +} + +# +# file_section_exists - Inspect whether a marker-delimited section is present. +# +# Returns: +# 0 when the target file contains at least one valid marker pair. +# 1 when the target file is missing or the marker pair is absent. +# 2 when marker pairs are asymmetric or misordered and need manual repair. +# +file_section_exists() { + if [[ $# -ne 3 ]]; then + log_error "file_section_exists: expected ." + return 2 + fi + + local target_file="$1" beginning_marker="$2" end_marker="$3" + local beginning_marker_count end_marker_count + + [[ -f "$target_file" ]] || return 1 + __file_section_marker_counts__ "$target_file" "$beginning_marker" "$end_marker" \ + beginning_marker_count end_marker_count || return $? + + ((beginning_marker_count > 0)) +} + +# +# file_section_needs_update - Inspect whether add/update would change a section. +# +# Returns: +# 0 when adding or updating the section would change the target file. +# 1 when the first existing marker-delimited section already matches. +# 2 when marker pairs are asymmetric or misordered and need manual repair. +# +file_section_needs_update() { + if [[ $# -lt 3 ]]; then + log_error "file_section_needs_update: expected [content_lines...]." + return 2 + fi + + local target_file="$1" beginning_marker="$2" end_marker="$3" + local beginning_marker_count end_marker_count + local current_content_file="" new_content_file="" status=0 + shift 3 + + [[ -f "$target_file" ]] || return 0 + __file_section_marker_counts__ "$target_file" "$beginning_marker" "$end_marker" \ + beginning_marker_count end_marker_count || return $? + ((beginning_marker_count > 0)) || return 0 + + if ! std_make_temp_file new_content_file base-file-section-new; then + log_error "Failed to create temporary content file for '$target_file'." + return 2 + fi + if (($# > 0)); then + if ! printf '%s\n' "$@" > "$new_content_file"; then + log_error "Failed to write replacement content for '$target_file'." + __file_remove_temp_paths__ "$new_content_file" + return 2 + fi + elif ! : > "$new_content_file"; then + log_error "Failed to write replacement content for '$target_file'." + __file_remove_temp_paths__ "$new_content_file" + return 2 + fi + + if ! std_make_temp_file current_content_file base-file-section-current; then + log_error "Failed to create temporary current content file for '$target_file'." + __file_remove_temp_paths__ "$new_content_file" + return 2 + fi + + if ! awk -v START_M="$beginning_marker" -v END_M="$end_marker" ' + BEGIN { + in_section = 0 + processed = 0 + } + $0 == START_M && processed == 0 { + in_section = 1 + next + } + $0 == END_M && in_section == 1 { + processed = 1 + exit + } + in_section == 1 { + print $0 + } + END { + if (processed == 0) { + exit 1 + } + } + ' "$target_file" > "$current_content_file"; then + log_error "Failed to read existing section in '$target_file'." + __file_remove_temp_paths__ "$current_content_file" "$new_content_file" + return 2 + fi + + if cmp -s "$current_content_file" "$new_content_file"; then + status=1 + fi + + __file_remove_temp_paths__ "$current_content_file" "$new_content_file" + return "$status" +} + # # update_file_section - Idempotently manages a block of text within a file, # demarcated by start and end markers. diff --git a/lib/bash/file/tests/lib_file.bats b/lib/bash/file/tests/lib_file.bats index 4e404a3..048e070 100644 --- a/lib/bash/file/tests/lib_file.bats +++ b/lib/bash/file/tests/lib_file.bats @@ -220,6 +220,104 @@ EOF [[ "$output" == *"Section already up to date in '$target'."* ]] } +@test "file_section_exists detects a present marked section" { + local target="$TEST_TMPDIR/config.txt" + cat <<'EOF' > "$target" +before +# BEGIN +managed +# END +after +EOF + + capture_command file_section_exists "$target" "# BEGIN" "# END" + + [ "$status" -eq 0 ] +} + +@test "file_section_exists returns no-change for absent and missing target files" { + local target="$TEST_TMPDIR/config.txt" + printf 'plain\ncontent\n' > "$target" + + capture_command file_section_exists "$target" "# BEGIN" "# END" + [ "$status" -eq 1 ] + + capture_command file_section_exists "$TEST_TMPDIR/missing.txt" "# BEGIN" "# END" + [ "$status" -eq 1 ] +} + +@test "file_section_exists rejects asymmetric markers" { + local target="$TEST_TMPDIR/config.txt" + cat <<'EOF' > "$target" +before +# BEGIN +orphaned +EOF + + bats_run file_section_exists "$target" "# BEGIN" "# END" + + [ "$status" -eq 2 ] + [[ "$output" == *"Asymmetric markers in '$target': 1 start, 0 end. Manual repair needed."* ]] +} + +@test "file_section_exists rejects misordered markers" { + local target="$TEST_TMPDIR/config.txt" + cat <<'EOF' > "$target" +before +# END +middle +# BEGIN +after +EOF + + bats_run file_section_exists "$target" "# BEGIN" "# END" + + [ "$status" -eq 2 ] + [[ "$output" == *"Misordered markers in '$target'. Manual repair needed."* ]] +} + +@test "file_section_needs_update detects changed marked section content" { + local target="$TEST_TMPDIR/config.txt" + cat <<'EOF' > "$target" +before +# BEGIN +old +# END +after +EOF + + capture_command file_section_needs_update "$target" "# BEGIN" "# END" "new" + + [ "$status" -eq 0 ] +} + +@test "file_section_needs_update returns no-change for matching section content" { + local target="$TEST_TMPDIR/config.txt" + cat <<'EOF' > "$target" +before +# BEGIN +same +content +# END +after +EOF + + capture_command file_section_needs_update "$target" "# BEGIN" "# END" "same" "content" + + [ "$status" -eq 1 ] +} + +@test "file_section_needs_update returns change-needed for absent and missing target files" { + local target="$TEST_TMPDIR/config.txt" + printf 'plain\ncontent\n' > "$target" + + capture_command file_section_needs_update "$target" "# BEGIN" "# END" "new" + [ "$status" -eq 0 ] + + capture_command file_section_needs_update "$TEST_TMPDIR/missing.txt" "# BEGIN" "# END" "new" + [ "$status" -eq 0 ] +} + @test "update_file_section does not export replacement content to awk" { local awk_log="$TEST_TMPDIR/awk-env.log" local target="$TEST_TMPDIR/config.txt"