diff --git a/README.md b/README.md
index 119c701..84b5554 100644
--- a/README.md
+++ b/README.md
@@ -155,7 +155,10 @@ clickhousectl local server start --name dev # Named "dev"
clickhousectl local server start --version stable # Use a specific version (installs if needed, doesn't change default)
clickhousectl local server start --foreground # Run in foreground (-F / --fg)
clickhousectl local server start --http-port 8124 --tcp-port 9001 # Explicit ports
-clickhousectl local server start -- --config-file=/path/to/config.xml
+clickhousectl local server start --config-file analytics # Apply a custom config (see "Custom config files" below)
+
+# List custom config files available to --config-file
+clickhousectl local server configs
# List all servers (running and stopped)
clickhousectl local server list
@@ -186,6 +189,28 @@ clickhousectl local server dotenv --user default --password secret --database my
**Global server management:** Use `--global` with `list`, `stop`, and `stop-all` to operate across all projects system-wide. `server list --global` shows all running ClickHouse servers with a Project column indicating which directory each belongs to.
+#### Custom config files
+
+Drop ClickHouse config files into `~/.clickhouse/configs/` and apply one by name when starting a server:
+
+```bash
+mkdir -p ~/.clickhouse/configs
+cat > ~/.clickhouse/configs/analytics.xml <<'EOF'
+
+
+ system
+
+
+
+EOF
+ # List available config files
+clickhousectl local server start --config-file analytics # Start a server with it
+```
+
+The named file is **overlaid on top of ClickHouse's built-in defaults** (it is staged into the server's `config.d/` directory), so it only needs to contain the settings you want to change — you don't have to reproduce a full config. Files may be `.xml`, `.yaml`, or `.yml`; reference them by name with or without the extension (e.g. `--config-file analytics` or `--config-file analytics.xml`). `--config-file` takes a name within `~/.clickhouse/configs/` **not a path**.
+
+The managed data directory (`.clickhouse/servers//data/`) and the HTTP/TCP ports are always forced as command-line overrides, which take precedence over the config file. This means a custom config can never break the managed server lifecycle (`list`, `stop`, `remove`, `dotenv`) regardless of its contents. Starting a server again without `--config-file` reverts it to plain defaults.
+
#### Local Postgres (Docker-backed)
When you also need a local Postgres alongside ClickHouse — e.g. for testing CDC pipelines or ingesting from Postgres — use `local postgres`. Each instance is keyed on `(name, major version)` so the same name can host multiple Postgres majors with isolated data: data lives at `.clickhouse/servers/-pg/data/`, metadata at `.clickhouse/servers/-pg.json`, and the container is `clickhousectl-pg--`. ClickHouse paths (`/data/`, `.json`) stay separate, so a name can be used by both engines. Requires Docker to be installed and running.
diff --git a/crates/clickhousectl/src/error.rs b/crates/clickhousectl/src/error.rs
index 83e13fd..50ceb4e 100644
--- a/crates/clickhousectl/src/error.rs
+++ b/crates/clickhousectl/src/error.rs
@@ -67,6 +67,12 @@ pub enum Error {
#[error("Invalid server name '{0}': must not contain path separators or '..'")]
InvalidServerName(String),
+ #[error("{0}")]
+ ConfigNotFound(String),
+
+ #[error("Invalid config name '{0}': must be a file in the configs dir, not a path (no '/', '\\', or '..')")]
+ InvalidConfigName(String),
+
#[error("Docker is not available: {0}")]
DockerNotAvailable(String),
diff --git a/crates/clickhousectl/src/local/cli.rs b/crates/clickhousectl/src/local/cli.rs
index e681583..8a720bc 100644
--- a/crates/clickhousectl/src/local/cli.rs
+++ b/crates/clickhousectl/src/local/cli.rs
@@ -165,6 +165,11 @@ CONTEXT FOR AGENTS:
Runs in background by default. Use --foreground (-F / --fg) to run in foreground.
If --name is given and that server is already running, the command will error.
Shows count of already-running servers before starting.
+ Use --config-file to apply a custom ClickHouse config file from ~/.clickhouse/configs/
+ (see `clickhousectl local server configs`). The file is merged as an overlay on top of
+ ClickHouse's built-in defaults (via config.d), so it can contain just the settings you want
+ to change (e.g. ). The data directory and ports stay managed regardless of the
+ file's contents (they are forced as command-line overrides).
Related: `clickhousectl local server list` to see servers, `clickhousectl local server stop ` to stop one.")]
Start {
/// Server name (default: \"default\", or random if default is already running)
@@ -187,11 +192,27 @@ CONTEXT FOR AGENTS:
#[arg(long, alias = "fg", short = 'F')]
foreground: bool,
+ /// Overlay a named config file from ~/.clickhouse/configs/ on top of the defaults (see `server configs`)
+ #[arg(long, value_name = "NAME")]
+ config_file: Option,
+
/// Arguments to pass to clickhouse-server
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec,
},
+ /// List custom config files available to `server start --config-file`
+ #[command(after_help = "\
+CONTEXT FOR AGENTS:
+ Lists ClickHouse config files in ~/.clickhouse/configs/ and prints that directory's path.
+ Drop a config file there (e.g. analytics.xml) and start a server with it via
+ `clickhousectl local server start --config-file analytics`. The file is overlaid on top of
+ ClickHouse's built-in defaults (config.d merge), so it only needs the settings you want to
+ change. Files may be .xml, .yaml, or .yml; reference them by name with or without the
+ extension.
+ Related: `clickhousectl local server start --config-file `.")]
+ Configs,
+
/// List all server instances (running and stopped)
#[command(after_help = "\
CONTEXT FOR AGENTS:
@@ -411,3 +432,53 @@ CONTEXT FOR AGENTS:
local: bool,
},
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::cli::{Cli, Commands};
+ use clap::Parser;
+
+ fn local_command(args: &[&str]) -> LocalCommands {
+ let mut argv = vec!["clickhousectl", "local"];
+ argv.extend_from_slice(args);
+ let cli = Cli::try_parse_from(argv).unwrap();
+ let Commands::Local(local) = cli.command else {
+ panic!("expected local command");
+ };
+ local.command
+ }
+
+ #[test]
+ fn parses_server_start_config_file() {
+ let LocalCommands::Server {
+ command: ServerCommands::Start { config_file, .. },
+ } = local_command(&["server", "start", "--config-file", "analytics"])
+ else {
+ panic!("expected server start");
+ };
+ assert_eq!(config_file.as_deref(), Some("analytics"));
+ }
+
+ #[test]
+ fn server_start_config_file_defaults_to_none() {
+ let LocalCommands::Server {
+ command: ServerCommands::Start { config_file, args, .. },
+ } = local_command(&["server", "start"])
+ else {
+ panic!("expected server start");
+ };
+ assert_eq!(config_file, None);
+ assert!(args.is_empty());
+ }
+
+ #[test]
+ fn parses_server_configs() {
+ let LocalCommands::Server {
+ command: ServerCommands::Configs,
+ } = local_command(&["server", "configs"])
+ else {
+ panic!("expected server configs");
+ };
+ }
+}
diff --git a/crates/clickhousectl/src/local/config.rs b/crates/clickhousectl/src/local/config.rs
new file mode 100644
index 0000000..ac917c9
--- /dev/null
+++ b/crates/clickhousectl/src/local/config.rs
@@ -0,0 +1,382 @@
+//! Resolution and listing of custom server config files.
+//!
+//! Users drop named ClickHouse config files into `~/.clickhouse/configs/` and
+//! reference them by name with `clickhousectl local server start --config-file
+//! `. The file is passed to ClickHouse as `--config-file`; the launcher
+//! still forces `--path=./` and the ports as command-line overrides (which beat
+//! config-file values), so the managed server lifecycle is preserved regardless
+//! of what the config file contains.
+
+use crate::error::{Error, Result};
+use crate::paths;
+use std::path::{Path, PathBuf};
+
+/// Recognized config file extensions, in resolution priority order.
+const CONFIG_EXTS: [&str; 3] = ["xml", "yaml", "yml"];
+
+/// Filename stem for the chctl-managed `config.d` overlay file.
+const OVERLAY_STEM: &str = "chctl-config";
+
+/// Returns true if `name` already ends in a recognized config extension.
+fn has_config_ext(name: &str) -> bool {
+ Path::new(name)
+ .extension()
+ .and_then(|e| e.to_str())
+ .map(|e| CONFIG_EXTS.contains(&e))
+ .unwrap_or(false)
+}
+
+/// Lists the names of config files in `dir` (those with a recognized
+/// extension), sorted. Returns an empty vec if the directory does not exist.
+pub fn list_configs_in(dir: &Path) -> Vec {
+ let Ok(entries) = std::fs::read_dir(dir) else {
+ return Vec::new();
+ };
+ let mut names: Vec = entries
+ .flatten()
+ .filter(|e| e.path().is_file())
+ .filter_map(|e| {
+ let path = e.path();
+ let ext = path.extension().and_then(|e| e.to_str())?;
+ if CONFIG_EXTS.contains(&ext) {
+ path.file_name()
+ .and_then(|n| n.to_str())
+ .map(|s| s.to_string())
+ } else {
+ None
+ }
+ })
+ .collect();
+ names.sort();
+ names
+}
+
+/// Rejects config names that would resolve outside `dir`.
+///
+/// A named config is, by design, a file living directly in the configs store.
+/// Without this guard `dir.join(name)` would let an absolute path or `..`
+/// segments escape the store (`Path::join` replaces the base on an absolute
+/// argument), copying arbitrary files into the server overlay. Mirrors
+/// `server::validate_server_name`.
+fn validate_config_name(name: &str) -> Result<()> {
+ if name.is_empty()
+ || name.contains('/')
+ || name.contains('\\')
+ || name.contains('\0')
+ || name == "."
+ || name == ".."
+ {
+ return Err(Error::InvalidConfigName(name.to_string()));
+ }
+ Ok(())
+}
+
+/// Resolves a config `name` to a file path within `dir`.
+///
+/// If `name` already carries a recognized extension, that exact file must
+/// exist. Otherwise each known extension is tried; exactly one match must be
+/// found. Missing or ambiguous names produce a helpful error. Names that would
+/// escape `dir` (path separators or `..`) are rejected.
+pub fn resolve_config_in(dir: &Path, name: &str) -> Result {
+ validate_config_name(name)?;
+
+ if has_config_ext(name) {
+ let path = dir.join(name);
+ if path.is_file() {
+ return Ok(path);
+ }
+ return Err(not_found_error(dir, name));
+ }
+
+ let matches: Vec = CONFIG_EXTS
+ .iter()
+ .map(|ext| dir.join(format!("{name}.{ext}")))
+ .filter(|p| p.is_file())
+ .collect();
+
+ match matches.len() {
+ 1 => Ok(matches.into_iter().next().unwrap()),
+ 0 => Err(not_found_error(dir, name)),
+ _ => {
+ let exts = matches
+ .iter()
+ .filter_map(|p| p.file_name().and_then(|n| n.to_str()))
+ .collect::>()
+ .join(", ");
+ Err(Error::ConfigNotFound(format!(
+ "config '{name}' is ambiguous in {} ({exts}); specify the file extension",
+ dir.display()
+ )))
+ }
+ }
+}
+
+fn not_found_error(dir: &Path, name: &str) -> Error {
+ let available = list_configs_in(dir);
+ let avail = if available.is_empty() {
+ "none".to_string()
+ } else {
+ available.join(", ")
+ };
+ Error::ConfigNotFound(format!(
+ "config '{name}' not found in {} (available: {avail})",
+ dir.display()
+ ))
+}
+
+/// Resolves a named config from `~/.clickhouse/configs/` to its absolute path.
+pub fn resolve_config(name: &str) -> Result {
+ resolve_config_in(&paths::configs_dir()?, name)
+}
+
+/// Lists the available config file names in `~/.clickhouse/configs/`.
+pub fn list_configs() -> Result> {
+ Ok(list_configs_in(&paths::configs_dir()?))
+}
+
+/// Stages (or clears) the chctl-managed config overlay in `/config.d/`.
+///
+/// ClickHouse merges files in the `config.d/` directory next to its working
+/// directory with its built-in defaults, so a partial override file takes
+/// effect without replacing the whole config. We own a single file there named
+/// `chctl-config.`; any previously staged overlay (in any recognized
+/// extension) is removed first, so restarting a server without `--config-file`
+/// reverts cleanly to plain defaults.
+pub fn apply_config_overlay(data_dir: &Path, source: Option<&Path>) -> Result<()> {
+ let config_d = data_dir.join("config.d");
+
+ // Drop any overlay we previously staged before applying the new state.
+ for ext in CONFIG_EXTS {
+ let stale = config_d.join(format!("{OVERLAY_STEM}.{ext}"));
+ if stale.exists() {
+ std::fs::remove_file(&stale)?;
+ }
+ }
+
+ let Some(source) = source else {
+ return Ok(());
+ };
+
+ // Preserve the source extension so ClickHouse parses XML vs YAML correctly.
+ let ext = source
+ .extension()
+ .and_then(|e| e.to_str())
+ .filter(|e| CONFIG_EXTS.contains(e))
+ .unwrap_or("xml");
+ std::fs::create_dir_all(&config_d)?;
+ std::fs::copy(source, config_d.join(format!("{OVERLAY_STEM}.{ext}")))?;
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn write_file(dir: &Path, name: &str) {
+ std::fs::write(dir.join(name), "").unwrap();
+ }
+
+ #[test]
+ fn resolves_by_bare_name() {
+ let tmp = tempfile::tempdir().unwrap();
+ write_file(tmp.path(), "analytics.xml");
+ let path = resolve_config_in(tmp.path(), "analytics").unwrap();
+ assert_eq!(path, tmp.path().join("analytics.xml"));
+ }
+
+ #[test]
+ fn resolves_yaml_by_bare_name() {
+ let tmp = tempfile::tempdir().unwrap();
+ write_file(tmp.path(), "prod.yaml");
+ let path = resolve_config_in(tmp.path(), "prod").unwrap();
+ assert_eq!(path, tmp.path().join("prod.yaml"));
+ }
+
+ #[test]
+ fn resolves_with_explicit_extension() {
+ let tmp = tempfile::tempdir().unwrap();
+ write_file(tmp.path(), "dev.xml");
+ let path = resolve_config_in(tmp.path(), "dev.xml").unwrap();
+ assert_eq!(path, tmp.path().join("dev.xml"));
+ }
+
+ #[test]
+ fn explicit_extension_missing_errors() {
+ let tmp = tempfile::tempdir().unwrap();
+ let err = resolve_config_in(tmp.path(), "dev.xml").unwrap_err();
+ let msg = err.to_string();
+ assert!(msg.contains("not found"), "got: {msg}");
+ }
+
+ #[test]
+ fn not_found_lists_available() {
+ let tmp = tempfile::tempdir().unwrap();
+ write_file(tmp.path(), "dev.xml");
+ write_file(tmp.path(), "prod.yaml");
+ let err = resolve_config_in(tmp.path(), "missing").unwrap_err();
+ let msg = err.to_string();
+ assert!(msg.contains("missing"), "got: {msg}");
+ assert!(msg.contains("dev.xml"), "got: {msg}");
+ assert!(msg.contains("prod.yaml"), "got: {msg}");
+ }
+
+ #[test]
+ fn not_found_reports_none_when_empty() {
+ let tmp = tempfile::tempdir().unwrap();
+ let err = resolve_config_in(tmp.path(), "missing").unwrap_err();
+ assert!(err.to_string().contains("available: none"));
+ }
+
+ #[test]
+ fn ambiguous_bare_name_errors() {
+ let tmp = tempfile::tempdir().unwrap();
+ write_file(tmp.path(), "shared.xml");
+ write_file(tmp.path(), "shared.yaml");
+ let err = resolve_config_in(tmp.path(), "shared").unwrap_err();
+ let msg = err.to_string();
+ assert!(msg.contains("ambiguous"), "got: {msg}");
+ assert!(msg.contains("shared.xml"));
+ assert!(msg.contains("shared.yaml"));
+ }
+
+ #[test]
+ fn rejects_parent_dir_escape() {
+ let tmp = tempfile::tempdir().unwrap();
+ // A real file outside the configs dir that an escape could target.
+ let outside = tmp.path().join("outside.xml");
+ std::fs::write(&outside, "").unwrap();
+ let configs = tmp.path().join("configs");
+ std::fs::create_dir(&configs).unwrap();
+
+ let err = resolve_config_in(&configs, "../outside").unwrap_err();
+ assert!(
+ matches!(err, Error::InvalidConfigName(_)),
+ "got: {err:?}"
+ );
+ }
+
+ #[test]
+ fn rejects_parent_dir_escape_with_extension() {
+ let tmp = tempfile::tempdir().unwrap();
+ let outside = tmp.path().join("outside.xml");
+ std::fs::write(&outside, "").unwrap();
+ let configs = tmp.path().join("configs");
+ std::fs::create_dir(&configs).unwrap();
+
+ let err = resolve_config_in(&configs, "../outside.xml").unwrap_err();
+ assert!(
+ matches!(err, Error::InvalidConfigName(_)),
+ "got: {err:?}"
+ );
+ }
+
+ #[test]
+ fn rejects_absolute_path() {
+ let tmp = tempfile::tempdir().unwrap();
+ write_file(tmp.path(), "dev.xml");
+ // Absolute path would make `dir.join` discard the configs dir entirely.
+ let abs = tmp.path().join("dev.xml");
+ let err = resolve_config_in(tmp.path(), abs.to_str().unwrap()).unwrap_err();
+ assert!(
+ matches!(err, Error::InvalidConfigName(_)),
+ "got: {err:?}"
+ );
+ }
+
+ #[test]
+ fn rejects_dotdot() {
+ let tmp = tempfile::tempdir().unwrap();
+ let err = resolve_config_in(tmp.path(), "..").unwrap_err();
+ assert!(
+ matches!(err, Error::InvalidConfigName(_)),
+ "got: {err:?}"
+ );
+ }
+
+ #[test]
+ fn list_filters_and_sorts() {
+ let tmp = tempfile::tempdir().unwrap();
+ write_file(tmp.path(), "prod.yaml");
+ write_file(tmp.path(), "dev.xml");
+ write_file(tmp.path(), "notes.txt");
+ std::fs::create_dir(tmp.path().join("subdir.xml")).unwrap();
+ let configs = list_configs_in(tmp.path());
+ assert_eq!(configs, vec!["dev.xml", "prod.yaml"]);
+ }
+
+ #[test]
+ fn list_nonexistent_dir_is_empty() {
+ let tmp = tempfile::tempdir().unwrap();
+ let missing = tmp.path().join("does-not-exist");
+ assert!(list_configs_in(&missing).is_empty());
+ }
+
+ #[test]
+ fn overlay_stages_file_with_extension() {
+ let tmp = tempfile::tempdir().unwrap();
+ let src = tmp.path().join("src.xml");
+ std::fs::write(&src, "1").unwrap();
+ let data_dir = tmp.path().join("data");
+ std::fs::create_dir(&data_dir).unwrap();
+
+ apply_config_overlay(&data_dir, Some(&src)).unwrap();
+
+ let staged = data_dir.join("config.d").join("chctl-config.xml");
+ assert!(staged.is_file());
+ assert_eq!(
+ std::fs::read_to_string(&staged).unwrap(),
+ "1"
+ );
+ }
+
+ #[test]
+ fn overlay_preserves_yaml_extension() {
+ let tmp = tempfile::tempdir().unwrap();
+ let src = tmp.path().join("src.yaml");
+ std::fs::write(&src, "a: 1").unwrap();
+ let data_dir = tmp.path().join("data");
+
+ apply_config_overlay(&data_dir, Some(&src)).unwrap();
+
+ assert!(data_dir.join("config.d").join("chctl-config.yaml").is_file());
+ }
+
+ #[test]
+ fn overlay_none_clears_previous() {
+ let tmp = tempfile::tempdir().unwrap();
+ let src = tmp.path().join("src.xml");
+ std::fs::write(&src, "").unwrap();
+ let data_dir = tmp.path().join("data");
+
+ apply_config_overlay(&data_dir, Some(&src)).unwrap();
+ assert!(data_dir.join("config.d").join("chctl-config.xml").is_file());
+
+ apply_config_overlay(&data_dir, None).unwrap();
+ assert!(!data_dir.join("config.d").join("chctl-config.xml").exists());
+ }
+
+ #[test]
+ fn overlay_switching_extension_removes_old() {
+ let tmp = tempfile::tempdir().unwrap();
+ let xml = tmp.path().join("a.xml");
+ std::fs::write(&xml, "").unwrap();
+ let yaml = tmp.path().join("b.yaml");
+ std::fs::write(&yaml, "a: 1").unwrap();
+ let data_dir = tmp.path().join("data");
+
+ apply_config_overlay(&data_dir, Some(&xml)).unwrap();
+ apply_config_overlay(&data_dir, Some(&yaml)).unwrap();
+
+ let config_d = data_dir.join("config.d");
+ assert!(!config_d.join("chctl-config.xml").exists());
+ assert!(config_d.join("chctl-config.yaml").is_file());
+ }
+
+ #[test]
+ fn overlay_none_on_empty_is_noop() {
+ let tmp = tempfile::tempdir().unwrap();
+ let data_dir = tmp.path().join("data");
+ // Should not error even though config.d does not exist.
+ apply_config_overlay(&data_dir, None).unwrap();
+ }
+}
diff --git a/crates/clickhousectl/src/local/mod.rs b/crates/clickhousectl/src/local/mod.rs
index 77c69eb..e94cdc5 100644
--- a/crates/clickhousectl/src/local/mod.rs
+++ b/crates/clickhousectl/src/local/mod.rs
@@ -1,4 +1,5 @@
pub mod cli;
+pub mod config;
pub mod discovery;
pub mod docker;
pub mod output;
@@ -265,12 +266,14 @@ fn run_client(
Err(Error::Exec(err.to_string()))
}
+#[allow(clippy::too_many_arguments)]
async fn start_server(
name: Option,
version_spec: Option,
http_port: Option,
tcp_port: Option,
foreground: bool,
+ config_file: Option,
args: Vec,
json: bool,
) -> Result<()> {
@@ -318,29 +321,49 @@ async fn start_server(
http_port, tcp_port
);
}
- // Reject --config-file / -C in passthrough args. A custom config file
- // redirects where ClickHouse stores data, which breaks the managed
- // server lifecycle (list, stop, remove, dotenv all rely on the data
- // directory living under .clickhouse/servers//). Individual
- // --setting=value flags are fine — they don't change the data directory.
- // Users who need a fully custom config should run `clickhouse server` directly.
+ // Reject --config-file / -C in passthrough args. Passing a raw config path
+ // here would bypass the managed `--config-file` handling below and could
+ // redirect where ClickHouse stores data, breaking the managed server
+ // lifecycle (list, stop, remove, dotenv all rely on the data directory
+ // living under .clickhouse/servers//). Individual --setting=value
+ // flags are fine — they don't change the data directory.
if args
.iter()
.any(|a| a.starts_with("--config-file") || a.starts_with("-C"))
{
return Err(Error::Exec(
- "--config-file / -C cannot be passed through to managed servers. \
- Individual --setting=value flags are supported. \
- For a fully custom config, run `clickhouse server` directly."
+ "--config-file / -C cannot be passed through in trailing args. \
+ Use `--config-file ` with a file in ~/.clickhouse/configs/ \
+ (see `clickhousectl local server configs`). \
+ Individual --setting=value flags are supported."
.into(),
));
}
+ // Resolve a named config file before any process is spawned, so a bad name
+ // fails fast with a helpful error.
+ let resolved_config = match &config_file {
+ Some(name) => Some(config::resolve_config(name)?),
+ None => None,
+ };
+
let mut cmd = Command::new(&binary);
cmd.arg("server");
server::ensure_server_data_dir(&server_name)?;
- cmd.current_dir(server::server_data_dir(&server_name));
+ let data_dir = server::server_data_dir(&server_name);
+
+ // Stage the named config as a config.d overlay inside the data dir. With no
+ // --config-file, ClickHouse uses its built-in defaults and merges any
+ // config.d/ next to its working directory, so a partial override file (e.g.
+ // just ) takes effect without replacing the whole config.
+ // Passing it as --config-file instead would replace the embedded defaults
+ // and a partial file would fail to start. The forced --path=./ and port
+ // flags below are command-line overrides that still win over the file, so
+ // the managed lifecycle is preserved regardless of the file's contents.
+ config::apply_config_overlay(&data_dir, resolved_config.as_deref())?;
+
+ cmd.current_dir(&data_dir);
cmd.args(init::server_flags());
cmd.args(server::port_flags(http_port, tcp_port));
@@ -415,6 +438,16 @@ async fn start_server(
}
}
+fn list_configs(json: bool) -> Result<()> {
+ let dir = paths::configs_dir()?;
+ let out = output::ServerConfigsOutput {
+ dir: dir.display().to_string(),
+ configs: config::list_configs()?,
+ };
+ output::print_output(&out, json);
+ Ok(())
+}
+
fn dotenv_server(
name: Option<&str>,
use_local: bool,
@@ -565,8 +598,22 @@ async fn run_server_commands(command: ServerCommands, json: bool) -> Result<()>
http_port,
tcp_port,
foreground,
+ config_file,
args,
- } => start_server(name, version, http_port, tcp_port, foreground, args, json).await,
+ } => {
+ start_server(
+ name,
+ version,
+ http_port,
+ tcp_port,
+ foreground,
+ config_file,
+ args,
+ json,
+ )
+ .await
+ }
+ ServerCommands::Configs => list_configs(json),
ServerCommands::List { global } => {
if global {
list_servers_global(json)
diff --git a/crates/clickhousectl/src/local/output.rs b/crates/clickhousectl/src/local/output.rs
index 9b70bfe..7f13716 100644
--- a/crates/clickhousectl/src/local/output.rs
+++ b/crates/clickhousectl/src/local/output.rs
@@ -173,6 +173,36 @@ impl fmt::Display for InitOutput {
}
}
+// ── server configs ──────────────────────────────────────────────────────────
+
+#[derive(Debug, Clone, Serialize)]
+pub struct ServerConfigsOutput {
+ pub dir: String,
+ pub configs: Vec,
+}
+
+impl fmt::Display for ServerConfigsOutput {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ if self.configs.is_empty() {
+ writeln!(f, "No config files in {}", self.dir)?;
+ write!(
+ f,
+ "Drop a ClickHouse config file there, then start with: \
+ clickhousectl local server start --config-file "
+ )?;
+ return Ok(());
+ }
+ writeln!(f, "Config files in {}:", self.dir)?;
+ for name in &self.configs {
+ writeln!(f, " {name}")?;
+ }
+ write!(
+ f,
+ "Use with: clickhousectl local server start --config-file "
+ )
+ }
+}
+
// ── server start ────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize)]
@@ -786,6 +816,42 @@ mod tests {
assert_eq!(json["servers"].as_array().unwrap().len(), 0);
}
+ #[test]
+ fn server_configs_json() {
+ let output = ServerConfigsOutput {
+ dir: "/home/user/.clickhouse/configs".to_string(),
+ configs: vec!["dev.xml".to_string(), "prod.yaml".to_string()],
+ };
+ let json: serde_json::Value =
+ serde_json::from_str(&serde_json::to_string_pretty(&output).unwrap()).unwrap();
+ assert_eq!(json["dir"], "/home/user/.clickhouse/configs");
+ assert_eq!(json["configs"][0], "dev.xml");
+ assert_eq!(json["configs"][1], "prod.yaml");
+ assert_eq!(json["configs"].as_array().unwrap().len(), 2);
+ }
+
+ #[test]
+ fn server_configs_display_empty() {
+ let output = ServerConfigsOutput {
+ dir: "/home/user/.clickhouse/configs".to_string(),
+ configs: vec![],
+ };
+ let text = output.to_string();
+ assert!(text.contains("No config files"));
+ assert!(text.contains("--config-file"));
+ }
+
+ #[test]
+ fn server_configs_display_with_entries() {
+ let output = ServerConfigsOutput {
+ dir: "/home/user/.clickhouse/configs".to_string(),
+ configs: vec!["dev.xml".to_string()],
+ };
+ let text = output.to_string();
+ assert!(text.contains("dev.xml"));
+ assert!(text.contains("Use with:"));
+ }
+
#[test]
fn server_remove_json() {
let output = ServerRemoveOutput {
diff --git a/crates/clickhousectl/src/paths.rs b/crates/clickhousectl/src/paths.rs
index 676fd04..2375ad6 100644
--- a/crates/clickhousectl/src/paths.rs
+++ b/crates/clickhousectl/src/paths.rs
@@ -32,6 +32,11 @@ pub fn default_file() -> Result {
Ok(base_dir()?.join("default"))
}
+/// Returns the custom server configs directory (~/.clickhouse/configs/)
+pub fn configs_dir() -> Result {
+ Ok(base_dir()?.join("configs"))
+}
+
/// Ensures all necessary directories exist
pub fn ensure_dirs() -> Result<()> {
let versions = versions_dir()?;