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"))))