#!/bin/bash # ============================================================================ # Horatio — build, test, and run script for Arch Linux # # Prerequisites: # - Dart SDK (dart) — pacman -S dart # - Flutter SDK — flutter-bin (AUR) or manual install # - pip — for openai-whisper (Linux speech-to-text) # # Usage: # ./run.sh # Full pipeline: analyze + test + run # ./run.sh test # Run core tests only # ./run.sh analyze # Run analysis only # ./run.sh run # Build and launch the desktop app # ./run.sh web # Run as Flutter web app (for inspection/testing) # ./run.sh -f # Force-run, ignoring the step cache # ============================================================================ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" readonly SCRIPT_DIR readonly CORE_DIR="$SCRIPT_DIR/horatio_core" readonly APP_DIR="$SCRIPT_DIR/horatio_app" readonly CACHE_DIR="$SCRIPT_DIR/.cache" FORCE=false # -- Caching helpers --------------------------------------------------------- # Compute a sha256 over the contents of every file matching a find expression. # Usage: files_hash # Example: files_hash "$CORE_DIR" -name '*.dart' files_hash() { local dir="$1"; shift find "$dir" "$@" -type f -print0 \ | sort -z \ | xargs -0 sha256sum \ | sha256sum \ | awk '{ print $1 }' } # Check whether a step can be skipped. Returns 0 (skip) when the cached hash # matches the current hash, 1 (run) otherwise. Always returns 1 when FORCE. step_cached() { local step="$1" local current_hash="$2" if $FORCE; then return 1 fi local cache_file="$CACHE_DIR/$step" if [[ -f "$cache_file" ]] && [[ "$(cat "$cache_file")" == "$current_hash" ]]; then return 0 fi return 1 } # Record a successful step so subsequent runs can skip it. cache_step() { local step="$1" local hash="$2" mkdir -p "$CACHE_DIR" printf '%s' "$hash" > "$CACHE_DIR/$step" } # -- Helpers ----------------------------------------------------------------- check_command() { local cmd="$1" local pkg="$2" if ! command -v "$cmd" &>/dev/null; then echo "ERROR: '$cmd' not found. Install with: $pkg" exit 1 fi } heading() { echo "" echo "══════════════════════════════════════════════════════════════" echo " $1" echo "══════════════════════════════════════════════════════════════" } # Parses an lcov.info file and fails if coverage is below the threshold. # $1: path to lcov.info # $2: package name (for error messages) # $3: minimum coverage percentage (integer, e.g. 100) check_coverage() { local lcov_file="$1" local pkg_name="$2" local threshold="$3" if [[ ! -f "$lcov_file" ]]; then echo "ERROR: Coverage file not found: $lcov_file" exit 1 fi local coverage coverage=$(awk -F'[,:]' ' /^DA:/ { total++; if ($3 == 0) uncov++ } END { if (total == 0) { print 0; exit } printf "%.1f", ((total - uncov) / total) * 100 } ' "$lcov_file") echo " $pkg_name coverage: ${coverage}% (threshold: ${threshold}%)" # Compare as integers (awk handles the float comparison). if awk "BEGIN { exit !(${coverage} < ${threshold}) }"; then echo "ERROR: $pkg_name coverage ${coverage}% is below ${threshold}%." exit 1 fi } # -- Dependency checks ------------------------------------------------------- check_deps() { if ! command -v dart &>/dev/null; then # dart may come from flutter-bin; install standalone only if flutter is also missing. if ! command -v flutter &>/dev/null; then echo "ERROR: 'dart' not found. Install with: pacman -S dart" exit 1 fi fi } ensure_whisper() { if command -v whisper &>/dev/null; then return fi heading "Installing OpenAI Whisper (Linux speech-to-text)" check_command pipx "pacman -S python-pipx" pipx install openai-whisper check_command whisper "pipx install openai-whisper" } ensure_flutter() { if command -v flutter &>/dev/null; then return fi heading "Installing Flutter SDK" if ! command -v pacman &>/dev/null; then echo "ERROR: 'flutter' not found and no pacman available." echo "Install from: https://flutter.dev/docs/get-started/install" exit 1 fi # flutter-bin bundles Dart and conflicts with the standalone dart package. if pacman -Qi dart &>/dev/null; then echo "Removing standalone 'dart' package (flutter-bin includes Dart)..." sudo pacman -Rdd --noconfirm dart fi echo "Flutter not found — installing flutter-bin via AUR..." if command -v yay &>/dev/null; then yay -S --needed --noconfirm flutter-bin elif command -v paru &>/dev/null; then paru -S --needed --noconfirm flutter-bin else echo "ERROR: No AUR helper (yay/paru) found." echo "Install manually: yay -S flutter-bin (or from flutter.dev)" exit 1 fi # Verify it worked. check_command flutter "yay -S flutter-bin" } # -- Core package tasks ------------------------------------------------------ core_get() { local h h=$(files_hash "$CORE_DIR" -name 'pubspec.yaml') if step_cached core_get "$h"; then echo " [cached] core_get — skipping" return fi heading "Upgrading core dependencies" cd "$CORE_DIR" dart pub upgrade --major-versions cache_step core_get "$h" } core_analyze() { local h h=$(files_hash "$CORE_DIR" -name '*.dart' -o -name 'analysis_options.yaml') if step_cached core_analyze "$h"; then echo " [cached] core_analyze — skipping" return fi heading "Analyzing horatio_core" cd "$CORE_DIR" dart analyze --fatal-infos cache_step core_analyze "$h" } core_test() { local h h=$(files_hash "$CORE_DIR" -name '*.dart') if step_cached core_test "$h"; then echo " [cached] core_test — skipping" return fi heading "Testing horatio_core (with coverage)" cd "$CORE_DIR" flutter test --coverage check_coverage "$CORE_DIR/coverage/lcov.info" "horatio_core" 100 cache_step core_test "$h" } core_format() { local h h=$(files_hash "$CORE_DIR" -name '*.dart') if step_cached core_format "$h"; then echo " [cached] core_format — skipping" return fi heading "Formatting horatio_core" cd "$CORE_DIR" dart format --set-exit-if-changed . cache_step core_format "$h" } # -- App tasks --------------------------------------------------------------- app_get() { local h h=$(files_hash "$APP_DIR" -name 'pubspec.yaml') if step_cached app_get "$h"; then echo " [cached] app_get — skipping" return fi heading "Upgrading app dependencies" cd "$APP_DIR" flutter pub upgrade --major-versions cache_step app_get "$h" } app_codegen() { local h h=$(files_hash "$APP_DIR/lib/database" -name '*.dart' ! -name '*.g.dart') if step_cached app_codegen "$h"; then echo " [cached] app_codegen — skipping" return fi heading "Running drift codegen" cd "$APP_DIR" dart run build_runner build --delete-conflicting-outputs # Inject // coverage:ignore-file into generated .g.dart files so that # flutter test --coverage excludes them automatically (VS Code reads # the raw lcov.info without the awk filter in app_test). local gfiles gfiles=$(find "$APP_DIR/lib" -name '*.g.dart' -exec grep -L 'coverage:ignore-file' {} +) || true if [[ -n "$gfiles" ]]; then echo "$gfiles" | xargs sed -i '1a\// coverage:ignore-file' fi cache_step app_codegen "$h" } app_analyze() { local h h=$(files_hash "$APP_DIR" -name '*.dart' -o -name 'analysis_options.yaml') if step_cached app_analyze "$h"; then echo " [cached] app_analyze — skipping" return fi heading "Analyzing horatio_app" cd "$APP_DIR" flutter analyze --fatal-infos cache_step app_analyze "$h" } app_test() { local h h=$(cat <(files_hash "$CORE_DIR" -name '*.dart') <(files_hash "$APP_DIR" -name '*.dart') | sha256sum | awk '{ print $1 }') if step_cached app_test "$h"; then echo " [cached] app_test — skipping" return fi heading "Testing horatio_app (with coverage)" cd "$APP_DIR" flutter test --coverage # Filter generated files from coverage (drift codegen + table schemas). local lcov="$APP_DIR/coverage/lcov.info" awk '/^SF:.*(\.g\.dart|tables\/)/{skip=1} /^end_of_record/{if(skip){skip=0;next}} !skip' \ "$lcov" > "${lcov}.tmp" && mv "${lcov}.tmp" "$lcov" check_coverage "$lcov" "horatio_app" 100 cache_step app_test "$h" } app_build() { local h h=$(cat <(files_hash "$CORE_DIR" -name '*.dart') <(files_hash "$APP_DIR" -name '*.dart' -o -name 'pubspec.yaml' -o -name 'pubspec.lock' -o -name 'CMakeLists.txt') | sha256sum | awk '{ print $1 }') if step_cached app_build "$h"; then echo " [cached] app_build — skipping" return fi heading "Building horatio_app (Linux desktop)" cd "$APP_DIR" flutter build linux --release cache_step app_build "$h" } app_run() { heading "Running horatio_app (Linux desktop)" cd "$APP_DIR" flutter run -d linux } app_web() { heading "Running horatio_app (Flutter web — for inspection)" cd "$APP_DIR" flutter run -d chrome --web-port=8080 } # -- Pipelines --------------------------------------------------------------- do_dead_code() { local h h=$(cat <(files_hash "$CORE_DIR" -name '*.dart') <(files_hash "$APP_DIR" -name '*.dart') | sha256sum | awk '{ print $1 }') if step_cached dead_code "$h"; then echo " [cached] dead_code — skipping" return fi heading "Dead code detection & auto-removal" bash "$SCRIPT_DIR/dead_code.sh" cache_step dead_code "$h" } do_analyze() { check_deps core_get core_format core_analyze ensure_flutter app_get app_codegen do_dead_code } do_test() { check_deps core_get core_test ensure_flutter app_get app_codegen app_test } do_full() { do_analyze do_test do_run echo "" echo "All checks passed." } do_run() { check_deps ensure_flutter ensure_whisper core_get app_get app_codegen app_analyze app_build app_run } do_web() { check_deps ensure_flutter ensure_whisper core_get app_get app_codegen app_analyze app_web } do_deploy() { check_deps ensure_flutter core_get app_get app_codegen heading "Deploying to Android device (BL-9000)" bash "$SCRIPT_DIR/deploy.sh" "$@" } # -- Main -------------------------------------------------------------------- main() { # Parse flags. while [[ "${1:-}" == -* ]]; do case "$1" in -f|--force) FORCE=true; shift ;; *) echo "Unknown flag: $1"; exit 1 ;; esac done local cmd="${1:-full}" case "$cmd" in analyze) do_analyze ;; test) do_test ;; dead-code) do_dead_code ;; full) do_full ;; run) do_run ;; web) do_web ;; deploy) shift; do_deploy "$@" ;; *) echo "Usage: $0 [-f|--force] {analyze|test|dead-code|full|run|web|deploy}" exit 1 ;; esac } main "$@"