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 + query_log
+
+
+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()?;