#![allow(clippy::default_trait_access)]
use crate::cli::ConfigCommands;
use crate::config::{
self, CliOverrides, ConfigLayer, ConfigPaths, default_config_layer,
discover_optional_beads_dir_with_cli, id_config_from_layer, load_legacy_user_config,
load_project_config, load_user_config, resolve_actor,
};
use crate::error::{BeadsError, Result};
use crate::output::OutputContext;
use fsqlite::Connection;
use fsqlite_types::SqliteValue;
use rich_rust::prelude::*;
use serde_json::json;
use shell_words::split as split_shell_words;
use std::collections::BTreeMap;
use std::env;
use std::fs::{self, File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use tracing::{debug, info, trace};
#[derive(Debug, Clone, Copy)]
enum ConfigSource {
Default,
Jsonl,
Db,
LegacyUser,
User,
Project,
Environment,
Cli,
}
impl ConfigSource {
fn label(self) -> &'static str {
match self {
Self::Default => "default",
Self::Jsonl => "jsonl inference",
Self::Db => "db",
Self::LegacyUser => "legacy user",
Self::User => "user config",
Self::Project => ".beads/config",
Self::Environment => "environment",
Self::Cli => "cli",
}
}
fn heading(self) -> &'static str {
match self {
Self::Default => "Default",
Self::Jsonl => "JSONL",
Self::Db => "DB",
Self::LegacyUser => "Legacy User",
Self::User => "User",
Self::Project => "Project",
Self::Environment => "Environment",
Self::Cli => "CLI",
}
}
}
struct ConfigEntry {
key: String,
value: String,
source: ConfigSource,
}
struct LayerWithSource {
source: ConfigSource,
layer: ConfigLayer,
}
struct TempConfigFileGuard {
path: PathBuf,
persist: bool,
}
impl TempConfigFileGuard {
fn new(path: PathBuf) -> Self {
Self {
path,
persist: false,
}
}
fn persist(&mut self) {
self.persist = true;
}
}
impl Drop for TempConfigFileGuard {
fn drop(&mut self) {
if !self.persist {
let _ = fs::remove_file(&self.path);
}
}
}
fn create_temp_config_file(config_path: &Path) -> Result<(PathBuf, File)> {
let pid = std::process::id();
for attempt in 0..64_u32 {
let extension = if attempt == 0 {
format!("yaml.{pid}.tmp")
} else {
format!("yaml.{pid}.{attempt}.tmp")
};
let temp_path = config_path.with_extension(extension);
match OpenOptions::new()
.write(true)
.create_new(true)
.open(&temp_path)
{
Ok(file) => return Ok((temp_path, file)),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(error) => return Err(error.into()),
}
}
Err(BeadsError::Config(format!(
"Failed to allocate temp config file for {}",
config_path.display()
)))
}
fn write_config_atomically(config_path: &Path, yaml: &str) -> Result<()> {
let existing_permissions = fs::metadata(config_path)
.ok()
.map(|metadata| metadata.permissions());
let (temp_path, mut temp_file) = create_temp_config_file(config_path)?;
let mut guard = TempConfigFileGuard::new(temp_path.clone());
temp_file.write_all(yaml.as_bytes())?;
temp_file.sync_all()?;
drop(temp_file);
fs::rename(&temp_path, config_path)?;
if let Some(permissions) = existing_permissions
&& let Err(error) = fs::set_permissions(config_path, permissions)
{
tracing::warn!(
path = %config_path.display(),
error = %error,
"Failed to restore original config file permissions after atomic rewrite"
);
}
guard.persist();
Ok(())
}
pub fn execute(
command: &ConfigCommands,
json_mode: bool,
overrides: &CliOverrides,
ctx: &OutputContext,
) -> Result<()> {
match command {
ConfigCommands::Path => show_paths(json_mode, overrides, ctx),
ConfigCommands::Edit => edit_config(),
ConfigCommands::List { project, user } => {
let beads_dir = discover_optional_beads_dir_with_cli(overrides)?;
show_config(
beads_dir.as_ref(),
overrides,
*project,
*user,
json_mode,
ctx,
)
}
ConfigCommands::Set { args } => set_config_value(args, json_mode, overrides, ctx),
ConfigCommands::Delete { key } => delete_config_value(key, json_mode, overrides, ctx),
ConfigCommands::Get { key } => {
let beads_dir = discover_optional_beads_dir_with_cli(overrides)?;
get_config_value(key, beads_dir.as_ref(), overrides, json_mode, ctx)
}
}
}
fn overrides_without_no_db(overrides: &CliOverrides) -> CliOverrides {
let mut adjusted = overrides.clone();
adjusted.no_db = None;
adjusted
}
fn build_layers(
beads_dir: Option<&PathBuf>,
overrides: &CliOverrides,
) -> Result<Vec<LayerWithSource>> {
let defaults = default_config_layer();
let db_overrides = overrides_without_no_db(overrides);
let (jsonl_inferred_layer, db_layer) = if let Some(dir) = beads_dir {
let paths = config::resolve_paths(dir, db_overrides.db.as_ref())?;
(
load_jsonl_inferred_layer(&paths)?,
load_db_layer_without_recovery(&paths),
)
} else {
(ConfigLayer::default(), ConfigLayer::default())
};
let legacy_user = load_legacy_user_config()?;
let user = load_user_config()?;
let project = if let Some(dir) = beads_dir {
load_project_config(dir)?
} else {
ConfigLayer::default()
};
let env_layer = ConfigLayer::from_env();
let cli_layer = overrides.as_layer();
Ok(vec![
LayerWithSource {
source: ConfigSource::Default,
layer: defaults,
},
LayerWithSource {
source: ConfigSource::Jsonl,
layer: jsonl_inferred_layer,
},
LayerWithSource {
source: ConfigSource::Db,
layer: db_layer,
},
LayerWithSource {
source: ConfigSource::LegacyUser,
layer: legacy_user,
},
LayerWithSource {
source: ConfigSource::User,
layer: user,
},
LayerWithSource {
source: ConfigSource::Project,
layer: project,
},
LayerWithSource {
source: ConfigSource::Environment,
layer: env_layer,
},
LayerWithSource {
source: ConfigSource::Cli,
layer: cli_layer,
},
])
}
fn load_jsonl_inferred_layer(paths: &ConfigPaths) -> Result<ConfigLayer> {
let mut layer = ConfigLayer::default();
if let Some(prefix) = config::first_prefix_from_jsonl(&paths.jsonl_path)? {
layer.runtime.insert("issue_prefix".to_string(), prefix);
}
Ok(layer)
}
fn load_db_layer_without_recovery(paths: &ConfigPaths) -> ConfigLayer {
if !paths.db_path.is_file() {
return ConfigLayer::default();
}
match config::with_database_family_snapshot(&paths.db_path, |snapshot_db_path| {
let conn = Connection::open(snapshot_db_path.to_string_lossy().into_owned())?;
let rows = conn.query("SELECT key, value FROM config")?;
let mut layer = ConfigLayer::default();
for row in rows {
let Some(key) = row.get(0).and_then(SqliteValue::as_text) else {
continue;
};
let Some(value) = row.get(1).and_then(SqliteValue::as_text) else {
continue;
};
if config::is_startup_key(key) {
continue;
}
layer.runtime.insert(key.to_string(), value.to_string());
}
Ok(layer)
}) {
Ok(layer) => layer,
Err(err) => {
debug!(
path = %paths.db_path.display(),
error = %err,
"Skipping DB config layer because the database could not be snapshotted for read-only access"
);
ConfigLayer::default()
}
}
}
fn merge_layers(layers: &[LayerWithSource]) -> ConfigLayer {
let mut merged = ConfigLayer::default();
for layer in layers {
merged.merge_from(&layer.layer);
}
merged
}
fn canonical_config_key(key: &str) -> String {
key.trim().to_lowercase().replace('-', "_")
}
fn resolve_source(key: &str, layers: &[LayerWithSource]) -> ConfigSource {
let canonical = canonical_config_key(key);
for layer in layers.iter().rev() {
if layer.layer.runtime.contains_key(&canonical)
|| layer.layer.startup.contains_key(&canonical)
{
return layer.source;
}
}
ConfigSource::Default
}
fn format_config_value(value: &str) -> String {
let trimmed = value.trim();
if trimmed.is_empty() {
return "\"\"".to_string();
}
if (trimmed.starts_with('"') && trimmed.ends_with('"'))
|| (trimmed.starts_with('\'') && trimmed.ends_with('\''))
{
return trimmed.to_string();
}
if trimmed.parse::<i64>().is_ok() || trimmed.parse::<f64>().is_ok() {
return trimmed.to_string();
}
let lower = trimmed.to_ascii_lowercase();
if matches!(lower.as_str(), "true" | "false" | "null") {
return trimmed.to_string();
}
format!("\"{trimmed}\"")
}
fn render_config_table(title: &str, entries: &[ConfigEntry], ctx: &OutputContext) {
let theme = ctx.theme();
if entries.is_empty() {
let panel = Panel::from_text("No configuration values found.")
.title(Text::styled(title, theme.panel_title.clone()))
.box_style(theme.box_style)
.border_style(theme.panel_border.clone());
ctx.render(&panel);
return;
}
let mut table = Table::new()
.box_style(theme.box_style)
.border_style(theme.panel_border.clone())
.title(Text::styled(title, theme.panel_title.clone()));
table = table
.with_column(Column::new("Key").min_width(16).max_width(30))
.with_column(Column::new("Value").min_width(12).max_width(50))
.with_column(Column::new("Source").min_width(12).max_width(20));
for entry in entries {
let key_cell = Cell::new(Text::styled(&entry.key, theme.emphasis.clone()));
let value_cell = Cell::new(Text::new(entry.value.clone()));
let source_cell = Cell::new(Text::styled(entry.source.label(), theme.dimmed.clone()));
table.add_row(Row::new(vec![key_cell, value_cell, source_cell]));
}
ctx.render(&table);
}
fn render_kv_table(title: &str, rows: &[(String, String)], ctx: &OutputContext) {
let theme = ctx.theme();
if rows.is_empty() {
return;
}
let mut table = Table::new()
.box_style(theme.box_style)
.border_style(theme.panel_border.clone())
.title(Text::styled(title, theme.panel_title.clone()));
table = table
.with_column(Column::new("Key").min_width(16).max_width(30))
.with_column(Column::new("Value").min_width(12).max_width(50));
for (key, value) in rows {
let key_cell = Cell::new(Text::styled(key, theme.emphasis.clone()));
let value_cell = Cell::new(Text::new(value.clone()));
table.add_row(Row::new(vec![key_cell, value_cell]));
}
ctx.render(&table);
}
fn show_paths(_json_mode: bool, overrides: &CliOverrides, ctx: &OutputContext) -> Result<()> {
let paths = discover_optional_beads_dir_with_cli(overrides)?
.map(|beads_dir| ConfigPaths::resolve(&beads_dir, overrides.db.as_ref()))
.transpose()?;
let user_config_path = get_user_config_path();
let legacy_user_path = get_legacy_user_config_path();
let project_path = paths.as_ref().and_then(ConfigPaths::project_config_path);
if ctx.is_json() {
let output = json!({
"user_config": user_config_path.map(|p| p.display().to_string()),
"legacy_user_config": legacy_user_path.map(|p| p.display().to_string()),
"project_config": project_path.map(|p| p.display().to_string()),
});
ctx.json_pretty(&output);
} else if ctx.is_quiet() {
return Ok(());
} else {
if let Some(path) = user_config_path {
let exists = path.exists();
let status = if exists { "exists" } else { "not found" };
println!("User config: {} ({})", path.display(), status);
} else {
println!("User config: (none)");
}
if let Some(path) = legacy_user_path
&& path.exists()
{
println!("Legacy user config: {} (found)", path.display());
}
if let Some(path) = project_path {
let exists = path.exists();
let status = if exists { "exists" } else { "not found" };
println!("Project config: {} ({})", path.display(), status);
} else {
println!("Project config: (none)");
}
}
Ok(())
}
fn edit_config() -> Result<()> {
let config_path = get_user_config_path().ok_or_else(|| {
crate::error::BeadsError::Config("HOME environment variable not set".to_string())
})?;
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent)?;
}
if !config_path.exists() {
let default_content = r"# br configuration
# See `br config list` for available options
# Issue ID prefix
# issue_prefix: br
# Default priority for new issues (0-4)
# default_priority: 2
# Default issue type
# default_type: task
";
fs::write(&config_path, default_content)?;
}
let editor = env::var("EDITOR")
.or_else(|_| env::var("VISUAL"))
.unwrap_or_else(|_| "vi".to_string());
let (program, editor_args) = parse_editor_command(&editor)?;
let status = Command::new(&program)
.args(&editor_args)
.arg(&config_path)
.status()?;
if !status.success() {
eprintln!("Editor exited with status: {status}");
}
Ok(())
}
fn parse_editor_command(editor: &str) -> Result<(String, Vec<String>)> {
let parts = split_shell_words(editor)
.map_err(|e| BeadsError::Config(format!("Invalid EDITOR/VISUAL command: {e}")))?;
let Some((program, args)) = parts.split_first() else {
return Err(BeadsError::Config(
"EDITOR/VISUAL command cannot be empty".to_string(),
));
};
Ok((program.clone(), args.to_vec()))
}
fn get_config_value(
key: &str,
beads_dir: Option<&PathBuf>,
overrides: &CliOverrides,
_json_mode: bool,
ctx: &OutputContext,
) -> Result<()> {
debug!(key, "Reading config key");
let layers = build_layers(beads_dir, overrides)?;
let layer = merge_layers(&layers);
let canonical_key = canonical_config_key(key);
let value = layer
.runtime
.get(&canonical_key)
.or_else(|| layer.startup.get(&canonical_key))
.cloned();
if ctx.is_json() {
let output = json!({
"key": key,
"value": value,
});
ctx.json_pretty(&output);
} else if let Some(v) = value {
if ctx.is_quiet() {
return Ok(());
}
if ctx.is_rich() {
let source = resolve_source(key, &layers);
trace!(key, source = ?source, "Config source resolved");
render_config_table(
"Config Value",
&[ConfigEntry {
key: key.to_string(),
value: format_config_value(&v),
source,
}],
ctx,
);
} else {
println!("{v}");
}
} else {
return Err(crate::error::BeadsError::Config(format!(
"Config key not found: {key}"
)));
}
Ok(())
}
fn set_config_value(
args: &[String],
_json_mode: bool,
overrides: &CliOverrides,
ctx: &OutputContext,
) -> Result<()> {
let (key, value) = match args.len() {
1 => args[0]
.split_once('=')
.ok_or_else(|| crate::error::BeadsError::Validation {
field: "config".to_string(),
reason: "Invalid format. Use: --set key=value or --set key value".to_string(),
})?,
2 => (args[0].as_str(), args[1].as_str()),
_ => {
return Err(crate::error::BeadsError::Validation {
field: "config".to_string(),
reason: "Invalid number of arguments".to_string(),
});
}
};
let (config_path, is_project) =
if let Some(beads_dir) = discover_optional_beads_dir_with_cli(overrides)? {
(beads_dir.join("config.yaml"), true)
} else {
let path = get_user_config_path().ok_or_else(|| {
crate::error::BeadsError::Config("HOME environment variable not set".to_string())
})?;
(path, false)
};
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent)?;
}
let mut config = load_mutable_yaml_config(&config_path)?;
let parts: Vec<&str> = key.split('.').collect();
let old_value = get_yaml_value(&config, &parts);
set_yaml_value(&mut config, &parts, parse_scalar_config_value(value));
let yaml_str = serde_yml::to_string(&config)?;
write_config_atomically(&config_path, &yaml_str)?;
info!(
key,
old_value = old_value.as_deref(),
new_value = value,
"Config updated"
);
if ctx.is_json() {
let output = json!({
"key": key,
"value": value,
"path": config_path.display().to_string(),
"scope": if is_project { "project" } else { "user" }
});
ctx.json_pretty(&output);
} else if ctx.is_quiet() {
return Ok(());
} else if ctx.is_rich() {
let theme = ctx.theme();
let mut content = Text::new("");
content.append_styled("Configuration updated\n", theme.emphasis.clone());
content.append("\n");
content.append_styled("Key: ", theme.dimmed.clone());
content.append_styled(key, theme.issue_title.clone());
content.append("\n");
content.append_styled("Value: ", theme.dimmed.clone());
content.append(&format_config_value(value));
content.append("\n");
if let Some(old) = old_value {
content.append_styled("Previous: ", theme.dimmed.clone());
content.append(&format_config_value(&old));
content.append("\n");
}
content.append_styled("Scope: ", theme.dimmed.clone());
content.append(if is_project { "project" } else { "user" });
content.append("\n");
content.append_styled("Path: ", theme.dimmed.clone());
content.append(&config_path.display().to_string());
content.append("\n");
let panel = Panel::from_rich_text(&content, ctx.width())
.title(Text::styled("Config Set", theme.panel_title.clone()))
.box_style(theme.box_style)
.border_style(theme.panel_border.clone());
ctx.render(&panel);
} else {
println!("Set {key}={value} in {}", config_path.display());
}
Ok(())
}
fn parse_scalar_config_value(value: &str) -> serde_yml::Value {
value.parse::<bool>().map_or_else(
|_| {
value.parse::<i64>().map_or_else(
|_| match value.parse::<f64>() {
Ok(parsed) => serde_yml::Value::Number(parsed.into()),
Err(_) if value == "null" => serde_yml::Value::Null,
Err(_) => serde_yml::Value::String(value.to_string()),
},
|parsed| serde_yml::Value::Number(parsed.into()),
)
},
serde_yml::Value::Bool,
)
}
fn set_yaml_value(config: &mut serde_yml::Value, parts: &[&str], value: serde_yml::Value) {
if parts.is_empty() {
return;
}
if !matches!(config, serde_yml::Value::Mapping(_)) {
*config = serde_yml::Value::Mapping(serde_yml::Mapping::default());
}
if parts.len() == 1 {
if let serde_yml::Value::Mapping(map) = config {
map.insert(serde_yml::Value::String(parts[0].to_string()), value);
}
return;
}
if let serde_yml::Value::Mapping(map) = config {
let key = serde_yml::Value::String(parts[0].to_string());
let entry = map
.entry(key)
.or_insert_with(|| serde_yml::Value::Mapping(serde_yml::Mapping::default()));
if !matches!(entry, serde_yml::Value::Mapping(_)) {
*entry = serde_yml::Value::Mapping(serde_yml::Mapping::default());
}
set_yaml_value(entry, &parts[1..], value);
}
}
fn get_yaml_value(value: &serde_yml::Value, parts: &[&str]) -> Option<String> {
if parts.is_empty() {
return None;
}
if let serde_yml::Value::Mapping(map) = value {
let key = serde_yml::Value::String(parts[0].to_string());
let child = map.get(&key)?;
if parts.len() == 1 {
return yaml_value_to_string(child);
}
return get_yaml_value(child, &parts[1..]);
}
None
}
fn yaml_value_to_string(value: &serde_yml::Value) -> Option<String> {
match value {
serde_yml::Value::Null => Some("null".to_string()),
serde_yml::Value::Bool(value) => Some(value.to_string()),
serde_yml::Value::Number(value) => Some(value.to_string()),
serde_yml::Value::String(value) => Some(value.clone()),
_ => serde_yml::to_string(value)
.ok()
.map(|value| value.trim().to_string()),
}
}
fn load_mutable_yaml_config(config_path: &Path) -> Result<serde_yml::Value> {
if !config_path.exists() {
return Ok(serde_yml::Value::Mapping(serde_yml::Mapping::default()));
}
let contents = fs::read_to_string(config_path)?;
match serde_yml::from_str(&contents) {
Ok(serde_yml::Value::Null) => Ok(serde_yml::Value::Mapping(serde_yml::Mapping::default())),
Ok(value) => Ok(value),
Err(err) => Err(BeadsError::Config(format!(
"Failed to parse YAML config {}: {err}",
config_path.display()
))),
}
}
fn delete_config_value(
key: &str,
_json_mode: bool,
overrides: &CliOverrides,
ctx: &OutputContext,
) -> Result<()> {
let beads_dir = discover_optional_beads_dir_with_cli(overrides)?;
let prepared_project_delete = beads_dir
.as_ref()
.map(|dir| prepare_yaml_delete(&dir.join("config.yaml"), key))
.transpose()?
.flatten();
let prepared_user_delete = get_user_config_path()
.map(|path| prepare_yaml_delete(&path, key))
.transpose()?
.flatten();
let prepared_legacy_user_delete = get_legacy_user_config_path()
.map(|path| prepare_yaml_delete(&path, key))
.transpose()?
.flatten();
let mut db_deleted = false;
if let Some(dir) = &beads_dir
&& !matches!(overrides.no_db, Some(true))
{
let mut storage_ctx = config::open_storage_with_cli(dir, overrides)?;
db_deleted = storage_ctx.storage.delete_config(key)?;
storage_ctx.flush_no_db_if_dirty()?;
}
let project_deleted = apply_prepared_yaml_delete(prepared_project_delete)?;
let user_deleted = apply_prepared_yaml_delete(prepared_user_delete)?;
let legacy_user_deleted = apply_prepared_yaml_delete(prepared_legacy_user_delete)?;
if ctx.is_json() {
let output = json!({
"key": key,
"deleted_from_db": db_deleted,
"deleted_from_project": project_deleted,
"deleted_from_user": user_deleted || legacy_user_deleted,
});
ctx.json_pretty(&output);
} else if ctx.is_quiet() {
return Ok(());
} else if db_deleted || project_deleted || user_deleted || legacy_user_deleted {
let mut sources = Vec::new();
if db_deleted {
sources.push("DB");
}
if project_deleted {
sources.push("Project");
}
if user_deleted || legacy_user_deleted {
sources.push("User");
}
if ctx.is_rich() {
let theme = ctx.theme();
let mut content = Text::new("");
content.append_styled("Configuration deleted\n", theme.emphasis.clone());
content.append("\n");
content.append_styled("Key: ", theme.dimmed.clone());
content.append_styled(key, theme.issue_title.clone());
content.append("\n");
content.append_styled("Sources: ", theme.dimmed.clone());
content.append(&sources.join(", "));
content.append("\n");
let panel = Panel::from_rich_text(&content, ctx.width())
.title(Text::styled("Config Delete", theme.panel_title.clone()))
.box_style(theme.box_style)
.border_style(theme.panel_border.clone());
ctx.render(&panel);
} else {
println!("Deleted config key: {key} (from {})", sources.join(", "));
}
} else if ctx.is_rich() {
let theme = ctx.theme();
let message = format!("Config key not found: {key}");
let panel = Panel::from_text(&message)
.title(Text::styled("Config Delete", theme.panel_title.clone()))
.box_style(theme.box_style)
.border_style(theme.panel_border.clone());
ctx.render(&panel);
} else {
println!("Config key not found: {key}");
}
Ok(())
}
struct PreparedYamlDelete {
path: PathBuf,
yaml: String,
}
fn prepare_yaml_delete(config_path: &Path, key: &str) -> Result<Option<PreparedYamlDelete>> {
if !config_path.exists() {
return Ok(None);
}
let mut config = load_mutable_yaml_config(config_path)?;
if !delete_from_yaml(&mut config, key) {
return Ok(None);
}
let yaml_str = serde_yml::to_string(&config)?;
Ok(Some(PreparedYamlDelete {
path: config_path.to_path_buf(),
yaml: yaml_str,
}))
}
fn apply_prepared_yaml_delete(prepared: Option<PreparedYamlDelete>) -> Result<bool> {
let Some(prepared) = prepared else {
return Ok(false);
};
write_config_atomically(&prepared.path, &prepared.yaml)?;
Ok(true)
}
fn delete_from_yaml(value: &mut serde_yml::Value, key: &str) -> bool {
let parts: Vec<&str> = key.split('.').collect();
delete_nested(value, &parts)
}
fn delete_nested(value: &mut serde_yml::Value, path: &[&str]) -> bool {
if path.is_empty() {
return false;
}
if let serde_yml::Value::Mapping(map) = value {
let key = serde_yml::Value::String(path[0].to_string());
if path.len() == 1 {
return map.remove(&key).is_some();
}
if let Some(child) = map.get_mut(&key) {
return delete_nested(child, &path[1..]);
}
}
false
}
#[allow(clippy::too_many_lines)]
fn show_config(
beads_dir: Option<&PathBuf>,
overrides: &CliOverrides,
project_only: bool,
user_only: bool,
json_mode: bool,
ctx: &OutputContext,
) -> Result<()> {
if project_only {
if let Some(dir) = beads_dir {
let layer = load_project_config(dir)?;
output_layer(&layer, ConfigSource::Project, json_mode, ctx);
return Ok(());
}
if ctx.is_json() {
ctx.json(&serde_json::Map::new());
} else if ctx.is_quiet() {
return Ok(());
} else if ctx.is_rich() {
let theme = ctx.theme();
let panel = Panel::from_text("No project config (no .beads directory found).")
.title(Text::styled(
"Project Configuration",
theme.panel_title.clone(),
))
.box_style(theme.box_style)
.border_style(theme.panel_border.clone());
ctx.render(&panel);
} else {
println!("No project config (no .beads directory found)");
}
return Ok(());
}
if user_only {
let layer = load_user_config()?;
output_layer(&layer, ConfigSource::User, json_mode, ctx);
return Ok(());
}
let layers = build_layers(beads_dir, overrides)?;
let layer = merge_layers(&layers);
let id_config = id_config_from_layer(&layer);
let actor = resolve_actor(&layer);
if ctx.is_json() {
let mut all_keys: BTreeMap<String, serde_json::Value> = BTreeMap::new();
for (k, v) in &layer.runtime {
all_keys.insert(k.clone(), json!(v));
}
for (k, v) in &layer.startup {
all_keys.insert(k.clone(), json!(v));
}
all_keys.insert("_computed.prefix".to_string(), json!(id_config.prefix));
all_keys.insert(
"_computed.min_hash_length".to_string(),
json!(id_config.min_hash_length),
);
all_keys.insert(
"_computed.max_hash_length".to_string(),
json!(id_config.max_hash_length),
);
all_keys.insert("_computed.actor".to_string(), json!(actor));
ctx.json_pretty(&all_keys);
} else if ctx.is_quiet() {
return Ok(());
} else if ctx.is_rich() {
let mut entries = Vec::new();
let mut keys: Vec<_> = layer.runtime.keys().chain(layer.startup.keys()).collect();
keys.sort();
keys.dedup();
for key in keys {
let value = layer
.runtime
.get(key)
.or_else(|| layer.startup.get(key))
.cloned()
.unwrap_or_default();
let source = resolve_source(key, &layers);
trace!(key, source = ?source, "Config source resolved");
entries.push(ConfigEntry {
key: key.clone(),
value: format_config_value(&value),
source,
});
}
render_config_table("Configuration", &entries, ctx);
let computed_rows = vec![
("prefix".to_string(), format_config_value(&id_config.prefix)),
(
"min_hash_length".to_string(),
format_config_value(&id_config.min_hash_length.to_string()),
),
(
"max_hash_length".to_string(),
format_config_value(&id_config.max_hash_length.to_string()),
),
("actor".to_string(), format_config_value(&actor)),
];
render_kv_table("Computed Values", &computed_rows, ctx);
} else {
println!("Current configuration (merged):");
println!();
let mut runtime_keys: Vec<_> = layer.runtime.keys().collect();
runtime_keys.sort();
let mut startup_keys: Vec<_> = layer.startup.keys().collect();
startup_keys.sort();
if !runtime_keys.is_empty() {
println!("Runtime settings:");
for key in runtime_keys {
if let Some(value) = layer.runtime.get(key) {
println!(" {key}: {value}");
}
}
println!();
}
if !startup_keys.is_empty() {
println!("Startup settings:");
for key in startup_keys {
if let Some(value) = layer.startup.get(key) {
println!(" {key}: {value}");
}
}
println!();
}
println!("Computed values:");
println!(" prefix: {}", id_config.prefix);
println!(" min_hash_length: {}", id_config.min_hash_length);
println!(" max_hash_length: {}", id_config.max_hash_length);
println!(" actor: {actor}");
}
Ok(())
}
fn output_layer(layer: &ConfigLayer, source: ConfigSource, _json_mode: bool, ctx: &OutputContext) {
if ctx.is_json() {
let mut all_keys: BTreeMap<String, &str> = BTreeMap::new();
for (k, v) in &layer.runtime {
all_keys.insert(k.clone(), v);
}
for (k, v) in &layer.startup {
all_keys.insert(k.clone(), v);
}
ctx.json_pretty(&all_keys);
} else if ctx.is_quiet() {
} else if ctx.is_rich() {
let mut all_keys: Vec<_> = layer.runtime.keys().chain(layer.startup.keys()).collect();
all_keys.sort();
all_keys.dedup();
let entries = all_keys
.into_iter()
.filter_map(|key| {
let value = layer
.runtime
.get(key)
.or_else(|| layer.startup.get(key))
.cloned()?;
Some(ConfigEntry {
key: key.clone(),
value: format_config_value(&value),
source,
})
})
.collect::<Vec<_>>();
render_config_table(
&format!("{} Configuration", source.heading()),
&entries,
ctx,
);
} else {
println!("{} configuration:", source.heading());
println!();
let mut all_keys: Vec<_> = layer.runtime.keys().chain(layer.startup.keys()).collect();
all_keys.sort();
all_keys.dedup();
if all_keys.is_empty() {
println!(" (empty)");
} else {
for key in all_keys {
let value = layer
.runtime
.get(key)
.or_else(|| layer.startup.get(key))
.unwrap();
println!(" {key}: {value}");
}
}
}
}
fn get_user_config_path() -> Option<PathBuf> {
let home = env::var("HOME").ok()?;
let config_root = PathBuf::from(home).join(".config");
let beads_path = config_root.join("beads").join("config.yaml");
if beads_path.exists() {
return Some(beads_path);
}
let legacy_path = config_root.join("bd").join("config.yaml");
if legacy_path.exists() {
return Some(legacy_path);
}
Some(beads_path)
}
fn get_legacy_user_config_path() -> Option<PathBuf> {
env::var("HOME")
.ok()
.map(|home| PathBuf::from(home).join(".beads").join("config.yaml"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_user_config_path_format() {
if let Some(path) = get_user_config_path() {
assert!(path.ends_with("config.yaml"));
let path_str = path.to_string_lossy();
assert!(
path_str.contains(".config/beads") || path_str.contains(".config/bd"),
"unexpected user config path: {path_str}"
);
}
}
#[test]
fn test_set_config_invalid_format() {
let args = vec!["no_equals_sign".to_string()];
let ctx = OutputContext::from_flags(false, false, true);
let result = set_config_value(&args, false, &CliOverrides::default(), &ctx);
assert!(result.is_err());
}
#[test]
fn test_nested_key_parsing() {
let parts: Vec<&str> = "display.color".split('.').collect();
assert_eq!(parts.len(), 2);
assert_eq!(parts[0], "display");
assert_eq!(parts[1], "color");
}
#[test]
fn test_parse_editor_command_splits_flags() {
let (program, args) = parse_editor_command("code --wait").unwrap();
assert_eq!(program, "code");
assert_eq!(args, vec!["--wait"]);
}
#[test]
fn test_parse_editor_command_respects_quotes() {
let (program, args) = parse_editor_command("\"my editor\" --flag").unwrap();
assert_eq!(program, "my editor");
assert_eq!(args, vec!["--flag"]);
}
#[test]
fn test_delete_config_key_from_yaml_file_removes_value() {
let dir = tempfile::TempDir::new().unwrap();
let config_path = dir.path().join("config.yaml");
fs::write(&config_path, "display:\n color: true\n").unwrap();
let deleted =
apply_prepared_yaml_delete(prepare_yaml_delete(&config_path, "display.color").unwrap())
.unwrap();
assert!(deleted);
let contents = fs::read_to_string(&config_path).unwrap();
assert!(!contents.contains("color"));
}
#[test]
fn test_write_config_atomically_skips_stale_temp_path() {
let dir = tempfile::TempDir::new().unwrap();
let config_path = dir.path().join("config.yaml");
fs::write(&config_path, "display:\n color: true\n").unwrap();
let stale_temp = config_path.with_extension(format!("yaml.{}.tmp", std::process::id()));
fs::write(&stale_temp, "stale-temp-sentinel").unwrap();
write_config_atomically(&config_path, "display:\n color: false\n").unwrap();
assert_eq!(
fs::read_to_string(&stale_temp).unwrap(),
"stale-temp-sentinel",
"existing temp file should not be clobbered"
);
assert!(
fs::read_to_string(&config_path)
.unwrap()
.contains("color: false"),
"config should be updated via a fresh temp file"
);
}
#[cfg(unix)]
#[test]
fn test_write_config_atomically_preserves_existing_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::TempDir::new().unwrap();
let config_path = dir.path().join("config.yaml");
fs::write(&config_path, "display:\n color: true\n").unwrap();
fs::set_permissions(&config_path, fs::Permissions::from_mode(0o600)).unwrap();
write_config_atomically(&config_path, "display:\n color: false\n").unwrap();
let mode = fs::metadata(&config_path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "atomic rewrite should preserve file mode");
}
#[test]
fn test_build_layers_does_not_create_missing_db_for_read_only_access() {
let dir = tempfile::TempDir::new().unwrap();
let beads_dir = dir.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
let layers = build_layers(Some(&beads_dir), &CliOverrides::default()).unwrap();
assert!(
!beads_dir.join("beads.db").exists(),
"config read paths must not create a database as a side effect"
);
assert!(!layers.is_empty());
}
#[test]
fn test_build_layers_reads_db_layer_from_startup_db_override() {
let dir = tempfile::TempDir::new().unwrap();
let beads_dir = dir.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
fs::write(beads_dir.join("config.yaml"), "db: custom.db\n").unwrap();
let db_path = crate::util::resolve_cache_dir(&beads_dir).join("custom.db");
let mut storage = crate::storage::SqliteStorage::open(&db_path).unwrap();
storage.set_config("issue_prefix", "proj").unwrap();
let layers = build_layers(Some(&beads_dir), &CliOverrides::default()).unwrap();
let db_layer = layers
.iter()
.find(|layer| matches!(layer.source, ConfigSource::Db))
.expect("db layer");
assert_eq!(
db_layer
.layer
.runtime
.get("issue_prefix")
.map(String::as_str),
Some("proj")
);
}
#[test]
fn test_build_layers_infers_prefix_from_jsonl_for_read_only_config_views() {
let dir = tempfile::TempDir::new().unwrap();
let beads_dir = dir.path().join(".beads");
fs::create_dir_all(&beads_dir).unwrap();
fs::write(
beads_dir.join("issues.jsonl"),
r#"{"id":"proj-abc12","title":"Example"}"#,
)
.unwrap();
let layers = build_layers(Some(&beads_dir), &CliOverrides::default()).unwrap();
let jsonl_layer = layers
.iter()
.find(|l| matches!(l.source, ConfigSource::Jsonl))
.expect("should have a JSONL layer");
assert_eq!(
jsonl_layer
.layer
.runtime
.get("issue_prefix")
.map(String::as_str),
Some("proj"),
"JSONL layer should infer prefix from issue IDs"
);
}
#[test]
fn test_set_yaml_value_overwrites_scalar_root() {
let mut config = serde_yml::Value::String("legacy".to_string());
let parts = ["display"];
set_yaml_value(
&mut config,
&parts,
serde_yml::Value::String("true".to_string()),
);
let serde_yml::Value::Mapping(map) = config else {
unreachable!("expected mapping root");
};
let key = serde_yml::Value::String("display".to_string());
assert_eq!(
map.get(&key),
Some(&serde_yml::Value::String("true".to_string()))
);
}
#[test]
fn test_set_yaml_value_overwrites_scalar_child() {
let mut map = serde_yml::Mapping::default();
map.insert(
serde_yml::Value::String("display".to_string()),
serde_yml::Value::String("legacy".to_string()),
);
let mut config = serde_yml::Value::Mapping(map);
let parts = ["display", "color"];
set_yaml_value(
&mut config,
&parts,
serde_yml::Value::String("blue".to_string()),
);
let serde_yml::Value::Mapping(root) = config else {
unreachable!("expected mapping root");
};
let display_key = serde_yml::Value::String("display".to_string());
let Some(serde_yml::Value::Mapping(display_map)) = root.get(&display_key) else {
unreachable!("expected display mapping");
};
let color_key = serde_yml::Value::String("color".to_string());
assert_eq!(
display_map.get(&color_key),
Some(&serde_yml::Value::String("blue".to_string()))
);
}
}