Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 198 additions & 6 deletions crates/clickhousectl/src/local/cli.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,86 @@
use crate::version_manager::{self, VersionSpec};
use clap::{Args, Subcommand};
use std::str::FromStr;

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InstallVersionArg {
ClickHouse(VersionSpec),
Postgres(String),
}

impl FromStr for InstallVersionArg {
type Err = String;

fn from_str(input: &str) -> Result<Self, Self::Err> {
let input = input.trim();
if let Some(tag) = input
.strip_prefix("postgres@")
.or_else(|| input.strip_prefix("postgres:"))
{
return Ok(Self::Postgres(tag.to_string()));
}

version_manager::parse_version_spec(input)
.map(Self::ClickHouse)
.map_err(|error| error.to_string())
}
}

/// Kept distinct from `ServerVersionArg` so each command owns its accepted inputs and errors.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UseVersionArg(VersionSpec);

impl UseVersionArg {
pub(crate) fn into_spec(self) -> VersionSpec {
self.0
}
}

impl FromStr for UseVersionArg {
type Err = String;

fn from_str(input: &str) -> Result<Self, Self::Err> {
let input = input.trim();
if input.starts_with("postgres@") || input.starts_with("postgres:") {
return Err(
"Postgres image selectors are only supported by `local install`; `local use` requires a ClickHouse version"
.to_string(),
);
}

version_manager::parse_version_spec(input)
.map(Self)
.map_err(|error| error.to_string())
}
}

/// Kept distinct from `UseVersionArg` so each command owns its accepted inputs and errors.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServerVersionArg(VersionSpec);

impl ServerVersionArg {
pub(crate) fn into_spec(self) -> VersionSpec {
self.0
}
}

impl FromStr for ServerVersionArg {
type Err = String;

fn from_str(input: &str) -> Result<Self, Self::Err> {
let input = input.trim();
if input.starts_with("postgres@") || input.starts_with("postgres:") {
return Err(
"Postgres image selectors are only supported by `local install`; `local server start --version` requires a ClickHouse version"
.to_string(),
);
}

version_manager::parse_version_spec(input)
.map(Self)
.map_err(|error| error.to_string())
}
}

