refactor(cli): centralize output mode handling

This commit is contained in:
rcmerci
2026-04-10 22:06:20 +08:00
parent 7dabea4361
commit 22f1e6c867
13 changed files with 322 additions and 116 deletions

View File

@@ -2,6 +2,7 @@
"Shared CLI parsing utilities."
(:require [babashka.cli :as cli]
[clojure.string :as string]
[logseq.cli.output-mode :as output-mode]
[logseq.cli.style :as style]
[logseq.common.config :as common-config]))
@@ -22,7 +23,7 @@
:coerce :long}
:output {:desc "Output format. Default: human"
:alias :o
:validate #{"human" "json" "edn"}}
:validate output-mode/allowed-values}
:verbose {:desc "Enable verbose debug logging to stderr"
:alias :v
:coerce :boolean}
@@ -244,6 +245,12 @@
:else nil))
(defn- valid-leading-global-value?
[opt-key value]
(case opt-key
:output (contains? output-mode/allowed-values value)
true))
(defn parse-leading-global-opts
[args]
(loop [remaining args
@@ -255,7 +262,9 @@
(if (contains? global-flag-options opt-key)
(recur (rest remaining) (assoc opts opt-key true))
(if-let [value (second remaining)]
(recur (drop 2 remaining) (assoc opts opt-key value))
(if (valid-leading-global-value? opt-key value)
(recur (drop 2 remaining) (assoc opts opt-key value))
{:opts opts :args remaining})
{:opts opts :args (rest remaining)}))
{:opts opts :args remaining})))))

View File

@@ -6,6 +6,7 @@
[clojure.walk :as walk]
[logseq.cli.command.core :as core]
[logseq.cli.command.id :as id-command]
[logseq.cli.output-mode :as output-mode]
[logseq.cli.server :as cli-server]
[logseq.cli.style :as style]
[logseq.cli.transport :as transport]
@@ -999,85 +1000,69 @@
(p/let [tree-data (attach-property-titles config (:repo action) tree-data)]
(render-tree-text tree-data action)))
(defn- sanitize-structured-tree
[tree-data]
(-> tree-data
strip-show-internal-data
strip-block-uuid))
(defn- structured-show-result
[mode data]
{:status :ok
:data data
:output-format mode})
(defn- multi-id-structured-data
[results]
(mapv (fn [{:keys [ok? tree id error]}]
(if ok?
(sanitize-structured-tree tree)
(multi-id-error-entry id error)))
results))
(defn execute-show
[action config]
(-> (p/let [cfg (cli-server/ensure-server! config (:repo action))
format (:output-format config)
ids (:ids action)
multi-id? (:multi-id? action)]
(if (and (seq ids) multi-id?)
(p/let [results (p/all (map (fn [id]
(-> (build-tree-data cfg (assoc action :id id))
(p/then (fn [tree-data]
{:ok? true
:id id
:tree tree-data}))
(p/catch (fn [error]
{:ok? false
:id id
:error error}))))
ids))
ok-results (filter :ok? results)
id->tree-ids (into {}
(map (fn [{:keys [id tree]}]
[id (collect-tree-ids (:root tree))]))
ok-results)
contained? (fn [id]
(some (fn [[other-id tree-ids]]
(and (not= other-id id)
(contains? tree-ids id)))
id->tree-ids))
results (vec (remove (fn [{:keys [ok? id]}]
(and ok? (contained? id)))
results))
sanitize-tree (fn [tree]
(-> tree
strip-show-internal-data
strip-block-uuid))
render-result (fn [{:keys [ok? tree id error]}]
(if ok?
(render-tree-text-with-properties cfg action tree)
(multi-id-error-message id error)))]
(case format
:edn
{:status :ok
:data (mapv (fn [{:keys [ok? tree id error]}]
(if ok?
(sanitize-tree tree)
(multi-id-error-entry id error)))
results)
:output-format :edn}
:json
{:status :ok
:data (mapv (fn [{:keys [ok? tree id error]}]
(if ok?
(sanitize-tree tree)
(multi-id-error-entry id error)))
results)
:output-format :json}
(p/let [messages (p/all (map render-result results))]
{:status :ok
:data {:message (string/join multi-id-delimiter messages)}})))
(p/let [tree-data (build-tree-data cfg action)]
(case format
:edn
(let [tree-data (-> tree-data
strip-show-internal-data
strip-block-uuid)]
{:status :ok
:data tree-data
:output-format :edn})
:json
(let [tree-data (-> tree-data
strip-show-internal-data
strip-block-uuid)]
{:status :ok
:data tree-data
:output-format :json})
(p/let [message (render-tree-text-with-properties cfg action tree-data)]
{:status :ok
:data {:message message}})))))))
(p/let [cfg (cli-server/ensure-server! config (:repo action))
mode (output-mode/parse (:output-format config))
ids (:ids action)
multi-id? (:multi-id? action)]
(if (and (seq ids) multi-id?)
(p/let [results (p/all (map (fn [id]
(-> (build-tree-data cfg (assoc action :id id))
(p/then (fn [tree-data]
{:ok? true
:id id
:tree tree-data}))
(p/catch (fn [error]
{:ok? false
:id id
:error error}))))
ids))
ok-results (filter :ok? results)
id->tree-ids (into {}
(map (fn [{:keys [id tree]}]
[id (collect-tree-ids (:root tree))]))
ok-results)
contained? (fn [id]
(some (fn [[other-id tree-ids]]
(and (not= other-id id)
(contains? tree-ids id)))
id->tree-ids))
results (vec (remove (fn [{:keys [ok? id]}]
(and ok? (contained? id)))
results))
render-result (fn [{:keys [ok? tree id error]}]
(if ok?
(render-tree-text-with-properties cfg action tree)
(multi-id-error-message id error)))]
(if (output-mode/structured? mode)
(structured-show-result mode (multi-id-structured-data results))
(p/let [messages (p/all (map render-result results))]
{:status :ok
:data {:message (string/join multi-id-delimiter messages)}})))
(p/let [tree-data (build-tree-data cfg action)]
(if (output-mode/structured? mode)
(structured-show-result mode (sanitize-structured-tree tree-data))
(p/let [message (render-tree-text-with-properties cfg action tree-data)]
{:status :ok
:data {:message message}}))))))

View File

@@ -4,6 +4,7 @@
[logseq.cli.auth :as cli-auth]
[logseq.cli.command.core :as core]
[logseq.cli.config :as cli-config]
[logseq.cli.output-mode :as output-mode]
[logseq.cli.server :as cli-server]
[logseq.cli.transport :as transport]
[logseq.common.cognito-config :as cognito-config]
@@ -76,7 +77,6 @@
(def ^:private sync-start-timeout-ms 10000)
(def ^:private sync-start-poll-interval-ms 1000)
(def ^:private sync-download-timeout-ms (* 30 60 1000))
(def ^:private structured-output-formats #{:json :edn})
(def ^:private sync-start-skipped-states
#{:inactive :stopped})
@@ -560,7 +560,7 @@
[action config]
(if (:progress-explicit? action)
(true? (:progress action))
(not (contains? structured-output-formats (:output-format config)))))
(not (output-mode/structured? (:output-format config)))))
(defn- download-progress-message
[graph-id event-type payload]

View File

@@ -6,6 +6,7 @@
["fs" :as fs]
["os" :as os]
["path" :as node-path]
[logseq.cli.output-mode :as output-mode]
[logseq.common.graph :as common-graph]))
(defn- parse-int
@@ -30,17 +31,6 @@
:else nil))
(def ^:private output-formats
#{:human :json :edn})
(defn- parse-output-format
[value]
(let [kw (cond
(keyword? value) value
(string? value) (-> value string/trim string/lower-case keyword)
:else nil)]
(when (output-formats kw)
kw)))
(defn- default-config-path
[]
@@ -105,7 +95,7 @@
(assoc :logout-timeout-ms (parse-int (gobj/get env "LOGSEQ_CLI_LOGOUT_TIMEOUT_MS")))
(seq (gobj/get env "LOGSEQ_CLI_OUTPUT"))
(assoc :output-format (parse-output-format (gobj/get env "LOGSEQ_CLI_OUTPUT")))
(assoc :output-format (output-mode/parse (gobj/get env "LOGSEQ_CLI_OUTPUT")))
(seq (gobj/get env "LOGSEQ_CLI_CONFIG"))
(assoc :config-path (gobj/get env "LOGSEQ_CLI_CONFIG")))))
@@ -126,12 +116,12 @@
(:config-path env)
(:config-path defaults))
file-config (or (read-config-file config-path) {})
output-format (or (parse-output-format (:output-format opts))
(parse-output-format (:output opts))
(parse-output-format (:output-format env))
(parse-output-format (:output env))
(parse-output-format (:output-format file-config))
(parse-output-format (:output file-config)))
output-format (or (output-mode/parse (:output-format opts))
(output-mode/parse (:output opts))
(output-mode/parse (:output-format env))
(output-mode/parse (:output env))
(output-mode/parse (:output-format file-config))
(output-mode/parse (:output file-config)))
merged (merge defaults file-config env opts {:config-path config-path})
list-title-max-display-width (or (parse-positive-int (:list-title-max-display-width merged))
list-title-max-display-width-default)]

View File

@@ -4,6 +4,7 @@
[clojure.string :as string]
[clojure.walk :as walk]
[logseq.cli.command.core :as command-core]
[logseq.cli.output-mode :as output-mode]
[logseq.cli.style :as style]
[logseq.common.util :as common-util]
["string-width" :default string-width]))
@@ -954,11 +955,8 @@
(let [result (-> result
normalize-graph-result
sanitize-result)
format (cond
(= output-format :edn) :edn
(= output-format :json) :json
:else :human)]
(case format
mode (or (output-mode/parse output-format) :human)]
(case mode
:json (->json result)
:edn (->edn result)
(->human result opts))))

View File

@@ -2,11 +2,13 @@
"CLI entrypoint for invoking db-worker-node."
(:refer-clojure :exclude [run!])
(:require [lambdaisland.glogi :as log]
[logseq.cli.command.core :as command-core]
[logseq.cli.commands :as commands]
[logseq.cli.config :as config]
[logseq.cli.data-dir :as data-dir]
[logseq.cli.format :as format]
[logseq.cli.log :as cli-log]
[logseq.cli.output-mode :as output-mode]
[logseq.cli.profile :as profile]
[logseq.cli.version :as version]
[promesa.core :as p]))
@@ -44,6 +46,30 @@
:status (if (zero? (:exit-code result)) :ok :error)})))
result))
(defn- parse-argv-output-format
[args]
(let [{:keys [opts]} (command-core/parse-leading-global-opts args)]
(or (output-mode/parse (:output-format opts))
(output-mode/parse (:output opts)))))
(defn- parsed-output-format
[parsed]
(or (output-mode/parse (get-in parsed [:options :output-format]))
(output-mode/parse (get-in parsed [:options :output]))))
(defn- resolve-output-format
[args parsed cfg result]
(or (output-mode/parse (:output-format result))
(output-mode/parse (:output-format cfg))
(parsed-output-format parsed)
(parse-argv-output-format args)))
(defn- format-opts
[args parsed cfg result]
(if-let [mode (resolve-output-format args parsed cfg result)]
{:output-format mode}
{}))
(defn- handle-unexpected-error
"Provide clean, consistent error handling for unexpected errors in run!"
[profile-session parsed cfg error]
@@ -86,9 +112,18 @@
(cond
(:help? parsed)
(p/resolved
(attach-profile-lines profile-session parsed
{:exit-code 0
:output (:summary parsed)}))
(let [mode (resolve-output-format args parsed nil nil)]
(attach-profile-lines
profile-session
parsed
{:exit-code 0
:output (if (output-mode/structured? mode)
(profile/time! profile-session "cli.format-result"
(fn []
(format/format-result {:status :ok
:data {:message (:summary parsed)}}
{:output-format mode})))
(:summary parsed))})))
(not (:ok? parsed))
(p/resolved
@@ -101,7 +136,7 @@
(format/format-result {:status :error
:error (:error parsed)
:command (:command parsed)}
{})))}))
(format-opts args parsed nil nil))))}))
(= :version (:command parsed))
(p/resolved
@@ -154,9 +189,7 @@
(fn []
(commands/execute (:action action-result) cfg)))
(p/then (fn [result]
(let [opts (cond-> cfg
(:output-format result)
(assoc :output-format (:output-format result)))]
(let [opts (merge cfg (format-opts args parsed cfg result))]
(attach-profile-lines
profile-session
parsed

View File

@@ -0,0 +1,29 @@
(ns logseq.cli.output-mode
"Shared output mode utilities for CLI human/json/edn output handling."
(:require [clojure.string :as string]))
(def allowed-keywords
#{:human :json :edn})
(def allowed-values
(into #{} (map name) allowed-keywords))
(def ^:private structured-keywords
#{:json :edn})
(defn parse
[value]
(let [mode (cond
(keyword? value) value
(string? value) (some-> value string/trim string/lower-case keyword)
:else nil)]
(when (contains? allowed-keywords mode)
mode)))
(defn string-value
[value]
(some-> (parse value) name))
(defn structured?
[value]
(contains? structured-keywords (parse value)))

View File

@@ -202,6 +202,14 @@
(p/resolved nil))))
(defn- contains-block-uuid?
[value]
(cond
(map? value) (or (contains? value :block/uuid)
(some contains-block-uuid? (vals value)))
(sequential? value) (some contains-block-uuid? value)
:else false))
(deftest test-render-referenced-entities-footer
(let [render-footer (fn [ordered-uuids uuid->entity]
(call-private 'render-referenced-entities-footer ordered-uuids uuid->entity))
@@ -325,7 +333,7 @@
:block/title "Root"
:block/page {:db/id 201}}}
:children-by-page-id {201 [{:db/id 12
:block/title (str "Child [[" uuid-a "]]")
:block/title (str "Child [[" uuid-a "]]" )
:block/order 0
:block/parent {:db/id 1}}]}
:uuid-entities {(string/lower-case uuid-a)
@@ -345,3 +353,66 @@
(is (not (string/includes? plain "Referenced Entities (")))))
(p/catch (fn [e] (is false (str "unexpected error: " e))))
(p/finally done)))))
(deftest test-execute-show-structured-output-single-and-multi-id
(async done
(let [invoke-mock (make-show-invoke-mock
{:entities-by-id {1 {:db/id 1
:block/uuid (uuid "11111111-1111-1111-1111-111111111111")
:block/title "Root A"
:block/page {:db/id 101}}
2 {:db/id 2
:block/uuid (uuid "22222222-2222-2222-2222-222222222222")
:block/title "Root B"
:block/page {:db/id 102}}}
:children-by-page-id {101 [{:db/id 11
:block/uuid (uuid "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
:block/title "Child A"
:block/order 0
:block/parent {:db/id 1}}]
102 [{:db/id 22
:block/uuid (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
:block/title "Child B"
:block/order 0
:block/parent {:db/id 2}}]}})]
(-> (p/with-redefs [cli-server/ensure-server! (fn [config _] config)
transport/invoke invoke-mock]
(p/let [single-json (show-command/execute-show {:type :show
:repo "demo"
:id 1
:linked-references? false}
{:output-format :json})
single-edn (show-command/execute-show {:type :show
:repo "demo"
:id 1
:linked-references? false}
{:output-format :edn})
multi-json (show-command/execute-show {:type :show
:repo "demo"
:ids [1 2]
:multi-id? true
:linked-references? false}
{:output-format :json})
multi-edn (show-command/execute-show {:type :show
:repo "demo"
:ids [1 2]
:multi-id? true
:linked-references? false}
{:output-format :edn})]
(doseq [result [single-json single-edn multi-json multi-edn]]
(is (= :ok (:status result)))
(is (not (contains-block-uuid? (:data result)))))
(is (= :json (:output-format single-json)))
(is (= :edn (:output-format single-edn)))
(is (= 1 (get-in single-json [:data :root :db/id])))
(is (= 1 (get-in single-edn [:data :root :db/id])))
(is (= :json (:output-format multi-json)))
(is (= :edn (:output-format multi-edn)))
(is (= [1 2]
(mapv #(get-in % [:root :db/id]) (:data multi-json))))
(is (= [1 2]
(mapv #(get-in % [:root :db/id]) (:data multi-edn))))))
(p/catch (fn [e] (is false (str "unexpected error: " e))))
(p/finally done)))))

View File

@@ -688,6 +688,16 @@
_ (is (= [] @subscribe-calls))
_ (is (= [] @printed-lines))
_ (is (= 0 @close-calls))
_ (execute-with-runtime-auth {:type :sync-download
:repo "logseq_db_demo"
:graph "demo"
:progress-explicit? false}
{:base-url "http://example"
:data-dir "/tmp"
:output-format :edn})
_ (is (= [] @subscribe-calls))
_ (is (= [] @printed-lines))
_ (is (= 0 @close-calls))
_ (execute-with-runtime-auth {:type :sync-download
:repo "logseq_db_demo"
:graph "demo"

View File

@@ -528,6 +528,12 @@
(is (true? (:ok? result)))
(is (= "json" (get-in result [:options :output])))))
(testing "global output option rejects invalid values"
(let [result (commands/parse-args ["--output" "yaml" "graph" "list"])]
(is (false? (:ok? result)))
(is (= :invalid-options (get-in result [:error :code])))
(is (string/includes? (string/lower-case (get-in result [:error :message])) "output"))))
(testing "global profile option defaults to absent"
(let [result (commands/parse-args ["graph" "list"])]
(is (true? (:ok? result)))

View File

@@ -89,6 +89,15 @@
:output "json"})]
(is (= :edn (:output-format result)))))
(deftest test-output-format-invalid-values-fallback
(let [dir (node-helper/create-tmp-dir)
cfg-path (node-path/join dir "cli.edn")
_ (fs/writeFileSync cfg-path "{:output-format :edn}")
env {"LOGSEQ_CLI_OUTPUT" "yaml"}
result (with-env env #(config/resolve-config {:config-path cfg-path
:output "xml"}))]
(is (= :edn (:output-format result)))))
(deftest test-default-paths
(let [result (config/resolve-config {})
expected-config-path (node-path/join (.homedir os) "logseq" "cli.edn")]

View File

@@ -1,5 +1,6 @@
(ns logseq.cli.main-test
(:require [cljs.test :refer [async deftest is]]
(:require [cljs.reader :as reader]
[cljs.test :refer [async deftest is]]
[clojure.string :as string]
[logseq.cli.commands :as commands]
[logseq.cli.main :as cli-main]
@@ -28,6 +29,40 @@
(done)))
(p/finally done))))
(deftest test-help-output-respects-structured-modes
(async done
(-> (p/let [json-result (cli-main/run! ["--output" "json" "--help"] {:exit? false})
edn-result (cli-main/run! ["--output" "edn" "--help"] {:exit? false})
json-output (js->clj (js/JSON.parse (:output json-result)) :keywordize-keys true)
edn-output (reader/read-string (:output edn-result))]
(is (= 0 (:exit-code json-result)))
(is (= 0 (:exit-code edn-result)))
(is (= "ok" (:status json-output)))
(is (= :ok (:status edn-output)))
(is (string/includes? (get-in json-output [:data :message]) "Usage: logseq"))
(is (string/includes? (get-in edn-output [:data :message]) "Usage: logseq")))
(p/catch (fn [e]
(is false (str "unexpected error: " e))
(done)))
(p/finally done))))
(deftest test-parse-error-output-respects-structured-modes
(async done
(-> (p/let [json-result (cli-main/run! ["--output" "json" "wat"] {:exit? false})
edn-result (cli-main/run! ["--output" "edn" "wat"] {:exit? false})
json-output (js->clj (js/JSON.parse (:output json-result)) :keywordize-keys true)
edn-output (reader/read-string (:output edn-result))]
(is (= 1 (:exit-code json-result)))
(is (= 1 (:exit-code edn-result)))
(is (= "error" (:status json-output)))
(is (= :error (:status edn-output)))
(is (some? (get-in json-output [:error :message])))
(is (some? (get-in edn-output [:error :message]))))
(p/catch (fn [e]
(is false (str "unexpected error: " e))
(done)))
(p/finally done))))
(deftest test-result->exit-code
(let [result->exit-code #'cli-main/result->exit-code]
(is (= 0 (result->exit-code {:status :ok})))

View File

@@ -0,0 +1,31 @@
(ns logseq.cli.output-mode-test
(:require [cljs.test :refer [deftest is testing]]
[logseq.cli.output-mode :as output-mode]))
(deftest test-allowed-output-modes
(is (= #{:human :json :edn} output-mode/allowed-keywords))
(is (= #{"human" "json" "edn"} output-mode/allowed-values)))
(deftest test-parse
(testing "parses string and keyword output modes"
(is (= :human (output-mode/parse "human")))
(is (= :json (output-mode/parse " JSON ")))
(is (= :edn (output-mode/parse :edn))))
(testing "returns nil for unknown or blank values"
(is (nil? (output-mode/parse "")))
(is (nil? (output-mode/parse " ")))
(is (nil? (output-mode/parse "yaml")))
(is (nil? (output-mode/parse :yaml)))
(is (nil? (output-mode/parse nil)))))
(deftest test-structured-output
(is (true? (output-mode/structured? :json)))
(is (true? (output-mode/structured? "edn")))
(is (false? (output-mode/structured? :human)))
(is (false? (output-mode/structured? "yaml"))))
(deftest test-string-value
(is (= "json" (output-mode/string-value :json)))
(is (= "edn" (output-mode/string-value "EDN")))
(is (nil? (output-mode/string-value :yaml))))