diff --git a/README.md b/README.md index 0afc0caf..ef9df227 100644 --- a/README.md +++ b/README.md @@ -285,6 +285,8 @@ clickhousectl local server dotenv --local --user default --database mydb # Incl Stopping a server preserves its data and identity metadata, so it remains visible in `server list` with a `stopped` status. Version and ports are shown only while running because they are resolved again on each start. Starting the same name resumes the existing data directory. +Project-local server commands select `.clickhouse` under the exact current working directory. They do not search parent directories, so running `list`, `stop`, or `remove` from a child directory selects a different project scope. Change to the local project root where the server was started first; this is where `.clickhouse` typically lives. There is intentionally no project-path override for project-local commands; `server stop --global --project ` is only for an explicitly confirmed server found with `server list --global`. + Without a name, `server stop` selects an existing `default`, then a sole known ClickHouse server. It succeeds without changing anything when none exist, and requires a name or `server stop-all` when multiple non-default servers exist. Bare `server remove` is deliberately stricter: it removes an existing `default` only and otherwise requires an explicit name, even when there is just one custom server. **Server naming:** Without a name, the first server is called "default". If "default" is already running, a random name is generated (e.g. "bold-crane"). Pass a name positionally for stable identities you can start/stop repeatedly. @@ -968,13 +970,44 @@ Local runtime failures also use structured output when `local --json` is set or { "error": { "code": "server_not_found", - "message": "Server 'default' not found", - "command": "clickhousectl local server list" + "message": "Server 'default' was not found in the current project", + "project_scope": { + "kind": "exact_current_project", + "path": "/path/to/project", + "parent_projects_searched": false + }, + "server": { + "name": "default" + }, + "guidance": [ + { + "action": "return_to_project_root", + "message": "Change to the local project root where the server was started", + "command": "cd " + }, + { + "action": "list_project_servers", + "message": "List servers after returning to that exact project", + "command": "clickhousectl local server list" + }, + { + "action": "list_global_servers", + "message": "Locate running ClickHouse servers across projects", + "command": "clickhousectl local server list --global" + }, + { + "action": "stop_global_project_server", + "message": "After confirming the project, stop the server with explicit global project selection", + "command": "clickhousectl local server stop --global --project " + } + ] } } ``` -`error.code` and `error.message` are always present. `error.command` is an optional safe recovery command. Messages are built from allowlisted fields and never serialize raw I/O errors, credentials, SQL, container logs, Docker diagnostics, or arbitrary fallback details. Human local errors retain the concise `Error: ...` format. Clap usage errors, Cloud errors, and child-process output are not wrapped in this local schema. +`error.code` and `error.message` are always present. General errors can include an optional top-level `error.command` safe recovery command. Project-local `server stop` and `server remove` not-found errors instead include `project_scope`, `server`, and ordered `guidance`; their top-level `command` field is absent. Messages are built from allowlisted fields and never serialize raw I/O errors, credentials, SQL, container logs, Docker diagnostics, or arbitrary fallback details. Human local errors retain the concise `Error: ...` format. Clap usage errors, Cloud errors, and child-process output are not wrapped in this local schema. + +When `.clickhouse` is absent from the current directory, bare `server stop` includes the same `project_scope` and `guidance` in its successful no-op output, while bare `server remove` includes them in its `server_selection_required` error. This distinguishes a missing project root from an initialized project that has no matching ClickHouse server. Managed `local client` failures deliberately use dedicated `managed_client_*` codes rather than the general server codes. Their error object includes `project_scope.path` (the canonical directory inspected), `server.selection` and `server.name`, an optional `server.binary_version`, and ordered `guidance` entries with allowlisted messages and optional commands. No raw lock, metadata, or I/O error is included in JSON. The nested shape distinguishes this exact-project lookup contract from failures in other local commands without changing those commands' stable envelopes. diff --git a/crates/clickhousectl/src/error.rs b/crates/clickhousectl/src/error.rs index 31eaf8c8..06faa344 100644 --- a/crates/clickhousectl/src/error.rs +++ b/crates/clickhousectl/src/error.rs @@ -260,6 +260,84 @@ impl std::error::Error for ManagedClientError { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProjectServerCommand { + Stop, + Remove, +} + +impl fmt::Display for ProjectServerCommand { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Stop => "stop", + Self::Remove => "remove", + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProjectServerNotFound { + pub command: ProjectServerCommand, + pub project_dir: PathBuf, + pub server_name: String, +} + +impl fmt::Display for ProjectServerNotFound { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!( + f, + "Server '{}' was not found in project '{}'.", + self.server_name, + self.project_dir.display() + )?; + writeln!( + f, + "Project-local server {} uses the exact current working directory; parent `.clickhouse` directories are not searched.", + self.command + )?; + write!( + f, + "Return to the local project root where the server was started and run `clickhousectl local server list`; use `clickhousectl local server list --global` to locate running servers in other projects" + )?; + if self.command == ProjectServerCommand::Stop { + write!( + f, + "; after confirming the project, use `clickhousectl local server stop --global --project `" + )?; + } + write!(f, ".") + } +} + +impl std::error::Error for ProjectServerNotFound {} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProjectServerStateMissing { + pub command: ProjectServerCommand, + pub project_dir: PathBuf, +} + +impl fmt::Display for ProjectServerStateMissing { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!( + f, + "No `.clickhouse` project state was found in '{}'.", + self.project_dir.display() + )?; + writeln!( + f, + "Project-local server {} uses the exact current working directory; parent `.clickhouse` directories are not searched.", + self.command + )?; + write!( + f, + "The `.clickhouse` directory typically lives in the local project root where the server was started. Return there and run `clickhousectl local server list`; use `clickhousectl local server list --global` to locate running servers in other projects." + ) + } +} + +impl std::error::Error for ProjectServerStateMissing {} + #[derive(Error, Debug)] #[allow(dead_code)] pub enum Error { @@ -410,6 +488,12 @@ pub enum Error { #[error("Server '{0}' not found")] ServerNotFound(String), + #[error("{0}")] + ProjectServerNotFound(ProjectServerNotFound), + + #[error("{0}")] + ProjectServerStateMissing(ProjectServerStateMissing), + #[error("{0}")] ManagedClient(ManagedClientError), diff --git a/crates/clickhousectl/src/init.rs b/crates/clickhousectl/src/init.rs index 248de5ea..b0dab256 100644 --- a/crates/clickhousectl/src/init.rs +++ b/crates/clickhousectl/src/init.rs @@ -7,6 +7,12 @@ pub fn local_dir() -> PathBuf { .join(".clickhouse") } +/// The physical directory whose project-local state is selected by this +/// invocation. Local commands intentionally do not search parent directories. +pub fn canonical_project_dir() -> Result { + Ok(std::env::current_dir()?.canonicalize()?) +} + pub fn project_dir() -> PathBuf { std::env::current_dir() .expect("failed to get current directory") diff --git a/crates/clickhousectl/src/local/cli.rs b/crates/clickhousectl/src/local/cli.rs index f77d021a..da7855fa 100644 --- a/crates/clickhousectl/src/local/cli.rs +++ b/crates/clickhousectl/src/local/cli.rs @@ -296,6 +296,8 @@ CONTEXT FOR AGENTS: Manage named local server instances. Project-scoped `server list` and `server stop-all` include both ClickHouse processes and Docker-backed Postgres containers; other commands here manage ClickHouse. + Project-local commands use .clickhouse under the exact current working directory and do + not search parent directories. Change to the project root before running them. Each server has its own data directory. Data is stored in .clickhouse/servers//data/ and persists between restarts. Typical: `clickhousectl local server start` (starts \"default\"), `clickhousectl local server start test`. @@ -402,6 +404,8 @@ CONTEXT FOR AGENTS: #[command(after_help = "\ CONTEXT FOR AGENTS: Shows all named ClickHouse server instances and their status. + The default list uses only .clickhouse under the exact current working directory; parent + directories are not searched. Use --global to list running ClickHouse servers across projects. Processes that exited unexpectedly are retained and shown as stopped. Running ClickHouse entries also show their PID, version, and ports. Related: `clickhousectl local server start` to start a server, `clickhousectl local server stop [name]` to stop one.")] @@ -420,6 +424,8 @@ CONTEXT FOR AGENTS: Use `clickhousectl local server list` to find names. The server's data and metadata are preserved so it remains visible in `server list`. Restart with `clickhousectl local server start `. + Project-local lookup uses the exact current working directory and never searches parent + .clickhouse directories. Change to the project root, or inspect --global before global action. Related: `clickhousectl local server list` to see servers.")] Stop { /// Name of the server to stop (auto-selects default or a sole ClickHouse server when omitted) @@ -466,6 +472,8 @@ CONTEXT FOR AGENTS: This is irreversible — all data for this server instance will be lost. Without a name, removes \"default\" only when it exists. It never guesses a custom server; use `server list`, then pass a custom name positionally. + Project-local lookup uses the exact current working directory and never searches parent + .clickhouse directories. Change to the project root before removing server data. Related: `clickhousectl local server stop [name]` to stop first, `clickhousectl local server list` to see servers.")] Remove { /// Name of the server to remove (only an existing "default" is selected when omitted) diff --git a/crates/clickhousectl/src/local/mod.rs b/crates/clickhousectl/src/local/mod.rs index be50ef3f..10b3dfe6 100644 --- a/crates/clickhousectl/src/local/mod.rs +++ b/crates/clickhousectl/src/local/mod.rs @@ -10,7 +10,8 @@ pub mod symlink; use cli::{ClientVersionArg, InstallVersionArg, LocalCommands, ServerCommands, ServerVersionArg}; use crate::error::{ - Error, ManagedClientError, ManagedClientErrorKind, ManagedClientSelection, Result, + Error, ManagedClientError, ManagedClientErrorKind, ManagedClientSelection, + ProjectServerCommand, ProjectServerNotFound, ProjectServerStateMissing, Result, }; use crate::{init, paths, version_manager}; use std::io::Write; @@ -291,7 +292,7 @@ fn run_client( }; // Resolve symlinks so diagnostics identify the one physical directory // whose project-local state was inspected. - let project_dir = std::env::current_dir()?.canonicalize()?; + let project_dir = init::canonical_project_dir()?; let managed_error = |kind, binary_version| { Error::ManagedClient(ManagedClientError { kind, @@ -892,6 +893,8 @@ fn stop_server( if let Some(name) = explicit_name { server::validate_server_name(name)?; } + let project_dir = init::canonical_project_dir()?; + let project_state_missing = !init::local_dir().is_dir(); // Recover orphaned servers so we can stop processes // that lost their metadata files. @@ -916,6 +919,13 @@ fn stop_server( stopped: false, selection: output::ServerSelection::Implicit, reason: "no_clickhouse_servers", + project_scope: project_state_missing + .then(|| output::exact_current_project_scope(&project_dir)), + guidance: if project_state_missing { + output::project_scope_guidance(Some(ProjectServerCommand::Stop)) + } else { + Vec::new() + }, }; output::print_output(&out, json); return Ok(()); @@ -961,7 +971,11 @@ fn stop_server( Ok(()) } // No such server in this project — surface the typo. - StopOutcome::NotFound => Err(Error::ServerNotFound(name)), + StopOutcome::NotFound => Err(Error::ProjectServerNotFound(ProjectServerNotFound { + command: ProjectServerCommand::Stop, + project_dir, + server_name: name, + })), } } @@ -973,6 +987,8 @@ fn remove_server(name_input: ServerNameInput, json: bool) -> Result<()> { if let Some(name) = explicit_name { server::validate_server_name(name)?; } + let project_dir = init::canonical_project_dir()?; + let project_state_missing = !init::local_dir().is_dir(); // Recover orphaned servers so we correctly detect a running // process even when its metadata file is missing. @@ -987,6 +1003,14 @@ fn remove_server(name_input: ServerNameInput, json: bool) -> Result<()> { if names.iter().any(|name| name == "default") { ("default".to_string(), output::ServerSelection::Implicit) } else { + if project_state_missing && names.is_empty() { + return Err(Error::ProjectServerStateMissing( + ProjectServerStateMissing { + command: ProjectServerCommand::Remove, + project_dir, + }, + )); + } return Err(Error::ServerRemoveSelectionRequired { available: names.len(), }); @@ -999,7 +1023,11 @@ fn remove_server(name_input: ServerNameInput, json: bool) -> Result<()> { } let data_dir = server::server_data_dir(&name); if !data_dir.exists() { - return Err(Error::ServerNotFound(name)); + return Err(Error::ProjectServerNotFound(ProjectServerNotFound { + command: ProjectServerCommand::Remove, + project_dir, + server_name: name, + })); } // Remove the whole server directory (parent of data/) let server_dir = data_dir.parent().unwrap(); @@ -1034,6 +1062,7 @@ fn classify_stop(running: bool, exists_on_disk: bool) -> StopOutcome { } fn list_servers_local(json: bool) -> Result<()> { + let project_dir = init::canonical_project_dir()?; let entries = server::list_all_servers()?; let running_count = entries.iter().filter(|e| e.running).count(); let total = entries.len(); @@ -1111,6 +1140,12 @@ fn list_servers_local(json: bool) -> Result<()> { .collect(), total_servers: total, total_running_servers: running_count, + project_scope: Some(output::exact_current_project_scope(&project_dir)), + guidance: if total == 0 { + output::project_scope_guidance(None) + } else { + Vec::new() + }, }; output::print_output(&out, json); Ok(()) @@ -1137,6 +1172,8 @@ fn list_servers_global(json: bool) -> Result<()> { .collect(), total_servers: total, total_running_servers: total, + project_scope: None, + guidance: Vec::new(), }; output::print_output(&out, json); Ok(()) diff --git a/crates/clickhousectl/src/local/output.rs b/crates/clickhousectl/src/local/output.rs index 7a078a38..c27dcfca 100644 --- a/crates/clickhousectl/src/local/output.rs +++ b/crates/clickhousectl/src/local/output.rs @@ -5,11 +5,12 @@ use crate::error::{ Error, ManagedClientError, ManagedClientErrorKind, ManagedClientSelection, NetworkStage, - PortKind, + PortKind, ProjectServerCommand, ProjectServerNotFound, ProjectServerStateMissing, }; use serde::Serialize; use std::fmt; use std::io::Write; +use std::path::Path; use tabled::{Table, Tabled, settings::Style}; /// Stable codes for local runtime failures. New codes may be added, but @@ -48,6 +49,19 @@ struct LocalProjectScope { path: String, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum LocalProjectScopeKind { + ExactCurrentProject, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct ServerProjectScope { + kind: LocalProjectScopeKind, + path: String, + parent_projects_searched: bool, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] enum LocalServerSelection { @@ -70,6 +84,23 @@ struct LocalGuidance { command: Option<&'static str>, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum LocalGuidanceAction { + ListProjectServers, + ListGlobalServers, + ReturnToProjectRoot, + StopGlobalProjectServer, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct ProjectServerGuidance { + action: LocalGuidanceAction, + message: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + command: Option<&'static str>, +} + #[derive(Debug, PartialEq, Eq, Serialize)] struct ManagedClientErrorDetail { code: LocalErrorCode, @@ -79,11 +110,35 @@ struct ManagedClientErrorDetail { guidance: Vec, } +#[derive(Debug, PartialEq, Eq, Serialize)] +struct ProjectServerErrorDetail { + code: LocalErrorCode, + message: String, + project_scope: ServerProjectScope, + server: LocalProjectServer, + guidance: Vec, +} + +#[derive(Debug, PartialEq, Eq, Serialize)] +struct ProjectServerStateMissingDetail { + code: LocalErrorCode, + message: &'static str, + project_scope: ServerProjectScope, + guidance: Vec, +} + +#[derive(Debug, PartialEq, Eq, Serialize)] +struct LocalProjectServer { + name: String, +} + #[derive(Debug, PartialEq, Eq, Serialize)] #[serde(untagged)] enum LocalErrorBody { General(LocalErrorDetail), ManagedClient(ManagedClientErrorDetail), + ProjectServer(ProjectServerErrorDetail), + ProjectServerStateMissing(ProjectServerStateMissingDetail), } #[derive(Debug, PartialEq, Eq, Serialize)] @@ -98,6 +153,18 @@ impl LocalErrorOutput { error: LocalErrorBody::ManagedClient(ManagedClientErrorDetail::from_error(error)), }; } + if let Error::ProjectServerNotFound(error) = error { + return Self { + error: LocalErrorBody::ProjectServer(ProjectServerErrorDetail::from_error(error)), + }; + } + if let Error::ProjectServerStateMissing(error) = error { + return Self { + error: LocalErrorBody::ProjectServerStateMissing( + ProjectServerStateMissingDetail::from_error(error), + ), + }; + } let detail = match error { Error::ServerNotFound(name) => LocalErrorDetail { code: LocalErrorCode::ServerNotFound, @@ -318,6 +385,74 @@ impl ManagedClientErrorDetail { } } +impl ProjectServerErrorDetail { + fn from_error(error: &ProjectServerNotFound) -> Self { + Self { + code: LocalErrorCode::ServerNotFound, + message: format!( + "Server '{}' was not found in the current project", + error.server_name + ), + project_scope: exact_current_project_scope(&error.project_dir), + server: LocalProjectServer { + name: error.server_name.clone(), + }, + guidance: project_scope_guidance(Some(error.command)), + } + } +} + +impl ProjectServerStateMissingDetail { + fn from_error(error: &ProjectServerStateMissing) -> Self { + Self { + code: LocalErrorCode::ServerSelectionRequired, + message: "No project-local server state was found in the current directory; no server was removed", + project_scope: exact_current_project_scope(&error.project_dir), + guidance: project_scope_guidance(Some(error.command)), + } + } +} + +pub(crate) fn exact_current_project_scope(project_dir: &Path) -> ServerProjectScope { + ServerProjectScope { + kind: LocalProjectScopeKind::ExactCurrentProject, + path: project_dir.display().to_string(), + parent_projects_searched: false, + } +} + +pub(crate) fn project_scope_guidance( + command: Option, +) -> Vec { + let mut guidance = vec![ + ProjectServerGuidance { + action: LocalGuidanceAction::ReturnToProjectRoot, + message: "Change to the local project root where the server was started", + command: Some("cd "), + }, + ProjectServerGuidance { + action: LocalGuidanceAction::ListProjectServers, + message: "List servers after returning to that exact project", + command: Some("clickhousectl local server list"), + }, + ProjectServerGuidance { + action: LocalGuidanceAction::ListGlobalServers, + message: "Locate running ClickHouse servers across projects", + command: Some("clickhousectl local server list --global"), + }, + ]; + if command == Some(ProjectServerCommand::Stop) { + guidance.push(ProjectServerGuidance { + action: LocalGuidanceAction::StopGlobalProjectServer, + message: "After confirming the project, stop the server with explicit global project selection", + command: Some( + "clickhousectl local server stop --global --project ", + ), + }); + } + guidance +} + fn start_guidance(selection: ManagedClientSelection) -> LocalGuidance { match selection { ManagedClientSelection::Default => LocalGuidance { @@ -589,6 +724,10 @@ pub struct ServerListOutput { pub servers: Vec, pub total_servers: usize, pub total_running_servers: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) project_scope: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub(crate) guidance: Vec, } #[derive(Tabled)] @@ -646,6 +785,17 @@ struct ServerListRowGlobal { impl fmt::Display for ServerListOutput { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { if self.servers.is_empty() { + if let Some(scope) = &self.project_scope { + writeln!(f, "No servers found in project '{}'.", scope.path)?; + writeln!( + f, + "Project-local server list uses the exact current working directory; parent `.clickhouse` directories are not searched." + )?; + return write!( + f, + "Return to the local project root where the server was started and run `clickhousectl local server list`, or use `clickhousectl local server list --global` to locate running servers in other projects." + ); + } write!(f, "No servers")?; return Ok(()); } @@ -831,11 +981,32 @@ pub struct ServerStopNoopOutput { pub stopped: bool, pub selection: ServerSelection, pub reason: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) project_scope: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub(crate) guidance: Vec, } impl fmt::Display for ServerStopNoopOutput { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "No ClickHouse servers found; nothing to stop") + write!(f, "No ClickHouse servers found; nothing to stop")?; + if let Some(scope) = &self.project_scope { + writeln!(f)?; + writeln!( + f, + "No `.clickhouse` directory existed under project '{}' when the command started.", + scope.path + )?; + writeln!( + f, + "Project-local server stop uses the exact current working directory; parent `.clickhouse` directories are not searched." + )?; + write!( + f, + "The `.clickhouse` directory typically lives in the local project root where the server was started. Return there and run `clickhousectl local server list`, or use `clickhousectl local server list --global` to locate running servers in other projects." + )?; + } + Ok(()) } } @@ -1002,6 +1173,21 @@ mod tests { }), "managed_client_project_state_unavailable", ), + ( + Error::ProjectServerNotFound(ProjectServerNotFound { + command: ProjectServerCommand::Stop, + project_dir: "/project".into(), + server_name: "default".into(), + }), + "server_not_found", + ), + ( + Error::ProjectServerStateMissing(ProjectServerStateMissing { + command: ProjectServerCommand::Remove, + project_dir: "/project".into(), + }), + "server_selection_required", + ), (Error::ServerNotFound("default".into()), "server_not_found"), ( Error::ServerStopSelectionRequired { available: 2 }, @@ -1266,6 +1452,8 @@ mod tests { ], total_servers: 2, total_running_servers: 1, + project_scope: Some(exact_current_project_scope(Path::new("/project"))), + guidance: Vec::new(), }; let json: serde_json::Value = serde_json::from_str(&serde_json::to_string_pretty(&output).unwrap()).unwrap(); @@ -1281,6 +1469,8 @@ mod tests { assert!(json["servers"][1].get("version").is_none()); assert_eq!(json["total_servers"], 2); assert_eq!(json["total_running_servers"], 1); + assert_eq!(json["project_scope"]["path"], "/project"); + assert_eq!(json["project_scope"]["parent_projects_searched"], false); } #[test] @@ -1289,6 +1479,8 @@ mod tests { servers: vec![], total_servers: 0, total_running_servers: 0, + project_scope: Some(exact_current_project_scope(Path::new("/project"))), + guidance: project_scope_guidance(None), }; let json: serde_json::Value = serde_json::from_str(&serde_json::to_string_pretty(&output).unwrap()).unwrap(); @@ -1296,6 +1488,8 @@ mod tests { assert_eq!(json["servers"].as_array().unwrap().len(), 0); assert_eq!(json["total_servers"], 0); assert_eq!(json["total_running_servers"], 0); + assert_eq!(json["project_scope"]["kind"], "exact_current_project"); + assert_eq!(json["guidance"][2]["action"], "list_global_servers"); } #[test] @@ -1586,6 +1780,8 @@ mod tests { ], total_servers: 2, total_running_servers: 1, + project_scope: None, + guidance: Vec::new(), }; let text = output.to_string(); assert!(text.contains("Name")); @@ -1610,10 +1806,28 @@ mod tests { servers: vec![], total_servers: 0, total_running_servers: 0, + project_scope: None, + guidance: Vec::new(), }; assert_eq!(output.to_string(), "No servers"); } + #[test] + fn server_list_display_empty_project_explains_exact_scope() { + let output = ServerListOutput { + servers: vec![], + total_servers: 0, + total_running_servers: 0, + project_scope: Some(exact_current_project_scope(Path::new("/project"))), + guidance: project_scope_guidance(None), + }; + let text = output.to_string(); + assert!(text.contains("No servers found in project '/project'")); + assert!(text.contains("exact current working directory")); + assert!(text.contains("parent `.clickhouse` directories are not searched")); + assert!(text.contains("clickhousectl local server list --global")); + } + #[test] fn server_list_display_single() { let output = ServerListOutput { @@ -1630,6 +1844,8 @@ mod tests { }], total_servers: 1, total_running_servers: 1, + project_scope: None, + guidance: Vec::new(), }; let text = output.to_string(); assert!(text.contains("1 server, 1 running")); diff --git a/crates/clickhousectl/tests/local_server_project_scope_errors_test.rs b/crates/clickhousectl/tests/local_server_project_scope_errors_test.rs new file mode 100644 index 00000000..c6465dba --- /dev/null +++ b/crates/clickhousectl/tests/local_server_project_scope_errors_test.rs @@ -0,0 +1,327 @@ +//! Project-scope diagnostics for local server list, stop, and remove. + +use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +fn clickhousectl_binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_clickhousectl")) +} + +fn canonical(path: &Path) -> String { + path.canonicalize() + .expect("canonical project path") + .display() + .to_string() +} + +fn run(project: &Path, home: &Path, args: &[&str]) -> Output { + Command::new(clickhousectl_binary()) + .env_clear() + .env("DO_NOT_TRACK", "1") + .env("HOME", home) + .env("PATH", "/usr/bin:/bin") + .current_dir(project) + .args(args) + .output() + .expect("run clickhousectl") +} + +fn write_server(project: &Path, name: &str, pid: u32) { + let servers = project.join(".clickhouse/servers"); + std::fs::create_dir_all(servers.join(name).join("data")).expect("create server data directory"); + std::fs::write( + servers.join(format!("{name}.json")), + serde_json::to_vec_pretty(&json!({ + "name": name, + "pid": pid, + "version": if pid == 0 { "" } else { "25.12.9.61" }, + "http_port": if pid == 0 { 0 } else { 8123 }, + "tcp_port": if pid == 0 { 0 } else { 9000 }, + "started_at": "1700000000", + "cwd": canonical(project), + "engine": "clickhouse" + })) + .unwrap(), + ) + .expect("write server metadata"); +} + +fn json_stdout(output: &Output) -> Value { + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).expect("parse stdout JSON") +} + +fn json_error(output: &Output) -> Value { + assert_eq!( + output.status.code(), + Some(1), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(output.stdout.is_empty()); + serde_json::from_slice(&output.stderr).expect("parse structured error") +} + +fn assert_exact_scope(value: &Value, project: &Path) { + assert_eq!(value["kind"], "exact_current_project"); + assert_eq!(value["path"], canonical(project)); + assert_eq!(value["parent_projects_searched"], false); +} + +#[test] +fn omitted_commands_explain_absent_project_state() { + let root = tempfile::tempdir().expect("create root"); + let home = tempfile::tempdir().expect("create home"); + let stop_project = root.path().join("stop-project"); + let remove_project = root.path().join("remove-project"); + std::fs::create_dir(&stop_project).expect("create stop project"); + std::fs::create_dir(&remove_project).expect("create remove project"); + + let stop = json_stdout(&run( + &stop_project, + home.path(), + &["local", "--json", "server", "stop"], + )); + assert_eq!(stop["stopped"], false); + assert_exact_scope(&stop["project_scope"], &stop_project); + assert_eq!( + stop["guidance"][0]["message"], + "Change to the local project root where the server was started" + ); + + let remove = json_error(&run( + &remove_project, + home.path(), + &["local", "--json", "server", "remove"], + )); + assert_eq!(remove["error"]["code"], "server_selection_required"); + assert!(remove["error"].get("command").is_none()); + assert!(remove["error"].get("server").is_none()); + assert_exact_scope(&remove["error"]["project_scope"], &remove_project); + assert_eq!( + remove["error"]["guidance"][0]["message"], + "Change to the local project root where the server was started" + ); + + let human_project = root.path().join("human-project"); + std::fs::create_dir(&human_project).expect("create human project"); + let human = run(&human_project, home.path(), &["local", "server", "remove"]); + assert_eq!(human.status.code(), Some(1)); + let stderr = String::from_utf8_lossy(&human.stderr); + assert!(stderr.contains("No `.clickhouse` project state was found")); + assert!(stderr.contains("parent `.clickhouse` directories are not searched")); + assert!(stderr.contains("where the server was started")); +} + +#[test] +fn root_and_child_scope_running_and_stopped_metadata_independently() { + let project = tempfile::tempdir().expect("create project"); + let home = tempfile::tempdir().expect("create home"); + write_server(project.path(), "running", std::process::id()); + write_server(project.path(), "stopped", 0); + + let root_list = json_stdout(&run( + project.path(), + home.path(), + &["local", "--json", "server", "list"], + )); + assert_eq!(root_list["total_servers"], 2); + assert_eq!(root_list["total_running_servers"], 1); + assert_exact_scope(&root_list["project_scope"], project.path()); + assert!(root_list.get("guidance").is_none()); + assert_eq!(root_list["servers"][0]["name"], "running"); + assert_eq!(root_list["servers"][0]["running"], true); + assert_eq!(root_list["servers"][1]["name"], "stopped"); + assert_eq!(root_list["servers"][1]["running"], false); + + let root_stop = json_stdout(&run( + project.path(), + home.path(), + &["local", "--json", "server", "stop", "stopped"], + )); + assert_eq!(root_stop["already_stopped"], true); + + let child = project.path().join("child"); + std::fs::create_dir(&child).expect("create child directory"); + let stop = json_error(&run( + &child, + home.path(), + &["local", "--json", "server", "stop", "running"], + )); + assert_eq!(stop["error"]["code"], "server_not_found"); + assert_eq!(stop["error"]["server"]["name"], "running"); + assert_exact_scope(&stop["error"]["project_scope"], &child); + assert_eq!( + stop["error"]["guidance"][3]["command"], + "clickhousectl local server stop --global --project " + ); + + let remove = json_error(&run( + &child, + home.path(), + &["local", "--json", "server", "remove", "stopped"], + )); + assert_eq!(remove["error"]["code"], "server_not_found"); + assert_eq!(remove["error"]["server"]["name"], "stopped"); + assert_exact_scope(&remove["error"]["project_scope"], &child); + assert_eq!(remove["error"]["guidance"].as_array().unwrap().len(), 3); + + let human_remove = run( + &child, + home.path(), + &["local", "server", "remove", "stopped"], + ); + assert_eq!(human_remove.status.code(), Some(1)); + let stderr = String::from_utf8_lossy(&human_remove.stderr); + assert!(stderr.contains(&canonical(&child)), "stderr: {stderr}"); + assert!( + stderr.contains("Project-local server remove uses the exact current working directory") + ); + assert!(stderr.contains("parent `.clickhouse` directories are not searched")); + assert!(!stderr.contains("server stop --global")); + + for error in [&stop, &remove] { + for guidance in error["error"]["guidance"].as_array().unwrap() { + if let Some(command) = guidance.get("command").and_then(Value::as_str) { + assert!( + !command.contains(&canonical(&child)), + "recovery command interpolated a raw path: {command}" + ); + } + } + } + + let child_list = json_stdout(&run( + &child, + home.path(), + &["local", "--json", "server", "list"], + )); + assert_eq!(child_list["total_servers"], 0); + assert_exact_scope(&child_list["project_scope"], &child); + assert_eq!( + child_list["guidance"][2]["command"], + "clickhousectl local server list --global" + ); + + let human = run(&child, home.path(), &["local", "server", "list"]); + assert!(human.status.success()); + let stdout = String::from_utf8_lossy(&human.stdout); + assert!(stdout.contains(&canonical(&child)), "stdout: {stdout}"); + assert!(stdout.contains("exact current working directory")); + assert!(stdout.contains("parent `.clickhouse` directories are not searched")); + assert!(stdout.contains("clickhousectl local server list --global")); + + assert!( + project + .path() + .join(".clickhouse/servers/running.json") + .exists() + ); + assert!( + project + .path() + .join(".clickhouse/servers/stopped/data") + .is_dir() + ); + let removed = json_stdout(&run( + project.path(), + home.path(), + &["local", "--json", "server", "remove", "stopped"], + )); + assert_eq!(removed["name"], "stopped"); +} + +#[test] +fn nested_project_state_wins_over_parent_state() { + let project = tempfile::tempdir().expect("create project"); + let home = tempfile::tempdir().expect("create home"); + let nested = project.path().join("nested"); + std::fs::create_dir(&nested).expect("create nested project"); + write_server(project.path(), "outer", 0); + write_server(&nested, "inner", 0); + + let list = json_stdout(&run( + &nested, + home.path(), + &["local", "--json", "server", "list"], + )); + assert_eq!(list["total_servers"], 1); + assert_eq!(list["servers"][0]["name"], "inner"); + assert_exact_scope(&list["project_scope"], &nested); + + let outer = json_error(&run( + &nested, + home.path(), + &["local", "--json", "server", "stop", "outer"], + )); + assert_exact_scope(&outer["error"]["project_scope"], &nested); + + let inner = json_stdout(&run( + &nested, + home.path(), + &["local", "--json", "server", "remove", "inner"], + )); + assert_eq!(inner["name"], "inner"); + assert!( + project + .path() + .join(".clickhouse/servers/outer/data") + .is_dir() + ); +} + +#[cfg(unix)] +#[test] +fn symlinked_cwd_reports_and_uses_the_canonical_project() { + let root = tempfile::tempdir().expect("create root"); + let home = tempfile::tempdir().expect("create home"); + let project = root.path().join("real-project"); + let alias = root.path().join("project-alias"); + std::fs::create_dir(&project).expect("create project"); + std::os::unix::fs::symlink(&project, &alias).expect("create project symlink"); + write_server(&project, "dev", 0); + + let list = json_stdout(&run( + &alias, + home.path(), + &["local", "--json", "server", "list"], + )); + assert_eq!(list["servers"][0]["name"], "dev"); + assert_exact_scope(&list["project_scope"], &project); + assert!(!list.to_string().contains("project-alias")); + + let missing = json_error(&run( + &alias, + home.path(), + &["local", "--json", "server", "remove", "missing"], + )); + assert_exact_scope(&missing["error"]["project_scope"], &project); + assert!(!missing.to_string().contains("project-alias")); +} + +#[test] +fn global_list_omits_project_local_scope_and_recovery() { + let project = tempfile::tempdir().expect("create project"); + let home = tempfile::tempdir().expect("create home"); + + let local = json_stdout(&run( + project.path(), + home.path(), + &["local", "--json", "server", "list"], + )); + assert!(local.get("project_scope").is_some()); + assert!(local.get("guidance").is_some()); + + let global = json_stdout(&run( + project.path(), + home.path(), + &["local", "--json", "server", "list", "--global"], + )); + assert!(global.get("project_scope").is_none()); + assert!(global.get("guidance").is_none()); +} diff --git a/crates/clickhousectl/tests/local_server_selection_test.rs b/crates/clickhousectl/tests/local_server_selection_test.rs index 7d4f463f..28e69216 100644 --- a/crates/clickhousectl/tests/local_server_selection_test.rs +++ b/crates/clickhousectl/tests/local_server_selection_test.rs @@ -108,15 +108,53 @@ fn omitted_stop_is_a_human_and_json_noop_with_zero_servers() { json!({ "stopped": false, "selection": "implicit", - "reason": "no_clickhouse_servers" + "reason": "no_clickhouse_servers", + "project_scope": { + "kind": "exact_current_project", + "path": project.path().canonicalize().unwrap(), + "parent_projects_searched": false + }, + "guidance": [ + { + "action": "return_to_project_root", + "message": "Change to the local project root where the server was started", + "command": "cd " + }, + { + "action": "list_project_servers", + "message": "List servers after returning to that exact project", + "command": "clickhousectl local server list" + }, + { + "action": "list_global_servers", + "message": "Locate running ClickHouse servers across projects", + "command": "clickhousectl local server list --global" + }, + { + "action": "stop_global_project_server", + "message": "After confirming the project, stop the server with explicit global project selection", + "command": "clickhousectl local server stop --global --project " + } + ] }) ); - let human = run(project.path(), home.path(), &["local", "server", "stop"]); + let human_project = tempfile::tempdir().expect("create human project"); + let human = run( + human_project.path(), + home.path(), + &["local", "server", "stop"], + ); assert_success(&human); assert_eq!( String::from_utf8_lossy(&human.stdout), - "No ClickHouse servers found; nothing to stop\n" + format!( + "No ClickHouse servers found; nothing to stop\n\ + No `.clickhouse` directory existed under project '{}' when the command started.\n\ + Project-local server stop uses the exact current working directory; parent `.clickhouse` directories are not searched.\n\ + The `.clickhouse` directory typically lives in the local project root where the server was started. Return there and run `clickhousectl local server list`, or use `clickhousectl local server list --global` to locate running servers in other projects.\n", + human_project.path().canonicalize().unwrap().display() + ) ); assert!(human.stderr.is_empty()); } @@ -316,7 +354,10 @@ fn omitted_stop_requires_a_name_or_stop_all_for_many_non_default_servers() { assert_eq!(explicit_unknown.status.code(), Some(1)); let error: Value = serde_json::from_slice(&explicit_unknown.stderr).unwrap(); assert_eq!(error["error"]["code"], "server_not_found"); - assert_eq!(error["error"]["message"], "Server 'missing' not found"); + assert_eq!( + error["error"]["message"], + "Server 'missing' was not found in the current project" + ); } } @@ -325,6 +366,8 @@ fn omitted_remove_never_selects_custom_servers() { for names in [&[][..], &["dev"][..], &["alpha", "beta"][..]] { let project = tempfile::tempdir().expect("create project"); let home = tempfile::tempdir().expect("create home"); + std::fs::create_dir_all(project.path().join(".clickhouse/servers")) + .expect("create existing project state"); for name in names { create_stopped_server(project.path(), name); } diff --git a/crates/clickhousectl/tests/local_structured_errors_test.rs b/crates/clickhousectl/tests/local_structured_errors_test.rs index 5fd563f6..2066afce 100644 --- a/crates/clickhousectl/tests/local_structured_errors_test.rs +++ b/crates/clickhousectl/tests/local_structured_errors_test.rs @@ -38,6 +38,44 @@ fn expected_error(code: &str, message: &str, recovery: Option<&str>) -> String { format!("{}\n", serde_json::to_string_pretty(&value).unwrap()) } +fn expected_project_server_error(project: &Path, name: &str) -> String { + let value = serde_json::json!({ + "error": { + "code": "server_not_found", + "message": format!("Server '{name}' was not found in the current project"), + "project_scope": { + "kind": "exact_current_project", + "path": project.canonicalize().unwrap(), + "parent_projects_searched": false + }, + "server": { "name": name }, + "guidance": [ + { + "action": "return_to_project_root", + "message": "Change to the local project root where the server was started", + "command": "cd " + }, + { + "action": "list_project_servers", + "message": "List servers after returning to that exact project", + "command": "clickhousectl local server list" + }, + { + "action": "list_global_servers", + "message": "Locate running ClickHouse servers across projects", + "command": "clickhousectl local server list --global" + }, + { + "action": "stop_global_project_server", + "message": "After confirming the project, stop the server with explicit global project selection", + "command": "clickhousectl local server stop --global --project " + } + ] + } + }); + format!("{}\n", serde_json::to_string_pretty(&value).unwrap()) +} + fn assert_structured_failure(output: &Output, expected: &str) { assert_eq!( output.status.code(), @@ -70,11 +108,7 @@ fn unused_port() -> u16 { fn explicit_json_and_agent_mode_emit_the_same_exact_server_error() { let project = tempfile::tempdir().expect("create project"); let home = tempfile::tempdir().expect("create home"); - let expected = expected_error( - "server_not_found", - "Server 'missing' not found", - Some("clickhousectl local server list"), - ); + let expected = expected_project_server_error(project.path(), "missing"); let explicit = run( project.path(), @@ -127,7 +161,7 @@ fn fresh_home_json_error_defers_telemetry_notice_to_human_mode() { assert!(output.stdout.is_empty()); let stderr = String::from_utf8(output.stderr).expect("human stderr is UTF-8"); assert!( - stderr.contains("Error: Server 'missing' not found"), + stderr.contains("Error: Server 'missing' was not found in project"), "{stderr}" ); assert!(stderr.contains("anonymous usage data"), "{stderr}"); @@ -152,11 +186,7 @@ fn telemetry_debug_does_not_append_to_a_structured_error() { assert_structured_failure( &output, - &expected_error( - "server_not_found", - "Server 'missing' not found", - Some("clickhousectl local server list"), - ), + &expected_project_server_error(project.path(), "missing"), ); } @@ -296,7 +326,12 @@ fn human_and_clap_errors_keep_their_existing_formats() { assert!(human.stdout.is_empty()); assert_eq!( String::from_utf8_lossy(&human.stderr), - "Error: Server 'missing' not found\n" + format!( + "Error: Server 'missing' was not found in project '{}'.\n\ + Project-local server stop uses the exact current working directory; parent `.clickhouse` directories are not searched.\n\ + Return to the local project root where the server was started and run `clickhousectl local server list`; use `clickhousectl local server list --global` to locate running servers in other projects; after confirming the project, use `clickhousectl local server stop --global --project `.\n", + project.path().canonicalize().unwrap().display() + ) ); let clap = run( diff --git a/crates/clickhousectl/tests/telemetry_test.rs b/crates/clickhousectl/tests/telemetry_test.rs index ccd2912b..3366976b 100644 --- a/crates/clickhousectl/tests/telemetry_test.rs +++ b/crates/clickhousectl/tests/telemetry_test.rs @@ -365,6 +365,42 @@ async fn managed_client_failure_details_never_reach_telemetry() { } } +#[tokio::test] +async fn server_scope_failure_paths_never_reach_telemetry() { + let sandbox = Sandbox::new().await; + sandbox.write_state(false); + let root = tempfile::tempdir().unwrap(); + let project = root.path().join("server-project-private-token"); + let server_name = "server-name-private-token"; + std::fs::create_dir(&project).unwrap(); + + let output = sandbox + .command(&["local", "server", "stop", server_name]) + .current_dir(&project) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(1)); + let raw_message = stderr_of(&output); + assert!(raw_message.contains(server_name)); + assert!(raw_message.contains("server-project-private-token")); + + let payloads = sandbox.wait_for_requests(1).await; + let event = &payloads[0]; + assert_eq!(event["command"], "local server stop"); + assert_eq!(event["exit_code"], 1); + let raw_payload = serde_json::to_string(event).unwrap(); + for sensitive in [ + server_name, + "server-project-private-token", + raw_message.as_str(), + ] { + assert!( + !raw_payload.contains(sensitive), + "server scope detail leaked into telemetry: {raw_payload}" + ); + } +} + #[cfg(unix)] #[tokio::test] async fn child_exit_code_reaches_the_telemetry_tail_unchanged() {