soonix/lib/module.nix

434 lines
12 KiB
Nix
Raw Normal View History

2025-08-25 16:45:22 +02:00
{
pkgs,
config,
lib,
...
}: let
inherit (lib) types mkOption concatMapStringsSep mapAttrsToList;
2025-08-25 16:45:22 +02:00
soonix_lib = import ./. {inherit pkgs;};
inherit (soonix_lib) engines buildAllFiles;
in {
options = {
hooks = mkOption {
type = types.attrsOf (types.submodule ({
name,
config,
...
}: {
options = {
name = mkOption {
type = types.str;
internal = true;
default = name;
};
output = mkOption {
type = types.str;
description = ''
The relative path where the generated file should be placed.
'';
2025-08-25 16:45:22 +02:00
};
generator = mkOption {
type = types.enum ["nix" "string" "derivation" "gotmpl" "jinja" "template"];
description = ''
Which engine to use for content generation.
'';
default = "nix";
2025-08-25 16:45:22 +02:00
};
data = mkOption {
type = types.anything;
description = ''
The input data for the chosen generator.
'';
2025-08-25 16:45:22 +02:00
};
opts = mkOption {
type = types.attrs;
default = {};
description = ''
Generator-specific options.
'';
2025-08-25 16:45:22 +02:00
};
hook = mkOption {
type = types.submodule {
options = {
mode = mkOption {
type = types.enum ["link" "copy"];
default = "link";
description = ''
How the file should be managed (link or copy).
'';
2025-08-25 16:45:22 +02:00
};
gitignore = mkOption {
type = types.bool;
default = true;
description = ''
Whether to add the output path to .gitignore.
'';
2025-08-25 16:45:22 +02:00
};
extra = mkOption {
type = types.str;
default = "";
description = ''
Additional bash commands to execute after file operation.
'';
2025-08-25 16:45:22 +02:00
};
};
};
default = {};
description = ''
Hook-specific options.
'';
2025-08-25 16:45:22 +02:00
};
generatedDerivation = mkOption {
type = types.package;
internal = true;
readOnly = true;
description = ''
The generated derivation for this file.
'';
2025-08-25 16:45:22 +02:00
};
};
config = {
generatedDerivation =
(engines.${config.generator} or (throw "Generator ${config.generator} not found"))
{
inherit (config) opts data name;
};
};
}));
default = {};
description = ''
Configuration of the hooks.
'';
2025-08-25 16:45:22 +02:00
};
shellHook = mkOption {
type = types.str;
readOnly = true;
description = ''
Generated shell hook script (as a string) for managing all files. (readonly)
'';
2025-08-25 16:45:22 +02:00
};
shellHookFile = mkOption {
type = types.package;
readOnly = true;
description = ''
Generated shell hook script for managing all files. (readonly)
'';
2025-08-25 16:45:22 +02:00
};
devshellModule = mkOption {
type = types.attrs;
readOnly = true;
description = ''
Devshell module with automatically configured hooks, just import and you're good to go. (readonly)
'';
};
2025-08-25 16:45:22 +02:00
finalFiles = mkOption {
type = types.package;
readOnly = true;
description = ''
Aggregated derivation containing all managed files. (readonly)
'';
};
packages = mkOption {
type = types.attrsOf types.package;
readOnly = true;
description = ''
Packages for updating the hooks without a devshell. (readonly)
'';
example = {
"soonix" = "<derivation>";
};
2025-08-25 16:45:22 +02:00
};
};
config = let
hooks = config.hooks;
# allow excluding gitignore since stuff like renovate can't use/commit it anyways
runHooks = concatMapStringsSep "\n" (hookName: let
hook = hooks.${hookName};
modes = {
link =
# sh
''
if [[ ! -L "${hook.output}" ]] || [[ "$(readlink "${hook.output}")" != "${hook.generatedDerivation}" ]]; then
_soonix_log "info" "${hookName}" "Creating symlink: ${hook.output} -> ${hook.generatedDerivation}"
if [[ "$CHECK_MODE" != "true" ]]; then
mkdir -p "$(dirname "${hook.output}")"
ln -sf "${hook.generatedDerivation}" "${hook.output}"
else
_soonix_log "info" "${hookName}" "Would create symlink: ${hook.output} -> ${hook.generatedDerivation}"
fi
_changed=true
else
_soonix_log "info" "${hookName}" "Symlink up to date: ${hook.output}"
fi
'';
copy =
# sh
''
if [[ ! -f "${hook.output}" ]] || ! cmp -s "${hook.generatedDerivation}" "${hook.output}"; then
_soonix_log "info" "${hookName}" "Copying file: ${hook.generatedDerivation} -> ${hook.output}"
if [[ "$CHECK_MODE" != "true" ]]; then
mkdir -p "$(dirname "${hook.output}")"
# required since they're read only
rm -f "${hook.output}"
cp "${hook.generatedDerivation}" "${hook.output}"
else
_soonix_log "info" "${hookName}" "Would copy file: ${hook.generatedDerivation} -> ${hook.output}"
fi
_changed=true
else
_soonix_log "info" "${hookName}" "File up to date: ${hook.output}"
fi
'';
};
2025-08-25 16:45:22 +02:00
optionalGitignore =
if hook.hook.gitignore
then ''
_soonix_add_to_gitignore "${hook.output}"
2025-08-25 16:45:22 +02:00
''
else "";
isGitignored =
if hook.hook.gitignore
then "true"
else "false";
in
builtins.addErrorContext "[soonix] while generating script for ${hookName}"
# sh
''
# Process hook: ${hookName}
# Skip if SKIP_GITIGNORE is set and this hook is gitignored
if [[ "$SKIP_GITIGNORE" == "true" && "${isGitignored}" == "true" ]]; then
: # skip
else
while IFS= read -r line; do
case "$line" in
UPDATED) _soonix_updated+=("${hookName}") ;;
UPTODATE) _soonix_uptodate+=("${hookName}") ;;
*) echo "$line" ;;
esac
done < <(
set -euo pipefail
_changed=false
2025-08-25 16:45:22 +02:00
${modes.${hook.hook.mode} or (throw "Mode ${hook.hook.mode} doesnt exist")}
2025-08-25 16:45:22 +02:00
if [[ "$CHECK_MODE" != "true" ]]; then
# Add to gitignore if requested
${optionalGitignore}
2025-08-25 16:45:22 +02:00
# Run extra commands if file changed
if [[ "$_changed" == "true" && -n "${hook.hook.extra}" ]]; then
_soonix_log "info" "${hookName}" "Running extra command: ${hook.hook.extra}"
eval "${hook.hook.extra}"
fi
fi
2025-08-25 16:45:22 +02:00
if [[ "$_changed" == "true" ]]; then
echo "UPDATED"
else
echo "UPTODATE"
fi
) || {
_soonix_log "error" "${hookName}" "Failed to process hook"
_soonix_failed+=("${hookName}")
}
fi
'')
(builtins.attrNames hooks);
2025-08-25 16:45:22 +02:00
generateShellHook =
builtins.addErrorContext "[soonix] while generating shell hook"
2025-08-25 16:45:22 +02:00
# sh
''
function _soonix() {
CHECK_MODE=''${CHECK_MODE:-false}
SKIP_GITIGNORE=''${SKIP_GITIGNORE:-false}
_soonix_log() {
local level="$1"
local hook="$2"
local message="$3"
[[ "''${SOONIX_LOG-}" == "true" ]] && echo "$level [$hook]: $message" || true
}
2025-08-25 16:45:22 +02:00
_soonix_add_to_gitignore() {
local file="$1"
local gitignore=".gitignore"
2025-08-25 16:45:22 +02:00
if [[ ! -f "$gitignore" ]]; then
touch "$gitignore"
2025-08-25 16:45:22 +02:00
fi
# Check if file is already in gitignore
if ! grep -Fxq "/$file" "$gitignore"; then
# Add sentinel comments if not present
if ! grep -q "# soonix" "$gitignore"; then
echo "" >> "$gitignore"
echo "# soonix" >> "$gitignore"
echo "# end soonix" >> "$gitignore"
fi
# Insert the file path before the end comment
${pkgs.gnused}/bin/sed -i "/# end soonix/i /$file" "$gitignore"
fi
}
2025-08-25 16:45:22 +02:00
_soonix_updated=()
_soonix_failed=()
_soonix_uptodate=()
2025-08-25 16:45:22 +02:00
${runHooks}
2025-08-25 16:45:22 +02:00
local output=$'\E[msoonix:\E[38;5;8m'
local status=0
2025-08-27 12:06:48 +02:00
if [[ ''${#_soonix_updated[@]} -gt 0 ]]; then
output="$output [updated: ''${_soonix_updated[*]}]" >&2
if [[ "$CHECK_MODE" == "true" ]]; then
status=2
fi
fi
if [[ ''${#_soonix_uptodate[@]} -gt 0 ]]; then
output="$output [unchanged: ''${_soonix_uptodate[*]}]" >&2
fi
if [[ ''${#_soonix_failed[@]} -gt 0 ]]; then
output="$output [failed: ''${_soonix_failed[*]}]" >&2
status=1
fi
2025-08-27 12:06:48 +02:00
printf "%s\E[m\n" "$output" >&2
2025-08-27 12:06:48 +02:00
if [[ $status -ne 0 ]]; then
exit $status
fi
}
_soonix
unset _soonix
2025-08-25 16:45:22 +02:00
'';
allFiles =
mapAttrsToList (name: hook: {
2025-08-25 16:45:22 +02:00
src = hook.generatedDerivation;
path = hook.output;
})
hooks;
in rec {
# nothing to do if no hooks exist
shellHook =
if (builtins.length (builtins.attrNames config.hooks) > 0)
then generateShellHook
2025-08-25 16:45:22 +02:00
else "";
shellHookFile = pkgs.writeShellScript "shellHook" shellHook;
devshellModule = {
imports = [./devshellModule.nix];
config.soonixShellHook = shellHook;
};
2025-08-25 16:45:22 +02:00
finalFiles = buildAllFiles allFiles;
# make it simpler to update the hooks without any devshell
packages = {
soonix =
pkgs.writeShellScriptBin "soonix"
# sh
''
set -euo pipefail
SKIP_GITIGNORE=false
CHECK_MODE=false
COMMAND=""
show_help() {
cat << EOF
soonix - Declarative file management tool
USAGE:
soonix <COMMAND> [OPTIONS]
COMMANDS:
update Update all managed files
check Check if all files are up to date (dry-run)
list List all managed file targets
help Show this help message
OPTIONS:
--skip-gitignore Skip files that would be added to .gitignore
EXAMPLES:
soonix update # Update all files
soonix update --skip-gitignore # Update only non-gitignored files
soonix list # List all targets
soonix list --skip-gitignore # List only non-gitignored targets
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
update|check|list|help)
COMMAND="$1"
shift
;;
--skip-gitignore)
SKIP_GITIGNORE=true
shift
;;
*)
echo "Error: Unknown argument '$1'" >&2
echo ""
show_help
exit 1
;;
esac
done
if [[ -z "$COMMAND" ]]; then
echo "Error: No command specified" >&2
echo ""
show_help
exit 1
fi
case "$COMMAND" in
help)
show_help
;;
list)
${concatMapStringsSep "\n" (hookName: let
hook = hooks.${hookName};
in ''
if [[ "$SKIP_GITIGNORE" == "true" && "${
if hook.hook.gitignore
then "true"
else "false"
}" == "true" ]]; then
: # skip
else
echo "${hook.output}"
fi
'') (builtins.attrNames hooks)}
;;
update)
${generateShellHook}
;;
check)
CHECK_MODE=true
echo "Checking files..."
${generateShellHook}
;;
esac
'';
};
2025-08-25 16:45:22 +02:00
};
}