#[derive(Args)]
pub struct LocalArgs {
Expand All @@ -17,8 +99,8 @@ pub enum LocalCommands {
CONTEXT FOR AGENTS:
`clickhousectl local use <version>` will auto-install if the version is missing and set as default.")]
Install {
/// Version to install. Accepts: "latest" (recommended), "stable", "lts", partial like "25.12", or exact like "25.12.9.61".
version: String,
/// Version to install. Accepts: "latest" (recommended), "stable", "lts", partial like "25.12", exact like "25.12.9.61", or a Postgres image selector like "postgres@18".
version: InstallVersionArg,

/// Force re-install even if version is already installed
#[arg(long)]
Expand Down Expand Up @@ -49,7 +131,7 @@ CONTEXT FOR AGENTS:
Related: `clickhousectl local which` to verify, `clickhousectl local server start` to start a server.")]
Use {
/// Version to use as default. Accepts: "latest" (recommended), "stable", "lts", partial like "25.12", or exact like "25.12.5.44".
version: String,
version: UseVersionArg,

/// Do not create or update the ~/.local/bin/clickhouse symlink
#[arg(long)]
Expand All @@ -67,6 +149,7 @@ CONTEXT FOR AGENTS:
Related: `clickhousectl local list` to see installed versions.")]
Remove {
/// Version to remove
// Keep this opaque: removal matches an installed directory name instead of resolving a version spec.
version: String,

/// Stop any running servers using this version, then remove it
Expand Down Expand Up @@ -198,7 +281,7 @@ CONTEXT FOR AGENTS:

/// ClickHouse version to use (e.g. "latest" (recommended), stable, lts, 25.12). Installs if needed. Does not change the default version.
#[arg(long, short = 'v')]
version: Option<String>,
version: Option<ServerVersionArg>,

/// HTTP port (default: 8123, auto-assigns a free port if in use)
#[arg(long)]
Expand Down Expand Up @@ -472,6 +555,7 @@ CONTEXT FOR AGENTS:
mod tests {
use super::*;
use crate::cli::{Cli, Commands};
use crate::version_manager::list::Channel;
use clap::Parser;

fn local_command(args: &[&str]) -> LocalCommands {
Expand All @@ -484,6 +568,104 @@ mod tests {
local.command
}

fn assert_version_rejected(args: &[&str], expected: &str) {
let mut argv = vec!["clickhousectl", "local"];
argv.extend_from_slice(args);
let error = Cli::try_parse_from(argv)
.err()
.expect("invalid version should fail during clap parsing");
assert_eq!(error.kind(), clap::error::ErrorKind::ValueValidation);
assert!(error.to_string().contains(expected), "{error}");
}

#[test]
fn parses_supported_clickhouse_version_forms_for_each_command() {
for (input, expected) in [
("latest", VersionSpec::Latest),
("stable", VersionSpec::Channel(Channel::Stable)),
("lts", VersionSpec::Channel(Channel::Lts)),
("25", VersionSpec::Major(25)),
("25.12", VersionSpec::Minor(25, 12)),
("25.12.9.61", VersionSpec::Exact("25.12.9.61".to_string())),
] {
let LocalCommands::Install {
version: InstallVersionArg::ClickHouse(version),
..
} = local_command(&["install", input])
else {
panic!("expected ClickHouse install version for {input}");
};
assert_eq!(version, expected);

let LocalCommands::Use { version, .. } = local_command(&["use", input]) else {
panic!("expected use version for {input}");
};
assert_eq!(version.into_spec(), expected);

let LocalCommands::Server {
command: ServerCommands::Start { version, .. },
} = local_command(&["server", "start", "--version", input])
else {
panic!("expected server version for {input}");
};
assert_eq!(
version.expect("version should be present").into_spec(),
expected
);
}
}

#[test]
fn rejects_malformed_clickhouse_versions_for_each_command() {
let expected = "all parts must be numeric";
assert_version_rejected(&["install", "not.a.version"], expected);
assert_version_rejected(&["use", "not.a.version"], expected);
assert_version_rejected(&["server", "start", "--version", "not.a.version"], expected);
}

#[test]
fn rejects_three_part_clickhouse_versions_for_each_command() {
let expected = "3-part version '25.12.9' is not supported";
assert_version_rejected(&["install", "25.12.9"], expected);
assert_version_rejected(&["use", "25.12.9"], expected);
assert_version_rejected(&["server", "start", "--version", "25.12.9"], expected);
}

#[test]
fn rejects_unsupported_clickhouse_version_shapes_for_each_command() {
let expected = "expected 1-2 or 4 parts";
assert_version_rejected(&["install", "25.12.9.61.2"], expected);
assert_version_rejected(&["use", "25.12.9.61.2"], expected);
assert_version_rejected(&["server", "start", "--version", "25.12.9.61.2"], expected);
}

#[test]
fn postgres_image_selectors_are_install_only() {
for (input, expected) in [
("postgres@18", "18"),
("postgres:17-alpine", "17-alpine"),
(" postgres@16 ", "16"),
] {
let LocalCommands::Install {
version: InstallVersionArg::Postgres(tag),
..
} = local_command(&["install", input])
else {
panic!("expected Postgres install version for {input}");
};
assert_eq!(tag, expected);
}

assert_version_rejected(
&["use", " postgres@18 "],
"only supported by `local install`; `local use` requires a ClickHouse version",
);
assert_version_rejected(
&["server", "start", "--version", " postgres@18 "],
"only supported by `local install`; `local server start --version` requires a ClickHouse version",
);
}

#[test]
fn use_help_documents_standard_clickhouse_subcommands() {
let error = Cli::try_parse_from(["clickhousectl", "local", "use", "--help"])
Expand Down Expand Up @@ -577,7 +759,12 @@ mod tests {
};
assert_eq!(name.as_deref(), Some("existing"));
assert_eq!(name_flag, None);
assert_eq!(version.as_deref(), Some("25.12.9.61"));
assert_eq!(
version
.map(ServerVersionArg::into_spec)
.map(|v| v.to_string()),
Some("25.12.9.61".to_string())
);
assert!(args.is_empty());
}

Expand Down Expand Up @@ -635,7 +822,12 @@ mod tests {
panic!("expected server start");
};
assert_eq!(name.as_deref(), Some("existing"));
assert_eq!(version.as_deref(), Some("25.12.9.61"));
assert_eq!(
version
.map(ServerVersionArg::into_spec)
.map(|v| v.to_string()),
Some("25.12.9.61".to_string())
);
assert_eq!(
args,
["--logger.level=trace", "--max_server_memory_usage=1000000"]
Expand Down
52 changes: 19 additions & 33 deletions crates/clickhousectl/src/local/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ pub mod postgres;
pub mod server;
pub mod symlink;

use cli::{LocalCommands, ServerCommands};
use cli::{InstallVersionArg, LocalCommands, ServerCommands, ServerVersionArg};

use crate::error::{Error, Result};
use crate::{init, paths, version_manager};
Expand All @@ -17,15 +17,17 @@ use std::process::Command;

pub async fn run(cmd: LocalCommands, json: bool) -> Result<()> {
match cmd {
LocalCommands::Install { version, force } => install(&version, force, json).await,
LocalCommands::Install { version, force } => install(version, force, json).await,
LocalCommands::List { remote } => {
if remote {
list_available(json).await
} else {
list_installed(json)
}
}
LocalCommands::Use { version, no_global } => use_version(&version, no_global, json).await,
LocalCommands::Use { version, no_global } => {
use_version(version.into_spec(), no_global, json).await
}
LocalCommands::Remove { version, force } => remove(&version, force, json),
LocalCommands::Which => which(json),
LocalCommands::Init => {
Expand All @@ -49,14 +51,6 @@ pub async fn run(cmd: LocalCommands, json: bool) -> Result<()> {
}
}

/// If the version spec looks like `postgres@<tag>` or `postgres:<tag>`, extract
/// the tag. The CLI accepts both `@` (more shell-friendly, no need to quote)
/// and `:` (matches Docker image syntax).
fn parse_postgres_install_spec(spec: &str) -> Option<&str> {
spec.strip_prefix("postgres@")
.or_else(|| spec.strip_prefix("postgres:"))
}

async fn install_postgres(tag: &str, force: bool, json: bool) -> Result<()> {
postgres::validate_pg_tag(tag)?;
let docker = docker::connect().await?;
Expand All @@ -82,11 +76,11 @@ async fn install_postgres(tag: &str, force: bool, json: bool) -> Result<()> {
Ok(())
}

async fn install(version_spec: &str, force: bool, json: bool) -> Result<()> {
if let Some(tag) = parse_postgres_install_spec(version_spec) {
return install_postgres(tag, force, json).await;
}
let spec = version_manager::parse_version_spec(version_spec)?;
async fn install(version: InstallVersionArg, force: bool, json: bool) -> Result<()> {
let spec = match version {
InstallVersionArg::ClickHouse(spec) => spec,
InstallVersionArg::Postgres(tag) => return install_postgres(&tag, force, json).await,
};
let platform = version_manager::platform::Platform::detect()?;

let version = version_manager::install::install_local_first(&spec, &platform, force).await?;
Expand Down Expand Up @@ -163,8 +157,11 @@ async fn list_available(json: bool) -> Result<()> {
Ok(())
}

async fn use_version(version_spec: &str, no_global: bool, json: bool) -> Result<()> {
let spec = version_manager::parse_version_spec(version_spec)?;
async fn use_version(
spec: version_manager::VersionSpec,
no_global: bool,
json: bool,
) -> Result<()> {
let platform = version_manager::platform::Platform::detect()?;

let version = version_manager::install::ensure_installed_local_first(&spec, &platform).await?;
Expand Down Expand Up @@ -316,7 +313,7 @@ fn run_client(
#[allow(clippy::too_many_arguments)]
async fn start_server(
name: Option<String>,
version_spec: Option<String>,
version_spec: Option<ServerVersionArg>,
http_port: Option<u16>,
tcp_port: Option<u16>,
foreground: bool,
Expand All @@ -339,8 +336,8 @@ async fn start_server(
return Err(Error::ServerAlreadyRunning(server_name));
}

let version = if let Some(spec_str) = &version_spec {
let spec = version_manager::parse_version_spec(spec_str)?;
let version = if let Some(spec) = version_spec {
let spec = spec.into_spec();
let platform = version_manager::platform::Platform::detect()?;
version_manager::install::ensure_installed_local_first(&spec, &platform).await?
} else {
Expand All @@ -353,7 +350,7 @@ async fn start_server(
// subsequent bare start too; `ensure_installed_local_first` returns the
// already-installed build silently if `latest` still resolves to it,
// otherwise it pulls the newer master build.
let spec = version_manager::parse_version_spec("latest")?;
let spec = version_manager::VersionSpec::Latest;
let platform = version_manager::platform::Platform::detect()?;
// Says "using", not "installing": on repeat starts the build is
// usually already installed and nothing is downloaded. The install
Expand Down Expand Up @@ -1147,17 +1144,6 @@ mod tests {
assert_eq!(output.servers[2].error, None);
}

#[test]
fn parse_postgres_install_spec_recognizes_at_and_colon() {
assert_eq!(parse_postgres_install_spec("postgres@17"), Some("17"));
assert_eq!(
parse_postgres_install_spec("postgres:17-alpine"),
Some("17-alpine")
);
assert_eq!(parse_postgres_install_spec("25.12"), None);
assert_eq!(parse_postgres_install_spec("stable"), None);
}

#[test]
fn update_dotenv_postgres_prefix_isolates_clickhouse_vars() {
let existing = "CLICKHOUSE_HOST=localhost\nCLICKHOUSE_PORT=9000\nDATABASE_URL=x\n";
Expand Down
2 changes: 1 addition & 1 deletion crates/clickhousectl/src/version_manager/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ pub use list::{
get_default_version, list_available_versions_from_builds, list_installed_versions,
set_default_version,
};
pub use spec::parse_version_spec;
pub use spec::{VersionSpec, parse_version_spec};
Loading