#!/bin/sh
set -euC

##/// dotd 1.0
#////
#//// Generate a file from fragment files in a directory.
#////
###// usage:
#////   dotd [options] [--] <file> [<dir>]
#////   dotd -h|--help
#////   dotd --version
#////
###// arguments:
####/   <file>
#////     File to generate. Specify - to print to stdout instead.
#////
####/   <dir>
#////     Directory containing fragment files. <file>.d is used if not
#////     specified.
#////
###// options:
####/   -a, --action <action>
#////     Action to perform on each fragment file. E.g.
#////       printf "#include \"%s\"\n"
#////     [default: cat]
#////
####/   -c, --comment <comment>
#////     If non-empty, adds comments starting with <comment> that the file is
#////     auto-generated and which fragment corresponds to which file. E.g.
#////       //
#////     [default: ]
#////
####/   -g, --glob <glob>
#////     Shell glob used to find fragment files, relative to <dir>. E.g.
#////       *.h
#////     [default: *]
#////
####/   -v, --validate <validate>
#////     Command to run on the (temporary) generated file. Only if the command
#////     returns success is the file moved to its final destination. E.g.
#////       /usr/sbin/sshd -t -f
#////     [default: ]

## Messages
help()    { sed -n 's|^#[#/]*/ \?||p' "$0";              exit 0;    }
version() { help | awk '/^$/{++p;next}p==0';             exit 0;    }
usage()   { help | awk '/^$/{++p;next}p==2';             exit 0;    }
parse()   { printf '%s: error: %s\n'   "$0" "$1"; usage; exit 1;    } >&2
error()   { printf '%s: error: %s\n'   "$0" "$1";        exit 1;    } >&2
warning() { printf '%s: warning: %s\n' "$0" "$1";                   } >&2
opt()     { [ $# -gt 1 ] || parse "option '$1' value not provided"; }
arg()     { [ $# -gt 1 ] || parse "argument '$1' not provided";     }

## Parse special options
case "${1-}"
in
  '-h'|'--help') help; ;;
  '--version') version; ;;
esac

## Parse options
action='cat'
comment=''
glob='*'
validate=''
while [ $# -gt 0 ]
do
  case "$1"
  in
    '-a'|'--action')   shift; opt 'action'   "$@"; action="$1";   ;;
    '-c'|'--comment')  shift; opt 'comment'  "$@"; comment="$1";  ;;
    '-g'|'--glob')     shift; opt 'glob'     "$@"; glob="$1";     ;;
    '-v'|'--validate') shift; opt 'validate' "$@"; validate="$1"; ;;
    '--') shift; break; ;;
    '-'?*) parse "unrecognized option '$1'"; ;;
    *) break; ;;
  esac
  shift
done

## Parse required arguments
arg 'file' "$@"; file="$1"; shift;

## Parse optional arguments
dir="${1-}"; shift $(($#>0));

## Parse unrecognized arguments
[ $# -eq 0 ] || parse "unrecognized argument: '$1'"

## Helpers
first='y'
comment_last="$(printf '%s' "$comment" | tail -c 1)"
empty()
{
  ! [ "$first" ] && [ "$comment" ] || return 0
  printf '\n'
}
comment()
{
  [ "$comment" ] || return 0
  printf '%s\n' "$1"
  first=''
}
action()
{
  eval "$action" "'$1'"
  first=''
}

## Set dir
if ! [ "$dir" ]
then
  [ "$file" != "-" ] || error "must specify <dir> when printing to stdout"
  dir="$file.d"
fi

## Generate temporary file
file_tmp="$(mktemp)"
{
  comment "$comment This file was auto-generated from '$dir/$glob'."
  for path in $dir/$glob
  do
    [ -r "$path" ] || continue
    empty
    comment "$comment$comment_last BEGIN $path"
    action "$path"
    comment "$comment END $path"
  done
} >> "$file_tmp"

## Validate
if [ "$validate" ] && ! eval "$validate" "'$file_tmp'"
then
  rm "$file_tmp"
  error "validation failed: $validate"
fi

## Optionally print to stdout
if [ "$file" = "-" ]
then
  cat "$file_tmp"
  rm "$file_tmp"
  exit 0
fi

## Otherwise move files
if [ -e "$file" ] && ! cmp -s "$file_tmp" "$file"
then
  file_bak="$(mktemp "$file.$(date "+%Y-%m-%d_%H:%M:%S").XXXX.bak")"
  warning "moving '$file' to '$file_bak'"
  mv "$file" "$file_bak"
fi
mv -f "$file_tmp" "$file"
