diff --git a/libs/kit-generator/src/kit/api.clj b/libs/kit-generator/src/kit/api.clj index b63c5a68..944d961f 100644 --- a/libs/kit-generator/src/kit/api.clj +++ b/libs/kit-generator/src/kit/api.clj @@ -1,13 +1,13 @@ (ns kit.api "Public API for kit-generator." (:require + [clojure.java.io :as jio] [clojure.string :as str] [clojure.test :as t] [kit.generator.hooks :as hooks] [kit.generator.io :as io] [kit.generator.modules :as modules] - [kit.generator.modules-log :refer [track-installation installed-modules - module-installed?]] + [kit.generator.modules-log :as modules-log :refer [module-installed?]] [kit.generator.modules.dependencies :as deps] [kit.generator.modules.generator :as generator] [kit.generator.snippets :as snippets])) @@ -181,31 +181,245 @@ ([module-key kit-edn-path {:keys [accept-hooks? dry?] :as opts}] (if dry? (print-installation-plan module-key kit-edn-path opts) - (let [{:keys [ctx pending-modules installed-modules]} (installation-plan module-key kit-edn-path opts) + (let [{:keys [ctx pending-modules installed-modules] plan-opts :opts} (installation-plan module-key kit-edn-path opts) accept-hooks-atom (atom accept-hooks?)] (report-already-installed installed-modules) (doseq [{:module/keys [key resolved-config] :as module} pending-modules] - (try - (track-installation ctx key - (generator/generate ctx module) - (hooks/run-hooks :post-install resolved-config - {:confirm (partial prompt-run-hooks accept-hooks-atom)}) - (report-install-module-success key resolved-config)) - (catch Exception e - (report-install-module-error key e)))))) + (let [feature-flag (get-in plan-opts [key :feature-flag] :default)] + (try + (generator/generate ctx module) + (let [manifest (modules-log/build-installation-manifest ctx module feature-flag)] + (hooks/run-hooks :post-install resolved-config + {:confirm (partial prompt-run-hooks accept-hooks-atom)}) + (modules-log/record-installation ctx key manifest) + (report-install-module-success key resolved-config)) + (catch Exception e + (modules-log/record-installation ctx key {:status :failed}) + (report-install-module-error key e))))))) :done)) (defn list-installed-modules "Lists installed modules and modules that failed to install, for the current project." [] - (doseq [[id status] (-> (read-ctx) - (installed-modules))] - (println id (if (= status :success) - "installed successfully" - "failed to install"))) + (let [ctx (read-ctx) + modules-root (modules/root ctx) + log (modules-log/read-modules-log modules-root)] + (doseq [[id entry] log] + (let [status (if (map? entry) (:status entry) entry)] + (println id (case status + :success "installed successfully" + :failed "failed to install" + (str "unknown status: " status)))))) :done) +;; --- Module removal --- + +(defn- check-file-status + "Checks whether a file created by module installation can be safely removed. + Returns :safe if unchanged (SHA matches), :modified if changed, :missing if deleted." + [{:keys [target sha256]}] + (let [f (jio/file target)] + (cond + (not (.exists f)) + :missing + + (nil? sha256) + :modified + + :else + (let [current-sha (modules-log/sha256 + (java.nio.file.Files/readAllBytes (.toPath f)))] + (if (= sha256 current-sha) + :safe + :modified))))) + +(defn- find-dependents + "Returns the set of installed module keys that depend on target-key. + Resolves each installed module using the feature flag it was installed with." + [ctx target-key] + (let [modules-root (modules/root ctx) + log (modules-log/read-modules-log modules-root) + installed-keys (->> log + (filter (fn [[_ entry]] + (let [status (if (map? entry) (:status entry) entry)] + (= :success status)))) + keys) + ;; Build opts map with each module's stored feature-flag + resolve-opts (->> installed-keys + (map (fn [mk] + (let [entry (get log mk) + ff (if (map? entry) + (:feature-flag entry :default) + :default)] + [mk {:feature-flag ff}]))) + (into {})) + loaded-ctx (modules/load-modules ctx resolve-opts)] + (deps/immediate-dependents loaded-ctx installed-keys target-key))) + +(defn removal-report + "Generates a removal report for an installed module. Returns a map with: + - :module-key + - :has-manifest? (whether detailed installation manifest is available) + - :safe-to-remove (files that can be auto-deleted, SHA matches) + - :modified-files (files modified since installation) + - :missing-files (files deleted since installation) + - :manual-steps (human-readable injection removal instructions) + - :dependents (installed modules that depend on this one) + Returns nil if the module is not installed." + ([module-key] + (removal-report module-key default-edn {})) + ([module-key opts] + (removal-report module-key default-edn opts)) + ([module-key kit-edn-path opts] + (let [ctx (read-ctx kit-edn-path) + modules-root (modules/root ctx) + log (modules-log/read-modules-log modules-root) + entry (get log module-key)] + (when entry + (let [manifest? (map? entry) + manifest (when manifest? entry) + dependents (try + (find-dependents ctx module-key) + (catch Exception e + (println "WARNING: error resolving dependents:" (.getMessage e)) + #{}))] + (if manifest? + ;; New format: detailed manifest with SHA comparison + (let [file-statuses (map (fn [asset] + (assoc asset :file-status (check-file-status asset))) + (:assets manifest)) + safe (vec (keep #(when (= :safe (:file-status %)) (:target %)) file-statuses)) + modified (vec (keep #(when (= :modified (:file-status %)) + {:path (:target %) :reason "content changed since installation"}) + file-statuses)) + missing (vec (keep #(when (= :missing (:file-status %)) (:target %)) file-statuses)) + manual-steps (mapv :description (:injections manifest))] + {:module-key module-key + :has-manifest? true + :safe-to-remove safe + :modified-files modified + :missing-files missing + :manual-steps manual-steps + :dependents dependents}) + ;; Old format: best-effort report from module config on disk + (let [loaded-ctx (try (modules/load-modules ctx) (catch Exception _ nil)) + descriptions (when loaded-ctx + (try + (let [module (modules/lookup-module loaded-ctx module-key)] + (generator/describe-actions module)) + (catch Exception _ nil)))] + {:module-key module-key + :has-manifest? false + :safe-to-remove [] + :modified-files [] + :manual-steps (vec (or descriptions + ["Module config not found. Review your project files manually."])) + :dependents dependents}))))))) + +(defn print-removal-report + "Prints a human-readable removal report for an installed module." + ([module-key] + (print-removal-report module-key default-edn {})) + ([module-key opts] + (print-removal-report module-key default-edn opts)) + ([module-key kit-edn-path opts] + (let [report (removal-report module-key kit-edn-path opts)] + (if (nil? report) + (println "Module" module-key "is not installed.") + (do + (println "REMOVAL REPORT for" (:module-key report)) + (println) + (when (seq (:dependents report)) + (println "WARNING: The following installed modules depend on" module-key ":") + (doseq [dep (:dependents report)] + (println " " dep)) + (println "Consider removing those first.") + (println)) + (when (seq (:safe-to-remove report)) + (println "FILES (safe to auto-remove, unchanged since installation):") + (doseq [f (:safe-to-remove report)] + (println " DELETE" f)) + (println)) + (when (seq (:modified-files report)) + (println "FILES (modified since installation, review before deleting):") + (doseq [{:keys [path reason]} (:modified-files report)] + (println " REVIEW" path "-" reason)) + (println)) + (when (seq (:missing-files report)) + (println "FILES (already deleted, no action needed):") + (doseq [f (:missing-files report)] + (println " GONE" f)) + (println)) + (when (seq (:manual-steps report)) + (println "MANUAL STEPS (undo code injections):") + (doseq [step (:manual-steps report)] + (println " -" step)) + (println)) + (when-not (:has-manifest? report) + (println "NOTE: This module was installed before removal tracking was added.") + (println " The above report is based on the module config and may not be") + (println " perfectly accurate. SHA comparison is not available.") + (println))))))) + +(defn remove-module + "Removes a module from the project. Auto-deletes files that are unchanged + since installation. Prints instructions for manual cleanup of injections. + + Options: + :force? - remove even if other modules depend on it + :dry? - only print the removal report, don't delete anything" + ([module-key] + (remove-module module-key default-edn {})) + ([module-key opts] + (remove-module module-key default-edn opts)) + ([module-key kit-edn-path {:keys [force? dry?] :as opts}] + (let [report (removal-report module-key kit-edn-path opts)] + (cond + (nil? report) + (println "ERROR: Module" module-key "is not installed.") + + (and (seq (:dependents report)) (not force?)) + (do (println "ERROR: Cannot remove" module-key + "because the following modules depend on it:") + (doseq [dep (:dependents report)] + (println " " dep)) + (println "Use :force? true to override.")) + + dry? + (print-removal-report module-key kit-edn-path opts) + + :else + (do + ;; Auto-delete safe files + (doseq [f (:safe-to-remove report)] + (println "Deleting" f) + (jio/delete-file (jio/file f) true)) + + ;; Report modified files + (when (seq (:modified-files report)) + (println) + (println "The following files were modified since installation.") + (println "Please review and delete manually:") + (doseq [{:keys [path]} (:modified-files report)] + (println " -" path))) + + ;; Report manual injection steps + (when (seq (:manual-steps report)) + (println) + (println "The following code was injected into existing files.") + (println "Please remove manually:") + (doseq [step (:manual-steps report)] + (println " -" step))) + + ;; Remove from install log + (let [ctx (read-ctx kit-edn-path)] + (modules-log/untrack-module ctx module-key)) + + (println) + (println "Module" module-key "has been uninstalled.")))) + :done)) + (def snippets-db (let [db (atom nil)] (fn [ctx & [reload?]] diff --git a/libs/kit-generator/src/kit/generator/modules/dependencies.clj b/libs/kit-generator/src/kit/generator/modules/dependencies.clj index 4e52a323..4f469f8a 100644 --- a/libs/kit-generator/src/kit/generator/modules/dependencies.clj +++ b/libs/kit-generator/src/kit/generator/modules/dependencies.clj @@ -51,3 +51,17 @@ (->> (dependency-tree ctx module-key opts) (dependency-order) (map #(modules/lookup-module ctx %)))) + +(defn immediate-dependents + "Returns the set of installed module keys that directly require target-key. + Used to check whether a module can be safely removed." + [ctx installed-module-keys target-key] + (->> installed-module-keys + (filter (fn [mk] + (when (not= mk target-key) + (try + (let [module (modules/lookup-module ctx mk) + requires (get-in module [:module/resolved-config :requires])] + (some #{target-key} requires)) + (catch Exception _ false))))) + set)) diff --git a/libs/kit-generator/src/kit/generator/modules_log.clj b/libs/kit-generator/src/kit/generator/modules_log.clj index a685d2d6..434dabea 100644 --- a/libs/kit-generator/src/kit/generator/modules_log.clj +++ b/libs/kit-generator/src/kit/generator/modules_log.clj @@ -3,7 +3,12 @@ (:require [clojure.java.io :as jio] [kit.generator.io :as io] - [kit.generator.modules :as modules])) + [kit.generator.modules :as modules] + [kit.generator.renderer :as renderer]) + (:import + java.nio.file.Files + java.security.MessageDigest + java.time.Instant)) (defn- modules-log-path [modules-root] (io/concat-path modules-root "install-log.edn")) @@ -17,17 +22,32 @@ (defn write-modules-log [modules-root log] (spit (modules-log-path modules-root) log)) +(defn- normalize-log-entry + "Normalizes a log entry to a map. Old entries are bare keywords (:success/:failed), + new entries are maps with a :status key." + [entry] + (if (map? entry) + entry + {:status entry})) + +(defn- entry-success? + "True if the log entry indicates successful installation." + [entry] + (= :success (:status (normalize-log-entry entry)))) + (defn module-installed? "True if the module identified by module-key was installed successfully." [ctx module-key] (let [modules-root (modules/root ctx) install-log (read-modules-log modules-root)] - (= :success (get install-log module-key)))) + (entry-success? (get install-log module-key)))) (defmacro track-installation "Records the installation status of a module identified by module-key. If the installation body throws an exception, the status is recorded as :failed. - If it completes successfully, the status is recorded as :success." + If it completes successfully, the status is recorded as :success. + + Deprecated: prefer record-installation for new code, which supports detailed manifests." [ctx module-key & body] `(let [modules-root# (modules/root ~ctx) install-log# (read-modules-log modules-root#)] @@ -46,4 +66,99 @@ [ctx] (let [modules-root (modules/root ctx) install-log (read-modules-log modules-root)] - (keys (filter (fn [[_ status]] (= status :success)) install-log)))) + (keys (filter (fn [[_ entry]] (entry-success? entry)) install-log)))) + +(defn installed-modules-log + "A map of module keys to their log entries, for modules that were installed successfully." + [ctx] + (let [modules-root (modules/root ctx) + install-log (read-modules-log modules-root)] + (->> install-log + (filter (fn [[_ entry]] (entry-success? entry))) + (into {})))) + +;; --- Manifest support for module removal --- + +(defn sha256 + "Computes the SHA-256 hex digest of a byte array or string." + [content] + (let [md (MessageDigest/getInstance "SHA-256") + bytes (if (string? content) + (.getBytes ^String content "UTF-8") + content)] + (->> (.digest md bytes) + (map #(format "%02x" (bit-and % 0xff))) + (apply str)))) + +(defn- slurp-bytes [path] + (Files/readAllBytes (.toPath (jio/file path)))) + +(defn- describe-injection-for-removal + "Produces a human-readable removal instruction for an injection." + [{:keys [type action value path]}] + (case [type action] + [:edn :merge] + (str "Remove keys " (pr-str (when (map? value) (keys value))) " from " path) + [:edn :append] + (str "Remove appended value " (pr-str value) " from " path) + [:clj :append-requires] + (str "Remove require(s) " (pr-str value) " from " path) + [:clj :append-build-task] + (str "Remove function " (when (sequential? value) (second value)) " from " path) + [:clj :append-build-task-call] + (str "Remove call to " (pr-str value) " from " path) + [:html :append] + (str "Remove appended HTML " (pr-str value) " from " path) + (str "Undo " action " in " path))) + +(defn build-installation-manifest + "Builds a detailed manifest of what a module installation did. + Called after successful generation to persist the record for later removal. + Note: SHA-256 hashes are computed from on-disk state after generation completes." + [ctx {:module/keys [resolved-config]} feature-flag] + (let [{:keys [actions hooks]} resolved-config + rendered-assets (for [asset (:assets actions) + :when (and (sequential? asset) (>= (count asset) 2))] + (let [[_ target-path] asset + rendered-target (renderer/render-template ctx target-path) + target-file (jio/file rendered-target)] + (cond-> {:target rendered-target} + (.exists target-file) + (assoc :sha256 (sha256 (slurp-bytes rendered-target)))))) + rendered-injections (for [inj (:injections actions)] + (let [rendered-path (renderer/render-template ctx (:path inj))] + {:type (:type inj) + :action (:action inj) + :path rendered-path + :description (describe-injection-for-removal + (assoc inj :path rendered-path))}))] + (cond-> {:status :success + :installed-at (str (Instant/now)) + :feature-flag feature-flag + :assets (vec rendered-assets) + :injections (vec rendered-injections)} + (seq hooks) + (assoc :hooks (vec (for [[hook-type commands] hooks] + {:type hook-type :commands (vec commands)})))))) + +(defn record-installation + "Records a module installation in the log with a detailed manifest or failure status." + [ctx module-key manifest] + (let [modules-root (modules/root ctx) + install-log (read-modules-log modules-root)] + (write-modules-log modules-root (assoc install-log module-key manifest)))) + +(defn module-manifest + "Retrieves the detailed installation manifest for a module. + Returns nil for modules installed before manifest tracking was added." + [ctx module-key] + (let [modules-root (modules/root ctx) + entry (get (read-modules-log modules-root) module-key)] + (when (map? entry) entry))) + +(defn untrack-module + "Removes a module entry from the installation log." + [ctx module-key] + (let [modules-root (modules/root ctx) + install-log (read-modules-log modules-root)] + (write-modules-log modules-root (dissoc install-log module-key)))) diff --git a/libs/kit-generator/test/kit_generator/dependencies_test.clj b/libs/kit-generator/test/kit_generator/dependencies_test.clj new file mode 100644 index 00000000..ef86815d --- /dev/null +++ b/libs/kit-generator/test/kit_generator/dependencies_test.clj @@ -0,0 +1,37 @@ +(ns kit-generator.dependencies-test + (:require + [clojure.test :refer [deftest is testing]] + [kit-generator.project :as project] + [kit.generator.modules :as modules] + [kit.generator.modules.dependencies :as deps])) + +(deftest test-immediate-dependents-finds-dependent + (testing "immediate-dependents returns modules that directly require the target" + ;; :meta with :extras feature-flag requires [:db] + (let [kit-edn-path (project/prepare-project "test/resources/modules") + ctx (modules/load-modules (project/read-ctx kit-edn-path) + {:meta {:feature-flag :extras}}) + installed-keys [:meta :db] + result (deps/immediate-dependents ctx installed-keys :db)] + (is (set? result)) + (is (contains? result :meta) + ":meta (with :extras) depends on :db")))) + +(deftest test-immediate-dependents-empty-when-no-dependents + (testing "immediate-dependents returns empty set when nothing depends on the target" + (let [kit-edn-path (project/prepare-project "test/resources/modules") + ctx (modules/load-modules (project/read-ctx kit-edn-path)) + installed-keys [:html] + result (deps/immediate-dependents ctx installed-keys :html)] + (is (empty? result) + "Nothing should depend on :html")))) + +(deftest test-immediate-dependents-excludes-self + (testing "immediate-dependents does not include the target module itself" + (let [kit-edn-path (project/prepare-project "test/resources/modules") + ctx (modules/load-modules (project/read-ctx kit-edn-path) + {:meta {:feature-flag :extras}}) + installed-keys [:meta :db] + result (deps/immediate-dependents ctx installed-keys :db)] + (is (not (contains? result :db)) + ":db should not be listed as its own dependent")))) diff --git a/libs/kit-generator/test/kit_generator/generator_test.clj b/libs/kit-generator/test/kit_generator/generator_test.clj index 5c7f69cb..f1679274 100644 --- a/libs/kit-generator/test/kit_generator/generator_test.clj +++ b/libs/kit-generator/test/kit_generator/generator_test.clj @@ -14,7 +14,9 @@ (defn module-installed? [module-key] (when-let [install-log (read-edn-safe (str source-folder "/modules/install-log.edn"))] - (= :success (get install-log module-key)))) + (let [entry (get install-log module-key)] + (or (= :success entry) + (= :success (:status entry)))))) (def seeded-files "Files that are directly copied. They will always be present in the target folder, diff --git a/libs/kit-generator/test/kit_generator/injection_description_test.clj b/libs/kit-generator/test/kit_generator/injection_description_test.clj new file mode 100644 index 00000000..4b4ff229 --- /dev/null +++ b/libs/kit-generator/test/kit_generator/injection_description_test.clj @@ -0,0 +1,65 @@ +(ns kit-generator.injection-description-test + (:require + [clojure.test :refer [deftest is testing]] + [kit.generator.modules-log])) + +(def describe-injection + "Access the private describe-injection-for-removal fn." + #'kit.generator.modules-log/describe-injection-for-removal) + +(deftest test-edn-merge-description + (testing ":edn :merge produces key-removal instruction" + (let [result (describe-injection {:type :edn :action :merge + :value {:foo 1 :bar 2} + :path "resources/system.edn"})] + (is (re-find #"Remove keys" result)) + (is (re-find #"system.edn" result))))) + +(deftest test-edn-append-description + (testing ":edn :append produces value-removal instruction" + (let [result (describe-injection {:type :edn :action :append + :value [:some-ref] + :path "resources/system.edn"})] + (is (re-find #"Remove appended value" result)) + (is (re-find #"system.edn" result))))) + +(deftest test-clj-append-requires-description + (testing ":clj :append-requires produces require-removal instruction" + (let [result (describe-injection {:type :clj :action :append-requires + :value ["[myapp.routes.pages]"] + :path "src/myapp/core.clj"})] + (is (re-find #"Remove require" result)) + (is (re-find #"core.clj" result))))) + +(deftest test-clj-append-build-task-description + (testing ":clj :append-build-task references the function name" + (let [result (describe-injection {:type :clj :action :append-build-task + :value '(defn build-css [] (println "hi")) + :path "src/build.clj"})] + (is (re-find #"Remove function" result)) + (is (re-find #"build.clj" result))))) + +(deftest test-clj-append-build-task-call-description + (testing ":clj :append-build-task-call references the call" + (let [result (describe-injection {:type :clj :action :append-build-task-call + :value '(build-css) + :path "src/build.clj"})] + (is (re-find #"Remove call" result)) + (is (re-find #"build.clj" result))))) + +(deftest test-html-append-description + (testing ":html :append produces HTML removal instruction" + (let [result (describe-injection {:type :html :action :append + :value "" + :path "resources/index.html"})] + (is (re-find #"Remove appended HTML" result)) + (is (re-find #"index.html" result))))) + +(deftest test-unknown-action-description + (testing "Unknown type/action falls back to generic instruction" + (let [result (describe-injection {:type :xml :action :replace + :value "" + :path "config.xml"})] + (is (re-find #"Undo" result)) + (is (re-find #"replace" result)) + (is (re-find #"config.xml" result))))) diff --git a/libs/kit-generator/test/kit_generator/modules_log_test.clj b/libs/kit-generator/test/kit_generator/modules_log_test.clj new file mode 100644 index 00000000..919dabeb --- /dev/null +++ b/libs/kit-generator/test/kit_generator/modules_log_test.clj @@ -0,0 +1,83 @@ +(ns kit-generator.modules-log-test + (:require + [clojure.java.io :as jio] + [clojure.test :refer [deftest is testing use-fixtures]] + [kit-generator.io :refer [clone-file delete-folder read-edn-safe]] + [kit.api :as kit] + [kit.generator.io :as io] + [kit.generator.modules-log :as modules-log])) + +(def source-folder "test/resources") +(def target-folder "test/resources/generated") +(def kit-edn-path "test/resources/kit.edn") +(def install-log-path "test/resources/modules/install-log.edn") + +(def seeded-files + [["sample-system.edn" "resources/system.edn"] + ["core.clj" "src/myapp/core.clj"]]) + +(use-fixtures :each + (fn [f] + (let [install-log (jio/file install-log-path)] + (when (.exists install-log) + (.delete install-log)) + (delete-folder target-folder) + (doseq [[source target] seeded-files] + (clone-file (io/concat-path source-folder source) (io/concat-path target-folder target))) + (f)))) + +;; --- sha256 --- + +(deftest test-sha256-string + (testing "SHA-256 of a known string" + ;; SHA-256 of empty string is well-known + (is (= "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + (modules-log/sha256 ""))) + ;; SHA-256 of "hello" + (is (= "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + (modules-log/sha256 "hello"))))) + +(deftest test-sha256-bytes + (testing "SHA-256 of a byte array matches string equivalent" + (let [content "test content"] + (is (= (modules-log/sha256 content) + (modules-log/sha256 (.getBytes content "UTF-8"))))))) + +;; --- installed-modules return type --- + +(deftest test-installed-modules-returns-seq-of-keys + (testing "installed-modules returns a seq of module keys, not a map" + (kit/install-module :meta kit-edn-path {:feature-flag :default}) + (let [ctx (kit/read-ctx kit-edn-path) + result (modules-log/installed-modules ctx)] + (is (sequential? (seq result)) + "Result should be seqable") + (is (not (map? result)) + "Result must not be a map") + (is (every? keyword? result) + "Each element should be a keyword (module key)") + (is (some #{:meta} result) + ":meta should be in the installed modules")))) + +;; --- module-manifest --- + +(deftest test-module-manifest-new-format + (testing "module-manifest returns manifest map for new-format entries" + (kit/install-module :meta kit-edn-path {:feature-flag :default}) + (let [ctx (kit/read-ctx kit-edn-path) + manifest (modules-log/module-manifest ctx :meta)] + (is (map? manifest)) + (is (= :success (:status manifest))) + (is (seq (:assets manifest)))))) + +(deftest test-module-manifest-old-format + (testing "module-manifest returns nil for old bare-keyword entries" + (spit install-log-path (pr-str {:html :success})) + (let [ctx (kit/read-ctx kit-edn-path) + manifest (modules-log/module-manifest ctx :html)] + (is (nil? manifest))))) + +(deftest test-module-manifest-not-installed + (testing "module-manifest returns nil for modules not in the log" + (let [ctx (kit/read-ctx kit-edn-path)] + (is (nil? (modules-log/module-manifest ctx :nonexistent)))))) diff --git a/libs/kit-generator/test/kit_generator/project.clj b/libs/kit-generator/test/kit_generator/project.clj index 6423568d..95214ae7 100644 --- a/libs/kit-generator/test/kit_generator/project.clj +++ b/libs/kit-generator/test/kit_generator/project.clj @@ -11,7 +11,9 @@ (defn module-installed? [module-key] (when-let [install-log (io/read-edn-safe (str project-root "/modules/install-log.edn"))] - (= :success (get install-log module-key)))) + (let [entry (get install-log module-key)] + (or (= :success entry) + (= :success (:status entry)))))) (defn prepare-project "Sets up a test project in `project-root` and returns the path to the kit.edn file. diff --git a/libs/kit-generator/test/kit_generator/removal_test.clj b/libs/kit-generator/test/kit_generator/removal_test.clj new file mode 100644 index 00000000..2acafe82 --- /dev/null +++ b/libs/kit-generator/test/kit_generator/removal_test.clj @@ -0,0 +1,161 @@ +(ns kit-generator.removal-test + (:require + [clojure.java.io :as jio] + [clojure.test :refer [deftest is testing use-fixtures]] + [kit-generator.io :refer [clone-file delete-folder read-edn-safe write-edn]] + [kit.api :as kit] + [kit.generator.io :as io] + [kit.generator.modules-log :as modules-log])) + +(def source-folder "test/resources") +(def target-folder "test/resources/generated") +(def kit-edn-path "test/resources/kit.edn") +(def install-log-path "test/resources/modules/install-log.edn") + +(def seeded-files + [["sample-system.edn" "resources/system.edn"] + ["core.clj" "src/myapp/core.clj"]]) + +(use-fixtures :each + (fn [f] + (let [install-log (jio/file install-log-path)] + (when (.exists install-log) + (.delete install-log)) + (delete-folder target-folder) + (doseq [[source target] seeded-files] + (clone-file (io/concat-path source-folder source) (io/concat-path target-folder target))) + (f)))) + +;; 1. Install creates a detailed manifest +(deftest test-install-creates-manifest + (testing "install-module persists a detailed manifest in install-log.edn" + (kit/install-module :html kit-edn-path {:feature-flag :default}) + (let [log (read-edn-safe install-log-path) + entry (get log :html)] + (is (map? entry) "Log entry should be a map, not a bare keyword") + (is (= :success (:status entry))) + (is (string? (:installed-at entry))) + (is (= :default (:feature-flag entry))) + (is (seq (:assets entry)) "Should have recorded assets") + (is (every? :sha256 (:assets entry)) "Each asset should have a SHA-256") + (is (every? :target (:assets entry)) "Each asset should have a target path") + (is (seq (:injections entry)) "Should have recorded injections") + (is (every? :description (:injections entry)) "Each injection should have a description")))) + +;; 2. Backward compatibility with old log format +(deftest test-backward-compat-old-format + (testing "module-installed? and removal-report work with old bare-keyword format" + ;; Write old-format log entry + (spit install-log-path (pr-str {:html :success})) + (let [ctx (kit/read-ctx kit-edn-path)] + (is (modules-log/module-installed? ctx :html) + "module-installed? should recognize old :success format") + (let [report (kit/removal-report :html kit-edn-path {})] + (is (some? report) "removal-report should work for old format") + (is (false? (:has-manifest? report))) + (is (empty? (:safe-to-remove report)) + "No SHA comparison available for old format"))))) + +;; 3. Removal report with unchanged files +(deftest test-removal-report-unchanged-files + (testing "All assets are safe-to-remove when unchanged since installation" + (kit/install-module :meta kit-edn-path {:feature-flag :default}) + (let [report (kit/removal-report :meta kit-edn-path {})] + (is (:has-manifest? report)) + (is (seq (:safe-to-remove report)) + "Unchanged files should be safe to remove") + (is (empty? (:modified-files report)) + "No files should be modified")))) + +;; 4. Removal report with modified files +(deftest test-removal-report-modified-files + (testing "Modified files are flagged in the removal report" + (kit/install-module :meta kit-edn-path {:feature-flag :default}) + ;; Modify a file that was created by the module + (let [css-path "test/resources/generated/resources/public/css/app.css"] + (spit css-path (str (slurp css-path) "\n/* user modification */")) + (let [report (kit/removal-report :meta kit-edn-path {})] + (is (seq (:modified-files report)) + "Modified file should appear in modified-files") + (is (some #(= (:path %) css-path) + (:modified-files report))))))) + +;; 4b. Removal report with missing files (deleted between install and report) +(deftest test-removal-report-missing-files + (testing "Files deleted after installation appear in :missing-files" + (kit/install-module :meta kit-edn-path {:feature-flag :default}) + (let [report-before (kit/removal-report :meta kit-edn-path {}) + target-file (first (:safe-to-remove report-before))] + (is (some? target-file) "Need at least one safe file to test with") + ;; Delete the file manually + (jio/delete-file (jio/file target-file)) + ;; Now the report should show it as missing + (let [report-after (kit/removal-report :meta kit-edn-path {})] + (is (some #{target-file} (:missing-files report-after)) + "Deleted file should appear in :missing-files") + (is (not (some #{target-file} (:safe-to-remove report-after))) + "Deleted file should not appear in :safe-to-remove"))))) + +;; 5. Remove module deletes files and cleans log +(deftest test-remove-module + (testing "remove-module deletes safe files and removes log entry" + (kit/install-module :meta kit-edn-path {:feature-flag :default}) + (let [report-before (kit/removal-report :meta kit-edn-path {})] + (is (seq (:safe-to-remove report-before))) + ;; Perform removal + (kit/remove-module :meta kit-edn-path {}) + ;; Verify files are deleted + (doseq [f (:safe-to-remove report-before)] + (is (not (.exists (jio/file f))) + (str "File should have been deleted: " f))) + ;; Verify log entry removed + (let [log (read-edn-safe install-log-path)] + (is (not (contains? log :meta)) + "Module should be removed from install log"))))) + +;; 6. Dependency check prevents removal +(deftest test-remove-module-dependency-check + (testing "Cannot remove a module that other installed modules depend on" + ;; :meta with :extras depends on :db + (kit/install-module :meta kit-edn-path {:feature-flag :extras}) + ;; Try to remove :db (which :meta depends on) + (let [output (with-out-str + (kit/remove-module :db kit-edn-path {}))] + (is (re-find #"(?i)cannot remove" output) + "Should report dependency error") + ;; Verify :db is still in the log + (let [ctx (kit/read-ctx kit-edn-path)] + (is (modules-log/module-installed? ctx :db) + ":db should still be installed"))))) + +;; 7. Force removal overrides dependency check +(deftest test-remove-module-force + (testing "Force removal works despite dependents" + (kit/install-module :meta kit-edn-path {:feature-flag :extras}) + (kit/remove-module :db kit-edn-path {:force? true}) + (let [ctx (kit/read-ctx kit-edn-path)] + (is (not (modules-log/module-installed? ctx :db)) + ":db should be removed after force")))) + +;; 8. Dry run doesn't delete anything +(deftest test-remove-module-dry-run + (testing "Dry run prints report but doesn't delete files or update log" + (kit/install-module :meta kit-edn-path {:feature-flag :default}) + (let [report (kit/removal-report :meta kit-edn-path {})] + (kit/remove-module :meta kit-edn-path {:dry? true}) + ;; Files should still exist + (doseq [f (:safe-to-remove report)] + (is (.exists (jio/file f)) + (str "File should still exist after dry run: " f))) + ;; Log entry should still exist + (let [ctx (kit/read-ctx kit-edn-path)] + (is (modules-log/module-installed? ctx :meta) + "Module should still be installed after dry run"))))) + +;; 9. Removing nonexistent module +(deftest test-remove-nonexistent-module + (testing "Removing a module that isn't installed prints an error" + (let [output (with-out-str + (kit/remove-module :nonexistent kit-edn-path {}))] + (is (re-find #"(?i)not installed" output) + "Should report that module is not installed"))))