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
36 changes: 34 additions & 2 deletions crates/clickhousectl/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,12 @@ pub enum Error {
#[error("Unsupported platform: {os}/{arch}")]
UnsupportedPlatform { os: String, arch: String },

#[error("Failed to create directory: {0}")]
CreateDir(PathBuf),
#[error("Failed to create directory '{}': {source}", path.display())]
CreateDir {
path: PathBuf,
#[source]
source: std::io::Error,
},

#[error("Download failed: {0}")]
Download(String),
Expand Down Expand Up @@ -68,6 +72,18 @@ pub enum Error {
#[error("Extraction failed: {0}")]
Extract(String),

#[error(
"Failed to extract archive '{}' to '{}': {source}",
archive.display(),
destination.display()
)]
ExtractArchive {
archive: PathBuf,
destination: PathBuf,
#[source]
source: std::io::Error,
},

#[error("Server '{0}' is not running")]
ServerNotRunning(String),

Expand Down Expand Up @@ -180,4 +196,20 @@ mod tests {
assert_eq!(Error::Cancelled.exit_code(), 3);
assert_eq!(Error::AuthRequired("nope".into()).exit_code(), 4);
}

#[test]
fn create_dir_error_includes_path_and_permission_cause() {
let error = Error::CreateDir {
path: PathBuf::from("/read-only/clickhouse/versions"),
source: std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"permission denied by test",
),
};

assert_eq!(
error.to_string(),
"Failed to create directory '/read-only/clickhouse/versions': permission denied by test"
);
}
}
44 changes: 41 additions & 3 deletions crates/clickhousectl/src/local/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@ use crate::version_manager::{self, VersionSpec};
use clap::{Args, Subcommand};
use std::str::FromStr;

const INSTALL_AFTER_HELP: &str = "\
CONTEXT FOR AGENTS:
`clickhousectl local use <version>` auto-installs a missing version and sets it as default.

EXAMPLES:
clickhousectl local install latest
clickhousectl local install 26.8
clickhousectl local install 26.8.1.1760
clickhousectl local install postgres@18

CLICKHOUSE DOWNLOAD:
Binaries install at ~/.clickhouse/versions/<version>/clickhouse and are approximately 150 MB.
Downloads use builds.clickhouse.com, with packages.clickhouse.com fallback on Linux and
github.com on macOS. To bootstrap without setting a default, run
`clickhousectl local server start`; it installs `latest` when needed.";

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InstallVersionArg {
ClickHouse(VersionSpec),
Expand Down Expand Up @@ -95,9 +111,7 @@ pub struct LocalArgs {
#[derive(Subcommand)]
pub enum LocalCommands {
/// Install a ClickHouse version
#[command(after_help = "\
CONTEXT FOR AGENTS:
`clickhousectl local use <version>` will auto-install if the version is missing and set as default.")]
#[command(after_help = INSTALL_AFTER_HELP)]
Install {
/// 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,
Expand Down Expand Up @@ -666,6 +680,30 @@ mod tests {
);
}

#[test]
fn install_help_covers_install_requirements() {
let error = Cli::try_parse_from(["clickhousectl", "local", "install", "--help"])
.err()
.expect("--help should stop parsing");
assert_eq!(error.kind(), clap::error::ErrorKind::DisplayHelp);
let help = error.to_string();

for required in [
"clickhousectl local install latest",
"~/.clickhouse/versions/<version>/clickhouse",
"approximately 150 MB",
"builds.clickhouse.com",
"packages.clickhouse.com",
"github.com",
"clickhousectl local server start",
] {
assert!(
help.contains(required),
"missing {required:?} from:\n{help}"
);
}
}

#[test]
fn use_help_documents_standard_clickhouse_subcommands() {
let error = Cli::try_parse_from(["clickhousectl", "local", "use", "--help"])
Expand Down
41 changes: 38 additions & 3 deletions crates/clickhousectl/src/paths.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::error::{Error, Result};
use std::path::PathBuf;
use std::path::{Path, PathBuf};

/// Returns the base directory for ClickHouse CLI (~/.clickhouse/)
pub fn base_dir() -> Result<PathBuf> {
Expand Down Expand Up @@ -40,8 +40,14 @@ pub fn configs_dir() -> Result<PathBuf> {
/// Ensures all necessary directories exist
pub fn ensure_dirs() -> Result<()> {
let versions = versions_dir()?;
std::fs::create_dir_all(&versions).map_err(|_| Error::CreateDir(versions))?;
Ok(())
ensure_dir(&versions)
}

pub(crate) fn ensure_dir(path: &Path) -> Result<()> {
std::fs::create_dir_all(path).map_err(|source| Error::CreateDir {
path: path.to_path_buf(),
source,
})
}

/// Returns the user-local PATH-style bin directory (~/.local/bin/)
Expand All @@ -59,3 +65,32 @@ pub fn global_bin_dir() -> Result<PathBuf> {
pub fn global_clickhouse_symlink() -> Result<PathBuf> {
Ok(global_bin_dir()?.join("clickhouse"))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn ensure_dir_includes_path_and_not_a_directory_cause() {
let temp = tempfile::tempdir().unwrap();
let file = temp.path().join("not-a-directory");
std::fs::write(&file, "content").unwrap();
let directory = file.join("versions");

let error = ensure_dir(&directory).unwrap_err();
let Error::CreateDir { path, source } = &error else {
panic!("expected create-directory error: {error}");
};

assert_eq!(path, &directory);
assert_eq!(source.kind(), std::io::ErrorKind::NotADirectory);
assert_eq!(
error.to_string(),
format!(
"Failed to create directory '{}': {}",
directory.display(),
source
)
);
}
}
Loading