Show effective settings in bds-cli config.

This commit is contained in:
2026-07-20 09:28:26 +02:00
parent 269e4b0e4a
commit 3739da0984
8 changed files with 198 additions and 51 deletions

View File

@@ -874,32 +874,63 @@ fn create_gallery(
fn config(db: &Database, command: ConfigCommand) -> Result<CommandOutput> {
match command {
ConfigCommand::Get { key } => {
let value = engine::settings::get(db.conn(), &key)?
let value = engine::settings::get_effective(db.conn(), &key)?
.ok_or_else(|| anyhow!("{key} is not set"))?;
let value = printable_config_value(&key, &value);
Ok(output(&value, json!({"key": key, "value": value})))
}
ConfigCommand::Set { key, value } => {
cli_sync::run_cli_mutation(db.conn(), || {
engine::settings::set(db.conn(), &key, &value)
if key == engine::settings::ONLINE_API_KEY {
engine::ai::save_online_api_key(db.conn(), &value)
} else {
engine::settings::set(db.conn(), &key, &value)
}
})?;
let printable = printable_config_value(&key, &value);
Ok(output(
&format!("{key} = {value}"),
json!({"key": key, "value": value}),
&format!("{key} = {printable}"),
json!({"key": key, "value": printable}),
))
}
ConfigCommand::List => {
let settings = bds_core::db::queries::setting::list_all_settings(db.conn())?;
let settings = engine::settings::list_effective(db.conn())?;
let mut message = String::new();
let mut values = serde_json::Map::new();
for setting in settings {
let _ = writeln!(message, "{}={}", setting.key, setting.value);
values.insert(setting.key, Value::String(setting.value));
for (key, value) in settings {
let value = printable_config_value(&key, &value);
let _ = writeln!(message, "{key}={value}");
values.insert(key, Value::String(value));
}
Ok(output(message.trim_end(), Value::Object(values)))
}
}
}
fn printable_config_value(key: &str, value: &str) -> String {
let key = key.to_ascii_lowercase();
if [
"api_key",
"access_key",
"private_key",
"password",
"secret",
"token",
"credential",
]
.iter()
.any(|name| key.ends_with(name) || key.split(['.', '-']).any(|part| part == *name))
{
if value.trim().is_empty() {
"<not set>".to_string()
} else {
"<set>".to_string()
}
} else {
value.to_string()
}
}
fn project(db: &Database, command: ProjectCommand) -> Result<CommandOutput> {
match command {
ProjectCommand::List => {
@@ -1268,6 +1299,24 @@ mod tests {
fn config_and_project_families_dispatch_success_and_failure() {
let fixture = Fixture::new(false);
assert!(fixture.run(&["config", "get", "missing"], "").is_err());
let defaults = fixture.run(&["config", "list"], "").unwrap();
assert_eq!(defaults.data["editor.default_mode"], "markdown");
assert_eq!(defaults.data["editor.diff_view_style"], "inline");
assert_eq!(defaults.data["ai.endpoint.online.api_key"], "<not set>");
assert!(
!defaults
.message
.contains("app.search-index-rebuild-required")
);
assert_eq!(
fixture
.run(&["config", "get", "editor.default_mode"], "")
.unwrap()
.message,
"markdown"
);
fixture
.run(&["config", "set", "editor.mode", "markdown"], "")
.unwrap();
@@ -1276,6 +1325,31 @@ mod tests {
.unwrap();
assert_eq!(read.data["value"], "markdown");
assert!(read.to_string().contains("\"ok\":true"));
let secret = fixture
.run(&["config", "set", "service.api_key", "secret-token"], "")
.unwrap();
assert_eq!(secret.message, "service.api_key = <set>");
assert!(!secret.to_string().contains("secret-token"));
let secret = fixture
.run(&["--json", "config", "get", "service.api_key"], "")
.unwrap();
assert_eq!(secret.data["value"], "<set>");
assert!(!secret.to_string().contains("secret-token"));
let secrets = fixture.run(&["--json", "config", "list"], "").unwrap();
assert_eq!(secrets.data["service.api_key"], "<set>");
assert!(!secrets.to_string().contains("secret-token"));
let db = open_database(&fixture.database_path).unwrap();
engine::settings::set(db.conn(), "ai.endpoint.online.api_key_configured", "true").unwrap();
let configured = fixture.run(&["config", "list"], "").unwrap();
assert_eq!(configured.data["ai.endpoint.online.api_key"], "<set>");
assert!(
!configured
.message
.contains("ai.endpoint.online.api_key_configured")
);
assert!(fixture.run(&["project", "switch", "missing"], "").is_err());
let project = fixture._root.path().join("second");

View File

@@ -197,30 +197,36 @@ pub fn save_endpoint(conn: &Connection, endpoint: &AiEndpointConfig) -> EngineRe
checked_at,
)?;
if endpoint.kind == AiEndpointKind::Online {
let entry = endpoint_keyring_entry(endpoint.kind)?;
if let Some(api_key) = &endpoint.api_key {
if api_key.trim().is_empty() {
entry.delete_credential().ok();
set_setting(
conn,
&endpoint_setting_key(endpoint.kind, "api_key_configured"),
"false",
checked_at,
)?;
} else {
entry.set_password(api_key.trim()).map_err(keyring_error)?;
set_setting(
conn,
&endpoint_setting_key(endpoint.kind, "api_key_configured"),
"true",
checked_at,
)?;
}
save_online_api_key_at(conn, api_key, checked_at)?;
}
}
Ok(())
}
pub fn save_online_api_key(conn: &Connection, api_key: &str) -> EngineResult<()> {
save_online_api_key_at(conn, api_key, now_unix_ms())
}
fn save_online_api_key_at(conn: &Connection, api_key: &str, updated_at: i64) -> EngineResult<()> {
let entry = endpoint_keyring_entry(AiEndpointKind::Online)?;
let configured = !api_key.trim().is_empty();
if configured {
entry.set_password(api_key.trim()).map_err(keyring_error)?;
} else {
match entry.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => {}
Err(error) => return Err(keyring_error(error)),
}
}
set_setting(
conn,
&endpoint_setting_key(AiEndpointKind::Online, "api_key_configured"),
if configured { "true" } else { "false" },
updated_at,
)
}
pub fn save_model_preferences(
conn: &Connection,
default_model: Option<&str>,

View File

@@ -1,9 +1,32 @@
use std::collections::BTreeMap;
use crate::db::DbConnection as Connection;
use crate::db::queries::setting;
use crate::engine::{EngineError, EngineResult, domain_events};
use crate::util::now_unix_ms;
pub const UI_LANGUAGE_KEY: &str = "ui.language";
pub const ONLINE_API_KEY: &str = "ai.endpoint.online.api_key";
const ONLINE_API_KEY_CONFIGURED: &str = "ai.endpoint.online.api_key_configured";
const DEFAULTS: &[(&str, &str)] = &[
("editor.default_mode", "markdown"),
("editor.diff_view_style", "inline"),
("editor.wrap_long_lines", "true"),
("editor.hide_unchanged_regions", "false"),
("ai.endpoint.online.url", ""),
("ai.endpoint.online.model", ""),
(ONLINE_API_KEY, ""),
("ai.endpoint.airplane.url", ""),
("ai.endpoint.airplane.model", ""),
("ai.default_model", ""),
("ai.title_model", ""),
("ai.image_model", ""),
("ai.system_prompt", ""),
("mcp.http.enabled", "false"),
("data.automatic_rebuild", "true"),
("style.theme", "system"),
("style.content_width", "72"),
];
pub fn get(conn: &Connection, key: &str) -> EngineResult<Option<String>> {
match setting::get_setting_by_key(conn, key) {
@@ -13,6 +36,49 @@ pub fn get(conn: &Connection, key: &str) -> EngineResult<Option<String>> {
}
}
pub fn get_effective(conn: &Connection, key: &str) -> EngineResult<Option<String>> {
if key == ONLINE_API_KEY {
return Ok(Some(
get(conn, ONLINE_API_KEY_CONFIGURED)?
.filter(|value| value == "true")
.map_or_else(String::new, |_| "configured".to_string()),
));
}
if let Some(value) = get(conn, key)? {
return Ok(Some(value));
}
if key == UI_LANGUAGE_KEY {
return Ok(Some(crate::i18n::detect_os_locale().code().to_string()));
}
Ok(DEFAULTS
.iter()
.find(|(candidate, _)| *candidate == key)
.map(|(_, value)| (*value).to_string()))
}
pub fn list_effective(conn: &Connection) -> EngineResult<BTreeMap<String, String>> {
let mut values = DEFAULTS
.iter()
.map(|(key, value)| ((*key).to_string(), (*value).to_string()))
.collect::<BTreeMap<_, _>>();
values.insert(
UI_LANGUAGE_KEY.to_string(),
crate::i18n::detect_os_locale().code().to_string(),
);
for value in setting::list_all_settings(conn)? {
if !value.key.starts_with("app.")
&& value.key != ONLINE_API_KEY
&& value.key != ONLINE_API_KEY_CONFIGURED
{
values.insert(value.key, value.value);
}
}
if get(conn, ONLINE_API_KEY_CONFIGURED)?.as_deref() == Some("true") {
values.insert(ONLINE_API_KEY.to_string(), "configured".to_string());
}
Ok(values)
}
pub fn set(conn: &Connection, key: &str, value: &str) -> EngineResult<()> {
set_at(conn, key, value, now_unix_ms())
}

View File

@@ -1836,8 +1836,8 @@ impl TuiApp {
(
"Diff style",
"editor.diff_view_style",
FieldKind::Enum(&["unified", "split"]),
"unified",
FieldKind::Enum(&["inline", "side-by-side"]),
"inline",
),
(
"Wrap long lines",
@@ -2066,7 +2066,7 @@ impl TuiApp {
) {
fallback
} else {
engine::settings::get(db.conn(), key)?.unwrap_or(fallback)
engine::settings::get_effective(db.conn(), key)?.unwrap_or(fallback)
};
Ok(SettingField {
label: setting_field_label(self.locale, key, label),

View File

@@ -6796,28 +6796,25 @@ impl BdsApp {
.map(|template| template.slug)
.collect();
}
if let Ok(setting) =
bds_core::db::queries::setting::get_setting_by_key(db.conn(), "editor.default_mode")
if let Ok(Some(value)) =
engine::settings::get_effective(db.conn(), "editor.default_mode")
{
state.default_mode = setting.value;
state.default_mode = value;
}
if let Ok(setting) = bds_core::db::queries::setting::get_setting_by_key(
db.conn(),
"editor.diff_view_style",
) {
state.diff_view_style = setting.value;
if let Ok(Some(value)) =
engine::settings::get_effective(db.conn(), "editor.diff_view_style")
{
state.diff_view_style = value;
}
if let Ok(setting) = bds_core::db::queries::setting::get_setting_by_key(
db.conn(),
"editor.wrap_long_lines",
) {
state.wrap_long_lines = setting.value == "true";
if let Ok(Some(value)) =
engine::settings::get_effective(db.conn(), "editor.wrap_long_lines")
{
state.wrap_long_lines = value == "true";
}
if let Ok(setting) = bds_core::db::queries::setting::get_setting_by_key(
db.conn(),
"editor.hide_unchanged_regions",
) {
state.hide_unchanged_regions = setting.value == "true";
if let Ok(Some(value)) =
engine::settings::get_effective(db.conn(), "editor.hide_unchanged_regions")
{
state.hide_unchanged_regions = value == "true";
}
if let Ok(setting) =
bds_core::db::queries::setting::get_setting_by_key(db.conn(), "ai.system_prompt")
@@ -6848,7 +6845,7 @@ impl BdsApp {
state.title_model = ai_settings.title_model.unwrap_or_default();
state.image_model = ai_settings.image_model.unwrap_or_default();
}
state.mcp_enabled = engine::settings::get(db.conn(), "mcp.http.enabled")
state.mcp_enabled = engine::settings::get_effective(db.conn(), "mcp.http.enabled")
.ok()
.flatten()
.is_some_and(|value| value == "true");