Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions lib/bash/file/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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

Expand Down
128 changes: 128 additions & 0 deletions lib/bash/file/lib_file.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <target_file> <beginning_marker> <end_marker>."
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 <target_file> <beginning_marker> <end_marker> [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.
Expand Down
98 changes: 98 additions & 0 deletions lib/bash/file/tests/lib_file.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading