diff --git a/deps.edn b/deps.edn index d47587c..f5927c3 100644 --- a/deps.edn +++ b/deps.edn @@ -1,21 +1,22 @@ {:deps - {org.clojure/clojure {:mvn/version "1.12.3"} - metosin/malli {:mvn/version "0.19.2"} - cli-matic/cli-matic {:mvn/version "0.5.4"} - org.clojure/data.json {:mvn/version "2.5.1"} - metosin/jsonista {:mvn/version "0.3.11"} - hiccup/hiccup {:mvn/version "2.0.0"} - funcool/lentes {:mvn/version "1.3.3"} - clj-commons/clj-yaml {:mvn/version "1.0.29"} - org.clj-commons/pretty {:mvn/version "3.6.7"} - io.github.paintparty/bling {:mvn/version "0.8.8"} - mvxcvi/puget {:mvn/version "1.3.4"} - org.slf4j/slf4j-nop {:mvn/version "2.0.17"} - ;; SQL database dependencies - com.github.seancorfield/next.jdbc {:mvn/version "1.3.955"} - com.github.seancorfield/honeysql {:mvn/version "2.7.1350"} - dev.weavejester/ragtime.next-jdbc {:mvn/version "0.9.4"} - org.xerial/sqlite-jdbc {:mvn/version "3.47.1.0"}} + {org.clojure/clojure {:mvn/version "1.12.3"} + metosin/malli {:mvn/version "0.19.2"} + cli-matic/cli-matic {:mvn/version "0.5.4"} + org.clojure/data.json {:mvn/version "2.5.1"} + metosin/jsonista {:mvn/version "0.3.11"} + hiccup/hiccup {:mvn/version "2.0.0"} + funcool/lentes {:mvn/version "1.3.3"} + clj-commons/clj-yaml {:mvn/version "1.0.29"} + org.clj-commons/pretty {:mvn/version "3.6.7"} + io.github.paintparty/bling {:mvn/version "0.8.8"} + mvxcvi/puget {:mvn/version "1.3.4"} + org.slf4j/slf4j-nop {:mvn/version "2.0.17"} + failjure/failjure {:mvn/version "2.3.0"} + ;; SQL database dependencies + com.github.seancorfield/next.jdbc {:mvn/version "1.3.955"} + com.github.seancorfield/honeysql {:mvn/version "2.7.1350"} + dev.weavejester/ragtime.next-jdbc {:mvn/version "0.9.4"} + org.xerial/sqlite-jdbc {:mvn/version "3.47.1.0"}} :paths ["src" "classes" diff --git a/src/clojure_skills/cli.clj b/src/clojure_skills/cli.clj index e2f96ee..d20e734 100644 --- a/src/clojure_skills/cli.clj +++ b/src/clojure_skills/cli.clj @@ -13,6 +13,7 @@ [clojure-skills.search :as search] [clojure-skills.sync :as sync] [clojure.string :as str] + [failjure.core :as f] [next.jdbc :as jdbc])) (set! *warn-on-reflection* true) @@ -77,10 +78,13 @@ (*exit-fn* 1)))) (defn handle-command-errors - "Execute a command function with unified error handling." + "Execute a command function with unified error handling for both exceptions and failjure." [operation-name f & args] (try - (apply f args) + (let [result (apply f args)] + (when (f/failed? result) + (print-error (f/message result)) + (*exit-fn* 1))) (catch Exception e (print-error-with-exception (str operation-name " failed") e) (*exit-fn* 1)))) @@ -264,12 +268,12 @@ "List all skills associated with a prompt via fragments." [db prompt-id] (jdbc/execute! db - ["SELECT pfs.position, s.* + ["SELECT pfs.position, s.* FROM prompt_references pr JOIN prompt_fragments pf ON pr.target_fragment_id = pf.id JOIN prompt_fragment_skills pfs ON pf.id = pfs.fragment_id JOIN skills s ON pfs.skill_id = s.id - WHERE pr.source_prompt_id = ? + WHERE pr.source_prompt_id = ? AND pr.reference_type = 'fragment' ORDER BY pfs.position" prompt-id])) @@ -278,12 +282,12 @@ "List skills in embedded fragments (fragments that should be embedded in the prompt)." [db prompt-id] (jdbc/execute! db - ["SELECT pfs.position, s.* + ["SELECT pfs.position, s.* FROM prompt_references pr JOIN prompt_fragments pf ON pr.target_fragment_id = pf.id JOIN prompt_fragment_skills pfs ON pf.id = pfs.fragment_id JOIN skills s ON pfs.skill_id = s.id - WHERE pr.source_prompt_id = ? + WHERE pr.source_prompt_id = ? AND pr.reference_type = 'fragment' AND pf.name NOT LIKE '%-ref-%' ORDER BY pfs.position" @@ -293,12 +297,12 @@ "List skills in reference fragments (fragments that are tracked but not embedded)." [db prompt-id] (jdbc/execute! db - ["SELECT pfs.position, s.* + ["SELECT pfs.position, s.* FROM prompt_references pr JOIN prompt_fragments pf ON pr.target_fragment_id = pf.id JOIN prompt_fragment_skills pfs ON pf.id = pfs.fragment_id JOIN skills s ON pfs.skill_id = s.id - WHERE pr.source_prompt_id = ? + WHERE pr.source_prompt_id = ? AND pr.reference_type = 'fragment' AND pf.name LIKE '%-ref-%' ORDER BY pfs.position" @@ -306,11 +310,11 @@ (defn render-prompt-content "Compose full prompt content by combining prompt intro, embedded skills, and references. - + Args: db - Database connection prompt - Prompt map with :prompts/id and :prompts/content - + Returns: String with composed markdown content" [db prompt] @@ -409,11 +413,15 @@ (let [[config db] (load-config-and-db) stats (search/get-stats db) db-path (config/get-db-path config) + config-file-path (config/get-config-file-path) + project-config-path (config/get-project-config-file-path) permissions (get config :permissions {}) format (output/get-output-format json human config)] (output/output {:type :stats :configuration {:database-path db-path + :config-file-path config-file-path + :project-config-path project-config-path :auto-migrate (get-in config [:database :auto-migrate] true) :skills-directory (get-in config [:project :skills-dir] "skills") :prompts-directory (get-in config [:project :prompts-dir] "prompts") diff --git a/src/clojure_skills/db/core.clj b/src/clojure_skills/db/core.clj index 665322a..3ed0635 100644 --- a/src/clojure_skills/db/core.clj +++ b/src/clojure_skills/db/core.clj @@ -2,12 +2,22 @@ "Database connection management and operations." (:require [clojure-skills.config :as config] - [clojure-skills.db.migrate :as migrate])) + [clojure-skills.db.migrate :as migrate] + [clojure.java.io :as io] + [failjure.core :as f])) + +(defn ensure-db-dir + "Ensure the directory containing the database file exists." + [db-path] + (let [db-file (io/file db-path)] + (when-let [parent (.getParentFile db-file)] + (when-not (.exists parent) + (.mkdirs parent))))) (defn get-db "Get database connection spec from config. - Returns a simple db-spec map that next.jdbc can use directly. - Foreign keys are enabled for SQLite to support CASCADE deletes." + Returns a simple db-spec map that next.jdbc can use directly. + Foreign keys are enabled for SQLite to support CASCADE deletes." ([] (get-db (config/load-config))) ([config] @@ -20,7 +30,14 @@ (defn init-db "Initialize database with Ragtime migrations." ([db] - (migrate/migrate-db db)) + (let [result (migrate/migrate-db db)] + (if (f/failed? result) + (throw (ex-info "Database initialization failed" + {:reason (f/message result)})) + result))) ([] - (let [db (get-db)] - (init-db db)))) + (let [config (config/load-config) + db-path (config/get-db-path config)] + (ensure-db-dir db-path) + (let [db (get-db config)] + (init-db db))))) diff --git a/src/clojure_skills/db/migrate.clj b/src/clojure_skills/db/migrate.clj index bce4f2a..cd8e927 100644 --- a/src/clojure_skills/db/migrate.clj +++ b/src/clojure_skills/db/migrate.clj @@ -2,6 +2,7 @@ "Ragtime-based database migrations." (:require [clojure-skills.config :as config] + [failjure.core :as f] [ragtime.next-jdbc :as ragtime-jdbc] [ragtime.repl :as ragtime-repl] [ragtime.strategy])) @@ -9,76 +10,114 @@ (defn load-config "Load Ragtime configuration from application config." [] - (let [app-config (config/load-config) - db-path (config/get-db-path app-config) - db-spec {:dbtype "sqlite" :dbname db-path}] - {:datastore (ragtime-jdbc/sql-database db-spec) - :migrations (ragtime-jdbc/load-resources "migrations") - :strategy ragtime.strategy/apply-new})) + (f/try-all [app-config (config/load-config) + db-path (config/get-db-path app-config) + db-spec {:dbtype "sqlite" :dbname db-path}] + {:datastore (ragtime-jdbc/sql-database db-spec) + :migrations (ragtime-jdbc/load-resources "migrations") + :strategy ragtime.strategy/apply-new} + (f/when-failed [e] + (f/fail "Failed to load config: %s" (f/message e))))) (defn migrate "Run all pending migrations." [] - (let [config (load-config)] - (println "Running migrations...") - (ragtime-repl/migrate config) - (println "Migrations complete."))) + (f/attempt-all [config (load-config)] + (do + (println "Running migrations...") + (ragtime-repl/migrate config) + (println "Migrations complete.")) + (f/when-failed [e] + (do + (println "Migration failed:" (f/message e)) + (f/fail "Migration failed: %s" (f/message e)))))) (defn rollback "Rollback the last migration." ([] (rollback 1)) ([amount] - (let [config (load-config)] - (println (format "Rolling back %d migration(s)..." amount)) - (ragtime-repl/rollback config amount) - (println "Rollback complete.")))) + (f/attempt-all [config (load-config)] + (do + (println (format "Rolling back %d migration(s)..." amount)) + (ragtime-repl/rollback config amount) + (println "Rollback complete.")) + (f/when-failed [e] + (do + (println "Rollback failed:" (f/message e)) + (f/fail "Rollback failed: %s" (f/message e))))))) (defn rollback-all "Rollback all migrations." [] - (let [config (load-config) - migrations (:migrations config)] - (println (format "Rolling back all %d migration(s)..." (count migrations))) - (ragtime-repl/rollback config (count migrations)) - (println "Rollback complete."))) + (f/attempt-all [config (load-config) + migrations (:migrations config)] + (do + (println (format "Rolling back all %d migration(s)..." (count migrations))) + (ragtime-repl/rollback config (count migrations)) + (println "Rollback complete.")) + (f/when-failed [e] + (do + (println "Rollback failed:" (f/message e)) + (f/fail "Rollback failed: %s" (f/message e)))))) (defn -main "Main entry point for migration CLI." [& args] - (case (first args) - "migrate" (migrate) - "rollback" (if-let [amount (second args)] - (rollback (Integer/parseInt amount)) - (rollback)) - "rollback-all" (rollback-all) - (do - (println "Usage: clojure -M:migrate [args]") - (println "Commands:") - (println " migrate - Run all pending migrations") - (println " rollback [n] - Rollback last n migrations (default: 1)") - (println " rollback-all - Rollback all migrations") - (System/exit 1)))) + (f/attempt-all [] + (case (first args) + "migrate" (do + (migrate) + (System/exit 0)) + "rollback" (if-let [amount (second args)] + (do + (rollback (Integer/parseInt amount)) + (System/exit 0)) + (do + (rollback) + (System/exit 0))) + "rollback-all" (do + (rollback-all) + (System/exit 0)) + (do + (println "Usage: clojure -M:migrate [args]") + (println "Commands:") + (println " migrate - Run all pending migrations") + (println " rollback [n] - Rollback last n migrations (default: 1)") + (println " rollback-all - Rollback all migrations") + (System/exit 1))) + (f/when-failed [e] + (do + (println "Error in CLI:" (f/message e)) + (System/exit 1))))) (defn migrate-db "Migrate a specific database connection (useful for testing). Takes a db-spec map (e.g., {:dbtype \"sqlite\" :dbname \"test.db\"})." [db-spec] - (let [config {:datastore (ragtime-jdbc/sql-database db-spec) - :migrations (ragtime-jdbc/load-resources "migrations") - :strategy ragtime.strategy/apply-new}] - (ragtime-repl/migrate config))) + (f/try-all [datastore (f/try* (ragtime-jdbc/sql-database db-spec)) + migrations (f/try* (ragtime-jdbc/load-resources "migrations"))] + (let [config {:datastore datastore + :migrations migrations + :strategy ragtime.strategy/apply-new}] + (ragtime-repl/migrate config)) + (f/when-failed [e] + (f/fail "Database migration failed: %s" (f/message e))))) (defn reset-db "Reset database by rolling back all migrations and re-applying them. Takes a db-spec map (e.g., {:dbtype \"sqlite\" :dbname \"test.db\"})." [db-spec] - (let [config {:datastore (ragtime-jdbc/sql-database db-spec) - :migrations (ragtime-jdbc/load-resources "migrations") - :strategy ragtime.strategy/apply-new} - migrations (:migrations config)] - ;; Rollback all migrations - (when (pos? (count migrations)) - (ragtime-repl/rollback config (count migrations))) - ;; Re-apply all migrations - (ragtime-repl/migrate config))) + (f/try-all [datastore (f/try* (ragtime-jdbc/sql-database db-spec)) + migrations (f/try* (ragtime-jdbc/load-resources "migrations"))] + (let [config {:datastore datastore + :migrations migrations + :strategy ragtime.strategy/apply-new} + migrations-count (count migrations)] + ;; Rollback all migrations + (when (pos? migrations-count) + (ragtime-repl/rollback config migrations-count)) + ;; Re-apply all migrations + (ragtime-repl/migrate config)) + (f/when-failed [e] + (f/fail "Database reset failed: %s" (f/message e))))) diff --git a/src/clojure_skills/output.clj b/src/clojure_skills/output.clj index 6b1219a..8003cca 100644 --- a/src/clojure_skills/output.clj +++ b/src/clojure_skills/output.clj @@ -261,6 +261,9 @@ (println (bling/bling [:bold "Database Statistics"])) (println) (println (bling/bling [:underline "Configuration:"])) + (println (str " Global config: " (:config-file-path config))) + (when (:project-config-path config) + (println (str " Project config: " (:project-config-path config)))) (println (str " Database: " (:database-path config))) (println (str " Skills directory: " (:skills-directory config))) (println (str " Prompts directory: " (:prompts-directory config))) diff --git a/test/clojure_skills/cli_db_test.clj b/test/clojure_skills/cli_db_test.clj index 7648dfd..99b4234 100644 --- a/test/clojure_skills/cli_db_test.clj +++ b/test/clojure_skills/cli_db_test.clj @@ -41,8 +41,8 @@ ["SELECT name FROM sqlite_master WHERE type='table'"]) table-names (set (map :sqlite_master/name tables))] (testing "Then: the set should contain the tables created by migrations" - (is (contains? table-names "skills") "Skills table exists") - (is (contains? table-names "prompts") "Prompts table exists")))))) + (is (contains? table-names "skills")) + (is (contains? table-names "prompts"))))))) ;; Tests for db init command (deftest db-init-command-test @@ -162,8 +162,9 @@ ;; 3. Stats (let [{:keys [output]} (capture-output #(cli/cmd-stats {})) parsed (tu/parse-json-output output)] - (is (= "stats" (:type parsed))) - (is (map? (:database parsed)))) + (is (match? {:type "stats" + :database map?} + parsed))) ;; 4. Reset (let [{:keys [output]} (capture-output #(cli/cmd-reset-db {:force true}))] @@ -173,3 +174,17 @@ (let [count (jdbc/execute-one! tu/*connection* ["SELECT COUNT(*) as count FROM skills"])] (is (= 0 (:count count)))))))) + +(deftest db-stats-config-paths-test + (testing "Given: A database with configuration" + (binding [cli/*exit-fn* mock-exit] + (with-redefs [cli/load-config-and-db mock-load-config-and-db] + (testing "When: We request database statistics" + (let [{:keys [output]} (capture-output #(cli/cmd-stats {})) + parsed (tu/parse-json-output output)] + (testing "Then: Configuration should include config file paths" + (is (match? {:type "stats" + :configuration {:config-file-path string? + :project-config-path string? + :database-path string?}} + parsed))))))))) diff --git a/test/clojure_skills/db/prompt_fragments_test.clj b/test/clojure_skills/db/prompt_fragments_test.clj index a9595dd..e23b490 100644 --- a/test/clojure_skills/db/prompt_fragments_test.clj +++ b/test/clojure_skills/db/prompt_fragments_test.clj @@ -6,7 +6,8 @@ [clojure-skills.db.prompt-fragments :as pf] [next.jdbc :as jdbc] [next.jdbc.sql :as sql] - [clojure.java.io :as io])) + [clojure.java.io :as io] + [failjure.core :as f])) (defn create-test-db "Create a test database with all migrations applied" @@ -14,7 +15,10 @@ (let [db-file (str "test-prompt-fragments-" (java.util.UUID/randomUUID) ".db") db-spec {:dbtype "sqlite" :dbname db-file}] ;; Apply all migrations - (migrate/migrate-db db-spec) + (let [result (migrate/migrate-db db-spec)] + (when (f/failed? result) + (throw (ex-info "Test database migration failed" + {:reason (f/message result)})))) {:db-spec db-spec :db-file db-file})) (defn cleanup-test-db diff --git a/test/clojure_skills/test_utils.clj b/test/clojure_skills/test_utils.clj index db64aca..8017a0e 100644 --- a/test/clojure_skills/test_utils.clj +++ b/test/clojure_skills/test_utils.clj @@ -7,7 +7,8 @@ [ragtime.repl :as ragtime-repl] [ragtime.strategy] [ragtime.reporter] - [clojure-skills.db.migrate :as migrate]) + [clojure-skills.db.migrate :as migrate] + [failjure.core :as f]) (:import (org.sqlite.core DB) (org.sqlite SQLiteConnection))) @@ -61,7 +62,10 @@ (setup-test-db db-spec true)) ([db-spec run-migrations?] (when run-migrations? - (migrate/migrate-db db-spec)) + (let [result (migrate/migrate-db db-spec)] + (when (f/failed? result) + (throw (ex-info "Test database migration failed" + {:reason (f/message result)}))))) db-spec)) (defn cleanup-test-db @@ -83,8 +87,8 @@ "file::memory:?cache=shared" (or db-path (str "test-" (random-uuid) ".db"))) db-spec {:dbtype "sqlite" :dbname db-path}] - ;; Setup - (setup-test-db db-spec run-migrations?) + ;; Setup + (setup-test-db db-spec run-migrations?) ; Handles failjure results via setup-test-db ;; Create datasource for connection reuse (important for in-memory DBs) (let [datasource (jdbc/get-datasource db-spec)]