diff --git a/.claude/skills/rust/SKILL.md b/.claude/skills/rust/SKILL.md index 582735f4..86a1c0b4 100644 --- a/.claude/skills/rust/SKILL.md +++ b/.claude/skills/rust/SKILL.md @@ -150,3 +150,8 @@ fn validate(pos: usize, bytes: &[u8]) -> Option { ... } // checks inv ### Test-first for bugs When hitting a bug, write a failing test that reproduces it first. Only then write the fix. Tests document the exact failure mode and prevent regressions. + + +### Lints and allows + +Do not introduce new `allow` annotations unless absolutely necessary. If a lint or rule is disabled, add a comment explaining why. \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index c32ab8e3..79d766c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,12 @@ resolver = "2" version = "0.2.7" edition = "2024" rust-version = "1.92" +description = "APX application framework" +repository = "https://github.com/databricks-solutions/apx" +license = "Databricks License" +readme = "README.md" +keywords = ["databricks", "apps", "framework"] +categories = ["development-tools"] [workspace.dependencies] # Internal crates @@ -103,6 +109,103 @@ tempfile = "3.15" # Embedded resources which = "7" +[workspace.lints.rust] +unsafe_code = "forbid" +warnings = "deny" + +# Correctness +unused_must_use = "deny" +dead_code = "deny" +unused_imports = "deny" +unused_variables = "deny" +unused_qualifications = "deny" +unused_extern_crates = "deny" +# unreachable_pub = "deny" # too many existing items to fix at once + +# API hygiene +missing_docs = "deny" +missing_debug_implementations = "deny" +missing_copy_implementations = "deny" + +# Safety paranoia +trivial_casts = "deny" +trivial_numeric_casts = "deny" +elided_lifetimes_in_paths = "deny" +explicit_outlives_requirements = "deny" + +# Idioms +rust_2018_idioms = { level = "deny", priority = -1 } + +[workspace.lints.clippy] + +# Base strictness +all = { level = "deny", priority = -1 } +pedantic = { level = "deny", priority = -1 } +nursery = { level = "deny", priority = -1 } +cargo = { level = "deny", priority = -1 } + +# Transitive dependency version conflicts we cannot control +multiple_crate_versions = "allow" + +# Panic policy +unwrap_used = "deny" +expect_used = "deny" +panic = "deny" +panic_in_result_fn = "deny" + +# Debug leftovers +todo = "deny" +unimplemented = "deny" +dbg_macro = "deny" + +# API discipline +# missing_const_for_fn = "deny" +# missing_panics_doc = "deny" +# missing_errors_doc = "deny" + +# Performance paranoia +implicit_clone = "deny" +inefficient_to_string = "deny" +redundant_clone = "deny" +large_enum_variant = "deny" + +# Complexity +cognitive_complexity = "allow" # many existing functions exceed threshold; refactor incrementally +# too_many_lines = "deny" +# too_many_arguments = "deny" + +# Pedantic lints that are too noisy for this codebase +module_name_repetitions = "allow" +similar_names = "allow" +significant_drop_tightening = "allow" +doc_markdown = "allow" # 129 items — too pervasive to fix at once +missing_errors_doc = "allow" # 126 items — add incrementally +must_use_candidate = "allow" # 66 items — add incrementally +return_self_not_must_use = "allow" +missing_const_for_fn = "allow" # 30 items — add incrementally +redundant_closure_for_method_calls = "allow" # 36 items — stylistic preference +option_if_let_else = "allow" # 18 items — often less readable +unnecessary_wraps = "allow" +items_after_statements = "allow" +unnecessary_box_returns = "allow" # 26 items — deliberate design choice +cast_possible_wrap = "allow" # integer casts are intentional +cast_possible_truncation = "allow" +cast_sign_loss = "allow" +cast_precision_loss = "allow" +struct_field_names = "allow" # same as module_name_repetitions for fields +pub_use = "allow" +use_self = "allow" # 30 items — Self vs TypeName is stylistic +case_sensitive_file_extension_comparisons = { level = "allow", priority = 1 } # not relevant on our target platforms +too_many_lines = "allow" +missing_fields_in_debug = "allow" # deliberate Debug impls that hide fields +needless_pass_by_value = "allow" # 3 items — often needed for API compatibility +trivial_regex = "allow" +needless_raw_string_hashes = "allow" # cosmetic; many test strings use r#"..."# +iter_without_into_iter = "allow" + +# Style +wildcard_imports = "deny" + [profile.release] lto = "thin" codegen-units = 1 diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index edc80909..5b93f9eb 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -3,6 +3,15 @@ name = "apx-agent" version = "0.3.6" edition.workspace = true rust-version.workspace = true +description.workspace = true +repository.workspace = true +license.workspace = true +readme.workspace = true +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true [[bin]] name = "apx-agent" diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index 946a8f8a..2ae180db 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -1,18 +1,7 @@ -#![forbid(unsafe_code)] -#![deny(warnings, unused_must_use, dead_code, missing_debug_implementations)] -#![deny( - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - clippy::todo, - clippy::unimplemented, - clippy::dbg_macro -)] - //! APX Agent - Standalone OTLP log collector //! //! This crate provides the `apx-agent` binary, a standalone OpenTelemetry -//! log collector that receives OTLP logs and stores them in SQLite. +//! log collector that receives OTLP logs and stores them in `SQLite`. pub mod server; diff --git a/crates/agent/src/server.rs b/crates/agent/src/server.rs index 7261b1f9..118749f6 100644 --- a/crates/agent/src/server.rs +++ b/crates/agent/src/server.rs @@ -29,6 +29,11 @@ struct AppState { /// /// This function initializes storage, starts the cleanup scheduler, /// and runs the HTTP server. It blocks forever (or until error). +/// +/// # Errors +/// +/// Returns an error if storage initialization fails or the HTTP server +/// cannot bind to the configured address. pub async fn run_server() -> Result<(), String> { info!("Flux daemon starting..."); @@ -48,35 +53,26 @@ pub async fn run_server() -> Result<(), String> { run_http_server(storage).await } +/// Log the result of a cleanup operation. +fn log_cleanup_result(result: Result, label: &str) { + match result { + Ok(deleted) if deleted > 0 => info!("{label}: removed {deleted} old log records"), + Ok(_) => debug!("{label}: no old records to remove"), + Err(e) => error!("{label} failed: {e}"), + } +} + /// Periodic cleanup loop that runs within the daemon process. /// Deletes logs older than 7 days every hour. async fn run_cleanup_loop(storage: LogsDb) { - // Cleanup interval: 1 hour let interval = Duration::from_secs(60 * 60); - info!("Cleanup scheduler started (interval: 1 hour, retention: 7 days)"); - // Run initial cleanup - match storage.cleanup_old_logs().await { - Ok(deleted) if deleted > 0 => info!("Initial cleanup: removed {} old log records", deleted), - Ok(_) => debug!("Initial cleanup: no old records to remove"), - Err(e) => error!("Initial cleanup failed: {}", e), - } + log_cleanup_result(storage.cleanup_old_logs().await, "Initial cleanup"); loop { tokio::time::sleep(interval).await; - - match storage.cleanup_old_logs().await { - Ok(deleted) if deleted > 0 => { - info!("Cleanup: removed {} old log records", deleted); - } - Ok(_) => { - debug!("Cleanup: no old records to remove"); - } - Err(e) => { - error!("Cleanup failed: {}", e); - } - } + log_cleanup_result(storage.cleanup_old_logs().await, "Cleanup"); } } @@ -108,6 +104,15 @@ async fn health_check() -> impl IntoResponse { StatusCode::OK } +/// Dispatch log parsing based on content type. +fn parse_request_logs(content_type: &str, body: &[u8]) -> Result, String> { + if content_type.contains("application/x-protobuf") { + parse_protobuf_logs(body) + } else { + parse_json_logs(body) + } +} + /// Handle incoming OTLP logs (JSON or Protobuf). async fn handle_logs( State(state): State, @@ -119,22 +124,11 @@ async fn handle_logs( .and_then(|v| v.to_str().ok()) .unwrap_or("application/json"); - let records = if content_type.contains("application/x-protobuf") { - match parse_protobuf_logs(&body) { - Ok(r) => r, - Err(e) => { - error!("Failed to parse protobuf logs: {}", e); - return StatusCode::BAD_REQUEST; - } - } - } else { - // Default to JSON - match parse_json_logs(&body) { - Ok(r) => r, - Err(e) => { - error!("Failed to parse JSON logs: {}", e); - return StatusCode::BAD_REQUEST; - } + let records = match parse_request_logs(content_type, &body) { + Ok(r) => r, + Err(e) => { + error!("Failed to parse logs: {e}"); + return StatusCode::BAD_REQUEST; } }; @@ -150,7 +144,7 @@ async fn handle_logs( StatusCode::OK } Err(e) => { - error!("Failed to store logs: {}", e); + error!("Failed to store logs: {e}"); StatusCode::INTERNAL_SERVER_ERROR } } @@ -214,13 +208,13 @@ fn parse_json_logs(body: &[u8]) -> Result, String> { let severity_number = record .get("severityNumber") - .and_then(|v| v.as_i64()) - .map(|n| n as i32); + .and_then(serde_json::Value::as_i64) + .and_then(|n| i32::try_from(n).ok()); let severity_text = record .get("severityText") .and_then(|v| v.as_str()) - .map(|s| s.to_string()); + .map(ToString::to_string); let body = extract_any_value(record.get("body")); @@ -228,13 +222,13 @@ fn parse_json_logs(body: &[u8]) -> Result, String> { .get("traceId") .and_then(|v| v.as_str()) .filter(|s| !s.is_empty() && *s != "00000000000000000000000000000000") - .map(|s| s.to_string()); + .map(ToString::to_string); let span_id = record .get("spanId") .and_then(|v| v.as_str()) .filter(|s| !s.is_empty() && *s != "0000000000000000") - .map(|s| s.to_string()); + .map(ToString::to_string); let log_attrs = record .get("attributes") @@ -280,7 +274,7 @@ fn parse_protobuf_logs(body: &[u8]) -> Result, String> { .map(|kv| { serde_json::json!({ "key": kv.key, - "value": any_value_to_json(&kv.value) + "value": any_value_to_json(kv.value.as_ref()) }) }) .collect(); @@ -299,9 +293,11 @@ fn parse_protobuf_logs(body: &[u8]) -> Result, String> { for scope_log in resource_log.scope_logs { for record in scope_log.log_records { - let timestamp_ns = record.time_unix_nano as i64; - let observed_timestamp_ns = - (record.observed_time_unix_nano as i64).max(timestamp_ns); + let timestamp_ns = record.time_unix_nano.cast_signed(); + let observed_timestamp_ns = record + .observed_time_unix_nano + .cast_signed() + .max(timestamp_ns); let severity_number = if record.severity_number != 0 { Some(record.severity_number) @@ -337,7 +333,7 @@ fn parse_protobuf_logs(body: &[u8]) -> Result, String> { .map(|kv| { serde_json::json!({ "key": kv.key, - "value": any_value_to_json(&kv.value) + "value": any_value_to_json(kv.value.as_ref()) }) }) .collect(); @@ -376,7 +372,7 @@ fn parse_timestamp(value: Option<&serde_json::Value>) -> i64 { } } -/// Extract a string value from an OTLP AnyValue JSON structure. +/// Extract a string value from an OTLP `AnyValue` JSON structure. fn extract_any_value(value: Option<&serde_json::Value>) -> Option { let v = value?; @@ -396,12 +392,12 @@ fn extract_any_value(value: Option<&serde_json::Value>) -> Option { } // Try doubleValue - if let Some(n) = v.get("doubleValue").and_then(|v| v.as_f64()) { + if let Some(n) = v.get("doubleValue").and_then(serde_json::Value::as_f64) { return Some(n.to_string()); } // Try boolValue - if let Some(b) = v.get("boolValue").and_then(|v| v.as_bool()) { + if let Some(b) = v.get("boolValue").and_then(serde_json::Value::as_bool) { return Some(b.to_string()); } @@ -409,7 +405,7 @@ fn extract_any_value(value: Option<&serde_json::Value>) -> Option { Some(serde_json::to_string(v).unwrap_or_default()) } -/// Convert protobuf AnyValue to a string. +/// Convert protobuf `AnyValue` to a string. fn any_value_to_string(value: &opentelemetry_proto::tonic::common::v1::AnyValue) -> Option { use opentelemetry_proto::tonic::common::v1::any_value::Value; @@ -442,9 +438,9 @@ fn any_value_to_string(value: &opentelemetry_proto::tonic::common::v1::AnyValue) } } -/// Convert protobuf AnyValue to JSON. +/// Convert protobuf `AnyValue` to JSON. fn any_value_to_json( - value: &Option, + value: Option<&opentelemetry_proto::tonic::common::v1::AnyValue>, ) -> serde_json::Value { use opentelemetry_proto::tonic::common::v1::any_value::Value; @@ -462,7 +458,7 @@ fn any_value_to_json( let items: Vec = arr .values .iter() - .map(|v| any_value_to_json(&Some(v.clone()))) + .map(|v| any_value_to_json(Some(v))) .collect(); serde_json::json!({ "arrayValue": { "values": items } }) } @@ -473,7 +469,7 @@ fn any_value_to_json( .map(|kv| { serde_json::json!({ "key": kv.key, - "value": any_value_to_json(&kv.value) + "value": any_value_to_json(kv.value.as_ref()) }) }) .collect(); diff --git a/crates/apx/Cargo.toml b/crates/apx/Cargo.toml index 1dd358f6..178cd6fe 100644 --- a/crates/apx/Cargo.toml +++ b/crates/apx/Cargo.toml @@ -3,6 +3,15 @@ name = "apx-bin" version = "0.3.6" edition.workspace = true rust-version.workspace = true +description.workspace = true +repository.workspace = true +license.workspace = true +readme.workspace = true +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true [[bin]] name = "apx" diff --git a/crates/apx/build.rs b/crates/apx/build.rs index 7cc91e9c..4781e752 100644 --- a/crates/apx/build.rs +++ b/crates/apx/build.rs @@ -1,3 +1,4 @@ +//! Build script for apx-bin. use std::env; use std::fs; use std::path::{Path, PathBuf}; @@ -5,50 +6,54 @@ use std::path::{Path, PathBuf}; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; -fn main() { +fn main() -> Result<(), Box> { // Workspace root is two levels up from crates/apx/ let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap_or_default()); let workspace_root = manifest_dir .parent() .and_then(|p| p.parent()) - .expect("Could not find workspace root"); + .ok_or("Could not find workspace root")?; let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default(); let output_dir = workspace_root.join("src/apx/binaries"); - fs::create_dir_all(&output_dir).expect("Failed to create binaries output dir"); + fs::create_dir_all(&output_dir)?; // Clear old agent binaries - for entry in fs::read_dir(&output_dir).expect("Failed to read binaries output dir") { - let entry = entry.expect("Failed to read binaries output entry"); + for entry in fs::read_dir(&output_dir)? { + let entry = entry?; let path = entry.path(); if path.is_file() { let name = entry.file_name(); let name_str = name.to_string_lossy(); if name_str.starts_with("apx-agent") { - fs::remove_file(&path).expect("Failed to remove old agent binary"); + fs::remove_file(&path)?; } } } // Copy Agent binary - copy_agent_binary(workspace_root, &output_dir, &target_os, &target_arch); + copy_agent_binary(workspace_root, &output_dir, &target_os, &target_arch)?; // Watch for changes let agent_dir = workspace_root.join(".bins/agent"); println!("cargo:rerun-if-changed={}", agent_dir.display()); + + Ok(()) } -fn copy_agent_binary(workspace_root: &Path, output_dir: &Path, target_os: &str, target_arch: &str) { - let agent_src_name = match agent_binary_name(target_os, target_arch) { - Some(name) => name, - None => { - println!( - "cargo:warning=Agent binary not available for {target_os}-{target_arch}, skipping" - ); - return; - } +fn copy_agent_binary( + workspace_root: &Path, + output_dir: &Path, + target_os: &str, + target_arch: &str, +) -> Result<(), Box> { + let Some(agent_src_name) = agent_binary_name(target_os, target_arch) else { + println!( + "cargo:warning=Agent binary not available for {target_os}-{target_arch}, skipping" + ); + return Ok(()); }; let agent_source = workspace_root.join(".bins/agent").join(agent_src_name); @@ -57,7 +62,7 @@ fn copy_agent_binary(workspace_root: &Path, output_dir: &Path, target_os: &str, "cargo:warning=Agent binary not found at {}, skipping", agent_source.display() ); - return; + return Ok(()); } let agent_dest_name = if target_os == "windows" { @@ -66,23 +71,24 @@ fn copy_agent_binary(workspace_root: &Path, output_dir: &Path, target_os: &str, "apx-agent" }; let agent_dest = output_dir.join(agent_dest_name); - fs::copy(&agent_source, &agent_dest).expect("Failed to copy Agent binary"); - set_executable_permissions(&agent_dest); + fs::copy(&agent_source, &agent_dest)?; + set_executable_permissions(&agent_dest)?; println!("cargo:rerun-if-changed={}", agent_source.display()); + + Ok(()) } #[cfg(unix)] -fn set_executable_permissions(path: &Path) { - let mut perms = fs::metadata(path) - .expect("Failed to read binary metadata") - .permissions(); +fn set_executable_permissions(path: &Path) -> Result<(), Box> { + let mut perms = fs::metadata(path)?.permissions(); perms.set_mode(0o755); - fs::set_permissions(path, perms).expect("Failed to set binary permissions"); + fs::set_permissions(path, perms)?; + Ok(()) } #[cfg(not(unix))] -fn set_executable_permissions(_path: &Path) { - // No-op on Windows +fn set_executable_permissions(_path: &Path) -> Result<(), Box> { + Ok(()) } fn agent_binary_name(target_os: &str, target_arch: &str) -> Option<&'static str> { diff --git a/crates/apx/src/main.rs b/crates/apx/src/main.rs index 85b2ce0f..ec9a1c27 100644 --- a/crates/apx/src/main.rs +++ b/crates/apx/src/main.rs @@ -1,3 +1,4 @@ +//! APX CLI binary entrypoint. fn main() { apx_core::tracing_init::init_tracing(); std::process::exit(apx_cli::run_cli(std::env::args().collect())); diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index a2f0d86e..85948ca0 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -3,6 +3,15 @@ name = "apx-cli" version = "0.3.6" edition.workspace = true rust-version.workspace = true +description.workspace = true +repository.workspace = true +license.workspace = true +readme.workspace = true +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true [dependencies] apx-common.workspace = true diff --git a/crates/cli/build.rs b/crates/cli/build.rs index 4f7011ab..60d15ad0 100644 --- a/crates/cli/build.rs +++ b/crates/cli/build.rs @@ -1,3 +1,4 @@ +//! Build script for apx-cli. use std::process::Command; use std::time::SystemTime; @@ -9,16 +10,14 @@ fn main() { .ok() .filter(|o| o.status.success()) .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| s.trim().to_string()) - .unwrap_or_else(|| "unknown".to_string()); + .map_or_else(|| "unknown".to_string(), |s| s.trim().to_string()); println!("cargo:rustc-env=GIT_HASH={git_hash}"); // BUILD_TIMESTAMP — unix seconds let timestamp = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) - .map(|d| d.as_secs().to_string()) - .unwrap_or_else(|_| "0".to_string()); + .map_or_else(|_| "0".to_string(), |d| d.as_secs().to_string()); println!("cargo:rustc-env=BUILD_TIMESTAMP={timestamp}"); diff --git a/crates/cli/src/build.rs b/crates/cli/src/build.rs index 968f5c9f..0aa21a81 100644 --- a/crates/cli/src/build.rs +++ b/crates/cli/src/build.rs @@ -144,9 +144,8 @@ fn find_wheel_file(build_dir: &Path) -> Result { } async fn get_base_version(app_path: &Path) -> String { - let uv = match Uv::new().await { - Ok(uv) => uv, - Err(_) => return DEFAULT_FALLBACK_VERSION.to_string(), + let Ok(uv) = Uv::new().await else { + return DEFAULT_FALLBACK_VERSION.to_string(); }; match uv.run_hatch_version(app_path).await { Ok(version) if !version.is_empty() => version, diff --git a/crates/cli/src/common.rs b/crates/cli/src/common.rs index 79097bf1..0b053a9b 100644 --- a/crates/cli/src/common.rs +++ b/crates/cli/src/common.rs @@ -88,7 +88,7 @@ fn discover_apx_project(root: &Path) -> Result { } /// Check whether a `pyproject.toml` file contains a `[tool.apx]` section. -pub(crate) fn has_apx_config(pyproject_path: &Path) -> bool { +pub fn has_apx_config(pyproject_path: &Path) -> bool { fs::read_to_string(pyproject_path) .ok() .and_then(|s| s.parse::().ok()) @@ -97,7 +97,7 @@ pub(crate) fn has_apx_config(pyproject_path: &Path) -> bool { } /// Read, mutate, and write back a `pyproject.toml` via `toml_edit`. -pub(crate) fn modify_pyproject( +pub fn modify_pyproject( path: &Path, f: impl FnOnce(&mut DocumentMut) -> Result<(), String>, ) -> Result<(), String> { @@ -112,7 +112,7 @@ pub(crate) fn modify_pyproject( } /// Check whether a `pyproject.toml` file contains a `[tool.apx.ui]` section. -pub(crate) fn has_ui_config(pyproject_path: &Path) -> bool { +pub fn has_ui_config(pyproject_path: &Path) -> bool { fs::read_to_string(pyproject_path) .ok() .and_then(|s| s.parse::().ok()) @@ -121,6 +121,7 @@ pub(crate) fn has_ui_config(pyproject_path: &Path) -> bool { } #[cfg(test)] +// Reason: panicking on failure is idiomatic in tests #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; diff --git a/crates/cli/src/components/mod.rs b/crates/cli/src/components/mod.rs index cee2c831..cced7b48 100644 --- a/crates/cli/src/components/mod.rs +++ b/crates/cli/src/components/mod.rs @@ -1 +1 @@ -pub(crate) mod add; +pub mod add; diff --git a/crates/cli/src/dev/__internal_run_server.rs b/crates/cli/src/dev/__internal_run_server.rs index 118d19de..083ea857 100644 --- a/crates/cli/src/dev/__internal_run_server.rs +++ b/crates/cli/src/dev/__internal_run_server.rs @@ -11,7 +11,7 @@ use apx_core::dev::common::{ BACKEND_PORT_END, BACKEND_PORT_START, DB_PORT_END, DB_PORT_START, FRONTEND_PORT_END, FRONTEND_PORT_START, find_random_port_in_range, }; -use apx_core::dev::server::{ServerConfig, run_server}; +use apx_core::dev::server::{ServerConfig, resolve_databricks_profile, run_server}; use apx_core::dev::token; /// Maximum number of retries for subprocess port allocation @@ -37,22 +37,21 @@ async fn run_inner(args: InternalRunServerArgs) -> Result<(), String> { set_app_dir(args.app_dir.clone())?; // Read dev token from env (set by parent process in spawn_server) - let dev_token = match std::env::var(token::DEV_TOKEN_ENV) { - Ok(t) => t, - Err(_) => { - warn!( - "{} not set, generating ephemeral token (stop via lock file will not work)", - token::DEV_TOKEN_ENV - ); - token::generate() - } + let dev_token = if let Ok(t) = std::env::var(token::DEV_TOKEN_ENV) { + t + } else { + warn!( + "{} not set, generating ephemeral token (stop via lock file will not work)", + token::DEV_TOKEN_ENV + ); + token::generate() }; // Validate credentials before starting server (warn if skipped or failed) if args.skip_credentials_validation { warn!("Credentials validation skipped. API proxy may not work correctly."); } else { - let profile = std::env::var("DATABRICKS_CONFIG_PROFILE").unwrap_or_default(); + let profile = resolve_databricks_profile(&args.app_dir).unwrap_or_default(); if let Err(err) = apx_databricks_sdk::validate_credentials(&profile).await { warn!("Credentials validation failed: {err}. API proxy may not work correctly."); } diff --git a/crates/cli/src/dev/apply.rs b/crates/cli/src/dev/apply.rs index dde9f4b0..ef238b66 100644 --- a/crates/cli/src/dev/apply.rs +++ b/crates/cli/src/dev/apply.rs @@ -17,25 +17,19 @@ use apx_core::interop::{get_template_content, list_template_files}; // ─── Addon manifest types ─────────────────────────────── #[derive(serde::Deserialize, Default)] -#[allow(dead_code)] -pub(crate) struct AddonManifest { +pub struct AddonManifest { #[serde(default)] pub addon: AddonInfo, #[serde(default)] pub python: PythonMeta, #[serde(default)] - pub typescript: TypeScriptMeta, - #[serde(default)] pub components: ComponentsMeta, #[serde(default)] pub config: ConfigMeta, } #[derive(serde::Deserialize, Default)] -#[allow(dead_code)] -pub(crate) struct AddonInfo { - #[serde(default)] - pub name: String, +pub struct AddonInfo { #[serde(default)] pub display_name: String, #[serde(default)] @@ -55,7 +49,7 @@ pub(crate) struct AddonInfo { } #[derive(serde::Deserialize, Default)] -pub(crate) struct PythonMeta { +pub struct PythonMeta { #[serde(default)] pub dependencies: Vec, #[serde(default)] @@ -63,7 +57,7 @@ pub(crate) struct PythonMeta { } #[derive(serde::Deserialize, Default)] -pub(crate) struct PythonEdits { +pub struct PythonEdits { #[serde(default)] pub exports: Vec, #[serde(default)] @@ -73,41 +67,34 @@ pub(crate) struct PythonEdits { } #[derive(serde::Deserialize)] -pub(crate) struct AliasEntry { +pub struct AliasEntry { pub code: String, #[serde(default)] pub doc: Option, } #[derive(serde::Deserialize, Default)] -#[allow(dead_code)] -pub(crate) struct TypeScriptMeta { - #[serde(default)] - pub dependencies: Vec, -} - -#[derive(serde::Deserialize, Default)] -pub(crate) struct ComponentsMeta { +pub struct ComponentsMeta { #[serde(default)] pub install: Vec, } #[derive(serde::Deserialize, Default)] -pub(crate) struct ConfigMeta { +pub struct ConfigMeta { #[serde(default)] pub requires_bun: bool, } /// Read and parse the `addon.toml` manifest for an addon. -pub(crate) fn read_addon_manifest(addon_dir_name: &str) -> Option { - let path = format!("addons/{}/addon.toml", addon_dir_name); +pub fn read_addon_manifest(addon_dir_name: &str) -> Option { + let path = format!("addons/{addon_dir_name}/addon.toml"); let content = get_template_content(&path).ok()?; toml::from_str(&content).ok() } /// Discover all available addons by scanning embedded template files for `addon.toml`. /// Returns a list of (directory_name, manifest) pairs. -pub(crate) fn discover_all_addons() -> Vec<(String, AddonManifest)> { +pub fn discover_all_addons() -> Vec<(String, AddonManifest)> { let all_files = list_template_files("addons/"); let mut seen = std::collections::HashSet::new(); let mut addons = Vec::new(); @@ -266,11 +253,13 @@ impl FileChange { return None; } + use std::fmt::Write; + let diff = TextDiff::from_lines(existing, &self.new_content); let mut output = String::new(); - output.push_str(&format!("\x1b[1m--- {} (current)\x1b[0m\n", self.rel_path)); - output.push_str(&format!("\x1b[1m+++ {} (new)\x1b[0m\n", self.rel_path)); + let _ = writeln!(output, "\x1b[1m--- {} (current)\x1b[0m", self.rel_path); + let _ = writeln!(output, "\x1b[1m+++ {} (new)\x1b[0m", self.rel_path); for (idx, group) in diff.grouped_ops(3).iter().enumerate() { if idx > 0 { @@ -327,10 +316,7 @@ async fn apply_single_addon( if let Some(ref manifest) = manifest { for dep in &manifest.addon.depends_on { if !is_addon_applied(dep, app_dir)? { - println!( - "📦 Addon '{}' requires '{}' — applying it first...\n", - addon_name, dep - ); + println!("📦 Addon '{addon_name}' requires '{dep}' — applying it first...\n"); Box::pin(apply_single_addon(dep, yes, app_dir, app_name, app_slug)).await?; println!(); } @@ -392,19 +378,17 @@ fn apply_file_addon_by_name( app_name: &str, app_slug: &str, ) -> Result<(), String> { - let addon_prefix = format!("addons/{}/", addon_name); + let addon_prefix = format!("addons/{addon_name}/"); let addon_files = list_template_files(&addon_prefix); if addon_files.is_empty() { return Err(format!( - "Addon '{}' not found (no embedded templates with prefix '{}')", - addon_name, addon_prefix, + "Addon '{addon_name}' not found (no embedded templates with prefix '{addon_prefix}')", )); } println!( - "Applying {} addon to {}...\n", - addon_name, + "Applying {addon_name} addon to {}...\n", app_dir .canonicalize() .unwrap_or_else(|_| app_dir.to_path_buf()) @@ -444,14 +428,14 @@ fn apply_file_addon_by_name( println!("\x1b[1m--- Diffs ---\x1b[0m\n"); for file in &modified_files { if let Some(diff) = file.generate_diff() { - println!("{}", diff); + println!("{diff}"); println!(); } } } if unchanged_count > 0 { - println!("\x1b[90m{} file(s) unchanged\x1b[0m\n", unchanged_count); + println!("\x1b[90m{unchanged_count} file(s) unchanged\x1b[0m\n"); } // Summary line @@ -505,8 +489,7 @@ fn apply_file_addon_by_name( } println!( - "\n\x1b[32m✓\x1b[0m Applied {} addon: {} file(s) created, {} file(s) modified", - addon_name, created, modified + "\n\x1b[32m✓\x1b[0m Applied {addon_name} addon: {created} file(s) created, {modified} file(s) modified" ); Ok(()) @@ -516,7 +499,7 @@ fn apply_file_addon_by_name( /// /// Called by both `init` (after `render_embedded_templates`) and `apply_backend_addon`. /// Returns the number of AST edits applied. -pub(crate) fn apply_python_edits( +pub fn apply_python_edits( manifest: &AddonManifest, app_dir: &Path, app_slug: &str, @@ -592,8 +575,7 @@ pub(crate) fn apply_python_edits( for dep in &manifest.python.dependencies { let already = deps.iter().any(|v| { v.as_str() - .map(|s| s.starts_with(dep.split('>').next().unwrap_or(dep))) - .unwrap_or(false) + .is_some_and(|s| s.starts_with(dep.split('>').next().unwrap_or(dep))) }); if !already { deps.push(dep.as_str()); @@ -615,8 +597,7 @@ fn apply_backend_addon( app_slug: &str, ) -> Result<(), String> { println!( - "Applying {} backend addon to {}...\n", - addon_dir, + "Applying {addon_dir} backend addon to {}...\n", app_dir .canonicalize() .unwrap_or_else(|_| app_dir.to_path_buf()) @@ -624,7 +605,7 @@ fn apply_backend_addon( ); // 1. Copy template files from addon (embedded) - let addon_prefix = format!("addons/{}/", addon_dir); + let addon_prefix = format!("addons/{addon_dir}/"); let addon_files = list_template_files(&addon_prefix); let mut copied_files = Vec::new(); for file_path in &addon_files { @@ -688,10 +669,8 @@ fn apply_backend_addon( } println!( - "\n\x1b[32m✓\x1b[0m Applied {} backend addon: {} file(s) copied, {} AST edit(s) applied", - addon_dir, - copied_files.len(), - ast_edits_applied + "\n\x1b[32m✓\x1b[0m Applied {addon_dir} backend addon: {} file(s) copied, {ast_edits_applied} AST edit(s) applied", + copied_files.len() ); Ok(()) @@ -808,6 +787,7 @@ fn collect_file_changes( } #[cfg(test)] +// Reason: panicking on failure is idiomatic in tests #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; diff --git a/crates/cli/src/dev/logs.rs b/crates/cli/src/dev/logs.rs index 346e9218..4547ca45 100644 --- a/crates/cli/src/dev/logs.rs +++ b/crates/cli/src/dev/logs.rs @@ -50,11 +50,11 @@ async fn run_async(args: LogsArgs) -> Result<(), String> { // Check if dev server is running (optional - logs may exist even if server stopped) let lock_path = lock_path(&app_dir); - if !lock_path.exists() { - debug!("No dev server lockfile found, but will still try to read logs."); - } else { + if lock_path.exists() { let lock = read_lock(&lock_path)?; debug!(port = lock.port, "Dev server running at port."); + } else { + debug!("No dev server lockfile found, but will still try to read logs."); } // Check if database exists @@ -148,7 +148,7 @@ async fn follow_logs( } break; } - _ = tokio::time::sleep(Duration::from_millis(200)) => { + () = tokio::time::sleep(Duration::from_millis(200)) => { let current_time_ms = Utc::now().timestamp_millis(); // Flush expired aggregations diff --git a/crates/cli/src/dev/mcp.rs b/crates/cli/src/dev/mcp.rs index f910362e..8ae458c8 100644 --- a/crates/cli/src/dev/mcp.rs +++ b/crates/cli/src/dev/mcp.rs @@ -25,26 +25,21 @@ pub async fn run(_args: McpArgs) -> i32 { // Get SDK version via subprocess before spawning async task const DEFAULT_SDK_VERSION: &str = "0.89.0"; - let sdk_version = match get_databricks_sdk_version(None).await { - Ok(Some(v)) => { - tracing::info!("Found Databricks SDK version: {}", v); - v - } - Ok(None) | Err(_) => { - tracing::info!("SDK not detected locally, fetching latest version from GitHub"); - match fetch_latest_sdk_version().await { - Ok(v) => { - tracing::info!("Latest SDK version from GitHub: {}", v); - v - } - Err(e) => { - tracing::warn!( - "Failed to fetch latest SDK version: {}. Using default {}", - e, - DEFAULT_SDK_VERSION - ); - DEFAULT_SDK_VERSION.to_string() - } + let sdk_version = if let Ok(Some(v)) = get_databricks_sdk_version(None).await { + tracing::info!("Found Databricks SDK version: {v}"); + v + } else { + tracing::info!("SDK not detected locally, fetching latest version from GitHub"); + match fetch_latest_sdk_version().await { + Ok(v) => { + tracing::info!("Latest SDK version from GitHub: {v}"); + v + } + Err(e) => { + tracing::warn!( + "Failed to fetch latest SDK version: {e}. Using default {DEFAULT_SDK_VERSION}" + ); + DEFAULT_SDK_VERSION.to_string() } } }; diff --git a/crates/cli/src/dev/mod.rs b/crates/cli/src/dev/mod.rs index 4944c943..f99c2b95 100644 --- a/crates/cli/src/dev/mod.rs +++ b/crates/cli/src/dev/mod.rs @@ -1,9 +1,9 @@ -pub(crate) mod __internal_run_server; -pub(crate) mod apply; -pub(crate) mod check; -pub(crate) mod logs; -pub(crate) mod mcp; -pub(crate) mod restart; -pub(crate) mod start; -pub(crate) mod status; -pub(crate) mod stop; +pub mod __internal_run_server; +pub mod apply; +pub mod check; +pub mod logs; +pub mod mcp; +pub mod restart; +pub mod start; +pub mod status; +pub mod stop; diff --git a/crates/cli/src/flux/mod.rs b/crates/cli/src/flux/mod.rs index e74a829b..99457f5f 100644 --- a/crates/cli/src/flux/mod.rs +++ b/crates/cli/src/flux/mod.rs @@ -1,2 +1,2 @@ -pub(crate) mod start; -pub(crate) mod stop; +pub mod start; +pub mod stop; diff --git a/crates/cli/src/frontend/build.rs b/crates/cli/src/frontend/build.rs index 35768c93..5a58b269 100644 --- a/crates/cli/src/frontend/build.rs +++ b/crates/cli/src/frontend/build.rs @@ -95,17 +95,19 @@ pub async fn run_build_with_spinner( .map_err(|err| format!("Failed to run frontend build: {err}"))?; if output.exit_code != Some(0) { + use std::fmt::Write; + let mut error_msg = format!( "Frontend build failed with status {}", output.exit_code.unwrap_or(-1) ); if !output.stderr.is_empty() { - error_msg.push_str(&format!("\n\nStderr:\n{}", output.stderr.trim())); + let _ = write!(error_msg, "\n\nStderr:\n{}", output.stderr.trim()); } if !output.stdout.is_empty() { - error_msg.push_str(&format!("\n\nStdout:\n{}", output.stdout.trim())); + let _ = write!(error_msg, "\n\nStdout:\n{}", output.stdout.trim()); } return Err(error_msg); diff --git a/crates/cli/src/frontend/mod.rs b/crates/cli/src/frontend/mod.rs index 2a06753b..6fe45310 100644 --- a/crates/cli/src/frontend/mod.rs +++ b/crates/cli/src/frontend/mod.rs @@ -1,2 +1,2 @@ -pub(crate) mod build; -pub(crate) mod dev; +pub mod build; +pub mod dev; diff --git a/crates/cli/src/info.rs b/crates/cli/src/info.rs index ae01796f..38d8ce98 100644 --- a/crates/cli/src/info.rs +++ b/crates/cli/src/info.rs @@ -25,6 +25,7 @@ pub async fn run(_args: InfoArgs) -> i32 { run_cli_async_helper(run_inner).await } +// Reason: direct stdout is required for info display #[allow(clippy::print_stdout)] async fn run_inner() -> Result<(), String> { // --- apx section --- @@ -35,8 +36,10 @@ async fn run_inner() -> Result<(), String> { .parse::() .ok() .and_then(|secs| chrono::DateTime::from_timestamp(secs, 0)) - .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string()) - .unwrap_or_else(|| "unknown".to_string()); + .map_or_else( + || "unknown".to_string(), + |dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string(), + ); let os = std::env::consts::OS; let arch = std::env::consts::ARCH; @@ -56,14 +59,17 @@ async fn run_inner() -> Result<(), String> { DatabricksCli::info(), ); - for entry in [uv, bun, git, gh, databricks] { - print_tool_entry(&entry); - } + print_tool_entry(&uv); + print_tool_entry(&bun); + print_tool_entry(&git); + print_tool_entry(&gh); + print_tool_entry(&databricks); println!(); Ok(()) } +// Reason: direct stdout is required for info display #[allow(clippy::print_stdout)] fn print_tool_entry(entry: &ToolInfoEntry) { println!(); diff --git a/crates/cli/src/init.rs b/crates/cli/src/init.rs index 9f8da8ec..c0fefd0c 100644 --- a/crates/cli/src/init.rs +++ b/crates/cli/src/init.rs @@ -85,13 +85,16 @@ use apx_core::external::git::Git; use apx_core::interop::{get_template_content, list_template_files}; use std::time::Instant; +/// Arguments for the `apx init` command. #[derive(Args, Debug, Clone)] pub struct InitArgs { + /// Optional path where the app will be created. #[arg( value_name = "APP_PATH", help = "The path to the app. Defaults to current working directory" )] pub app_path: Option, + /// Project name (prompted interactively if omitted). #[arg( long = "name", short = 'n', @@ -109,12 +112,14 @@ pub struct InitArgs { /// Shorthand for --addons=none (backend-only, no addons) #[arg(long = "no-addons", conflicts_with = "addons")] pub no_addons: bool, + /// Databricks CLI profile name (prompted interactively if omitted). #[arg( long, short = 'p', help = "The Databricks profile to use. Will prompt if not provided" )] pub profile: Option, + /// Initialize as a uv workspace member at the given relative path. #[arg( long = "as-member", value_name = "MEMBER_PATH", @@ -125,6 +130,7 @@ pub struct InitArgs { pub as_member: Option, } +/// Execute the `apx init` command. pub async fn run(args: InitArgs) -> i32 { run_cli_async_helper(|| run_inner(args)).await } @@ -133,6 +139,53 @@ async fn run_inner(mut args: InitArgs) -> Result<(), String> { // Eagerly resolve uv (always needed) let _uv = apx_core::external::Uv::new().await?; + let (workspace_root, app_path, is_member) = resolve_app_path(&mut args)?; + + println!("Welcome to apx 🚀\n"); + + let (app_name, app_slug) = resolve_app_name(&mut args)?; + + let all_addons = discover_all_addons(); + let addon_names: Vec = all_addons.iter().map(|(name, _)| name.clone()).collect(); + let selected_addons = select_addons(&args, &addon_names, &all_addons)?; + + let ui_enabled = selected_addons.iter().any(|a| a == "ui"); + + select_profile(&mut args)?; + + // Resolve bun only for UI-enabled projects + if ui_enabled { + let _bun = Bun::new().await?; + } + + println!( + "\nInitializing app {} in {}\n", + app_name, + app_path + .canonicalize() + .unwrap_or_else(|_| app_path.clone()) + .display() + ); + + scaffold_project( + &app_path, + &app_name, + &app_slug, + &selected_addons, + args.profile.as_deref(), + )?; + + init_git_repo(&workspace_root, &app_path, is_member).await; + + install_addon_components(&app_path, &selected_addons).await?; + + print_success(&app_name, &workspace_root, &app_path, is_member); + + Ok(()) +} + +/// Resolve the workspace root, app path, and whether we are in member mode. +fn resolve_app_path(args: &mut InitArgs) -> Result<(PathBuf, PathBuf, bool), String> { let workspace_root = resolve_app_dir(args.app_path.take()); // Auto-detect member mode: if CWD has pyproject.toml without [tool.apx], @@ -160,8 +213,11 @@ async fn run_inner(mut args: InitArgs) -> Result<(), String> { workspace_root.clone() }; - println!("Welcome to apx 🚀\n"); + Ok((workspace_root, app_path, is_member)) +} +/// Prompt for or normalize the app name, returning `(app_name, app_slug)`. +fn resolve_app_name(args: &mut InitArgs) -> Result<(String, String), String> { if args.app_name.is_none() { let default_name = random_name(); let name = Input::::new() @@ -174,210 +230,212 @@ async fn run_inner(mut args: InitArgs) -> Result<(), String> { let app_name_raw = args.app_name.take().unwrap_or_default(); let app_name = normalize_app_name(&app_name_raw)?; - let app_slug = app_name.replace("-", "_"); + let app_slug = app_name.replace('-', "_"); + Ok((app_name, app_slug)) +} - // ─── Discover all available addons ───────────────────── - let all_addons = discover_all_addons(); - let addon_names: Vec = all_addons.iter().map(|(name, _)| name.clone()).collect(); +/// Discover, validate, and interactively select addons to enable. +fn select_addons( + args: &InitArgs, + addon_names: &[String], + all_addons: &[(String, crate::dev::apply::AddonManifest)], +) -> Result, String> { + if args.no_addons { + return Ok(Vec::new()); + } - // ─── Resolve addons ───────────────────────────────────── - let selected_addons: Vec = if args.no_addons { - // --no-addons → backend-only - Vec::new() - } else if let Some(ref addons) = args.addons { - // --addons=none → backend-only; --addons=ui,sidebar → explicit list + if let Some(ref addons) = args.addons { if addons.len() == 1 && addons[0] == "none" { - Vec::new() - } else { - // Validate addon names - for a in addons { - if !addon_names.contains(a) { - return Err(format!( - "Unknown addon '{}'. Available addons: {}", - a, - addon_names.join(", ") - )); - } - } - addons.clone() + return Ok(Vec::new()); } - } else { - // Interactive grouped multi-select - // Group addons by their group field, ordered: ui, backend, assistants, then rest - let group_order = ["ui", "backend", "assistants"]; - let mut groups: BTreeMap> = BTreeMap::new(); - let mut group_display_names: BTreeMap = BTreeMap::new(); - for (name, manifest) in &all_addons { - let group = if manifest.addon.group.is_empty() { - "common".to_string() - } else { - manifest.addon.group.clone() - }; - if !manifest.addon.group_display_name.is_empty() { - group_display_names - .entry(group.clone()) - .or_insert_with(|| manifest.addon.group_display_name.clone()); + // Validate addon names + for a in addons { + if !addon_names.contains(a) { + return Err(format!( + "Unknown addon '{}'. Available addons: {}", + a, + addon_names.join(", ") + )); } - groups.entry(group).or_default().push(( - name.clone(), - manifest.addon.display_name.clone(), - manifest.addon.description.clone(), - manifest.addon.default, - manifest.addon.order, - )); } - // Sort addons within each group by order - for group_addons in groups.values_mut() { - group_addons.sort_by_key(|(_, _, _, _, order)| *order); + return Ok(addons.clone()); + } + + // Interactive grouped multi-select + // Group addons by their group field, ordered: ui, backend, assistants, then rest + let group_order = ["ui", "backend", "assistants"]; + let mut groups: BTreeMap> = BTreeMap::new(); + let mut group_display_names: BTreeMap = BTreeMap::new(); + for (name, manifest) in all_addons { + let group = if manifest.addon.group.is_empty() { + "common".to_string() + } else { + manifest.addon.group.clone() + }; + if !manifest.addon.group_display_name.is_empty() { + group_display_names + .entry(group.clone()) + .or_insert_with(|| manifest.addon.group_display_name.clone()); } + groups.entry(group).or_default().push(( + name.clone(), + manifest.addon.display_name.clone(), + manifest.addon.description.clone(), + manifest.addon.default, + manifest.addon.order, + )); + } + // Sort addons within each group by order + for group_addons in groups.values_mut() { + group_addons.sort_by_key(|(_, _, _, _, order)| *order); + } - let mut ordered_groups: Vec = Vec::new(); - for g in &group_order { - if groups.contains_key(*g) { - ordered_groups.push(g.to_string()); - } + let mut ordered_groups: Vec = Vec::new(); + for g in &group_order { + if groups.contains_key(*g) { + ordered_groups.push(g.to_string()); } - for g in groups.keys() { - if !ordered_groups.contains(g) { - ordered_groups.push(g.clone()); - } + } + for g in groups.keys() { + if !ordered_groups.contains(g) { + ordered_groups.push(g.clone()); } + } - // Build flat list with header items interleaved - let mut labels: Vec = Vec::new(); - let mut defaults: Vec = Vec::new(); - let mut is_header: Vec = Vec::new(); - let mut addon_for_index: Vec> = Vec::new(); - - for group_name in &ordered_groups { - if let Some(group_addons) = groups.get(group_name) { - // Group header (non-selectable visually) - let header_label = group_display_names - .get(group_name) - .cloned() - .unwrap_or_else(|| capitalize_first(group_name)); - labels.push(format!("{}{}", HEADER_MARKER, header_label)); - defaults.push(false); - is_header.push(true); - addon_for_index.push(None); - - for (name, display_name, desc, default, _order) in group_addons { - let label_name = if display_name.is_empty() { - name.as_str() - } else { - display_name.as_str() - }; - let label = if desc.is_empty() { - label_name.to_string() - } else { - format!("{label_name} — {desc}") - }; - labels.push(label); - defaults.push(*default); - is_header.push(false); - addon_for_index.push(Some(name.clone())); - } + // Build flat list with header items interleaved + let mut labels: Vec = Vec::new(); + let mut defaults: Vec = Vec::new(); + let mut is_header: Vec = Vec::new(); + let mut addon_for_index: Vec> = Vec::new(); + + for group_name in &ordered_groups { + if let Some(group_addons) = groups.get(group_name) { + // Group header (non-selectable visually) + let header_label = group_display_names + .get(group_name) + .cloned() + .unwrap_or_else(|| capitalize_first(group_name)); + labels.push(format!("{HEADER_MARKER}{header_label}")); + defaults.push(false); + is_header.push(true); + addon_for_index.push(None); + + for (name, display_name, desc, default, _order) in group_addons { + let label_name = if display_name.is_empty() { + name.as_str() + } else { + display_name.as_str() + }; + let label = if desc.is_empty() { + label_name.to_string() + } else { + format!("{label_name} — {desc}") + }; + labels.push(label); + defaults.push(*default); + is_header.push(false); + addon_for_index.push(Some(name.clone())); } } + } - let label_refs: Vec<&str> = labels.iter().map(|l| l.as_str()).collect(); - let theme = GroupedTheme::new(); - let selections = MultiSelect::with_theme(&theme) - .with_prompt( - "Which addons would you like to enable? (space = toggle, enter = confirm, a = all)", - ) - .items(&label_refs) - .defaults(&defaults) - .report(false) - .interact() - .map_err(|err| format!("Failed to select addons: {err}"))?; + let label_refs: Vec<&str> = labels.iter().map(|l| l.as_str()).collect(); + let theme = GroupedTheme::new(); + let selections = MultiSelect::with_theme(&theme) + .with_prompt( + "Which addons would you like to enable? (space = toggle, enter = confirm, a = all)", + ) + .items(&label_refs) + .defaults(&defaults) + .report(false) + .interact() + .map_err(|err| format!("Failed to select addons: {err}"))?; + + // Filter out header indices and map to addon names + let selected: Vec = selections + .into_iter() + .filter(|&i| !is_header[i]) + .filter_map(|i| addon_for_index[i].clone()) + .collect(); + + if selected.is_empty() { + println!(" Addons: none"); + } else { + println!(" Addons: {}", selected.join(", ")); + } - // Filter out header indices and map to addon names - let selected: Vec = selections - .into_iter() - .filter(|&i| !is_header[i]) - .filter_map(|i| addon_for_index[i].clone()) - .collect(); + Ok(selected) +} - if selected.is_empty() { - println!(" Addons: none"); +/// Prompt the user to select a Databricks CLI profile. +fn select_profile(args: &mut InitArgs) -> Result<(), String> { + if args.profile.is_some() { + return Ok(()); + } + + let available_profiles = list_profiles()?; + if available_profiles.is_empty() { + println!("No Databricks profiles found in ~/.databrickscfg"); + let should_prompt = Confirm::new() + .with_prompt("Would you like to specify a profile name?") + .default(false) + .interact() + .map_err(|err| format!("Failed to read profile choice: {err}"))?; + if should_prompt { + let profile = Input::::new() + .with_prompt("Enter profile name") + .interact_text() + .map_err(|err| format!("Failed to read profile: {err}"))?; + args.profile = Some(profile); } else { - println!(" Addons: {}", selected.join(", ")); + args.profile = None; } - - selected - }; - - let ui_enabled = selected_addons.iter().any(|a| a == "ui"); - - if args.profile.is_none() { - let available_profiles = list_profiles()?; - if !available_profiles.is_empty() { - let mut items: Vec = available_profiles.clone(); - items.push("Enter manually".into()); - items.push("Skip".into()); - - let selection = Select::with_theme(&ColorfulTheme::default()) - .with_prompt("Which Databricks profile would you like to use?") - .items(&items) - .default(0) - .interact() + } else { + let mut items: Vec = available_profiles.clone(); + items.push("Enter manually".into()); + items.push("Skip".into()); + + let selection = Select::with_theme(&ColorfulTheme::default()) + .with_prompt("Which Databricks profile would you like to use?") + .items(&items) + .default(0) + .interact() + .map_err(|err| format!("Failed to read profile: {err}"))?; + + if selection == items.len() - 1 { + // "Skip" + args.profile = None; + } else if selection == items.len() - 2 { + // "Enter manually" + let profile = Input::::new() + .with_prompt("Enter profile name") + .interact_text() .map_err(|err| format!("Failed to read profile: {err}"))?; - - if selection == items.len() - 1 { - // "Skip" - args.profile = None; - } else if selection == items.len() - 2 { - // "Enter manually" - let profile = Input::::new() - .with_prompt("Enter profile name") - .interact_text() - .map_err(|err| format!("Failed to read profile: {err}"))?; - args.profile = Some(profile); - } else { - args.profile = Some(available_profiles[selection].clone()); - } + args.profile = Some(profile); } else { - println!("No Databricks profiles found in ~/.databrickscfg"); - let should_prompt = Confirm::new() - .with_prompt("Would you like to specify a profile name?") - .default(false) - .interact() - .map_err(|err| format!("Failed to read profile choice: {err}"))?; - if should_prompt { - let profile = Input::::new() - .with_prompt("Enter profile name") - .interact_text() - .map_err(|err| format!("Failed to read profile: {err}"))?; - args.profile = Some(profile); - } else { - args.profile = None; - } + args.profile = Some(available_profiles[selection].clone()); } } - // Resolve bun only for UI-enabled projects - if ui_enabled { - let _bun = Bun::new().await?; - } - - println!( - "\nInitializing app {} in {}\n", - app_name, - app_path - .canonicalize() - .unwrap_or_else(|_| app_path.clone()) - .display() - ); + Ok(()) +} +/// Create directories, render templates, apply addons, and set the profile. +fn scaffold_project( + app_path: &Path, + app_name: &str, + app_slug: &str, + selected_addons: &[String], + profile: Option<&str>, +) -> Result<(), String> { run_with_spinner( "📁 Preparing project layout...", "✅ Project layout prepared", || { - ensure_dir(&app_path)?; - render_embedded_templates("base/", &app_path, &app_name, &app_slug)?; + ensure_dir(app_path)?; + render_embedded_templates("base/", app_path, app_name, app_slug)?; - let dist_dir = app_path.join("src").join(&app_slug).join("__dist__"); + let dist_dir = app_path.join("src").join(app_slug).join("__dist__"); ensure_dir(&dist_dir)?; fs::write(dist_dir.join(".gitignore"), "*\n") .map_err(|err| format!("Failed to write dist .gitignore: {err}"))?; @@ -388,44 +446,47 @@ async fn run_inner(mut args: InitArgs) -> Result<(), String> { .map_err(|err| format!("Failed to write .build .gitignore: {err}"))?; // Apply all selected addon files - for addon_name in &selected_addons { - let prefix = format!("addons/{}/", addon_name); - render_embedded_templates(&prefix, &app_path, &app_name, &app_slug)?; + for addon_name in selected_addons { + let prefix = format!("addons/{addon_name}/"); + render_embedded_templates(&prefix, app_path, app_name, app_slug)?; // Apply Python AST edits and install skills from manifest if let Some(manifest) = read_addon_manifest(addon_name) { if let Some(ref skill_path) = manifest.addon.skill_path { - crate::skill::install::install_skills_to(&app_path, skill_path)?; + crate::skill::install::install_skills_to(app_path, skill_path)?; } - apply_python_edits(&manifest, &app_path, &app_slug)?; + apply_python_edits(&manifest, app_path, app_slug)?; } // Handle UI addon's pyproject merge if addon_name == "ui" { - merge_ui_pyproject_config(&app_path, &app_slug)?; + merge_ui_pyproject_config(app_path, app_slug)?; } } // Set profile AFTER addon templates, since addons may overwrite .env - if let Some(profile) = args.profile.as_deref() { + if let Some(profile) = profile { let mut dotenv = DotenvFile::read(&app_path.join(".env"))?; dotenv.update("DATABRICKS_CONFIG_PROFILE", profile)?; } Ok(()) }, - )?; + ) +} - // Git initialization logic (always operates at the workspace root) - let git_dir = if is_member { - &workspace_root - } else { - &app_path - }; - if !Git::is_available().await { - println!("⚠️ Git is not available - skipping git initialization"); - } else { - let git = Git::new().map_err(|e| e.to_string())?; +/// Initialize a git repository at the workspace root (or app path if not a member). +async fn init_git_repo(workspace_root: &Path, app_path: &Path, is_member: bool) { + let git_dir = if is_member { workspace_root } else { app_path }; + if Git::is_available().await { + let git = match Git::new().map_err(|e| e.to_string()) { + Ok(g) => g, + Err(err) => { + println!("⚠️ Git initialization failed: {err}"); + println!(" Continuing with project setup..."); + return; + } + }; let inside = git.is_inside_work_tree(git_dir).await.unwrap_or(false) || has_git_dir(git_dir); if inside { @@ -454,54 +515,69 @@ async fn run_inner(mut args: InitArgs) -> Result<(), String> { println!(" Continuing with project setup..."); } } + } else { + println!("⚠️ Git is not available - skipping git initialization"); + } +} + +/// Install components declared in addon manifests. +async fn install_addon_components( + app_path: &Path, + selected_addons: &[String], +) -> Result<(), String> { + if selected_addons.is_empty() { + return Ok(()); } - // Manifest-driven component installation - if !selected_addons.is_empty() { - let mut all_components: Vec = Vec::new(); - for addon_name in &selected_addons { - if let Some(manifest) = read_addon_manifest(addon_name) { - for comp in &manifest.components.install { - all_components.push(ComponentInput::new(comp)); - } + let mut all_components: Vec = Vec::new(); + for addon_name in selected_addons { + if let Some(manifest) = read_addon_manifest(addon_name) { + for comp in &manifest.components.install { + all_components.push(ComponentInput::new(comp)); } } + } - if !all_components.is_empty() { - let components_start = Instant::now(); - let sp = spinner("🎨 Adding components..."); + if !all_components.is_empty() { + let components_start = Instant::now(); + let sp = spinner("🎨 Adding components..."); - let result = add_components(&app_path, &all_components, true).await?; + let result = add_components(app_path, &all_components, true).await?; - sp.finish_and_clear(); - println!( - "✅ Components added ({})", - format_elapsed_ms(components_start) - ); + sp.finish_and_clear(); + println!( + "✅ Components added ({})", + format_elapsed_ms(components_start) + ); - if !result.warnings.is_empty() { - for warning in &result.warnings { - eprintln!(" ⚠️ {warning}"); - } + if !result.warnings.is_empty() { + for warning in &result.warnings { + eprintln!(" ⚠️ {warning}"); } } } + Ok(()) +} + +/// Print the final success message with instructions. +fn print_success(app_name: &str, workspace_root: &Path, app_path: &Path, is_member: bool) { println!(); println!("✨ Project {app_name} initialized successfully!"); let run_from = if is_member { workspace_root .canonicalize() - .unwrap_or_else(|_| workspace_root.clone()) + .unwrap_or_else(|_| workspace_root.to_path_buf()) } else { - app_path.canonicalize().unwrap_or_else(|_| app_path.clone()) + app_path + .canonicalize() + .unwrap_or_else(|_| app_path.to_path_buf()) }; println!( "🚀 Run `cd {} && apx dev start` to get started!", run_from.display() ); println!(" (Dependencies will be installed automatically on first run)"); - Ok(()) } fn normalize_app_name(app_name: &str) -> Result { @@ -555,7 +631,7 @@ fn ensure_dir(path: &Path) -> Result<(), String> { /// Paths containing `/base/` or starting with `base/` have `base` replaced with `app_slug`. /// Files ending in `.jinja2` are rendered through Tera; others are copied verbatim. /// `addon.toml` files are skipped (internal metadata, not user-facing). -pub(crate) fn render_embedded_templates( +pub fn render_embedded_templates( prefix: &str, target_dir: &Path, app_name: &str, @@ -622,7 +698,7 @@ pub(crate) fn render_embedded_templates( /// Programmatically add `[tool.apx.ui]` config and hatch build exclude to pyproject.toml. /// Idempotent — skips if already configured. -pub(crate) fn merge_ui_pyproject_config(app_dir: &Path, app_slug: &str) -> Result<(), String> { +pub fn merge_ui_pyproject_config(app_dir: &Path, app_slug: &str) -> Result<(), String> { use toml_edit::{Item, Table}; let pyproject_path = app_dir.join("pyproject.toml"); @@ -636,7 +712,7 @@ pub(crate) fn merge_ui_pyproject_config(app_dir: &Path, app_slug: &str) -> Resul } let mut ui = Table::new(); - ui["root"] = Item::Value(format!("src/{}/ui", app_slug).into()); + ui["root"] = Item::Value(format!("src/{app_slug}/ui").into()); let mut registries = Table::new(); registries["@animate-ui"] = Item::Value("https://animate-ui.com/r/{name}.json".into()); @@ -653,14 +729,12 @@ pub(crate) fn merge_ui_pyproject_config(app_dir: &Path, app_slug: &str) -> Resul .as_table_mut() .ok_or("tool.hatch.build is not a table")?; - let exclude = build_table["exclude"].or_insert(Item::Value(toml_edit::Value::Array( - toml_edit::Array::new(), - ))); + let exclude = build_table["exclude"].or_insert(Item::Value(Value::Array(Array::new()))); let exclude_arr = exclude .as_array_mut() .ok_or("tool.hatch.build.exclude is not an array")?; - let ui_exclude = format!("src/{}/ui", app_slug); + let ui_exclude = format!("src/{app_slug}/ui"); let already = exclude_arr .iter() .any(|v| v.as_str() == Some(ui_exclude.as_str())); @@ -693,7 +767,7 @@ fn ensure_workspace_config(root_pyproject: &Path, member_path: &Path) -> Result< let member_str = member_path.to_string_lossy().replace('\\', "/"); let member_glob = match member_str.rsplit_once('/') { Some((parent, _)) => format!("{parent}/*"), - None => member_str.to_string(), + None => member_str.clone(), }; if !root_pyproject.exists() { @@ -734,6 +808,7 @@ fn ensure_workspace_config(root_pyproject: &Path, member_path: &Path) -> Result< } #[cfg(test)] +// Reason: panicking on failure is idiomatic in tests #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; @@ -854,8 +929,7 @@ members = ["libs/*"] let hatch = tool["hatch"].or_insert(Item::Table(Table::new())); let build = hatch["build"].or_insert(Item::Table(Table::new())); let build_table = build.as_table_mut().ok_or("not a table")?; - build_table["artifacts"] = - Item::Value(toml_edit::Value::Array(toml_edit::Array::new())); + build_table["artifacts"] = Item::Value(Value::Array(Array::new())); Ok(()) }) .unwrap(); diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 82b867f9..6d7092e1 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -1,13 +1,8 @@ -#![forbid(unsafe_code)] -#![deny(warnings, unused_must_use, dead_code, missing_debug_implementations)] -#![deny( - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - clippy::todo, - clippy::unimplemented, - clippy::dbg_macro -)] +//! Command-line interface for the apx toolkit. +//! +//! This crate implements the `apx` CLI, providing subcommands for project +//! initialization, building, development server management, frontend tooling, +//! and more. pub(crate) mod __generate_openapi; pub(crate) mod build; @@ -19,11 +14,13 @@ pub(crate) mod feedback; pub(crate) mod flux; pub(crate) mod frontend; pub(crate) mod info; +/// Project initialization wizard and template rendering. pub mod init; pub(crate) mod skill; pub(crate) mod upgrade; use clap::{CommandFactory, Parser, Subcommand}; +use std::future::Future; #[derive(Parser)] #[command( @@ -121,6 +118,13 @@ enum FluxCommands { Stop(flux::stop::StopArgs), } +/// Standard Unix exit code for processes terminated by SIGINT (128 + signal number 2). +/// Used when the top-level Ctrl+C handler cancels the running command. +const EXIT_CODE_SIGINT: i32 = 130; + +/// Parse CLI arguments and execute the corresponding subcommand. +/// +/// Returns an exit code (0 for success, non-zero for failure). pub fn run_cli(args: Vec) -> i32 { let runtime = match tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -137,16 +141,32 @@ pub fn run_cli(args: Vec) -> i32 { } async fn run_cli_async(args: Vec) -> i32 { - // Restore terminal cursor visibility on Ctrl+C (SIGINT). - // dialoguer hides the cursor during interactive widgets; if the process - // is killed by a signal before cleanup runs, the cursor stays hidden. - tokio::spawn(async { - if tokio::signal::ctrl_c().await.is_ok() { - let _ = console::Term::stderr().show_cursor(); - std::process::exit(130); - } - }); + // Handle Ctrl+C at the top level instead of in a spawned background task. + // + // `tokio::signal::ctrl_c()` permanently replaces the OS default SIGINT handler, + // so the process will NOT self-terminate on Ctrl+C — we must handle it explicitly. + // + // Using `select!` here (rather than a competing `tokio::spawn`) avoids a race: + // when SIGINT arrives, the command future is dropped cooperatively at its next + // `.await` point, and `run_cli_async` returns normally. This lets the parent + // process (`uv run`) call `waitpid()` cleanly instead of getting ESRCH. + // + // Command-level Ctrl+C handlers (in `follow_logs`, `bun`, `server`, etc.) still + // work: if their inner `select!` processes the signal first, the command future + // completes and this outer `select!` takes the `run_command` branch instead. + let exit_code = tokio::select! { + code = run_command(args) => code, + _ = tokio::signal::ctrl_c() => EXIT_CODE_SIGINT, + }; + + // Restore cursor visibility — covers both normal exit and Ctrl+C. + // dialoguer hides the cursor during interactive widgets; this ensures + // it reappears regardless of which select! branch won. + let _ = console::Term::stderr().show_cursor(); + exit_code +} +async fn run_command(args: Vec) -> i32 { match Cli::try_parse_from(args) { Ok(cli) => match cli.command { Some(Commands::Init(init_args)) => init::run(init_args).await, @@ -196,10 +216,13 @@ async fn run_cli_async(args: Vec) -> i32 { } } +/// Run an async closure and convert its `Result` into an exit code. +/// +/// Returns 0 on success. On error, prints the message to stderr and returns 1. pub async fn run_cli_async_helper(f: F) -> i32 where F: FnOnce() -> Fut, - Fut: std::future::Future>, + Fut: Future>, { match f().await { Ok(()) => 0, diff --git a/crates/cli/src/skill/install.rs b/crates/cli/src/skill/install.rs index 0b649212..3efcef93 100644 --- a/crates/cli/src/skill/install.rs +++ b/crates/cli/src/skill/install.rs @@ -17,10 +17,10 @@ pub struct InstallArgs { } pub async fn run(args: InstallArgs) -> i32 { - run_cli_async_helper(|| run_inner(args)).await + run_cli_async_helper(|| async { run_inner(args) }).await } -async fn run_inner(args: InstallArgs) -> Result<(), String> { +fn run_inner(args: InstallArgs) -> Result<(), String> { let base_dir = if args.global { home_dir()? } else { @@ -71,7 +71,7 @@ pub fn install_skills_to(base_dir: &Path, skill_path: &str) -> Result bool { // Same base: compare pre-release // No pre-release > any pre-release (stable beats RC) match (cur_pre, lat_pre) { - (None, None) => true, // equal - (None, Some(_)) => true, // stable > rc - (Some(_), None) => false, // rc < stable + (None, None | Some(_)) => true, // equal or stable > rc + (Some(_), None) => false, // rc < stable (Some(a), Some(b)) => a >= b, } } @@ -223,7 +222,7 @@ fn compare_base(a: &[u64], b: &[u64]) -> std::cmp::Ordering { let va = a.get(i).copied().unwrap_or(0); let vb = b.get(i).copied().unwrap_or(0); match va.cmp(&vb) { - std::cmp::Ordering::Equal => continue, + std::cmp::Ordering::Equal => {} other => return other, } } diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 78437891..3939bbd1 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -3,6 +3,15 @@ name = "apx-common" version = "0.3.6" edition.workspace = true rust-version.workspace = true +description.workspace = true +repository.workspace = true +license.workspace = true +readme.workspace = true +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true [dependencies] serde.workspace = true diff --git a/crates/common/src/bundles.rs b/crates/common/src/bundles.rs index 94f4b549..3f3cb0b6 100644 --- a/crates/common/src/bundles.rs +++ b/crates/common/src/bundles.rs @@ -1,28 +1,40 @@ +//! Databricks bundle configuration parsing and app name resolution. + use std::collections::{BTreeSet, HashMap}; use std::path::Path; use serde::Deserialize; +/// Filename for Databricks bundle configuration. const DATABRICKS_YML: &str = "databricks.yml"; +/// Parsed representation of a `databricks.yml` configuration file. #[derive(Debug, Deserialize)] pub struct BundleConfig { + /// Top-level resources section of the bundle. #[serde(default)] pub resources: BundleResources, } +/// Resources section of a Databricks bundle configuration. #[derive(Debug, Default, Deserialize)] pub struct BundleResources { + /// Map of app resource keys to their definitions. #[serde(default)] pub apps: HashMap, } +/// A single Databricks App resource definition. #[derive(Debug, Deserialize)] pub struct AppResource { + /// Display name of the app. pub name: String, } impl BundleConfig { + /// # Errors + /// + /// Returns an error if `databricks.yml` is missing or cannot be parsed. pub fn from_path(dir: &Path) -> Result { let yml_path = dir.join(DATABRICKS_YML); if !yml_path.exists() { @@ -38,10 +50,14 @@ impl BundleConfig { Self::from_yaml(&contents) } + /// # Errors + /// + /// Returns an error if the YAML content cannot be parsed. pub fn from_yaml(yaml: &str) -> Result { serde_yaml::from_str(yaml).map_err(|e| format!("Failed to parse databricks.yml: {e}")) } + /// Return sorted, deduplicated app names from the bundle configuration. pub fn app_names(&self) -> Vec { let names: BTreeSet<&str> = self .resources @@ -54,6 +70,9 @@ impl BundleConfig { } } +/// # Errors +/// +/// Returns an error if zero or more than one app is defined in `databricks.yml`. pub fn resolve_single_app_name(project_dir: &Path) -> Result { let config = BundleConfig::from_path(project_dir)?; let names = config.app_names(); @@ -74,7 +93,11 @@ pub fn resolve_single_app_name(project_dir: &Path) -> Result { } #[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::needless_raw_string_hashes +)] mod tests { use super::*; @@ -82,13 +105,13 @@ mod tests { #[test] fn parse_single_app() { - let yaml = r#" + let yaml = r" resources: apps: my_app: name: my-cool-app source_code_path: ./src -"#; +"; let config = BundleConfig::from_yaml(yaml).unwrap(); assert_eq!(config.resources.apps.len(), 1); assert_eq!(config.resources.apps["my_app"].name, "my-cool-app"); @@ -139,40 +162,40 @@ resources: #[test] fn app_names_single() { - let yaml = r#" + let yaml = r" resources: apps: app1: name: alpha -"#; +"; let config = BundleConfig::from_yaml(yaml).unwrap(); assert_eq!(config.app_names(), vec!["alpha"]); } #[test] fn app_names_multiple_sorted() { - let yaml = r#" + let yaml = r" resources: apps: z_app: name: zulu a_app: name: alpha -"#; +"; let config = BundleConfig::from_yaml(yaml).unwrap(); assert_eq!(config.app_names(), vec!["alpha", "zulu"]); } #[test] fn app_names_deduplicates() { - let yaml = r#" + let yaml = r" resources: apps: app1: name: same-name app2: name: same-name -"#; +"; let config = BundleConfig::from_yaml(yaml).unwrap(); assert_eq!(config.app_names(), vec!["same-name"]); } diff --git a/crates/common/src/format.rs b/crates/common/src/format.rs index c1ad36c4..b35fe868 100644 --- a/crates/common/src/format.rs +++ b/crates/common/src/format.rs @@ -5,80 +5,82 @@ use chrono::{Local, TimeZone, Utc}; -use crate::{AggregatedRecord, LogRecord, source_label}; +use crate::{AggregatedRecord, LogRecord, ServiceKind}; + +// ANSI color codes for terminal output. +const ANSI_CYAN: &str = "\x1b[36m"; +const ANSI_MAGENTA: &str = "\x1b[35m"; +const ANSI_GREEN: &str = "\x1b[32m"; +const ANSI_YELLOW: &str = "\x1b[33m"; +const ANSI_RESET: &str = "\x1b[0m"; + +impl ServiceKind { + /// ANSI color escape for this service kind. + #[must_use] + pub const fn ansi_color(self) -> &'static str { + match self { + Self::App => ANSI_CYAN, + Self::Ui => ANSI_MAGENTA, + Self::Db => ANSI_GREEN, + Self::Other => ANSI_YELLOW, + } + } +} /// Format a timestamp in milliseconds to `YYYY-MM-DD HH:MM:SS.mmm` in local timezone. +#[must_use] pub fn format_timestamp(timestamp_ms: i64) -> String { - let datetime = Utc.timestamp_millis_opt(timestamp_ms).single(); - match datetime { - Some(dt) => { - let local_dt = dt.with_timezone(&Local); - local_dt.format("%Y-%m-%d %H:%M:%S%.3f").to_string() - } - None => "????-??-?? ??:??:??.???".to_string(), - } + Utc.timestamp_millis_opt(timestamp_ms).single().map_or_else( + || "????-??-?? ??:??:??.???".to_string(), + |dt| { + dt.with_timezone(&Local) + .format("%Y-%m-%d %H:%M:%S%.3f") + .to_string() + }, + ) } /// Format a timestamp in milliseconds to `HH:MM:SS.mmm` in local timezone. +#[must_use] pub fn format_short_timestamp(timestamp_ms: i64) -> String { - let datetime = Utc.timestamp_millis_opt(timestamp_ms).single(); - match datetime { - Some(dt) => { - let local_dt = dt.with_timezone(&Local); - local_dt.format("%H:%M:%S%.3f").to_string() - } - None => "??:??:??.???".to_string(), - } + Utc.timestamp_millis_opt(timestamp_ms).single().map_or_else( + || "??:??:??.???".to_string(), + |dt| dt.with_timezone(&Local).format("%H:%M:%S%.3f").to_string(), + ) } /// Format a log record for terminal display. /// /// Output: `2026-01-28 16:09:02.413 | app | ` +#[must_use] pub fn format_log_record(record: &LogRecord, colorize: bool) -> String { - let timestamp = format_timestamp(record.effective_timestamp_ms()); - let src = record.source_label(); - let padded_src = format!("{src:>3}"); - let message = record.body.as_deref().unwrap_or(""); - - if colorize { - let color_code = source_color(src); - let reset = "\x1b[0m"; - format!("{color_code}{timestamp} | {padded_src} | {message}{reset}") - } else { - format!("{timestamp} | {padded_src} | {message}") - } + let kind = ServiceKind::from_service_name(record.service_name.as_deref().unwrap_or("unknown")); + format_line( + &format_timestamp(record.effective_timestamp_ms()), + kind, + record.body.as_deref().unwrap_or(""), + colorize, + ) } /// Format an aggregated record for terminal display. +#[must_use] pub fn format_aggregated_record(agg: &AggregatedRecord, colorize: bool) -> String { - let timestamp = format_timestamp(agg.timestamp_ms); - let src = source_label(&agg.service_name); - let padded_src = format!("{src:>3}"); + let kind = ServiceKind::from_service_name(&agg.service_name); let message = format!("[{}] {}", agg.count, agg.template); - - if colorize { - let color_code = source_color(src); - let reset = "\x1b[0m"; - format!("{color_code}{timestamp} | {padded_src} | {message}{reset}") - } else { - format!("{timestamp} | {padded_src} | {message}") - } + format_line( + &format_timestamp(agg.timestamp_ms), + kind, + &message, + colorize, + ) } /// Format a log record for startup display (compact timestamp, always colorized, with channel). +#[must_use] pub fn format_startup_log(record: &LogRecord) -> String { let timestamp = format_timestamp(record.effective_timestamp_ms()); - - let service_name = record.service_name.as_deref().unwrap_or("unknown"); - let source = if service_name.ends_with("_app") { - "app" - } else if service_name.ends_with("_ui") { - " ui" - } else if service_name.ends_with("_db") { - " db" - } else { - "apx" - }; + let kind = ServiceKind::from_service_name(record.service_name.as_deref().unwrap_or("unknown")); let severity = record.severity_text.as_deref().unwrap_or("INFO"); let channel = match severity.to_uppercase().as_str() { @@ -87,21 +89,16 @@ pub fn format_startup_log(record: &LogRecord) -> String { }; let message = record.body.as_deref().unwrap_or(""); + let label = kind.label(); + let color = kind.ansi_color(); - let color_code = match source { - "app" => "\x1b[36m", // cyan - " ui" => "\x1b[35m", // magenta - " db" => "\x1b[32m", // green - _ => "\x1b[33m", // yellow - }; - let reset = "\x1b[0m"; - - format!("{color_code}{timestamp} | {source} | {channel} | {message}{reset}") + format!("{color}{timestamp} | {label:>3} | {channel} | {message}{ANSI_RESET}") } /// Format a subprocess log line with local timestamp and source prefix. /// /// Output: `2026-01-28 16:09:02.413 | app | ` +#[must_use] pub fn format_process_log_line(source: &str, message: &str) -> String { let now = Local::now(); let timestamp = now.format("%Y-%m-%d %H:%M:%S%.3f"); @@ -109,25 +106,37 @@ pub fn format_process_log_line(source: &str, message: &str) -> String { } /// ANSI color code for a source label. +#[must_use] pub fn source_color(src: &str) -> &'static str { match src { - "app" => "\x1b[36m", - "ui" => "\x1b[35m", - "db" => "\x1b[32m", - _ => "\x1b[33m", + "app" => ANSI_CYAN, + "ui" => ANSI_MAGENTA, + "db" => ANSI_GREEN, + _ => ANSI_YELLOW, + } +} + +/// Shared formatter for `timestamp | src | message` lines. +fn format_line(timestamp: &str, kind: ServiceKind, message: &str, colorize: bool) -> String { + let label = kind.label(); + if colorize { + let color = kind.ansi_color(); + format!("{color}{timestamp} | {label:>3} | {message}{ANSI_RESET}") + } else { + format!("{timestamp} | {label:>3} | {message}") } } /// Convert severity level string to OTLP severity number. +#[must_use] pub fn severity_to_number(level: &str) -> u8 { match level.to_uppercase().as_str() { "TRACE" => 1, "DEBUG" => 5, - "INFO" | "LOG" => 9, "WARN" | "WARNING" => 13, "ERROR" => 17, "FATAL" | "CRITICAL" => 21, - _ => 9, // default to INFO + _ => 9, // INFO, LOG, and unknown levels default to INFO } } @@ -139,6 +148,7 @@ pub fn severity_to_number(level: &str) -> u8 { /// - `"WARNING ..."`, `"ERROR ..."`, `"DEBUG ..."` etc. /// /// Returns `"INFO"` if no level found (most stderr is informational). +#[must_use] pub fn parse_python_severity(line: &str) -> &'static str { let trimmed = line.trim_start(); diff --git a/crates/common/src/hosts.rs b/crates/common/src/hosts.rs index a5ed3348..f1cc9f60 100644 --- a/crates/common/src/hosts.rs +++ b/crates/common/src/hosts.rs @@ -1,13 +1,15 @@ -/// Default bind address for all APX services (dev server, uvicorn, PGlite, flux). +//! Network host constants for binding, client connections, and browser URLs. + +/// Default bind address for all APX services (dev server, uvicorn, `PGlite`, flux). /// Loopback-only to prevent LAN exposure. Override with `--host 0.0.0.0` if needed. pub const BIND_HOST: &str = "127.0.0.1"; /// IPv4 loopback address for local client connections. -/// Used by: health probes, flux OTLP endpoints, OpenAPI fetching, proxy targets. +/// Used by: health probes, flux OTLP endpoints, `OpenAPI` fetching, proxy targets. /// Always use this (not "localhost") to avoid IPv4/IPv6 ambiguity. pub const CLIENT_HOST: &str = "127.0.0.1"; -/// IPv4 loopback as an octet array for std::net::SocketAddr construction. +/// IPv4 loopback as an octet array for `std::net::SocketAddr` construction. pub const CLIENT_HOST_OCTETS: [u8; 4] = [127, 0, 0, 1]; /// Hostname for browser-facing URLs and WebSocket connections. diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 2c9e2066..4b1f12bc 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -1,22 +1,15 @@ -#![forbid(unsafe_code)] -#![deny(warnings, unused_must_use, dead_code, missing_debug_implementations)] -#![deny( - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - clippy::todo, - clippy::unimplemented, - clippy::dbg_macro -)] - //! Shared types and utilities for APX flux system //! //! This crate contains shared functionality used by both the main `apx` CLI //! and the standalone `apx-agent` binary. +/// Databricks bundle configuration parsing and app name resolution. pub mod bundles; +/// Centralized log formatting, timestamp formatting, and severity utilities. pub mod format; +/// Network host constants for binding, client connections, and browser URLs. pub mod hosts; +/// Pure types and logic for flux OTEL log records, filtering, and aggregation. pub mod storage; use serde::{Deserialize, Serialize}; @@ -27,8 +20,8 @@ use std::time::Duration; // Re-export commonly used types pub use storage::{ - AggregatedRecord, LogAggregator, LogRecord, flux_dir, get_aggregation_key, should_skip_log, - should_skip_log_message, source_label, + AggregatedRecord, LogAggregator, LogRecord, ServiceKind, flux_dir, get_aggregation_key, + should_skip_log, should_skip_log_message, source_label, }; /// Version of the apx-common crate, used for agent version matching. @@ -46,19 +39,24 @@ const LOG_FILENAME: &str = "agent.log"; /// Lock file contents. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FluxLock { + /// OS process ID of the running agent. pub pid: u32, + /// TCP port the agent listens on. pub port: u16, + /// Unix timestamp (seconds) when the agent started. pub started_at: i64, + /// Crate version of the agent that wrote this lock. #[serde(default)] pub version: Option, } impl FluxLock { /// Create a new lock for the current process. + #[must_use] pub fn new(pid: u32) -> Self { let started_at = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) + .map(|d| d.as_secs().cast_signed()) .unwrap_or(0); Self { @@ -70,17 +68,29 @@ impl FluxLock { } } -/// Get the lock file path (~/.apx/logs/agent.lock). +/// Get the lock file path (`~/.apx/logs/agent.lock`). +/// +/// # Errors +/// +/// Returns an error if the home directory cannot be determined. pub fn lock_path() -> Result { Ok(flux_dir()?.join(LOCK_FILENAME)) } -/// Get the daemon log file path (~/.apx/logs/agent.log). +/// Get the daemon log file path (`~/.apx/logs/agent.log`). +/// +/// # Errors +/// +/// Returns an error if the home directory cannot be determined. pub fn log_path() -> Result { Ok(flux_dir()?.join(LOG_FILENAME)) } /// Read the lock file if it exists. +/// +/// # Errors +/// +/// Returns an error if the lock file exists but cannot be read or parsed. pub fn read_lock() -> Result, String> { let path = lock_path()?; if !path.exists() { @@ -97,6 +107,10 @@ pub fn read_lock() -> Result, String> { } /// Write the lock file. +/// +/// # Errors +/// +/// Returns an error if the lock file cannot be written. pub fn write_lock(lock: &FluxLock) -> Result<(), String> { let path = lock_path()?; @@ -112,6 +126,10 @@ pub fn write_lock(lock: &FluxLock) -> Result<(), String> { } /// Remove the lock file. +/// +/// # Errors +/// +/// Returns an error if the lock file exists but cannot be removed. pub fn remove_lock() -> Result<(), String> { let path = lock_path()?; if path.exists() { @@ -121,12 +139,14 @@ pub fn remove_lock() -> Result<(), String> { } /// Check if flux is accepting connections at the given port. +#[must_use] pub fn is_flux_listening(port: u16) -> bool { let addr = std::net::SocketAddr::from((hosts::CLIENT_HOST_OCTETS, port)); TcpStream::connect_timeout(&addr, Duration::from_millis(500)).is_ok() } /// Check if flux is currently running by testing TCP connectivity. +#[must_use] pub fn is_running() -> bool { is_flux_listening(FLUX_PORT) } diff --git a/crates/common/src/storage.rs b/crates/common/src/storage.rs index f63590fe..98274815 100644 --- a/crates/common/src/storage.rs +++ b/crates/common/src/storage.rs @@ -12,16 +12,27 @@ const FLUX_DIR: &str = ".apx/logs"; /// A log record to be inserted into the database. #[derive(Debug, Clone)] pub struct LogRecord { + /// Event timestamp in nanoseconds since epoch. pub timestamp_ns: i64, + /// Observed timestamp in nanoseconds since epoch. pub observed_timestamp_ns: i64, + /// OTLP severity number (1=TRACE, 9=INFO, 17=ERROR, etc.). pub severity_number: Option, + /// Human-readable severity level (e.g. "INFO", "ERROR"). pub severity_text: Option, + /// Log message body. pub body: Option, + /// Service name that emitted this log (e.g. "myapp_app", "myapp_db"). pub service_name: Option, + /// Filesystem path of the originating application. pub app_path: Option, - pub resource_attributes: Option, // JSON - pub log_attributes: Option, // JSON + /// OTLP resource attributes serialized as JSON. + pub resource_attributes: Option, + /// OTLP log attributes serialized as JSON. + pub log_attributes: Option, + /// Distributed trace identifier. pub trace_id: Option, + /// Span identifier within a trace. pub span_id: Option, } @@ -29,7 +40,8 @@ impl LogRecord { /// Return the effective timestamp in milliseconds, falling back to /// `observed_timestamp_ns` when `timestamp_ns` is zero (e.g. OpenTelemetry /// tracing bridge logs). - pub fn effective_timestamp_ms(&self) -> i64 { + #[must_use] + pub const fn effective_timestamp_ms(&self) -> i64 { let ns = if self.timestamp_ns == 0 { self.observed_timestamp_ns } else { @@ -39,22 +51,56 @@ impl LogRecord { } /// Derive a short source label from `service_name`. + #[must_use] pub fn source_label(&self) -> &'static str { - source_label(self.service_name.as_deref().unwrap_or("unknown")) + ServiceKind::from_service_name(self.service_name.as_deref().unwrap_or("unknown")).label() + } +} + +/// Fixed set of service kinds for display and color-coding. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ServiceKind { + /// Backend application service (`_app` suffix). + App, + /// Frontend UI service (`_ui` suffix). + Ui, + /// Database proxy service (`_db` suffix). + Db, + /// Any other service. + Other, +} + +impl ServiceKind { + /// Classify a service name by its `_app` / `_ui` / `_db` suffix. + #[must_use] + pub fn from_service_name(name: &str) -> Self { + if name.ends_with("_app") { + Self::App + } else if name.ends_with("_ui") { + Self::Ui + } else if name.ends_with("_db") { + Self::Db + } else { + Self::Other + } + } + + /// Short display label: `"app"`, `"ui"`, `"db"`, or `"apx"`. + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::App => "app", + Self::Ui => "ui", + Self::Db => "db", + Self::Other => "apx", + } } } /// Derive a short source label from a service name string. +#[must_use] pub fn source_label(service_name: &str) -> &'static str { - if service_name.ends_with("_app") { - "app" - } else if service_name.ends_with("_ui") { - "ui" - } else if service_name.ends_with("_db") { - "db" - } else { - "apx" - } + ServiceKind::from_service_name(service_name).label() } // --------------------------------------------------------------------------- @@ -72,6 +118,7 @@ const APX_MIN_SEVERITY: i32 = 5; /// This is used by OTEL forwarding in `process.rs` where only the message string /// is available. The full `should_skip_log(&LogRecord)` delegates to this function /// for message-based filtering. +#[must_use] pub fn should_skip_log_message(message: &str) -> bool { // OTEL batch processor internals if message.starts_with("BatchLogProcessor.") @@ -121,6 +168,7 @@ pub fn should_skip_log_message(message: &str) -> bool { } /// Check if a log record should be skipped (internal/noisy logs). +#[must_use] pub fn should_skip_log(record: &LogRecord) -> bool { let service_name = record.service_name.as_deref().unwrap_or(""); let severity_number = record.severity_number.unwrap_or(9); @@ -135,6 +183,7 @@ pub fn should_skip_log(record: &LogRecord) -> bool { } /// Get aggregation key for a message if it should be aggregated. +#[must_use] pub fn get_aggregation_key(record: &LogRecord) -> Option<(String, &'static str)> { let message = record.body.as_deref().unwrap_or(""); let service = record.service_name.as_deref().unwrap_or(""); @@ -159,9 +208,13 @@ pub fn get_aggregation_key(record: &LogRecord) -> Option<(String, &'static str)> /// A single flushed aggregation bucket. #[derive(Debug, Clone)] pub struct AggregatedRecord { + /// Number of messages aggregated in this bucket. pub count: usize, + /// Timestamp (ms) of the first message in the bucket. pub timestamp_ms: i64, + /// Human-readable summary template for the aggregated messages. pub template: &'static str, + /// Service name that produced the aggregated messages. pub service_name: String, } @@ -185,6 +238,8 @@ pub struct LogAggregator { // that only shows bucket count. impl LogAggregator { + /// Create a new empty aggregator. + #[must_use] pub fn new() -> Self { Self::default() } @@ -258,7 +313,11 @@ impl LogAggregator { } } -/// Get the flux directory path (~/.apx/logs). +/// Get the flux directory path (`~/.apx/logs`). +/// +/// # Errors +/// +/// Returns an error if the home directory cannot be determined. pub fn flux_dir() -> Result { let home = dirs::home_dir().ok_or("Could not determine home directory")?; Ok(home.join(FLUX_DIR)) diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 9d3c9eed..b78e2c74 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -3,6 +3,15 @@ name = "apx-core" version = "0.3.6" edition.workspace = true rust-version.workspace = true +description.workspace = true +repository.workspace = true +license.workspace = true +readme.workspace = true +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true [dependencies] apx-common.workspace = true diff --git a/crates/core/build.rs b/crates/core/build.rs index 297b0ab5..51007c5b 100644 --- a/crates/core/build.rs +++ b/crates/core/build.rs @@ -1,17 +1,18 @@ +//! Build script for apx-core. use std::env; use std::fs; use std::path::PathBuf; -fn main() { - let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); +fn main() -> Result<(), Box> { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?); let workspace_root = manifest_dir .parent() .and_then(|p| p.parent()) - .expect("Could not find workspace root"); - let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); + .ok_or("Could not find workspace root")?; + let out_dir = PathBuf::from(env::var("OUT_DIR")?); - copy_agent_binary(workspace_root, &out_dir); - copy_skill_files(workspace_root); + copy_agent_binary(workspace_root, &out_dir)?; + copy_skill_files(workspace_root)?; println!( "cargo:rerun-if-changed={}", @@ -29,6 +30,8 @@ fn main() { "cargo:rerun-if-changed={}", workspace_root.join("skills/apx").display() ); + + Ok(()) } /// Copy a platform-specific binary from `.bins//` to `OUT_DIR/`. @@ -38,29 +41,29 @@ fn copy_platform_binary( subdir: &str, dest_name: &str, platform_filename: impl FnOnce(&str, &str) -> Option<&'static str>, -) { +) -> Result<(), Box> { let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default(); let src_name = platform_filename(&target_os, &target_arch) - .unwrap_or_else(|| panic!("Unsupported target for {dest_name}: {target_os}-{target_arch}")); + .ok_or_else(|| format!("Unsupported target for {dest_name}: {target_os}-{target_arch}"))?; let source = workspace_root.join(".bins").join(subdir).join(src_name); - assert!( - source.exists(), - "Missing {dest_name} binary at {}", - source.display() - ); + if !source.exists() { + return Err(format!("Missing {dest_name} binary at {}", source.display()).into()); + } let dest = out_dir.join(dest_name); - fs::copy(&source, &dest).unwrap_or_else(|e| panic!("Failed to copy {dest_name} binary: {e}")); + fs::copy(&source, &dest)?; println!("cargo:rerun-if-changed={}", source.display()); + + Ok(()) } /// Copy skill files from the repo root into the claude addon template directory /// so they get embedded by rust-embed. This keeps `skills/apx/` as the single /// source of truth while still bundling them into the binary. -fn copy_skill_files(workspace_root: &std::path::Path) { +fn copy_skill_files(workspace_root: &std::path::Path) -> Result<(), Box> { let claude_addon = workspace_root.join("src/apx/templates/addons/claude"); let copies: &[(&str, &str)] = &[ @@ -79,17 +82,18 @@ fn copy_skill_files(workspace_root: &std::path::Path) { let src = workspace_root.join(src_rel); let dst = claude_addon.join(dst_rel); if let Some(parent) = dst.parent() { - fs::create_dir_all(parent) - .unwrap_or_else(|e| panic!("Failed to create directory {}: {e}", parent.display())); + fs::create_dir_all(parent)?; } - fs::copy(&src, &dst).unwrap_or_else(|e| { - panic!("Failed to copy {} -> {}: {e}", src.display(), dst.display()) - }); + fs::copy(&src, &dst)?; } + + Ok(()) } -fn copy_agent_binary(workspace_root: &std::path::Path, out_dir: &std::path::Path) { - // Naming convention from scripts/build_agent.py Target.output_filename +fn copy_agent_binary( + workspace_root: &std::path::Path, + out_dir: &std::path::Path, +) -> Result<(), Box> { copy_platform_binary( workspace_root, out_dir, @@ -103,5 +107,5 @@ fn copy_agent_binary(workspace_root: &std::path::Path, out_dir: &std::path::Path ("windows", "x86_64") => Some("apx-agent-windows-x64.exe"), _ => None, }, - ); + ) } diff --git a/crates/core/src/api_generator.rs b/crates/core/src/api_generator.rs index da686be7..a4045535 100644 --- a/crates/core/src/api_generator.rs +++ b/crates/core/src/api_generator.rs @@ -16,6 +16,7 @@ use crate::external::uv::Uv; use crate::interop::generate_openapi_spec; use crate::openapi; +/// Generate the OpenAPI spec and TypeScript client for a project. pub async fn generate_openapi(project_root: &Path) -> Result<(), String> { let metadata = read_project_metadata(project_root)?; let app_slug = metadata.app_slug.clone(); diff --git a/crates/core/src/app_state.rs b/crates/core/src/app_state.rs index b25c1f45..8607729a 100644 --- a/crates/core/src/app_state.rs +++ b/crates/core/src/app_state.rs @@ -3,6 +3,7 @@ use std::sync::OnceLock; static APP_DIR: OnceLock = OnceLock::new(); +/// Set the global application directory (once). Returns an error if already set to a different path. pub fn set_app_dir(app_dir: PathBuf) -> Result<(), String> { if let Some(existing) = APP_DIR.get() { if existing != &app_dir { @@ -18,6 +19,7 @@ pub fn set_app_dir(app_dir: PathBuf) -> Result<(), String> { .map_err(|_| "Failed to set app directory".to_string()) } +/// Get the global application directory, if it has been set. pub fn get_app_dir() -> Option { APP_DIR.get().cloned() } diff --git a/crates/core/src/common.rs b/crates/core/src/common.rs index 73c36557..9eab0d80 100644 --- a/crates/core/src/common.rs +++ b/crates/core/src/common.rs @@ -1,6 +1,8 @@ use indicatif::{ProgressBar, ProgressStyle}; use std::collections::HashMap; +use std::fmt::Write; use std::fs; +use std::future::Future; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::time::{Duration, Instant}; @@ -45,15 +47,24 @@ pub fn list_profiles() -> Result, String> { const DEFAULT_API_PREFIX: &str = "/api"; const PYPROJECT_FILENAME: &str = "pyproject.toml"; +/// Parsed project configuration from `pyproject.toml`. #[derive(Debug, Clone)] pub struct ProjectMetadata { + /// Human-readable application name. pub app_name: String, + /// Python package slug (used for directory names). pub app_slug: String, + /// Python module entrypoint (e.g. `"my_app.app:app"`). pub app_entrypoint: String, + /// API route prefix (default `"/api"`). pub api_prefix: String, + /// Path to the `_metadata.py` file relative to project root. pub metadata_path: PathBuf, + /// Optional UI root directory (present when `[tool.apx.ui]` is configured). pub ui_root: Option, + /// Optional UI component registries from `[tool.apx.ui.registries]`. pub ui_registries: Option>, + /// Dev server configuration parsed from `[tool.apx.dev]`. pub dev_config: DevConfig, } @@ -73,6 +84,7 @@ impl ProjectMetadata { } } +/// Read and parse project metadata from `pyproject.toml` in the given directory. pub fn read_project_metadata(project_root: &Path) -> Result { let pyproject_path = project_root.join(PYPROJECT_FILENAME); let pyproject_contents = fs::read_to_string(&pyproject_path) @@ -158,6 +170,7 @@ pub fn read_python_dependencies(project_root: &Path) -> Vec { .unwrap_or_default() } +/// Write (or update) the `_metadata.py` file and initialize the `__dist__` directory. pub fn write_metadata_file(project_root: &Path, metadata: &ProjectMetadata) -> Result<(), String> { let target_path = project_root.join(&metadata.metadata_path); tracing::debug!("Writing metadata file to {}", target_path.display()); @@ -218,10 +231,12 @@ pub fn write_metadata_file(project_root: &Path, metadata: &ProjectMetadata) -> R Ok(()) } +/// Create a directory and all parent directories if they don't exist. pub fn ensure_dir(path: &Path) -> Result<(), String> { fs::create_dir_all(path).map_err(|err| format!("Failed to create directory: {err}")) } +/// Run `bun install` in the given directory. pub async fn bun_install(app_dir: &Path) -> Result<(), String> { let bun = Bun::new().await?; tracing::debug!(app_dir = %app_dir.display(), "Running bun install"); @@ -283,12 +298,12 @@ pub async fn generate_version_file( let version = match uv.tool_run(app_dir, "uv-dynamic-versioning").await { Ok(output) if output.exit_code == Some(0) => { let v = output.stdout.trim().to_string(); - if !v.is_empty() { - tracing::debug!("uv-dynamic-versioning returned version: {}", v); - v - } else { + if v.is_empty() { tracing::warn!("uv-dynamic-versioning returned empty output, using fallback"); "0.0.0".to_string() + } else { + tracing::debug!("uv-dynamic-versioning returned version: {}", v); + v } } Ok(output) => { @@ -307,7 +322,7 @@ pub async fn generate_version_file( // Write the version file let content = format!("version = \"{version}\"\n"); tracing::debug!("Writing version file to {}", version_path.display()); - std::fs::write(&version_path, content) + fs::write(&version_path, content) .map_err(|err| format!("Failed to write version file: {err}"))?; tracing::debug!("Version file written successfully"); @@ -316,14 +331,20 @@ pub async fn generate_version_file( /// Result of preflight check with timing information. #[derive(Debug)] -#[allow(dead_code)] pub struct PreflightResult { + /// Parsed project metadata. pub metadata: ProjectMetadata, + /// Time spent verifying project layout (ms). pub layout_ms: u128, + /// Time spent running `uv sync` (ms). pub uv_sync_ms: u128, + /// Time spent generating the OpenAPI client (ms). pub openapi_ms: u128, + /// Time spent generating the version file (ms). pub version_ms: u128, + /// Time spent running `bun install` (ms), or `None` if skipped. pub bun_install_ms: Option, + /// Whether the project has a UI directory. pub has_ui: bool, } @@ -367,12 +388,12 @@ pub async fn run_preflight_checks(app_dir: &Path) -> Result Result ProgressBar { } // Spinner utilities for CLI operations +// Reason: spinner output is intentional user-facing display #[allow(clippy::print_stdout)] +/// Create a visible CLI spinner with the given message. pub fn spinner(message: &str) -> ProgressBar { let spinner = ProgressBar::new_spinner(); - spinner.set_style( - ProgressStyle::with_template("{spinner} {msg}") - .unwrap_or_else(|_| ProgressStyle::default_spinner()), - ); + // Reason: literal braces in spinner template, not format arguments + #[allow(clippy::literal_string_with_formatting_args)] + let style = ProgressStyle::with_template("{spinner} {msg}") + .unwrap_or_else(|_| ProgressStyle::default_spinner()); + spinner.set_style(style); spinner.enable_steady_tick(Duration::from_millis(80)); spinner.set_message(message.to_string()); spinner @@ -436,7 +461,9 @@ pub fn spinner(message: &str) -> ProgressBar { /// Output captured from a streaming command. #[derive(Debug, Default)] pub struct StreamingOutput { + /// Captured standard output. pub stdout: String, + /// Captured standard error. pub stderr: String, } @@ -511,11 +538,11 @@ pub async fn run_command_streaming_with_output( let mut full_error = format!("{error_msg}: exit code {}", status.code().unwrap_or(-1)); if !output.stderr.is_empty() { - full_error.push_str(&format!("\n\nStderr:\n{}", output.stderr)); + let _ = write!(full_error, "\n\nStderr:\n{}", output.stderr); } if !output.stdout.is_empty() { - full_error.push_str(&format!("\n\nStdout:\n{}", output.stdout)); + let _ = write!(full_error, "\n\nStdout:\n{}", output.stdout); } return Err(full_error); @@ -524,6 +551,7 @@ pub async fn run_command_streaming_with_output( Ok(output) } +/// Format elapsed time since `start` as a human-readable string (e.g. "1s 234ms"). pub fn format_elapsed_ms(start: Instant) -> String { let elapsed = start.elapsed(); if elapsed.as_secs() == 0 { @@ -534,7 +562,9 @@ pub fn format_elapsed_ms(start: Instant) -> String { format!("{seconds}s {remaining_ms}ms") } +// Reason: direct stdout is required for progress display #[allow(clippy::print_stdout)] +/// Run a synchronous closure with a spinner, printing the success message on completion. pub fn run_with_spinner(description: &str, success_message: &str, f: F) -> Result<(), String> where F: FnOnce() -> Result<(), String>, @@ -549,7 +579,9 @@ where result } +// Reason: direct stdout is required for progress display #[allow(clippy::print_stdout)] +/// Run an async closure with a spinner, printing the success message on completion. pub async fn run_with_spinner_async( description: &str, success_message: &str, @@ -557,7 +589,7 @@ pub async fn run_with_spinner_async( ) -> Result<(), String> where F: FnOnce() -> Fut, - Fut: std::future::Future>, + Fut: Future>, { let spinner = spinner(description); let start = Instant::now(); @@ -616,6 +648,7 @@ impl Timer { } #[cfg(test)] +// Reason: panicking on failure is idiomatic in tests #[allow(clippy::unwrap_used)] mod tests { use super::*; diff --git a/crates/core/src/components/add.rs b/crates/core/src/components/add.rs index 03a078f3..f2bd1bcf 100644 --- a/crates/core/src/components/add.rs +++ b/crates/core/src/components/add.rs @@ -10,12 +10,16 @@ use super::{ }; use crate::components::utils::format_relative_path; -#[derive(Debug)] +/// Outcome of attempting to write a single file. +#[derive(Debug, Clone, Copy)] pub enum WriteResult { + /// File was written (new or overwritten). Written, + /// File content was identical; no write needed. Unchanged, } +/// Write a planned file to disk if its content differs from the existing file. pub fn write_file_if_changed( file: &PlannedFile, force: bool, @@ -51,11 +55,14 @@ pub fn write_file_if_changed( /// Input for adding a component via the API #[derive(Debug, Clone)] pub struct ComponentInput { + /// Component name (e.g. `button`, `dialog`). pub name: String, + /// Optional registry name override. pub registry: Option, } impl ComponentInput { + /// Create a new input for the default registry. pub fn new(name: impl Into) -> Self { Self { name: name.into(), @@ -63,6 +70,7 @@ impl ComponentInput { } } + /// Create a new input targeting a specific registry. pub fn with_registry(name: impl Into, registry: impl Into) -> Self { Self { name: name.into(), @@ -74,11 +82,17 @@ impl ComponentInput { /// Result of adding components via the API #[derive(Debug, Default)] pub struct AddComponentsResult { + /// Paths of files that were written. pub written_paths: Vec, + /// Paths of files that were unchanged. pub unchanged_paths: Vec, + /// npm packages that were installed. pub dependencies_installed: Vec, + /// Dependencies auto-detected from source files. pub auto_detected_deps: Vec, + /// Path to the CSS file that was updated, if any. pub css_updated_path: Option, + /// Warnings produced during the operation. pub warnings: Vec, } @@ -112,7 +126,7 @@ pub async fn add_components( let mut all_deps: BTreeSet = BTreeSet::new(); let mut all_resolved: Vec = Vec::new(); let mut all_warnings: Vec = Vec::new(); - let mut seen_files: std::collections::HashSet = std::collections::HashSet::new(); + let mut seen_files: HashSet = HashSet::new(); for input in components { // Parse component name to extract registry prefix if present (e.g., @animate-ui/button) @@ -208,9 +222,8 @@ fn read_package_json_deps(app_dir: &Path) -> HashSet { let pkg_path = app_dir.join("package.json"); let mut deps = HashSet::new(); - let content = match std::fs::read_to_string(&pkg_path) { - Ok(c) => c, - Err(_) => return deps, + let Ok(content) = std::fs::read_to_string(&pkg_path) else { + return deps; }; let value: serde_json::Value = match serde_json::from_str(&content) { @@ -229,6 +242,7 @@ fn read_package_json_deps(app_dir: &Path) -> HashSet { deps } +/// Install npm packages via bun into the project. pub async fn bun_add(app_dir: &Path, deps: &[String]) -> Result<(), String> { if deps.is_empty() { return Ok(()); diff --git a/crates/core/src/components/cache.rs b/crates/core/src/components/cache.rs index e4a47563..0f9169fd 100644 --- a/crates/core/src/components/cache.rs +++ b/crates/core/src/components/cache.rs @@ -3,6 +3,8 @@ use crate::common::read_project_metadata; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; +use std::future::Future; +use std::hash::BuildHasher; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -25,7 +27,7 @@ const INITIAL_DELAY_MS: u64 = 125; async fn fetch_with_retry(operation: F, operation_name: &str) -> Result where F: Fn() -> Fut, - Fut: std::future::Future>, + Fut: Future>, { let mut last_error = String::new(); for attempt in 0..MAX_RETRIES { @@ -81,11 +83,15 @@ struct CachedRegistryIndex { /// Item from registry.json #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RegistryIndexItem { + /// Component name. pub name: String, + /// Brief description. #[serde(default)] pub description: Option, + /// npm dependencies. #[serde(default)] pub dependencies: Vec, + /// Other registry components this item depends on. #[serde(default, rename = "registryDependencies")] pub registry_dependencies: Vec, } @@ -182,18 +188,16 @@ pub fn load_cached_component( component_name: &str, registry_name: Option<&str>, ) -> Result)>, String> { - let cache_path = match get_component_cache_path(component_name, registry_name) { - Ok(path) => path, - Err(_) => return Ok(None), + let Ok(cache_path) = get_component_cache_path(component_name, registry_name) else { + return Ok(None); }; if !cache_path.exists() { return Ok(None); } - let content = match fs::read_to_string(&cache_path) { - Ok(c) => c, - Err(_) => return Ok(None), + let Ok(content) = fs::read_to_string(&cache_path) else { + return Ok(None); }; let cached: CachedItem = match serde_json::from_str(&content) { @@ -354,7 +358,8 @@ fn get_registry_index_url( RegistryConfig::Template(t) => t.clone(), RegistryConfig::Advanced(a) => a.url.clone(), }; - // Replace {name} with "registry" and remove {style} if present + // Reason: literal braces in URL template, not format arguments + #[allow(clippy::literal_string_with_formatting_args)] let url = template .replace("{name}", "registry") .replace("{style}", style); @@ -457,7 +462,9 @@ async fn fetch_and_cache_registry_index( } /// Check if any registry.json files need refresh (older than 1 hour) -pub fn needs_registry_refresh(registries: &HashMap) -> bool { +pub fn needs_registry_refresh( + registries: &HashMap, +) -> bool { // Check default registry if let Ok(path) = get_registry_index_path(None) && !is_file_fresh(&path, CACHE_TTL_HOURS) @@ -564,8 +571,9 @@ pub fn get_all_registry_indexes() -> Result = std::result::Result; /// High-level mutations requested by registry items. +// Reason: CSS operation names naturally share the 'Css' domain prefix #[allow(clippy::enum_variant_names)] #[derive(Debug)] pub enum CssMutation { /// Add a raw at-rule block (e.g. `@layer base`, `@keyframes foo`) - AddCssBlock { at_rule: String, body: String }, + AddCssBlock { + /// The at-rule identifier (e.g. `@layer base`, `@keyframes foo`). + at_rule: String, + /// The rule body content. + body: String, + }, - /// Add CSS variables to a selector (`:root`, `.dark`) + /// Add CSS variables to a selector (`:root`, `.dark`). AddCssVars { + /// CSS selector to target. selector: String, + /// Variable name-value pairs to add. vars: Vec<(String, String)>, }, - /// Add mappings inside `@theme inline` - AddThemeMappings { vars: Vec<(String, String)> }, + /// Add mappings inside `@theme inline`. + AddThemeMappings { + /// Theme variable name-value pairs. + vars: Vec<(String, String)>, + }, } /// Append-only CSS updater. @@ -59,6 +71,7 @@ pub struct CssUpdater { } impl CssUpdater { + /// Parse CSS source into an updater. pub fn new(source: &str) -> Result { let parsed = parse_css(source, CssParserOptions::default()); @@ -139,6 +152,7 @@ impl CssUpdater { Ok(changed) } + /// Consume the updater and return the final CSS source. pub fn finish(self) -> String { self.source } diff --git a/crates/core/src/components/mod.rs b/crates/core/src/components/mod.rs index 9ada69cb..0ad373d1 100644 --- a/crates/core/src/components/mod.rs +++ b/crates/core/src/components/mod.rs @@ -1,8 +1,14 @@ +/// High-level API for adding components to a project. pub mod add; +/// Registry index caching and refresh logic. pub mod cache; +/// Append-only CSS updater for component CSS variables and theme mappings. pub mod css_updater; +/// Data models for registry items, UI config, and related types. pub mod models; +/// Tailwind v3 config to CSS v4 transformer. pub mod tw_transform; +/// Path formatting and JSONC comment-stripping utilities. pub mod utils; // Re-export models for easier access @@ -18,6 +24,8 @@ pub use cache::{ use serde_json::Value; use std::collections::{BTreeSet, HashMap, HashSet}; +use std::future::Future; +use std::hash::BuildHasher; use std::path::{Path, PathBuf}; use std::time::Duration; use tracing::{debug, warn}; @@ -161,7 +169,7 @@ const INITIAL_DELAY_MS: u64 = 125; async fn fetch_with_retry(operation: F, operation_name: &str) -> Result where F: Fn() -> Fut, - Fut: std::future::Future>, + Fut: Future>, { let mut last_error = String::new(); for attempt in 0..MAX_RETRIES { @@ -189,6 +197,7 @@ where )) } +/// Fetch the upstream shadcn registry catalog, using cache when available. pub async fn fetch_registry_catalog_impl( client: &reqwest::Client, ) -> Result, String> { @@ -222,8 +231,9 @@ pub async fn fetch_registry_catalog_impl( Ok(catalog) } -pub fn merge_registries( - local: &HashMap, +/// Merge local (project-level) and discovered (catalog) registries, with local taking precedence. +pub fn merge_registries( + local: &HashMap, discovered: &[RegistryCatalogEntry], ) -> HashMap { let mut merged: HashMap = discovered @@ -243,34 +253,51 @@ pub fn merge_registries( merged } +/// A component that has been resolved from a registry. #[derive(Debug)] pub struct ResolvedComponent { + /// Component name. pub name: String, + /// Parsed registry item specification. pub spec: RegistryItem, + /// Registry the component was resolved from, if any. pub registry: Option, + /// Warnings produced during resolution. pub warnings: Vec, } +/// Plan describing which files and dependencies an `add` operation will produce. #[derive(Debug)] pub struct AddPlan { + /// Components included in the plan. pub components: Vec, + /// Files that will be written to disk. pub files_to_write: Vec, + /// npm dependency names required by the components. pub component_deps: BTreeSet, + /// Warnings collected during planning. pub warnings: Vec, } +/// A file that will be written by an add-component plan. #[derive(Debug)] pub struct PlannedFile { + /// Path relative to the project root. pub relative_path: PathBuf, + /// Absolute path on disk. pub absolute_path: PathBuf, + /// File content to write. pub content: String, + /// Name of the component that produced this file. pub source_component: String, } /// Request resolved from a registry definition (URL + optional headers/params). #[derive(Debug, Clone)] pub struct ResolvedRequest { + /// Fully resolved URL for the component spec. pub url: Url, + /// Extra HTTP headers to send with the request. pub headers: HashMap, } @@ -304,6 +331,8 @@ pub fn resolve_component_request( let style = cfg.style(); // 2) Default registry: shadcn/ui + // Reason: literal braces in user-facing message, not format arguments + #[allow(clippy::literal_string_with_formatting_args)] if registry.is_none() { let url_candidate = SHADCN_REGISTRY_ITEM_TEMPLATE .replace("{style}", style) @@ -319,7 +348,7 @@ pub fn resolve_component_request( ); return Ok(ResolvedRequest { - url: url.clone(), + url, headers: HashMap::new(), }); } @@ -357,7 +386,7 @@ pub fn resolve_component_request( ); Ok(ResolvedRequest { - url: url.clone(), + url, headers: HashMap::new(), }) } @@ -397,6 +426,7 @@ pub fn resolve_component_request( } } +/// Fetch a single component spec from its resolved request, using cache when available. pub async fn fetch_component_impl( client: &reqwest::Client, req: &ResolvedRequest, @@ -451,7 +481,7 @@ pub(crate) async fn fetch_http_component( .map_err(|e| format!("Failed to fetch component: {e}"))? .error_for_status() .map_err(|e| format!("Registry returned error: {e}"))? - .json::() + .json::() .await .map_err(|e| format!("Invalid component spec: {e}")) } @@ -476,13 +506,13 @@ async fn fetch_file_component( let path = req .url .to_file_path() - .map_err(|_| format!("Invalid file URL: {}", req.url))?; + .map_err(|()| format!("Invalid file URL: {}", req.url))?; let text = tokio::fs::read_to_string(&path) .await .map_err(|e| format!("Failed to read registry file {}: {e}", path.display()))?; - let value: serde_json::Value = + let value: Value = serde_json::from_str(&text).map_err(|e| format!("Invalid component spec: {e}"))?; let warnings = detect_forbidden_fields(&value); @@ -495,6 +525,7 @@ async fn fetch_file_component( Ok((item, warnings)) } +/// Recursively resolve a component and all its transitive dependencies. pub async fn resolve_component_closure( client: &reqwest::Client, cfg: &UiConfig, @@ -566,7 +597,7 @@ pub async fn resolve_component_closure( .await?; for dep in &spec.dependencies { - component_deps.insert(dep.to_string()); + component_deps.insert(dep.clone()); } stack.push(( @@ -616,6 +647,7 @@ pub async fn resolve_component_closure( Ok(ordered) } +/// Build an add-component plan without writing any files. pub async fn plan_add( client: &reqwest::Client, _app_dir: &Path, @@ -662,7 +694,7 @@ pub async fn plan_add( for resolved in &components { warnings.extend(resolved.warnings.clone()); for dep in &resolved.spec.dependencies { - component_deps.insert(dep.to_string()); + component_deps.insert(dep.clone()); } for file in &resolved.spec.files { @@ -729,9 +761,8 @@ enum OutputRoot { fn determine_output_root(file_type: Option<&str>) -> OutputRoot { match file_type { - Some("registry:ui") => OutputRoot::Components, Some("registry:hook") => OutputRoot::Hooks, - Some("registry:lib") | Some("registry:file") => OutputRoot::Lib, + Some("registry:lib" | "registry:file") => OutputRoot::Lib, _ => OutputRoot::Components, } } @@ -842,6 +873,8 @@ fn rewrite_registry_imports(content: &str) -> String { tw_transform::transform_tailwind_v3_to_v4(&result) } +// Reason: literal braces in code template, not format arguments +#[allow(clippy::literal_string_with_formatting_args)] fn apply_placeholders(template: &str, name: &str, style: &str) -> Result { if !template.contains("{name}") { return Err("Registry template missing {name} placeholder".to_string()); @@ -909,9 +942,8 @@ fn parse_registry_dependency( fn detect_forbidden_fields(value: &Value) -> Vec { let mut warnings = Vec::new(); - let obj = match value.as_object() { - Some(obj) => obj, - None => return warnings, + let Some(obj) = value.as_object() else { + return warnings; }; let name = obj .get("name") @@ -1038,6 +1070,7 @@ fn render_declaration_value(value: &Value) -> Result { } } +/// Apply a set of CSS mutations to the file at `css_path`. pub fn apply_css_updates(css_path: &Path, mutations: Vec) -> Result<(), String> { let source = std::fs::read_to_string(css_path).map_err(|e| format!("Failed to read CSS file: {e}"))?; @@ -1259,7 +1292,6 @@ mod tests { RegistryFile { path: path.to_string(), content: String::new(), - target: None, file_type: Some(file_type.to_string()), } } diff --git a/crates/core/src/components/models.rs b/crates/core/src/components/models.rs index 1b6fa4d9..1a92e38f 100644 --- a/crates/core/src/components/models.rs +++ b/crates/core/src/components/models.rs @@ -8,7 +8,9 @@ use crate::common::ProjectMetadata; /// UI configuration derived from pyproject.toml [tool.apx.ui] #[derive(Debug, Clone)] pub struct UiConfig { + /// Root directory of the frontend UI sources. pub root: PathBuf, + /// Named component registries (local overrides and catalog entries). pub registries: HashMap, } @@ -36,7 +38,7 @@ impl UiConfig { } /// Hardcoded shadcn style - pub fn style(&self) -> &str { + pub fn style(&self) -> &'static str { "new-york" } @@ -61,54 +63,76 @@ impl UiConfig { } } +/// A component registry configuration, either a simple URL template or an advanced config. #[derive(Debug, Clone, Deserialize)] #[serde(untagged)] pub enum RegistryConfig { + /// Simple URL template with `{name}` / `{style}` placeholders. Template(String), + /// Advanced registry with custom headers and parameters. Advanced(RegistryAdvanced), } +/// Advanced registry configuration with URL, headers, and query parameters. #[derive(Debug, Clone, Deserialize)] pub struct RegistryAdvanced { + /// Base URL template. pub url: String, + /// Extra HTTP headers to include. #[serde(default)] pub headers: HashMap, + /// Extra query / template parameters. #[serde(default)] pub params: HashMap, } +/// An entry from the upstream shadcn registry catalog. #[derive(Debug, Deserialize, serde::Serialize, Clone)] pub struct RegistryCatalogEntry { + /// Registry name (used as the `@name` prefix). pub name: String, + /// URL template for fetching component specs. pub url: String, // #[serde(default)] // pub homepage: Option, } +/// CSS rules represented as a JSON object. pub type CssRules = Map; -#[derive(Debug, Deserialize, serde::Serialize, Clone)] +/// The type of a registry item. +#[derive(Debug, Deserialize, serde::Serialize, Clone, Copy)] pub enum RegistryItemType { + /// A block-level layout component. #[serde(rename = "registry:block")] Block, + /// A UI component. #[serde(rename = "registry:component")] Component, + /// A library utility module. #[serde(rename = "registry:lib")] Lib, + /// A React hook. #[serde(rename = "registry:hook")] Hook, + /// A UI primitive. #[serde(rename = "registry:ui")] Ui, + /// A full page template. #[serde(rename = "registry:page")] Page, + /// A standalone file. #[serde(rename = "registry:file")] File, + /// A style definition. #[serde(rename = "registry:style")] Style, + /// A theme definition. #[serde(rename = "registry:theme")] Theme, + /// A generic registry item. #[serde(rename = "registry:item")] Item, } @@ -116,47 +140,63 @@ pub enum RegistryItemType { /// Component JSON (registry item) #[derive(Debug, Deserialize, serde::Serialize, Clone)] pub struct RegistryItem { + /// Component name. pub name: String, + /// Human-readable title. #[serde(default)] pub title: Option, + /// Brief description. #[serde(default)] pub description: Option, + /// Type of registry item. #[serde(rename = "type")] pub item_type: RegistryItemType, + /// Source files included in this item. pub files: Vec, + /// npm package dependencies. #[serde(default)] pub dependencies: Vec, + /// Other registry components this item depends on. #[serde(default, rename = "registryDependencies")] pub registry_dependencies: Vec, + /// CSS custom property overrides. #[serde(default, rename = "cssVars")] pub css_vars: Option, + /// Raw CSS rules to inject. #[serde(default)] pub css: Option, - /// Deprecated Tailwind v3 config - converted to CSS for Tailwind v4 + /// Deprecated Tailwind v3 config - converted to CSS for Tailwind v4. #[serde(default)] pub tailwind: Option, + /// Documentation URL. #[serde(default)] pub docs: Option, + /// Category tags for search and filtering. #[serde(default)] pub categories: Vec, + /// Arbitrary metadata. #[serde(default)] pub meta: Option, } +/// CSS custom properties scoped to theme/light/dark modes. #[derive(Debug, Deserialize, serde::Serialize, Clone)] pub struct CssVars { + /// Base theme variables. #[serde(default)] pub theme: HashMap, + /// Light-mode overrides. #[serde(default)] pub light: HashMap, + /// Dark-mode overrides. #[serde(default)] pub dark: HashMap, } @@ -165,22 +205,28 @@ pub struct CssVars { /// Structure: { config: { theme: { extend: { colors, keyframes, animation } } } } #[derive(Debug, Deserialize, serde::Serialize, Clone, Default)] pub struct TailwindConfig { + /// Optional inner config object. #[serde(default)] pub config: Option, } +/// Inner Tailwind configuration wrapping theme settings. #[derive(Debug, Deserialize, serde::Serialize, Clone, Default)] pub struct TailwindConfigInner { + /// Theme customization block. #[serde(default)] pub theme: Option, } +/// Tailwind theme configuration. #[derive(Debug, Deserialize, serde::Serialize, Clone, Default)] pub struct TailwindTheme { + /// Extended theme values (colors, keyframes, etc.). #[serde(default)] pub extend: Option, } +/// Tailwind theme extensions (colors, keyframes, animations, etc.). #[derive(Debug, Deserialize, serde::Serialize, Clone, Default)] pub struct TailwindThemeExtend { /// Color definitions - can be simple or nested: @@ -198,7 +244,7 @@ pub struct TailwindThemeExtend { #[serde(default)] pub animation: HashMap, - /// Font family definitions: { "heading": ["Poppins", "sans-serif"] } + /// Font family definitions: `{ "heading": ["Poppins", "sans-serif"] }` /// Converted to @theme inline { --font-{name}: value; } #[serde(default, rename = "fontFamily")] pub font_family: HashMap, @@ -214,17 +260,15 @@ pub struct TailwindThemeExtend { pub spacing: HashMap, } +/// A source file within a registry item. #[derive(Debug, Deserialize, serde::Serialize, Clone)] pub struct RegistryFile { + /// Relative output path for this file. pub path: String, + /// File content. pub content: String, - /// Some registry items include "target" (often empty). Keep it optional. - #[allow(dead_code)] - #[serde(default)] - pub target: Option, - - #[allow(dead_code)] + /// Optional file type hint. #[serde(default, rename = "type")] pub file_type: Option, } diff --git a/crates/core/src/components/tw_transform.rs b/crates/core/src/components/tw_transform.rs index 792b850f..28805e79 100644 --- a/crates/core/src/components/tw_transform.rs +++ b/crates/core/src/components/tw_transform.rs @@ -532,7 +532,7 @@ struct Span { impl Span { /// Convert this span into a `Region` borrowing from `source`. /// Returns `None` if the span is empty (start == end). - fn into_region<'a>(self, source: &'a str) -> Option> { + fn into_region(self, source: &str) -> Option> { if self.end > self.start { Some(Region { text: &source[self.start..self.end], @@ -748,7 +748,7 @@ fn rfind_variant_colon(token: &str) -> Option> { Some(CssSyntax::OpenBracket | CssSyntax::OpenParen) => depth -= 1, Some(CssSyntax::Colon) if depth == 0 => { return Some(VariantSplit { - prefix: &token[..pos + 1], + prefix: &token[..=pos], after_colon: &token[pos + 1..], }); } diff --git a/crates/core/src/components/utils.rs b/crates/core/src/components/utils.rs index ae30b70c..cc2ca460 100644 --- a/crates/core/src/components/utils.rs +++ b/crates/core/src/components/utils.rs @@ -1,143 +1,12 @@ use std::path::Path; -use tracing::trace; - -/// Strip JSONC comments from input. -/// -/// Supported: -/// - `// line comments` -/// - `/* block comments */` -/// -/// Guarantees: -/// - Does NOT strip comment markers inside string literals -/// - Preserves newlines to keep line numbers stable -#[allow(dead_code)] -pub fn strip_jsonc_comments(input: &str) -> String { - use tracing::debug; - - debug!( - input_length = input.len(), - "Starting JSONC comment stripping" - ); - - let mut out = String::with_capacity(input.len()); - - let mut chars = input.chars().peekable(); - - let mut in_string = false; - let mut in_line_comment = false; - let mut in_block_comment = false; - let mut prev_was_escape = false; - let mut line_num = 1; - let mut col_num = 0; - let mut chars_processed = 0; - let mut comments_found = 0; - let mut line_comments = 0; - let mut block_comments = 0; - - while let Some(c) = chars.next() { - chars_processed += 1; - col_num += 1; - - if c == '\n' { - line_num += 1; - col_num = 0; - } - - if in_line_comment { - if c == '\n' { - trace!(line = line_num, col = col_num, "Ending line comment"); - in_line_comment = false; - out.push('\n'); - } - continue; - } - - if in_block_comment { - if c == '*' && matches!(chars.peek(), Some('/')) { - chars.next(); // consume '/' - trace!(line = line_num, col = col_num, "Ending block comment"); - in_block_comment = false; - } else if c == '\n' { - // preserve newlines - out.push('\n'); - } - continue; - } - - match c { - '"' if !prev_was_escape => { - in_string = !in_string; - trace!( - line = line_num, - col = col_num, - in_string, - "String literal {}", - if in_string { "started" } else { "ended" } - ); - out.push(c); - } - - '/' if !in_string => match chars.peek() { - Some('/') => { - chars.next(); - line_comments += 1; - comments_found += 1; - trace!(line = line_num, col = col_num, "Found line comment (//)"); - in_line_comment = true; - } - Some('*') => { - chars.next(); - block_comments += 1; - comments_found += 1; - trace!( - line = line_num, - col = col_num, - "Found block comment start (/*)" - ); - in_block_comment = true; - } - _ => { - trace!( - line = line_num, - col = col_num, - next_char = ?chars.peek(), - "Forward slash not part of comment, preserving" - ); - out.push(c); - } - }, - - '\\' if in_string => { - trace!(line = line_num, col = col_num, "Escape character in string"); - out.push(c); - } - - _ => out.push(c), - } - - prev_was_escape = c == '\\' && !prev_was_escape; - } - - debug!( - output_length = out.len(), - chars_processed, - comments_found, - line_comments, - block_comments, - reduction_bytes = input.len().saturating_sub(out.len()), - "Comment stripping complete" - ); - - out -} /// Format a path as relative to the app directory, with ./ prefix and cleaned up ././ patterns. pub fn format_relative_path(path: &Path, app_dir: &Path) -> String { path.strip_prefix(app_dir) - .map(format_relative_string) - .unwrap_or_else(|_| path.display().to_string()) + .map_or_else(|_| path.display().to_string(), format_relative_string) } +/// Format a path as a clean relative string, stripping a leading `./` if present. pub fn format_relative_string(path: &Path) -> String { let s = path.to_string_lossy().to_string(); // Remove leading ./ if present diff --git a/crates/core/src/dev/backend.rs b/crates/core/src/dev/backend.rs index 7e5de83a..bbdb514f 100644 --- a/crates/core/src/dev/backend.rs +++ b/crates/core/src/dev/backend.rs @@ -46,7 +46,7 @@ const DEBOUNCE_MS: u64 = 150; /// All immutable and shared-state values needed to construct a [`Backend`]. /// Avoids a 12-parameter positional constructor. -pub(crate) struct BackendConfig { +pub struct BackendConfig { pub app_dir: PathBuf, pub app_slug: String, pub app_entrypoint: String, @@ -67,7 +67,7 @@ pub(crate) struct BackendConfig { /// Self-contained backend (uvicorn) lifecycle manager. /// `ProcessManager` interacts only through this API. -pub(crate) struct Backend { +pub struct Backend { child: Arc>>, cfg: BackendConfig, } @@ -333,8 +333,8 @@ impl DevProcess for Backend { drop(guard); match http_health_probe(CLIENT_HOST, self.cfg.backend_port).await { - ProbeResult::Responded(_) => "healthy", - ProbeResult::Failed(_) => "starting", + ProbeResult::Responded => "healthy", + ProbeResult::Failed => "starting", } } } diff --git a/crates/core/src/dev/client.rs b/crates/core/src/dev/client.rs index fc27a9a3..cf6f3a0e 100644 --- a/crates/core/src/dev/client.rs +++ b/crates/core/src/dev/client.rs @@ -44,14 +44,13 @@ pub enum HealthError { impl std::fmt::Display for HealthError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::ConnectionFailed(msg) => write!(f, "{msg}"), - Self::ServerError(msg) => write!(f, "{msg}"), + Self::ConnectionFailed(msg) | Self::ServerError(msg) => write!(f, "{msg}"), } } } /// Configuration for health check waiting behavior -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Copy)] pub struct HealthCheckConfig { /// Total timeout for health checks (in seconds) pub timeout_secs: u64, @@ -71,54 +70,18 @@ impl Default for HealthCheckConfig { } } -/// Wait for the dev server to become healthy. -/// Returns Ok(()) if healthy, Err with message if timeout exceeded. -#[allow(dead_code)] -pub async fn wait_for_healthy(port: u16, config: &HealthCheckConfig) -> Result<(), String> { - use std::time::Instant; - - // Give server time to start Python/tokio before polling - tokio::time::sleep(Duration::from_millis(config.initial_delay_ms)).await; - - let deadline = Instant::now() + Duration::from_secs(config.timeout_secs); - let mut first_attempt = true; - - while Instant::now() < deadline { - match status(port).await { - Ok(status_response) if status_response.status == "ok" => return Ok(()), - Ok(status_response) => { - // Log which services aren't ready yet (only on first attempt) - if first_attempt { - debug!( - "Services not ready - frontend: {}, backend: {}, db: {}", - status_response.frontend_status, - status_response.backend_status, - status_response.db_status - ); - first_attempt = false; - } - tokio::time::sleep(Duration::from_millis(config.retry_delay_ms)).await; - } - Err(e) => { - debug!("Health check error: {e}"); - tokio::time::sleep(Duration::from_millis(config.retry_delay_ms)).await; - } - } - } - - Err(format!( - "Dev server failed to become healthy after {}s timeout", - config.timeout_secs - )) -} - +/// Response from the dev server status endpoint. #[derive(Debug, Deserialize)] pub struct StatusResponse { + /// Overall server status. pub status: String, + /// Frontend process status. pub frontend_status: String, + /// Backend process status. pub backend_status: String, + /// Embedded database status. pub db_status: String, - /// True if any critical process (frontend/backend) has permanently failed and cannot recover + /// True if any critical process (frontend/backend) has permanently failed and cannot recover. pub failed: bool, } @@ -126,6 +89,7 @@ fn build_url(host: &str, port: u16, path: &str) -> String { format!("http://{host}:{port}{path}") } +/// Check if the dev server at the given port is healthy. pub async fn health(port: u16) -> Result { let url = build_url(CLIENT_HOST, port, "/_apx/health"); debug!(%url, "Sending dev server health request."); diff --git a/crates/core/src/dev/common.rs b/crates/core/src/dev/common.rs index db79c8ea..eda0092e 100644 --- a/crates/core/src/dev/common.rs +++ b/crates/core/src/dev/common.rs @@ -38,12 +38,11 @@ pub(crate) static HEALTH_CLIENT: LazyLock = LazyLock::new(|| { }); /// Result of an HTTP health probe against a backend/frontend service. -#[allow(dead_code)] pub(crate) enum ProbeResult { - /// Service responded with the given HTTP status code — it is up. - Responded(u16), + /// Service responded with an HTTP status code — it is up. + Responded, /// Connection or timeout error — service is not ready yet. - Failed(String), + Failed, } /// Probe a service by making an HTTP GET request to its root path. @@ -61,12 +60,12 @@ pub(crate) async fn http_health_probe(host: &str, port: u16) -> ProbeResult { } else { warn!(url = %url, status, elapsed_ms, "Health probe returned non-200"); } - ProbeResult::Responded(status) + ProbeResult::Responded } Err(err) => { let elapsed_ms = start.elapsed().as_millis(); debug!(url = %url, error = %err, elapsed_ms, "Health probe failed"); - ProbeResult::Failed(err.to_string()) + ProbeResult::Failed } } } @@ -79,28 +78,45 @@ pub enum Shutdown { Stop, } +/// Directory name for the dev lock file. pub const DEV_LOCK_DIR: &str = ".apx"; +/// Lock file name within the dev lock directory. pub const DEV_LOCK_FILE: &str = "dev.lock"; +/// Start of the frontend port range. pub const FRONTEND_PORT_START: u16 = 5000; +/// End of the frontend port range. pub const FRONTEND_PORT_END: u16 = 5999; +/// Start of the backend port range. pub const BACKEND_PORT_START: u16 = 8000; +/// End of the backend port range. pub const BACKEND_PORT_END: u16 = 8999; +/// Start of the dev server port range. pub const DEV_PORT_START: u16 = 9000; +/// Start of the embedded database port range. pub const DB_PORT_START: u16 = 4000; +/// End of the embedded database port range. pub const DB_PORT_END: u16 = 4999; +/// Serialized lock file for a running dev server instance. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DevLock { + /// OS process ID of the dev server. pub pid: u32, + /// RFC 3339 timestamp of when the server started. pub started_at: String, + /// Port the dev server is listening on. pub port: u16, + /// Command string used to start the server. pub command: String, + /// Absolute path to the application directory. pub app_dir: String, + /// Authentication token for control endpoints. #[serde(default, skip_serializing_if = "Option::is_none")] pub token: Option, } impl DevLock { + /// Create a new lock with the current UTC timestamp. pub fn new(pid: u32, port: u16, command: String, app_dir: &Path, token: String) -> Self { let started_at: DateTime = Utc::now(); Self { @@ -114,20 +130,24 @@ impl DevLock { } } +/// Return the `.apx` lock directory for the given app. pub fn lock_dir(app_dir: &Path) -> PathBuf { app_dir.join(DEV_LOCK_DIR) } +/// Return the full path to the dev lock file for the given app. pub fn lock_path(app_dir: &Path) -> PathBuf { lock_dir(app_dir).join(DEV_LOCK_FILE) } +/// Read and deserialize a dev lock file. pub fn read_lock(path: &Path) -> Result { let contents = fs::read_to_string(path).map_err(|err| format!("Failed to read lockfile: {err}"))?; serde_json::from_str(&contents).map_err(|err| format!("Invalid lockfile JSON: {err}")) } +/// Serialize and write a dev lock file, creating parent directories if needed. pub fn write_lock(path: &Path, lock: &DevLock) -> Result<(), String> { if let Some(parent) = path.parent() { ensure_dir(parent)?; @@ -137,6 +157,7 @@ pub fn write_lock(path: &Path, lock: &DevLock) -> Result<(), String> { fs::write(path, contents).map_err(|err| format!("Failed to write lockfile: {err}")) } +/// Remove a dev lock file if it exists. pub fn remove_lock(path: &Path) -> Result<(), String> { if path.exists() { fs::remove_file(path).map_err(|err| format!("Failed to remove lockfile: {err}"))?; @@ -205,7 +226,7 @@ pub(crate) async fn stop_child_tree(name: &str, child: &Arc> match timeout(Duration::from_secs(2), child.wait()).await { Ok(Ok(status)) => debug!(process = name, ?status, "Child process exited."), Ok(Err(err)) => { - warn!(error = %err, process = name, "Failed to wait for child process.") + warn!(error = %err, process = name, "Failed to wait for child process."); } Err(_) => warn!( process = name, diff --git a/crates/core/src/dev/embedded_db.rs b/crates/core/src/dev/embedded_db.rs index c94d4d13..94b06e75 100644 --- a/crates/core/src/dev/embedded_db.rs +++ b/crates/core/src/dev/embedded_db.rs @@ -45,7 +45,7 @@ const DEFAULT_DB: &str = "postgres"; /// Self-contained embedded database lifecycle manager. /// Encapsulates PGlite spawning, readiness polling, credential rotation, /// and health monitoring. ProcessManager interacts only through this API. -pub(crate) struct EmbeddedDb { +pub struct EmbeddedDb { child: Arc>>, port: u16, password: String, @@ -73,7 +73,7 @@ impl EmbeddedDb { let bun = Bun::new().await?; let password = token::generate(); - let child = Self::spawn_pglite(&bun, app_dir, host, port, app_slug).await?; + let child = Self::spawn_pglite(&bun, app_dir, host, port, app_slug)?; let child = Arc::new(Mutex::new(Some(child))); Self::wait_for_ready(port).await?; @@ -89,11 +89,6 @@ impl EmbeddedDb { }) } - #[allow(dead_code)] - pub fn port(&self) -> u16 { - self.port - } - pub fn password(&self) -> &str { &self.password } @@ -119,7 +114,7 @@ impl EmbeddedDb { // -- private helpers -- - async fn spawn_pglite( + fn spawn_pglite( bun: &Bun, app_dir: &Path, host: &str, @@ -271,22 +266,21 @@ impl EmbeddedDb { } let mut guard = child.lock().await; - match guard.as_mut() { - Some(c) => match c.try_wait() { + if let Some(c) = guard.as_mut() { + match c.try_wait() { Ok(Some(status)) => { warn!("Embedded database exited early with status: {:?}", status); break; } - Ok(None) => continue, + Ok(None) => {} Err(e) => { warn!("Failed to check embedded database status: {}", e); break; } - }, - None => { - warn!("Embedded database process handle lost"); - break; } + } else { + warn!("Embedded database process handle lost"); + break; } } }); diff --git a/crates/core/src/dev/frontend.rs b/crates/core/src/dev/frontend.rs index 7c37770b..a95ea16a 100644 --- a/crates/core/src/dev/frontend.rs +++ b/crates/core/src/dev/frontend.rs @@ -20,7 +20,7 @@ use apx_common::hosts::CLIENT_HOST; // --------------------------------------------------------------------------- /// All immutable and shared-state values needed to construct a [`Frontend`]. -pub(crate) struct FrontendConfig { +pub struct FrontendConfig { pub app_dir: PathBuf, pub app_slug: String, pub host: String, @@ -37,7 +37,7 @@ pub(crate) struct FrontendConfig { /// Self-contained frontend (Vite/Bun) lifecycle manager. /// `ProcessManager` interacts only through this API. -pub(crate) struct Frontend { +pub struct Frontend { child: Arc>>, cfg: FrontendConfig, } @@ -135,8 +135,8 @@ impl DevProcess for Frontend { drop(guard); match http_health_probe(CLIENT_HOST, self.cfg.frontend_port).await { - ProbeResult::Responded(_) => "healthy", - ProbeResult::Failed(_) => "starting", + ProbeResult::Responded => "healthy", + ProbeResult::Failed => "starting", } } } diff --git a/crates/core/src/dev/logging.rs b/crates/core/src/dev/logging.rs index d1c36d32..c312eb93 100644 --- a/crates/core/src/dev/logging.rs +++ b/crates/core/src/dev/logging.rs @@ -5,9 +5,14 @@ use serde::Deserialize; /// Browser log payload received from frontend via POST /_apx/logs #[derive(Debug, Deserialize)] pub struct BrowserLogPayload { + /// Log level (e.g. `"error"`, `"warn"`, `"info"`). pub level: String, + /// Log source identifier (e.g. `"console"`, `"onerror"`). pub source: String, + /// The log message text. pub message: String, + /// Optional JavaScript stack trace. pub stack: Option, + /// Unix timestamp in milliseconds. pub timestamp: i64, } diff --git a/crates/core/src/dev/mod.rs b/crates/core/src/dev/mod.rs index 98ea1e74..177c0c88 100644 --- a/crates/core/src/dev/mod.rs +++ b/crates/core/src/dev/mod.rs @@ -1,12 +1,19 @@ pub(crate) mod backend; +/// HTTP client for dev server health checks and control endpoints. pub mod client; +/// Shared types and constants for dev server management. pub mod common; pub(crate) mod embedded_db; pub(crate) mod frontend; pub mod logging; +/// OpenTelemetry log forwarding to the flux collector. pub mod otel; +/// Subprocess management for backend and frontend processes. pub mod process; +/// Reverse proxy layer for API and UI requests. pub mod proxy; +/// Axum-based dev server entry point and configuration. pub mod server; +/// Dev token generation for inter-process authentication. pub mod token; pub(crate) mod watcher; diff --git a/crates/core/src/dev/process.rs b/crates/core/src/dev/process.rs index ac9924ad..ff534577 100644 --- a/crates/core/src/dev/process.rs +++ b/crates/core/src/dev/process.rs @@ -24,6 +24,7 @@ use crate::dev::embedded_db::EmbeddedDb; use crate::dev::frontend::{Frontend, FrontendConfig}; use crate::dotenv::DotenvFile; +/// Manages the lifecycle of dev server child processes (backend, frontend, db). #[derive(Debug)] pub struct ProcessManager { frontend: Option>, @@ -57,7 +58,7 @@ impl ProcessManager { let dotenv_vars = Arc::new(Mutex::new(dotenv.get_vars())); let app_slug = metadata.app_slug.clone(); let app_entrypoint = metadata.app_entrypoint.clone(); - let dev_config = metadata.dev_config.clone(); + let dev_config = metadata.dev_config; let app_dir = app_dir .canonicalize() @@ -168,15 +169,11 @@ impl ProcessManager { }); } + /// Return the dev authentication token. pub fn dev_token(&self) -> &str { self.backend.dev_token() } - #[allow(dead_code)] - pub fn app_dir(&self) -> &Path { - &self.app_dir - } - /// Stop all managed processes using a phased shutdown approach: /// 1. Send SIGTERM to allow graceful shutdown /// 2. Wait briefly for processes to exit @@ -262,6 +259,7 @@ impl ProcessManager { self.frontend.is_some() } + /// Restart the backend (uvicorn) process with updated environment variables. pub async fn restart_uvicorn_with_env( &self, new_vars: HashMap, @@ -300,7 +298,7 @@ impl ProcessManager { match child.wait().await { Ok(status) => debug!(process = name, ?status, "Child process exited."), Err(err) => { - warn!(error = %err, process = name, "Failed to wait for child.") + warn!(error = %err, process = name, "Failed to wait for child."); } } } @@ -410,7 +408,7 @@ impl ProcessManager { /// Async wrapper for send_signal_to_tree that runs on a blocking thread. async fn send_signal_to_tree(pid: u32, signal: Signal, label: String) { let _ = tokio::task::spawn_blocking(move || { - Self::send_signal_to_tree_blocking(pid, signal, &label) + Self::send_signal_to_tree_blocking(pid, signal, &label); }) .await; } diff --git a/crates/core/src/dev/proxy.rs b/crates/core/src/dev/proxy.rs index cb197e2a..78e9f8cf 100644 --- a/crates/core/src/dev/proxy.rs +++ b/crates/core/src/dev/proxy.rs @@ -77,16 +77,19 @@ fn should_log_request(path: &str, is_ui: bool) -> bool { true } +/// Manages OAuth token refresh for Databricks API proxy requests. #[derive(Debug)] pub struct TokenManager { client: Option, } impl TokenManager { + /// Create a new token manager with an optional Databricks client. pub fn new(client: Option) -> Self { Self { client } } + /// Retrieve a fresh OAuth access token, refreshing if necessary. pub async fn get_token_refreshing_if_needed(&self) -> Option { let client = self.client.as_ref()?; match client.access_token().await { @@ -99,20 +102,31 @@ impl TokenManager { } } +/// Shared state for the API reverse proxy. #[derive(Clone, Debug)] pub struct ApiProxyState { + /// HTTP client used for proxied requests. pub client: reqwest::Client, + /// Backend host address. pub host: String, + /// Backend port. pub port: u16, + /// Token manager for OAuth header injection. pub token_manager: Arc, + /// Pre-computed forwarded user header value. pub forwarded_user_header: Option, } +/// Shared state for the UI reverse proxy. #[derive(Clone, Debug)] pub struct UiProxyState { + /// HTTP client used for proxied requests. pub client: reqwest::Client, + /// Frontend host address. pub host: String, + /// Frontend port. pub port: u16, + /// Dev token for authenticating proxy requests. pub dev_token: String, } @@ -184,10 +198,7 @@ async fn api_proxy_handler(State(state): State, req: Request, req: Request) let path_and_query = req .uri() .path_and_query() - .map(|pq| pq.as_str()) - .unwrap_or("/") + .map_or("/", |pq| pq.as_str()) .to_string(); proxy_request( req, @@ -255,6 +264,7 @@ async fn ui_proxy_handler(State(state): State, req: Request) .await } +// Reason: proxy forwarding inherently requires many context parameters #[allow(clippy::too_many_arguments)] async fn proxy_request( req: Request, @@ -292,6 +302,7 @@ async fn proxy_request( .await } +// Reason: proxy forwarding inherently requires many context parameters #[allow(clippy::too_many_arguments)] async fn proxy_http( req: Request, @@ -323,7 +334,7 @@ async fn proxy_http( } }; let mut builder = client.request(parts.method, url); - for (name, value) in parts.headers.iter() { + for (name, value) in &parts.headers { if is_hop_header(name.as_str()) { continue; } @@ -387,7 +398,7 @@ async fn proxy_http( let body_stream = response.bytes_stream(); let mut builder = Response::builder().status(status); - for (name, value) in headers.iter() { + for (name, value) in &headers { if is_hop_header(name.as_str()) { continue; } @@ -461,7 +472,7 @@ async fn proxy_websocket( None => break, } } - _ = tokio::time::sleep(idle_timeout) => { + () = tokio::time::sleep(idle_timeout) => { debug!("WebSocket idle timeout, closing proxy connection."); break; } @@ -512,7 +523,7 @@ fn is_websocket_request(headers: &HeaderMap) -> bool { fn filter_headers(headers: HeaderMap) -> HeaderMap { let mut filtered = HeaderMap::new(); - for (name, value) in headers.iter() { + for (name, value) in &headers { if is_hop_header(name.as_str()) { continue; } diff --git a/crates/core/src/dev/server.rs b/crates/core/src/dev/server.rs index 5f134091..18a21161 100644 --- a/crates/core/src/dev/server.rs +++ b/crates/core/src/dev/server.rs @@ -50,11 +50,17 @@ struct HealthResponse { /// All values needed to start the dev server's Axum instance + process manager. #[derive(Debug)] pub struct ServerConfig { + /// Application directory. pub app_dir: PathBuf, + /// Pre-bound TCP listener for the main server port. pub listener: tokio::net::TcpListener, + /// Port assigned to the backend (uvicorn) subprocess. pub backend_port: u16, + /// Port assigned to the frontend subprocess, if the project has a UI. pub frontend_port: Option, + /// Port assigned to the embedded database subprocess. pub db_port: u16, + /// Authentication token for dev control endpoints. pub dev_token: String, } @@ -96,24 +102,21 @@ pub async fn run_server(config: ServerConfig) -> Result<(), String> { // Resolve Databricks profile from env or .env file let profile = resolve_databricks_profile(&app_dir); - let databricks_client = match &profile { - Some(p) => { - match DatabricksClient::with_product(p, "apx", env!("CARGO_PKG_VERSION")).await { - Ok(client) => Some(client), - Err(err) => { - warn!( - "Failed to create Databricks client: {err}. API proxy will not forward authentication headers." - ); - None - } + let databricks_client = if let Some(p) = &profile { + match DatabricksClient::with_product(p, "apx", env!("CARGO_PKG_VERSION")).await { + Ok(client) => Some(client), + Err(err) => { + warn!( + "Failed to create Databricks client: {err}. API proxy will not forward authentication headers." + ); + None } } - None => { - warn!( - "No Databricks profile configured. API proxy will not forward authentication headers." - ); - None - } + } else { + warn!( + "No Databricks profile configured. API proxy will not forward authentication headers." + ); + None }; // Compute forwarded user header once at startup @@ -228,11 +231,12 @@ pub async fn run_server(config: ServerConfig) -> Result<(), String> { Ok(Shutdown::Stop) => { debug!("Stop signal received, shutting down server."); // Give process_manager.stop() a hard deadline to prevent indefinite hangs - match tokio::time::timeout(Duration::from_secs(10), process_manager.stop()) - .await + if let Err(_elapsed) = + tokio::time::timeout(Duration::from_secs(10), process_manager.stop()).await { - Ok(()) => debug!("Process shutdown complete."), - Err(_) => warn!("Process shutdown timed out after 10s, forcing exit."), + warn!("Process shutdown timed out after 10s, forcing exit."); + } else { + debug!("Process shutdown complete."); } // Remove lock file after processes are stopped @@ -459,7 +463,7 @@ async fn stop(headers: HeaderMap, State(state): State) -> StatusCode { } /// Resolve the Databricks profile name from env var or `.env` file. -fn resolve_databricks_profile(app_dir: &std::path::Path) -> Option { +pub fn resolve_databricks_profile(app_dir: &std::path::Path) -> Option { std::env::var("DATABRICKS_CONFIG_PROFILE").ok().or_else(|| { DotenvFile::read(&app_dir.join(".env")) .ok() diff --git a/crates/core/src/dev/watcher.rs b/crates/core/src/dev/watcher.rs index b8b0220d..fe03adde 100644 --- a/crates/core/src/dev/watcher.rs +++ b/crates/core/src/dev/watcher.rs @@ -4,6 +4,7 @@ //! that handle the common `tokio::spawn` + `loop` + `select!` boilerplate. //! Each watcher only needs to define what to check and how often. +use std::future::Future; use std::ops::ControlFlow; use tokio::sync::broadcast; @@ -19,7 +20,7 @@ use crate::dev::common::Shutdown; /// /// Return [`ControlFlow::Break`] from [`poll`](PollingWatcher::poll) to stop the watcher /// (e.g. when the watched resource disappears). -pub(crate) trait PollingWatcher: Send + 'static { +pub trait PollingWatcher: Send + 'static { /// Human-readable name for log messages (e.g. ".env", "filesystem"). fn label(&self) -> &'static str; @@ -33,14 +34,14 @@ pub(crate) trait PollingWatcher: Send + 'static { /// or `ControlFlow::Break(())` to stop this watcher. /// /// The returned future must be `Send` so it can run inside `tokio::spawn`. - fn poll(&mut self) -> impl std::future::Future> + Send; + fn poll(&mut self) -> impl Future> + Send; } /// Spawn a polling watcher as a background task. /// /// The task calls `watcher.poll()` at the configured interval and stops /// when either `shutdown_rx` fires or `poll()` returns `Break`. -pub(crate) fn spawn_polling_watcher( +pub fn spawn_polling_watcher( mut watcher: W, mut shutdown_rx: broadcast::Receiver, ) { @@ -56,7 +57,7 @@ pub(crate) fn spawn_polling_watcher( } } } - _ = tokio::time::sleep(watcher.poll_interval()) => { + () = tokio::time::sleep(watcher.poll_interval()) => { if watcher.poll().await.is_break() { break; } diff --git a/crates/core/src/dotenv.rs b/crates/core/src/dotenv.rs index f2df945e..abba48de 100644 --- a/crates/core/src/dotenv.rs +++ b/crates/core/src/dotenv.rs @@ -2,6 +2,7 @@ use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; +/// A parsed `.env` file that supports reading, updating, and writing back. #[derive(Debug, Clone)] pub struct DotenvFile { path: PathBuf, @@ -20,6 +21,7 @@ enum DotenvLine { } impl DotenvFile { + /// Read and parse a `.env` file. Returns an empty instance if the file does not exist. pub fn read(path: &Path) -> Result { if !path.exists() { return Ok(Self { @@ -60,6 +62,7 @@ impl DotenvFile { }) } + /// Return all variables as a key-value map. pub fn get_vars(&self) -> HashMap { let mut vars = HashMap::new(); for line in &self.lines { @@ -70,6 +73,7 @@ impl DotenvFile { vars } + /// Set a variable (insert or update) and write the file back to disk. pub fn update(&mut self, key: &str, value: &str) -> Result<(), String> { if !is_valid_key(key) { return Err(format!("Invalid dotenv variable name '{key}'")); @@ -118,9 +122,8 @@ impl DotenvFile { .lines .iter() .map(|line| match line { - DotenvLine::Comment(raw) => raw.as_str(), DotenvLine::Empty => "", - DotenvLine::Variable { raw, .. } => raw.as_str(), + DotenvLine::Comment(raw) | DotenvLine::Variable { raw, .. } => raw.as_str(), }) .collect::>() .join("\n"); @@ -170,9 +173,8 @@ fn parse_line(line: &str) -> Result { let mut value = export_stripped[eq_index + 1..].to_string(); if value.starts_with('"') || value.starts_with('\'') { - let quote = match value.chars().next() { - Some(q) => q, - None => return Err("Invalid empty quoted value".to_string()), + let Some(quote) = value.chars().next() else { + return Err("Invalid empty quoted value".to_string()); }; if !value.ends_with(quote) || value.len() == 1 { return Err("Invalid quoted value".to_string()); diff --git a/crates/core/src/external/bun.rs b/crates/core/src/external/bun.rs index 3f55afe5..62fa5324 100644 --- a/crates/core/src/external/bun.rs +++ b/crates/core/src/external/bun.rs @@ -42,7 +42,7 @@ impl Bun { /// Build a PATH with the apx bin directory prepended. /// This ensures child processes spawned by bun also use the apx-bundled bun. fn patched_path(&self) -> std::ffi::OsString { - let apx_bin_dir = self.path.parent().unwrap_or(Path::new("")); + let apx_bin_dir = self.path.parent().unwrap_or_else(|| Path::new("")); let current_path = std::env::var_os("PATH").unwrap_or_default(); let mut paths = vec![apx_bin_dir.to_path_buf()]; paths.extend(std::env::split_paths(¤t_path)); diff --git a/crates/core/src/external/databricks.rs b/crates/core/src/external/databricks.rs index 25581eb5..ddb2abac 100644 --- a/crates/core/src/external/databricks.rs +++ b/crates/core/src/external/databricks.rs @@ -22,7 +22,7 @@ pub struct DatabricksCli { impl DatabricksCli { /// Resolve `databricks` from PATH via the [`Resolvable`] trait. pub fn new() -> Result { - super::resolve_local::() + resolve_local::() .map(Self::from_resolved) .map_err(|_| CommandError::NotFound { tool: "databricks", diff --git a/crates/core/src/external/mod.rs b/crates/core/src/external/mod.rs index b82066c4..8658c07d 100644 --- a/crates/core/src/external/mod.rs +++ b/crates/core/src/external/mod.rs @@ -5,13 +5,19 @@ //! automatic resolution and optional download, and [`ToolCommand`] which wraps //! `tokio::process::Command` with tool-name context and ergonomic terminal methods. +/// Bun JavaScript runtime tool. pub mod bun; +/// Databricks CLI tool. pub mod databricks; +/// GitHub CLI (`gh`) tool. pub mod gh; +/// Git version control tool. pub mod git; +/// uv Python package manager tool. pub mod uv; use std::ffi::OsStr; +use std::future::Future; use std::path::{Path, PathBuf}; use std::process::Stdio; @@ -29,14 +35,18 @@ pub use uv::{Uv, UvTool}; // --------------------------------------------------------------------------- /// Where a binary was found. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Copy)] pub enum BinarySource { + /// Found via an environment variable override. EnvOverride, + /// Found on the system PATH. SystemPath, + /// Downloaded and managed by apx in `~/.apx/bin/`. ApxManaged, } impl BinarySource { + /// Human-readable label for this binary source. pub fn source_label(&self) -> &'static str { match self { BinarySource::EnvOverride => "env-override", @@ -49,11 +59,14 @@ impl BinarySource { /// A resolved binary path with its source. #[derive(Debug, Clone)] pub struct ResolvedBinary { + /// Absolute path to the resolved binary. pub path: PathBuf, + /// How the binary was found. pub source: BinarySource, } impl ResolvedBinary { + /// Human-readable label for the resolution source. pub fn source_label(&self) -> &'static str { self.source.source_label() } @@ -83,21 +96,25 @@ impl ToolCommand { } } + /// Append a single argument. pub fn arg(mut self, arg: impl AsRef) -> Self { self.inner.arg(arg); self } + /// Append multiple arguments. pub fn args(mut self, args: impl IntoIterator>) -> Self { self.inner.args(args); self } + /// Set an environment variable. pub fn env(mut self, key: impl AsRef, val: impl AsRef) -> Self { self.inner.env(key, val); self } + /// Set multiple environment variables. pub fn envs( mut self, vars: impl IntoIterator, impl AsRef)>, @@ -106,21 +123,25 @@ impl ToolCommand { self } + /// Set the working directory. pub fn cwd(mut self, dir: impl Into) -> Self { self.inner.current_dir(dir.into()); self } + /// Configure stdin handling. pub fn stdin(mut self, cfg: Stdio) -> Self { self.inner.stdin(cfg); self } + /// Configure stdout handling. pub fn stdout(mut self, cfg: Stdio) -> Self { self.inner.stdout(cfg); self } + /// Configure stderr handling. pub fn stderr(mut self, cfg: Stdio) -> Self { self.inner.stderr(cfg); self @@ -168,8 +189,11 @@ impl ToolCommand { /// Captured output from an external command. #[derive(Debug, Clone)] pub struct CommandOutput { + /// Captured standard output. pub stdout: String, + /// Captured standard error. pub stderr: String, + /// Process exit code, if available. pub exit_code: Option, } @@ -211,26 +235,40 @@ impl CommandOutput { /// Unified error type for all external command failures. #[derive(Debug, thiserror::Error)] pub enum CommandError { + /// The tool binary was not found on the system. #[error("{tool} not found — {hint}")] NotFound { + /// Tool name. tool: &'static str, + /// Human-readable install hint. hint: &'static str, }, + /// Failed to spawn the tool process. #[error("failed to spawn {tool}: {source}")] Spawn { + /// Tool name. tool: &'static str, + /// Underlying I/O error. source: std::io::Error, }, + /// The tool exited with a non-zero status code. #[error("{tool} failed (exit {code}):\n{stderr}")] Failed { + /// Tool name. tool: &'static str, + /// Exit code. code: i32, + /// Captured stdout. stdout: String, + /// Captured stderr. stderr: String, }, + /// The tool did not complete within the allowed time. #[error("{tool} timed out after {timeout_secs}s")] Timeout { + /// Tool name. tool: &'static str, + /// Timeout duration in seconds. timeout_secs: f64, }, } @@ -262,8 +300,11 @@ impl From for String { /// Provides identity (name, path, source) for a resolved tool. Concrete types /// expose `cmd() -> ToolCommand` and public domain methods instead. pub trait ExternalTool: std::fmt::Debug + Send + Sync { + /// Human-readable tool name. const NAME: &'static str; + /// Absolute path to the resolved binary. fn binary_path(&self) -> &Path; + /// How the binary was resolved. fn source(&self) -> &BinarySource; } @@ -302,7 +343,7 @@ pub trait Resolvable: ExternalTool + Sized { /// Auto-download and install the tool. Returns the resolved binary on success. /// /// Default implementation returns an error (for tools that are not auto-downloaded). - fn download() -> impl std::future::Future> + Send { + fn download() -> impl Future> + Send { async { Err(format!( "Cannot auto-download {}. {}", @@ -391,17 +432,24 @@ pub async fn resolve_with_download() -> Result, + /// Resolved binary path, if available. pub path: Option, + /// Resolution source label, if available. pub source: Option, + /// Error message if resolution failed. pub error: Option, } /// Trait for tools that can report their info for `apx info`. pub trait ToolInfo { - fn info() -> impl std::future::Future + Send; + /// Collect tool version and path info for display. + fn info() -> impl Future + Send; } /// Run ` --version` and return the trimmed stdout, or `"unknown"`. @@ -413,6 +461,5 @@ pub(crate) async fn get_version(path: &Path) -> String { .ok() .filter(|o| o.status.success()) .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| s.trim().to_string()) - .unwrap_or_else(|| "unknown".to_string()) + .map_or_else(|| "unknown".to_string(), |s| s.trim().to_string()) } diff --git a/crates/core/src/external/uv.rs b/crates/core/src/external/uv.rs index 11cda28a..b8953167 100644 --- a/crates/core/src/external/uv.rs +++ b/crates/core/src/external/uv.rs @@ -48,7 +48,7 @@ impl Uv { if let Some(cached) = UV_CELL.get() { return Ok(Self::from_resolved(cached.clone())); } - super::resolve_local::().map(Self::from_resolved) + resolve_local::().map(Self::from_resolved) } /// Create a `ToolCommand` for the uv binary. diff --git a/crates/core/src/feedback.rs b/crates/core/src/feedback.rs index 8ea8bc74..d95149ef 100644 --- a/crates/core/src/feedback.rs +++ b/crates/core/src/feedback.rs @@ -1,4 +1,5 @@ use std::fmt; +use std::fmt::Write; use crate::external::CommandError; use crate::external::gh::Gh; @@ -8,16 +9,22 @@ const GITHUB_REPO: &str = "databricks-solutions/apx"; /// Metadata auto-collected for feedback issues. #[derive(Debug, Clone)] pub struct FeedbackMetadata { + /// apx version string. pub apx_version: String, + /// Operating system name. pub os: String, + /// CPU architecture. pub arch: String, } /// Prepared feedback ready for preview and submission. #[derive(Debug, Clone)] pub struct PreparedFeedback { + /// Issue title. pub title: String, + /// Issue body in Markdown. pub body: String, + /// Pre-filled GitHub new-issue URL for manual submission. pub browser_url: String, } @@ -25,11 +32,17 @@ pub struct PreparedFeedback { #[derive(Debug)] pub enum FeedbackResult { /// Successfully created a GitHub issue. - Submitted { url: String }, + Submitted { + /// URL of the created issue. + url: String, + }, /// Could not submit automatically; includes fallback info. Fallback { + /// Issue title. title: String, + /// Issue body. body: String, + /// Pre-filled browser URL. url: String, }, } @@ -37,7 +50,9 @@ pub enum FeedbackResult { /// Errors from `gh` CLI submission. #[derive(Debug)] pub enum FeedbackError { + /// The `gh` CLI binary was not found. GhNotFound, + /// The `gh` CLI command failed. GhFailed(String), } @@ -97,7 +112,7 @@ pub fn format_issue_body( if let Some(cat) = category && !cat.is_empty() { - body.push_str(&format!("**Category**: {cat}\n\n")); + let _ = write!(body, "**Category**: {cat}\n\n"); } body.push_str(message); @@ -105,9 +120,9 @@ pub fn format_issue_body( if let Some(meta) = metadata { body.push_str("\n\n---\n"); body.push_str("**Metadata** (auto-collected)\n"); - body.push_str(&format!("- apx version: {}\n", meta.apx_version)); - body.push_str(&format!("- OS: {}\n", meta.os)); - body.push_str(&format!("- Arch: {}\n", meta.arch)); + let _ = writeln!(body, "- apx version: {}", meta.apx_version); + let _ = writeln!(body, "- OS: {}", meta.os); + let _ = writeln!(body, "- Arch: {}", meta.arch); } body @@ -129,11 +144,11 @@ pub fn github_new_issue_url(title: &str, body: &str) -> String { for b in s.bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - out.push(b as char) + out.push(b as char); } _ => { out.push('%'); - out.push_str(&format!("{b:02X}")); + let _ = write!(out, "{b:02X}"); } } } diff --git a/crates/core/src/flux/mod.rs b/crates/core/src/flux/mod.rs index e9128452..72904af5 100644 --- a/crates/core/src/flux/mod.rs +++ b/crates/core/src/flux/mod.rs @@ -171,12 +171,9 @@ pub fn ensure_running() -> Result<(), String> { /// /// Stops the running flux daemon and removes the lock file. pub fn stop() -> Result<(), String> { - let lock = match read_lock()? { - Some(l) => l, - None => { - debug!("Flux is not running (no lock file)"); - return Ok(()); - } + let Some(lock) = read_lock()? else { + debug!("Flux is not running (no lock file)"); + return Ok(()); }; if !is_flux_listening(lock.port) { diff --git a/crates/core/src/interop.rs b/crates/core/src/interop.rs index 3d90f3ed..63ae2686 100644 --- a/crates/core/src/interop.rs +++ b/crates/core/src/interop.rs @@ -86,6 +86,7 @@ pub fn list_template_files(prefix: &str) -> Vec { resources::list_templates(Some(prefix)) } +/// Generate the OpenAPI JSON spec and its hash by invoking the Python app. pub async fn generate_openapi_spec( project_root: &Path, app_entrypoint: &str, @@ -172,9 +173,7 @@ print(json.dumps(app.openapi(), indent=2)) pub async fn get_databricks_sdk_version( project_dir: Option<&Path>, ) -> Result, String> { - let label = project_dir - .map(|d| d.display().to_string()) - .unwrap_or_else(|| "default".to_string()); + let label = project_dir.map_or_else(|| "default".to_string(), |d| d.display().to_string()); debug!("get_databricks_sdk_version: checking (context: {label})"); let uv = match Uv::try_new() { diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 9b1418be..4703b5f3 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,35 +1,50 @@ -#![forbid(unsafe_code)] -#![deny(warnings, unused_must_use, dead_code, missing_debug_implementations)] -#![deny( - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - clippy::todo, - clippy::unimplemented, - clippy::dbg_macro, - clippy::print_stdout -)] +//! Core library for the apx application framework. +//! +//! Contains business logic for project scaffolding, dev server management, +//! component registries, SDK documentation search, and Databricks integration. +#![deny(clippy::print_stdout)] + +/// Agent integration utilities. pub mod agent; +/// OpenAPI spec generation and TypeScript client codegen. pub mod api_generator; +/// Global application directory state. pub mod app_state; +/// Common types, project metadata, and CLI utilities. pub mod common; +/// UI component registry operations (search, add, CSS updates). pub mod components; +/// Databricks SDK documentation parsing and indexing. pub mod databricks_sdk_doc; +/// Dev server lifecycle (process management, proxy, logging). pub mod dev; +/// `.env` file reader and writer. pub mod dotenv; +/// Binary download helpers for managed tool installs. pub mod download; +/// External tool abstraction (uv, bun, git, gh, databricks CLI). pub mod external; +/// User feedback issue creation (GitHub). pub mod feedback; +/// Flux log collector integration. pub mod flux; +/// Frontend build and scaffolding utilities. pub mod frontend; +/// Python interop (OpenAPI generation, SDK version detection). pub mod interop; +/// High-level operations (dev server, check, logs, healthcheck). pub mod ops; +/// Python source-code editing (AST-based import insertion). pub mod py_edit; +/// Embedded template resources. pub mod resources; +/// Full-text search indexes (component search, SDK docs). pub mod search; +/// Tracing / logging initialization. pub mod tracing_init; +/// OpenAPI specification parsing, TypeScript codegen, and related utilities. pub mod openapi; pub(crate) mod python_logging; pub(crate) mod registry; diff --git a/crates/core/src/openapi/emitter.rs b/crates/core/src/openapi/emitter.rs index a1fc7c14..e8bc03f4 100644 --- a/crates/core/src/openapi/emitter.rs +++ b/crates/core/src/openapi/emitter.rs @@ -7,6 +7,8 @@ //! 3. Codegen: ApiIR -> swc_ecma_ast::Module //! 4. Emit: Module -> String (via SWC's Emitter) +use std::rc::Rc; + use swc_common::SourceMap; use swc_common::sync::Lrc; use swc_ecma_ast::Module; @@ -32,14 +34,14 @@ pub fn generate(openapi_json: &str) -> Result { /// Emit a SWC Module to a TypeScript string. fn emit_module(module: &Module) -> Result { - let cm: Lrc = Default::default(); + let cm: Lrc = Rc::default(); let mut buf = vec![]; { let mut emitter = SwcEmitter { cfg: Config::default().with_ascii_only(false), cm: cm.clone(), comments: None, - wr: JsWriter::new(cm.clone(), "\n", &mut buf, None), + wr: JsWriter::new(cm, "\n", &mut buf, None), }; emitter .emit_module(module) diff --git a/crates/core/src/openapi/ir/api.rs b/crates/core/src/openapi/ir/api.rs index c03fc2fa..995cd3c6 100644 --- a/crates/core/src/openapi/ir/api.rs +++ b/crates/core/src/openapi/ir/api.rs @@ -6,9 +6,6 @@ //! - FetchIR: Fetch function representation //! - HookIR: React Query hook representation -// Allow dead code for IR types that are part of the design but not yet fully utilized. -#![allow(dead_code)] - use super::types::{TsTypeDef, TypeRef}; /// HTTP method @@ -22,7 +19,7 @@ pub enum HttpMethod { } impl HttpMethod { - pub fn as_str(&self) -> &'static str { + pub fn as_str(self) -> &'static str { match self { HttpMethod::Get => "GET", HttpMethod::Post => "POST", @@ -32,7 +29,7 @@ impl HttpMethod { } } - pub fn is_query(&self) -> bool { + pub fn is_query(self) -> bool { matches!(self, HttpMethod::Get) } } @@ -51,19 +48,9 @@ pub enum OperationKind { pub struct OperationIR { /// Sanitized TypeScript identifier (e.g., "listItems") pub name: String, - /// Query or mutation - pub kind: OperationKind, - /// URL path (e.g., "/items/{itemId}") - pub path: String, - /// HTTP method - pub method: HttpMethod, /// Normalized parameters (None = no params) pub params: Option, - /// Request body (None = no body) - pub body: Option, - /// Response information - pub response: ResponseIR, /// Precomputed fetch function IR pub fetch: FetchIR, diff --git a/crates/core/src/openapi/ir/builders.rs b/crates/core/src/openapi/ir/builders.rs index 70c106c6..5b55588a 100644 --- a/crates/core/src/openapi/ir/builders.rs +++ b/crates/core/src/openapi/ir/builders.rs @@ -6,6 +6,8 @@ use swc_atoms::Atom; use swc_common::{DUMMY_SP, SyntaxContext}; +// Reason: builder module uses most items from parent; explicit list would be unwieldy +#[allow(clippy::wildcard_imports)] use swc_ecma_ast::*; use super::types::{self as ir, TypeRef}; diff --git a/crates/core/src/openapi/ir/codegen.rs b/crates/core/src/openapi/ir/codegen.rs index 29affdde..bf53f71b 100644 --- a/crates/core/src/openapi/ir/codegen.rs +++ b/crates/core/src/openapi/ir/codegen.rs @@ -5,12 +5,16 @@ //! strings via SWC's codegen. use swc_common::DUMMY_SP; +// Reason: codegen module uses most items from parent; explicit list would be unwieldy +#[allow(clippy::wildcard_imports)] use swc_ecma_ast::*; use super::api::{ ApiIR, BodyContentType, FetchArgIR, FetchIR, HookIR, HookKind, OperationIR, ParamsIR, QueryKeyIR, ResponseContentType, UrlPart, }; +// Reason: codegen module uses most items from parent; explicit list would be unwieldy +#[allow(clippy::wildcard_imports)] use super::builders::*; use super::types::{TsType as IrTsType, TypeRef}; use super::utils::{escape_js_string, needs_bracket_notation}; @@ -543,10 +547,7 @@ fn response_data_expr(content_type: ResponseContentType) -> Expr { } /// Resolve the SWC type for a response based on content type. -fn resolve_content_type( - content_type: ResponseContentType, - ty: &TypeRef, -) -> Box { +fn resolve_content_type(content_type: ResponseContentType, ty: &TypeRef) -> Box { match content_type { ResponseContentType::Text => ts_kw!(string), ResponseContentType::Blob => ts_type_ref("Blob"), @@ -556,10 +557,10 @@ fn resolve_content_type( } /// Check if an SWC type is the `void` keyword. -fn is_void_type(ty: &swc_ecma_ast::TsType) -> bool { +fn is_void_type(ty: &TsType) -> bool { matches!( ty, - swc_ecma_ast::TsType::TsKeywordType(swc_ecma_ast::TsKeywordType { + TsType::TsKeywordType(TsKeywordType { kind: TsKeywordTypeKind::TsVoidKeyword, .. }) @@ -580,8 +581,7 @@ fn build_path_template(template: &[UrlPart]) -> String { } } }) - .collect::>() - .join("") + .collect::() } /// Build template literal quasis and expressions for a URL template. @@ -666,7 +666,7 @@ fn codegen_hook(hook: &HookIR) -> ModuleItem { let response_swc_type = resolve_content_type(hook.response_content_type, &hook.response_type); let is_void = is_void_type(&response_swc_type); - let wrapped_type: Box = if is_void { + let wrapped_type: Box = if is_void { ts_kw!(void) } else if hook.response_has_void_status { ts_union(vec![data_wrapper_type(response_swc_type), ts_kw!(void)]) @@ -681,10 +681,7 @@ fn codegen_hook(hook: &HookIR) -> ModuleItem { } /// Build `Omit, "queryKey" | "queryFn">`. -fn omit_query_opts( - options_type: &str, - wrapped: &swc_ecma_ast::TsType, -) -> Box { +fn omit_query_opts(options_type: &str, wrapped: &TsType) -> Box { let opts = ts_type_ref_with_params( options_type, vec![ @@ -700,8 +697,9 @@ fn omit_query_opts( } /// Generate a query hook (useQuery or useSuspenseQuery). +// Reason: template rendering is infallible for known-good templates #[allow(clippy::expect_used)] -fn codegen_query_hook(hook: &HookIR, wrapped_type: Box) -> ModuleItem { +fn codegen_query_hook(hook: &HookIR, wrapped_type: Box) -> ModuleItem { let key_fn = hook .query_key_fn .as_ref() @@ -792,12 +790,11 @@ fn codegen_query_hook(hook: &HookIR, wrapped_type: Box) -> } /// Generate a mutation hook. -fn codegen_mutation_hook(hook: &HookIR, wrapped_type: Box) -> ModuleItem { +fn codegen_mutation_hook(hook: &HookIR, wrapped_type: Box) -> ModuleItem { let vars_swc_type = hook .vars_type .as_ref() - .map(ir_typeref_to_swc) - .unwrap_or_else(|| ts_kw!(void)); + .map_or_else(|| ts_kw!(void), ir_typeref_to_swc); // Build mutation function expression let mutation_fn = if let Some(vars) = &hook.vars_type { @@ -887,6 +884,7 @@ fn codegen_mutation_hook(hook: &HookIR, wrapped_type: Box) } #[cfg(test)] +// Reason: panicking on failure is idiomatic in tests #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod tests { use super::super::api::*; diff --git a/crates/core/src/openapi/ir/normalize.rs b/crates/core/src/openapi/ir/normalize.rs index 6ac41f95..d257d13b 100644 --- a/crates/core/src/openapi/ir/normalize.rs +++ b/crates/core/src/openapi/ir/normalize.rs @@ -476,23 +476,22 @@ fn normalize_operation( // Determine if params is optional (used by both fetch and hooks) let params_optional = params .as_ref() - .map(|p| !p.fields.iter().any(|f| f.required)) - .unwrap_or(true); + .is_none_or(|p| !p.fields.iter().any(|f| f.required)); // Build fetch IR let fetch = build_fetch_ir( &name, path, method, - ¶ms, - &body, + params.as_ref(), + body.as_ref(), &response, params_optional, ); // Build query key (for queries only) let query_key = if kind == OperationKind::Query { - Some(build_query_key_ir(&name, path, ¶ms)) + Some(build_query_key_ir(&name, path, params.as_ref())) } else { None }; @@ -501,21 +500,16 @@ fn normalize_operation( let hooks = build_hooks( &name, kind, - ¶ms, + params.as_ref(), params_optional, - &body, + body.as_ref(), &response, - &query_key, + query_key.as_ref(), ); Ok(OperationIR { name, - kind, - path: path.to_string(), - method, params, - body, - response, fetch, hooks, query_key, @@ -606,10 +600,10 @@ fn normalize_param(p: &Parameter) -> ParamIR { .schema .as_ref() .and_then(|s| schema_to_ts_type(s).ok()) - .map(|t| TypeRef::Inline(Box::new(t))) - .unwrap_or(TypeRef::Inline(Box::new(TsType::Primitive( - TsPrimitive::String, - )))); + .map_or_else( + || TypeRef::Inline(Box::new(TsType::Primitive(TsPrimitive::String))), + |t| TypeRef::Inline(Box::new(t)), + ); let location = match p.location.as_str() { "path" => ParamLocation::Path, @@ -746,8 +740,8 @@ fn build_fetch_ir( name: &str, path: &str, method: HttpMethod, - params: &Option, - body: &Option, + params: Option<&ParamsIR>, + body: Option<&BodyIR>, response: &ResponseIR, params_optional: bool, ) -> FetchIR { @@ -793,7 +787,6 @@ fn build_fetch_ir( // Collect header params let header_params = params - .as_ref() .map(|p| { p.fields .iter() @@ -808,17 +801,16 @@ fn build_fetch_ir( args, response: response.clone(), url, - body: body.clone(), + body: body.cloned(), method, header_params, } } /// Build URL IR -fn build_url_ir(path: &str, params: &Option) -> UrlIR { +fn build_url_ir(path: &str, params: Option<&ParamsIR>) -> UrlIR { // Collect path params for lookup let path_params: Vec<&ParamIR> = params - .as_ref() .map(|p| { p.fields .iter() @@ -860,7 +852,6 @@ fn build_url_ir(path: &str, params: &Option) -> UrlIR { // Collect query params let query_params = params - .as_ref() .map(|p| { p.fields .iter() @@ -897,11 +888,11 @@ fn find_matching_param(placeholder: &str, params: &[&ParamIR]) -> Option } /// Build query key IR -fn build_query_key_ir(name: &str, path: &str, params: &Option) -> QueryKeyIR { +fn build_query_key_ir(name: &str, path: &str, params: Option<&ParamsIR>) -> QueryKeyIR { QueryKeyIR { fn_name: format!("{name}Key"), base_key: path.to_string(), - params_type: params.as_ref().map(|p| TypeRef::Named(p.type_name.clone())), + params_type: params.map(|p| TypeRef::Named(p.type_name.clone())), } } @@ -909,19 +900,19 @@ fn build_query_key_ir(name: &str, path: &str, params: &Option) -> Quer fn build_hooks( name: &str, kind: OperationKind, - params: &Option, + params: Option<&ParamsIR>, params_optional: bool, - body: &Option, + body: Option<&BodyIR>, response: &ResponseIR, - query_key: &Option, + query_key: Option<&QueryKeyIR>, ) -> Vec { let mut hooks = Vec::new(); let capitalized = capitalize_first(name); match kind { OperationKind::Query => { - let vars_type = params.as_ref().map(|p| TypeRef::Named(p.type_name.clone())); - let key_fn = query_key.as_ref().map(|k| k.fn_name.clone()); + let vars_type = params.map(|p| TypeRef::Named(p.type_name.clone())); + let key_fn = query_key.map(|k| k.fn_name.clone()); // params_required is true when there are params and at least one is required let params_required = params.is_some() && !params_optional; @@ -960,7 +951,7 @@ fn build_hooks( // Determine vars type // For FormData, use FormData type instead of the schema type - let vars_type = match (params.as_ref(), body.as_ref()) { + let vars_type = match (params, body) { (Some(p), Some(b)) => { let data_ty = if b.content_type == BodyContentType::FormData { TsType::Ref("FormData".into()) diff --git a/crates/core/src/openapi/ir/types.rs b/crates/core/src/openapi/ir/types.rs index cb1306ab..ed2880d1 100644 --- a/crates/core/src/openapi/ir/types.rs +++ b/crates/core/src/openapi/ir/types.rs @@ -5,9 +5,6 @@ //! all code-level AST (expressions, statements, functions) is now handled //! directly by SWC's `swc_ecma_ast`. -// Allow dead code for IR types that are part of the design but not yet fully utilized. -#![allow(dead_code)] - /// Reference to a type - either inline or named #[derive(Debug, Clone)] pub enum TypeRef { diff --git a/crates/core/src/openapi/ir/utils.rs b/crates/core/src/openapi/ir/utils.rs index f98481e6..21be5a0b 100644 --- a/crates/core/src/openapi/ir/utils.rs +++ b/crates/core/src/openapi/ir/utils.rs @@ -74,8 +74,7 @@ pub fn needs_bracket_notation(name: &str) -> bool { || !name .chars() .next() - .map(|c| c.is_ascii_alphabetic() || c == '_' || c == '$') - .unwrap_or(false) + .is_some_and(|c| c.is_ascii_alphabetic() || c == '_' || c == '$') || !name .chars() .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') @@ -134,12 +133,7 @@ pub fn sanitize_ts_identifier(name: &str) -> String { } // Prepend underscore if starts with digit - if result - .chars() - .next() - .map(|c| c.is_ascii_digit()) - .unwrap_or(false) - { + if result.chars().next().is_some_and(|c| c.is_ascii_digit()) { result = format!("_{result}"); } @@ -212,6 +206,7 @@ pub fn make_unknown_record() -> TsType { } #[cfg(test)] +// Reason: panicking on failure is idiomatic in tests #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; diff --git a/crates/core/src/openapi/mod.rs b/crates/core/src/openapi/mod.rs index c1e03d06..c0f3edff 100644 --- a/crates/core/src/openapi/mod.rs +++ b/crates/core/src/openapi/mod.rs @@ -13,6 +13,7 @@ pub use emitter::generate; pub use ir::utils::capitalize_first; #[cfg(test)] +// Reason: panicking on failure is idiomatic in tests #[allow(clippy::unwrap_used, clippy::expect_used, clippy::print_stdout)] mod tests { use super::*; @@ -2121,15 +2122,6 @@ mod tests { ); } - /// Helper that generates TypeScript code from OpenAPI JSON without tsc validation. - /// Use this for negative tests or tests that only check generation logic. - #[allow(dead_code, clippy::panic)] - fn generate_only(openapi_json: &str) -> String { - let result = generate(openapi_json); - assert!(result.is_ok(), "Generation failed: {:?}", result.err()); - result.unwrap() - } - // ========================================================================= // TypeScript compilation infrastructure (requires bun to be available on PATH) // ========================================================================= @@ -2274,6 +2266,7 @@ mod tests { /// Helper that generates TypeScript code from OpenAPI JSON and verifies it compiles with tsc. /// Use this ONLY for positive tests where generated code should be valid TypeScript. + // Reason: panicking on failure is idiomatic in tests #[allow(clippy::panic)] fn generate_and_verify(openapi_json: &str) -> String { let result = generate(openapi_json); diff --git a/crates/core/src/openapi/spec.rs b/crates/core/src/openapi/spec.rs index f127f738..630f74a0 100644 --- a/crates/core/src/openapi/spec.rs +++ b/crates/core/src/openapi/spec.rs @@ -9,7 +9,9 @@ use std::collections::HashMap; /// Root OpenAPI specification. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OpenApiSpec { + /// Map of URL paths to their operations. pub paths: HashMap, + /// Reusable schema components. #[serde(skip_serializing_if = "Option::is_none")] pub components: Option, } @@ -17,6 +19,7 @@ pub struct OpenApiSpec { /// Components section containing reusable schemas. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Components { + /// Named schemas that can be referenced via `$ref`. #[serde(skip_serializing_if = "Option::is_none")] pub schemas: Option>, } @@ -24,18 +27,25 @@ pub struct Components { /// A path item containing operations for different HTTP methods. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PathItem { + /// GET operation. #[serde(skip_serializing_if = "Option::is_none")] pub get: Option, + /// POST operation. #[serde(skip_serializing_if = "Option::is_none")] pub post: Option, + /// PUT operation. #[serde(skip_serializing_if = "Option::is_none")] pub put: Option, + /// PATCH operation. #[serde(skip_serializing_if = "Option::is_none")] pub patch: Option, + /// DELETE operation. #[serde(skip_serializing_if = "Option::is_none")] pub delete: Option, + /// HEAD operation. #[serde(skip_serializing_if = "Option::is_none")] pub head: Option, + /// OPTIONS operation. #[serde(skip_serializing_if = "Option::is_none")] pub options: Option, /// Path-level parameters shared by all operations. @@ -47,16 +57,22 @@ pub struct PathItem { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Operation { + /// Unique operation identifier. #[serde(skip_serializing_if = "Option::is_none")] pub operation_id: Option, + /// Short summary of the operation. #[serde(skip_serializing_if = "Option::is_none")] pub summary: Option, + /// Detailed description. #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, + /// Operation-level parameters. #[serde(skip_serializing_if = "Option::is_none")] pub parameters: Option>, + /// Request body definition. #[serde(skip_serializing_if = "Option::is_none")] pub request_body: Option, + /// Map of HTTP status codes to response definitions. #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub responses: HashMap, } @@ -64,13 +80,18 @@ pub struct Operation { /// A parameter (query, path, or header). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Parameter { + /// Parameter name. pub name: String, + /// Parameter location (`"query"`, `"path"`, `"header"`). #[serde(rename = "in")] pub location: String, + /// Whether the parameter is required. #[serde(default)] pub required: bool, + /// Parameter schema. #[serde(skip_serializing_if = "Option::is_none")] pub schema: Option, + /// Parameter description. #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, } @@ -78,8 +99,10 @@ pub struct Parameter { /// A request body definition. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RequestBody { + /// Whether the request body is required. #[serde(default)] pub required: bool, + /// Content types and their schemas. #[serde(skip_serializing_if = "Option::is_none")] pub content: Option>, } @@ -87,8 +110,10 @@ pub struct RequestBody { /// A response definition. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Response { + /// Response description. #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, + /// Content types and their schemas. #[serde(skip_serializing_if = "Option::is_none")] pub content: Option>, } @@ -96,6 +121,7 @@ pub struct Response { /// Media type content (e.g., application/json). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MediaType { + /// Schema for this media type. #[serde(skip_serializing_if = "Option::is_none")] pub schema: Option, } @@ -218,10 +244,15 @@ pub struct Schema { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum EnumValue { + /// A string variant. String(String), + /// An integer variant. Integer(i64), + /// A floating-point variant. Float(f64), + /// A boolean variant. Bool(bool), + /// A null variant. Null, } @@ -240,7 +271,9 @@ pub struct Discriminator { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum SchemaType { + /// A single type name (e.g. `"string"`). Single(String), + /// Multiple types (e.g. `["string", "null"]`). Multiple(Vec), } @@ -248,7 +281,9 @@ pub enum SchemaType { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum AdditionalProperties { + /// Boolean flag (true = any additional properties allowed). Bool(bool), + /// Schema describing additional property values. Schema(Box), } diff --git a/crates/core/src/ops/check.rs b/crates/core/src/ops/check.rs index 34ce02f6..6282a7e1 100644 --- a/crates/core/src/ops/check.rs +++ b/crates/core/src/ops/check.rs @@ -87,14 +87,16 @@ pub async fn run_check(app_dir: &Path, mode: OutputMode) -> Result<(), String> { if let Some(tsc_result) = tsc_result { let tsc_result = tsc_result?; - if !tsc_result.0 { + if tsc_result.0 { + emit(mode, "✅ [tsc] TypeScript compilation succeeded"); + } else { emit(mode, "❌ [tsc] TypeScript compilation failed"); let combined_output = if !tsc_result.2.is_empty() && !tsc_result.1.is_empty() { format!("{}\n{}", tsc_result.1, tsc_result.2) } else if !tsc_result.2.is_empty() { - tsc_result.2.clone() + tsc_result.2 } else if !tsc_result.1.is_empty() { - tsc_result.1.clone() + tsc_result.1 } else { String::new() }; @@ -111,20 +113,20 @@ pub async fn run_check(app_dir: &Path, mode: OutputMode) -> Result<(), String> { &combined_output } )); - } else { - emit(mode, "✅ [tsc] TypeScript compilation succeeded"); } } let ty_result = ty_result?; - if !ty_result.0 { + if ty_result.0 { + emit(mode, "✅ [ty] Python type check succeeded"); + } else { emit(mode, "❌ [ty] Python type check failed"); let combined_output = if !ty_result.1.is_empty() && !ty_result.2.is_empty() { format!("{}\n{}", ty_result.1, ty_result.2) } else if !ty_result.1.is_empty() { - ty_result.1.clone() + ty_result.1 } else if !ty_result.2.is_empty() { - ty_result.2.clone() + ty_result.2 } else { String::new() }; @@ -141,8 +143,6 @@ pub async fn run_check(app_dir: &Path, mode: OutputMode) -> Result<(), String> { &combined_output } )); - } else { - emit(mode, "✅ [ty] Python type check succeeded"); } if !errors.is_empty() { @@ -183,7 +183,7 @@ async fn generate_route_tree(app_dir: &Path, mode: OutputMode) -> Result<(), Str } let exit_code = out .exit_code - .map_or("signal".into(), |c: i32| c.to_string()); + .map_or_else(|| "signal".into(), |c: i32| c.to_string()); return Err(format!( "Route tree generation failed (exit {exit_code}):\n\ entrypoint: {entrypoint}\n\ diff --git a/crates/core/src/ops/dev.rs b/crates/core/src/ops/dev.rs index fe1647d8..f1f14d27 100644 --- a/crates/core/src/ops/dev.rs +++ b/crates/core/src/ops/dev.rs @@ -29,6 +29,7 @@ fn prepare_app_dir(app_dir: &Path) -> Result<(), String> { Ok(()) } +/// Check for an existing healthy dev server and return its port, cleaning up stale locks. pub async fn resolve_existing_server( app_dir: &Path, mode: OutputMode, @@ -46,13 +47,12 @@ pub async fn resolve_existing_server( return Ok(None); } - match health(lock.port).await { - Ok(true) => Ok(Some(lock.port)), - Ok(false) | Err(_) => { - emit(mode, "🧹 Cleaning up stale lock file..."); - remove_lock(&lock_path)?; - Ok(None) - } + if health(lock.port).await == Ok(true) { + Ok(Some(lock.port)) + } else { + emit(mode, "🧹 Cleaning up stale lock file..."); + remove_lock(&lock_path)?; + Ok(None) } } @@ -151,10 +151,15 @@ async fn wait_for_port_available(port: u16, mode: OutputMode) -> Result<(), Stri /// Shared by all `ServerLauncher` implementations. #[derive(Debug)] pub struct PreparedServer { + /// Allocated port for the dev server. pub port: u16, + /// Authentication token for control endpoints. pub dev_token: String, + /// Path to the lock file. pub lock_path: PathBuf, + /// Canonicalized application directory. pub canonical_app_dir: PathBuf, + /// Human-readable command description for display. pub command_display: String, } @@ -210,30 +215,41 @@ pub enum ServerLauncher { /// Spawns a background child process (`apx dev __internal__run_server`). /// Returns after healthcheck confirms the server is ready. Detached { + /// Application directory. app_dir: PathBuf, + /// Skip Databricks credentials validation. skip_credentials_validation: bool, + /// Maximum seconds to wait for healthy status. timeout_secs: u64, + /// Skip the health check entirely. skip_healthcheck: bool, + /// Output mode for progress messages. mode: OutputMode, }, /// Runs the Axum dev server in-process as an async task. /// Subprocess logs stream directly to the terminal. Returns after shutdown. Attached { + /// Application directory. app_dir: PathBuf, + /// Skip Databricks credentials validation. skip_credentials_validation: bool, }, } /// Result of a server launch. -#[derive(Debug)] +#[derive(Debug, Clone, Copy)] pub enum LaunchOutcome { /// Server is running in the background. Port is ready. - Running { port: u16 }, + Running { + /// The port the server is listening on. + port: u16, + }, /// Server ran in-process and has shut down. Shutdown, } impl ServerLauncher { + /// Execute the launch strategy with the given prepared server configuration. pub async fn launch(self, server: PreparedServer) -> Result { match self { Self::Detached { @@ -278,7 +294,7 @@ async fn launch_detached( spawn_detached_child(app_dir, skip_credentials_validation, &server).await?; if skip_healthcheck { - return finalize_skip_healthcheck(app_dir, mode, &server, &command, &mut child, start_time); + return finalize_skip_healthcheck(app_dir, mode, &server, &command, &child, start_time); } wait_for_healthy_or_cleanup( @@ -349,7 +365,7 @@ fn finalize_skip_healthcheck( mode: OutputMode, server: &PreparedServer, command: &str, - child: &mut tokio::process::Child, + child: &tokio::process::Child, start_time: Instant, ) -> Result { let pid = child.id().ok_or("Failed to get child process ID")?; @@ -465,7 +481,13 @@ async fn launch_attached( server: PreparedServer, ) -> Result { set_app_dir(app_dir.to_path_buf())?; - validate_credentials(skip_credentials_validation).await; + if skip_credentials_validation { + warn!("Credentials validation skipped. API proxy may not work correctly."); + } else { + validate_credentials(app_dir).await; + } + + crate::tracing_init::enable_dev_format(); let mut last_error = String::new(); @@ -488,7 +510,6 @@ async fn launch_attached( Err(e) if is_port_error(&e) && attempt < MAX_PORT_RETRIES => { warn!(attempt, error = %e, "Subprocess port conflict, retrying with new ports"); last_error = e; - continue; } Err(e) => { let _ = remove_lock(&server.lock_path); @@ -504,12 +525,8 @@ async fn launch_attached( } /// Warn if credentials are missing or invalid. -async fn validate_credentials(skip: bool) { - if skip { - warn!("Credentials validation skipped. API proxy may not work correctly."); - return; - } - let profile = std::env::var("DATABRICKS_CONFIG_PROFILE").unwrap_or_default(); +async fn validate_credentials(app_dir: &Path) { + let profile = crate::dev::server::resolve_databricks_profile(app_dir).unwrap_or_default(); if let Err(err) = apx_databricks_sdk::validate_credentials(&profile).await { warn!("Credentials validation failed: {err}. API proxy may not work correctly."); } diff --git a/crates/core/src/ops/healthcheck.rs b/crates/core/src/ops/healthcheck.rs index 7c4a4549..0bc53a05 100644 --- a/crates/core/src/ops/healthcheck.rs +++ b/crates/core/src/ops/healthcheck.rs @@ -34,7 +34,7 @@ pub async fn wait_for_healthy_with_logs( debug!("Received Ctrl+C, aborting startup"); return Err("Startup interrupted by user".to_string()); } - _ = tokio::time::sleep(Duration::from_millis(config.retry_delay_ms)) => { + () = tokio::time::sleep(Duration::from_millis(config.retry_delay_ms)) => { log_streamer.print_new_logs().await; attempt_count += 1; let elapsed_ms = start_time.elapsed().as_millis(); diff --git a/crates/core/src/ops/logs.rs b/crates/core/src/ops/logs.rs index cbd11ed8..c4fd77d0 100644 --- a/crates/core/src/ops/logs.rs +++ b/crates/core/src/ops/logs.rs @@ -7,6 +7,7 @@ use apx_common::format::{format_aggregated_record, format_log_record, format_tim use apx_common::{AggregatedRecord, LogAggregator, LogRecord, should_skip_log, source_label}; use apx_db::LogsDb; +/// Default duration string for log queries (10 minutes). pub const DEFAULT_LOG_DURATION: &str = "10m"; // --------------------------------------------------------------------------- @@ -79,11 +80,16 @@ pub async fn fetch_logs(app_dir: &Path, duration: &str) -> Result, + /// Log message body. pub message: String, } @@ -141,6 +147,7 @@ fn aggregated_record_to_entry(agg: &AggregatedRecord) -> LogEntry { // format_log_record, format_aggregated_record, and format_timestamp are // re-exported from apx_common::format (imported above). +/// Parse a human-friendly duration string (e.g. `"30s"`, `"10m"`, `"1h"`, `"2d"`). pub fn parse_duration(input: &str) -> Result { let trimmed = input.trim(); if trimmed.is_empty() { @@ -175,6 +182,7 @@ pub fn parse_duration(input: &str) -> Result { Ok(Duration::from_secs(seconds)) } +/// Compute a nanosecond-precision UNIX timestamp for `now - duration`. pub fn since_timestamp_nanos(duration: Duration) -> i64 { let now_ms = Utc::now().timestamp_millis() as u64; let now_ns = now_ms * 1_000_000; diff --git a/crates/core/src/ops/mod.rs b/crates/core/src/ops/mod.rs index cae7130a..a278f33f 100644 --- a/crates/core/src/ops/mod.rs +++ b/crates/core/src/ops/mod.rs @@ -1,5 +1,10 @@ +/// Type-checking (TypeScript + Python) operations. pub mod check; +/// Dev server start/stop/restart orchestration. pub mod dev; +/// Health check polling with log streaming. pub mod healthcheck; +/// Log querying, formatting, and structured retrieval. pub mod logs; +/// Startup log capture for early boot diagnostics. pub mod startup_logs; diff --git a/crates/core/src/ops/startup_logs.rs b/crates/core/src/ops/startup_logs.rs index f86e765c..01596056 100644 --- a/crates/core/src/ops/startup_logs.rs +++ b/crates/core/src/ops/startup_logs.rs @@ -54,18 +54,16 @@ impl StartupLogStreamer { /// Print any new logs since the last call. /// Returns the number of new log lines printed. pub async fn print_new_logs(&mut self) -> usize { - let storage = match &self.storage { - Some(s) => s, - None => return 0, + let Some(storage) = &self.storage else { + return 0; }; // Query logs since last ID - let records = match storage + let Ok(records) = storage .query_logs_after_id(Some(&self.app_path), self.last_log_id) .await - { - Ok(r) => r, - Err(_) => return 0, + else { + return 0; }; let mut count = 0; diff --git a/crates/core/src/py_edit/find.rs b/crates/core/src/py_edit/find.rs index c5b075b4..a706dac7 100644 --- a/crates/core/src/py_edit/find.rs +++ b/crates/core/src/py_edit/find.rs @@ -38,11 +38,10 @@ pub fn find_class<'a>(stmts: &'a [Stmt], class_name: &str) -> Option<&'a StmtCla /// Find the end offset of the last statement in a class body. pub fn class_body_end(class_def: &StmtClassDef) -> usize { - class_def - .body - .last() - .map(|stmt| stmt.range().end().into()) - .unwrap_or_else(|| class_def.range().end().into()) + class_def.body.last().map_or_else( + || class_def.range().end().into(), + |stmt| stmt.range().end().into(), + ) } /// Detect the indentation level of the first statement in a class body. @@ -50,7 +49,7 @@ pub fn class_body_indent(source: &str, class_def: &StmtClassDef) -> String { if let Some(first_stmt) = class_def.body.first() { let offset: usize = first_stmt.range().start().into(); // Walk backwards from the statement start to find the line start - let line_start = source[..offset].rfind('\n').map(|i| i + 1).unwrap_or(0); + let line_start = source[..offset].rfind('\n').map_or(0, |i| i + 1); let prefix = &source[line_start..offset]; // Extract leading whitespace let indent: String = prefix.chars().take_while(|c| c.is_whitespace()).collect(); @@ -61,9 +60,6 @@ pub fn class_body_indent(source: &str, class_def: &StmtClassDef) -> String { /// Info about a found function call expression. pub struct CallInfo { - /// The full range of the call expression. - #[allow(dead_code)] - pub range: TextRange, /// Existing keyword arguments. pub keywords: Vec, /// The range of the arguments (inside the parentheses). @@ -72,8 +68,6 @@ pub struct CallInfo { pub struct KeywordInfo { pub name: String, - #[allow(dead_code)] - pub range: TextRange, /// If the keyword value is a list, the range of that list expression. pub list_range: Option, } @@ -121,7 +115,6 @@ fn find_call_in_expr(expr: &Expr, call_target: &str) -> Option { .collect(); return Some(CallInfo { - range: call.range, keywords, args_end: usize::from(call.range.end()) - 1, // position of closing `)` }); @@ -137,11 +130,7 @@ fn keyword_info(kw: &Keyword) -> Option { } else { None }; - Some(KeywordInfo { - name, - range: kw.range, - list_range, - }) + Some(KeywordInfo { name, list_range }) } fn expr_name(expr: &Expr) -> String { diff --git a/crates/core/src/py_edit/mod.rs b/crates/core/src/py_edit/mod.rs index 96efe570..910c02dd 100644 --- a/crates/core/src/py_edit/mod.rs +++ b/crates/core/src/py_edit/mod.rs @@ -13,10 +13,13 @@ use ruff_text_size::Ranged; /// Errors returned by py_edit operations. #[derive(Debug, thiserror::Error)] pub enum PyEditError { + /// Python source failed to parse. #[error("Parse error: {0}")] Parse(String), + /// The item to add already exists in the source. #[error("Already present: {0}")] AlreadyPresent(String), + /// The target item was not found in the source. #[error("Not found: {0}")] NotFound(String), } @@ -123,6 +126,7 @@ pub fn add_call_keyword( } #[cfg(test)] +// Reason: panicking on failure is idiomatic in tests #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; diff --git a/crates/core/src/python_logging.rs b/crates/core/src/python_logging.rs index 34acb3be..5797f048 100644 --- a/crates/core/src/python_logging.rs +++ b/crates/core/src/python_logging.rs @@ -192,7 +192,7 @@ fn parse_formatters( for (name, formatter_value) in table { let formatter_table = formatter_value .as_table() - .ok_or_else(|| format!("formatter '{}' must be a table", name))?; + .ok_or_else(|| format!("formatter '{name}' must be a table"))?; let format = formatter_table .get("format") @@ -233,13 +233,13 @@ fn parse_handlers(value: Option<&toml::Value>) -> Result) -> Result Loggin // Override root if user provided one if user_config.root.is_some() { - config.root = user_config.root.clone(); + config.root.clone_from(&user_config.root); } // Use user's disable_existing_loggers if they explicitly set it @@ -536,6 +536,7 @@ pub async fn resolve_log_config( } #[cfg(test)] +// Reason: panicking on failure is idiomatic in tests #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod tests { use super::*; @@ -650,9 +651,10 @@ version = 1 // Create a temp file to simulate the external config existing let temp_dir = std::env::temp_dir(); let temp_file = temp_dir.join("logging.py"); - if std::fs::write(&temp_file, "# logging config").is_err() { - panic!("failed to write temp file"); - } + assert!( + std::fs::write(&temp_file, "# logging config").is_ok(), + "failed to write temp file" + ); let result = parse_dev_config(&value, &temp_dir); assert!(result.is_err()); @@ -839,8 +841,7 @@ console = { class = "logging.StreamHandler", formatter = "custom", stream = "ext "python", "-c", &format!( - "import json, logging.config; logging.config.dictConfig(json.load(open('{}')))", - config_path + "import json, logging.config; logging.config.dictConfig(json.load(open('{config_path}')))" ), ]) .output() @@ -849,8 +850,7 @@ console = { class = "logging.StreamHandler", formatter = "custom", stream = "ext if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); panic!( - "Default logging config failed Python validation:\n{}\n\nConfig JSON:\n{}", - stderr, json + "Default logging config failed Python validation:\n{stderr}\n\nConfig JSON:\n{json}" ); } } diff --git a/crates/core/src/registry.rs b/crates/core/src/registry.rs index 5190dfe8..cdb717e9 100644 --- a/crates/core/src/registry.rs +++ b/crates/core/src/registry.rs @@ -149,21 +149,10 @@ impl Registry { Err("No available ports".to_string()) } - - /// Get the number of registered servers - #[allow(dead_code)] - pub fn len(&self) -> usize { - self.data.servers.len() - } - - /// Check if the registry is empty - #[allow(dead_code)] - pub fn is_empty(&self) -> bool { - self.data.servers.is_empty() - } } #[cfg(test)] +// Reason: panicking on failure is idiomatic in tests #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; diff --git a/crates/core/src/search/component_index.rs b/crates/core/src/search/component_index.rs index 64a81323..436d82f6 100644 --- a/crates/core/src/search/component_index.rs +++ b/crates/core/src/search/component_index.rs @@ -25,9 +25,13 @@ pub struct ComponentRecord { /// Search result with component details #[derive(Debug, Clone)] pub struct SearchResult { + /// Unique component identifier. pub id: String, + /// Human-readable component name. pub name: String, + /// Registry the component belongs to. pub registry: String, + /// FTS5 relevance score (lower is more relevant). pub score: f32, } @@ -66,12 +70,6 @@ impl ComponentIndex { Ok(Self { fts }) } - /// Create with a specific pool (for testing or custom setups) - #[allow(dead_code)] - pub fn with_pool(pool: SqlitePool) -> Result { - Self::new(pool) - } - /// Get the FTS table name pub fn table_name() -> &'static str { TABLE_NAME @@ -239,10 +237,18 @@ impl ComponentIndex { } #[cfg(test)] +// Reason: panicking on failure is idiomatic in tests #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; + impl ComponentIndex { + /// Create with a specific pool (for testing) + pub fn with_pool(pool: SqlitePool) -> Result { + Self::new(pool) + } + } + async fn test_index() -> ComponentIndex { let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); ComponentIndex::with_pool(pool).unwrap() @@ -253,7 +259,7 @@ mod tests { fts.create_or_replace().await.unwrap(); let mut tx = fts.begin().await.unwrap(); for (id, name, registry, text) in rows { - fts.insert_str(&mut tx, &[id, name, registry, text]) + fts.insert_str(&mut tx, &[*id, *name, *registry, *text]) .await .unwrap(); } @@ -301,8 +307,7 @@ mod tests { assert!(!results.is_empty()); // Both buttons should be in results, card should not - let button_results: Vec<_> = results.iter().filter(|r| r.name == "button").collect(); - assert_eq!(button_results.len(), 2); + assert_eq!(results.iter().filter(|r| r.name == "button").count(), 2); // Default registry button should have higher score than custom let default_btn = results.iter().find(|r| r.id == "button").unwrap(); diff --git a/crates/core/src/search/docs_index.rs b/crates/core/src/search/docs_index.rs index 8e40891b..75d508cd 100644 --- a/crates/core/src/search/docs_index.rs +++ b/crates/core/src/search/docs_index.rs @@ -37,8 +37,11 @@ pub struct DocChunk { /// Search result with score #[derive(Debug, Clone, Serialize)] pub struct DocSearchResult { + /// Matched documentation text. pub text: String, + /// Path of the source file containing this result. pub source_file: String, + /// FTS5 relevance score (lower is more relevant). pub score: f32, } @@ -131,8 +134,7 @@ fn chunk_text( // Find last space before end enriched_text[start..end] .rfind(' ') - .map(|pos| start + pos) - .unwrap_or(end) + .map_or(end, |pos| start + pos) } else { end }; @@ -182,15 +184,6 @@ impl SDKDocsIndex { } } - /// Create with a specific pool (for testing or custom setups) - #[allow(dead_code)] - pub fn with_pool(pool: SqlitePool) -> Self { - Self { - pool, - version: None, - } - } - /// Get table name for a version pub fn table_name(version: &str) -> String { format!( @@ -427,6 +420,7 @@ impl SDKDocsIndex { } #[cfg(test)] +// Reason: panicking on failure is idiomatic in tests #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; @@ -454,6 +448,16 @@ mod tests { assert_eq!(op, "create"); } + impl SDKDocsIndex { + /// Create with a specific pool (for testing) + pub fn with_pool(pool: SqlitePool) -> Self { + Self { + pool, + version: None, + } + } + } + #[test] fn test_chunk_text_long_document() { // Create a long document that should be split diff --git a/crates/core/src/sources/databricks_sdk.rs b/crates/core/src/sources/databricks_sdk.rs index 2f15ff39..6d0c7d38 100644 --- a/crates/core/src/sources/databricks_sdk.rs +++ b/crates/core/src/sources/databricks_sdk.rs @@ -13,10 +13,11 @@ use std::path::{Path, PathBuf}; const GITHUB_REPO: &str = "databricks/databricks-sdk-py"; /// SDK documentation source enum -#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)] +#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "kebab-case")] pub enum SDKSource { #[serde(rename = "databricks-sdk-python")] + /// Databricks SDK for Python. DatabricksSdkPython, } @@ -115,14 +116,14 @@ pub async fn download_and_extract_sdk(version: &str) -> Result ); // Find root folder name - let root_folder = if !archive.is_empty() { + let root_folder = if archive.is_empty() { + return Err("Empty ZIP archive".to_string()); + } else { let first_file = archive .by_index(0) .map_err(|e| format!("Failed to read first file: {e}"))?; let name = first_file.name(); name.split('/').next().unwrap_or("").to_string() - } else { - return Err("Empty ZIP archive".to_string()); }; // Extract docs/ folder @@ -331,17 +332,17 @@ fn parse_rst_content(rst_content: &str) -> (String, String, String, String, Vec< if entity.is_empty() && let Some(e) = parsed.entity { - entity = e.clone(); + entity.clone_from(&e); symbols.push(e); } if let Some(s) = parsed.service { if service.is_empty() { - service = s.clone(); + service.clone_from(&s); } symbols.push(s); } if let Some(op) = parsed.operation { - operation = op.clone(); // Keep last operation (methods come after class) + operation.clone_from(&op); // Keep last operation (methods come after class) symbols.push(op); } } @@ -352,7 +353,7 @@ fn parse_rst_content(rst_content: &str) -> (String, String, String, String, Vec< if let Some(stripped) = trimmed.strip_prefix(':') { if let Some(colon_end) = stripped.find(':') { let field_name = &stripped[..colon_end]; - let field_value = trimmed.get(colon_end + 2..).map(|s| s.trim()).unwrap_or(""); + let field_value = trimmed.get(colon_end + 2..).map_or("", |s| s.trim()); let text = match field_name { f if f.starts_with("param ") => { @@ -372,7 +373,7 @@ fn parse_rst_content(rst_content: &str) -> (String, String, String, String, Vec< } "returns" if !field_value.is_empty() => format!("returns {field_value}"), "value" if !field_value.is_empty() => format!("value {field_value}"), - "members" | "undoc-members" => continue, // Skip directive options + // Skip directive options _ => continue, }; output.push(text); @@ -594,6 +595,7 @@ pub fn load_doc_files(docs_path: &Path) -> Result, String> { } #[cfg(test)] +// Reason: panicking on failure is idiomatic in tests #[allow(clippy::unwrap_used)] mod tests { use super::*; diff --git a/crates/core/src/tracing_init.rs b/crates/core/src/tracing_init.rs index 14333a3b..46f8bae9 100644 --- a/crates/core/src/tracing_init.rs +++ b/crates/core/src/tracing_init.rs @@ -1,6 +1,109 @@ +use std::fmt; +use std::sync::atomic::{AtomicBool, Ordering}; + +use chrono::Local; +use tracing::Subscriber; use tracing_subscriber::EnvFilter; +use tracing_subscriber::fmt::FormatEvent; +use tracing_subscriber::fmt::FormatFields; +use tracing_subscriber::fmt::format::Writer; use tracing_subscriber::prelude::*; +use tracing_subscriber::registry::LookupSpan; + +/// When `true`, the fmt layer uses the dev-friendly `| apx | out/err |` format +/// instead of the default verbose format with target/file/line. +static DEV_FORMAT: AtomicBool = AtomicBool::new(false); + +/// Enable the dev-friendly log format for attached mode. +/// +/// Once called, all subsequent tracing events will be formatted as: +/// `YYYY-MM-DD HH:MM:SS.mmm | apx | out | message` +pub fn enable_dev_format() { + DEV_FORMAT.store(true, Ordering::Relaxed); +} + +const ANSI_YELLOW: &str = "\x1b[33m"; +const ANSI_RESET: &str = "\x1b[0m"; + +/// A tracing event formatter that switches between dev-friendly and verbose formats +/// based on the [`DEV_FORMAT`] flag. +struct DevAwareFormatter; + +impl FormatEvent for DevAwareFormatter +where + S: Subscriber + for<'a> LookupSpan<'a>, + N: for<'a> FormatFields<'a> + 'static, +{ + fn format_event( + &self, + ctx: &tracing_subscriber::fmt::FmtContext<'_, S, N>, + mut writer: Writer<'_>, + event: &tracing::Event<'_>, + ) -> fmt::Result { + if DEV_FORMAT.load(Ordering::Relaxed) { + let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S%.3f"); + let channel = if *event.metadata().level() == tracing::Level::ERROR { + "err" + } else { + "out" + }; + + let mut visitor = MessageVisitor(String::new()); + event.record(&mut visitor); + let message = visitor.0; + + if writer.has_ansi_escapes() { + writeln!( + writer, + "{ANSI_YELLOW}{timestamp} | apx | {channel} | {message}{ANSI_RESET}" + ) + } else { + writeln!(writer, "{timestamp} | apx | {channel} | {message}") + } + } else { + // Verbose format with target, file, and line number + use tracing_subscriber::fmt::time::FormatTime; + use tracing_subscriber::fmt::time::SystemTime; + + let timer = SystemTime; + timer.format_time(&mut writer)?; + + let level = event.metadata().level(); + write!(writer, " {level:>5} ")?; + + let target = event.metadata().target(); + write!(writer, "{target}: ")?; + + if let (Some(file), Some(line)) = (event.metadata().file(), event.metadata().line()) { + write!(writer, "{file}:{line}: ")?; + } + + ctx.format_fields(writer.by_ref(), event)?; + writeln!(writer) + } + } +} + +/// Visitor that extracts the message field from a tracing event. +struct MessageVisitor(String); + +impl tracing::field::Visit for MessageVisitor { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn fmt::Debug) { + if field.name() == "message" { + self.0 = format!("{value:?}"); + } + } + + fn record_str(&mut self, field: &tracing::field::Field, value: &str) { + if field.name() == "message" { + self.0 = value.to_string(); + } + } +} +/// Initialize the tracing subscriber with optional OTLP log export. +/// +/// Reads `APX_LOG` for the log filter and `APX_OTEL_LOGS=1` to enable OTLP export. pub fn init_tracing() { let apx_root = "apx"; @@ -28,9 +131,7 @@ pub fn init_tracing() { fn init_tracing_fmt_only(filter: &str) { let fmt_layer = tracing_subscriber::fmt::layer() .with_writer(std::io::stderr) - .with_target(true) - .with_line_number(true) - .with_file(true) + .event_format(DevAwareFormatter) .with_filter(EnvFilter::new(filter)); if tracing_subscriber::registry() @@ -80,9 +181,7 @@ fn init_tracing_with_otel( let fmt_layer = tracing_subscriber::fmt::layer() .with_writer(std::io::stderr) - .with_target(true) - .with_line_number(true) - .with_file(true) + .event_format(DevAwareFormatter) .with_filter(EnvFilter::new(filter)); if tracing_subscriber::registry() diff --git a/crates/databricks_sdk/Cargo.toml b/crates/databricks_sdk/Cargo.toml index b275c2c3..72c3aab1 100644 --- a/crates/databricks_sdk/Cargo.toml +++ b/crates/databricks_sdk/Cargo.toml @@ -3,6 +3,15 @@ name = "apx-databricks-sdk" version = "0.3.6" edition.workspace = true rust-version.workspace = true +description.workspace = true +repository.workspace = true +license.workspace = true +readme.workspace = true +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true [dependencies] serde.workspace = true diff --git a/crates/databricks_sdk/build.rs b/crates/databricks_sdk/build.rs index 4defbcec..54912feb 100644 --- a/crates/databricks_sdk/build.rs +++ b/crates/databricks_sdk/build.rs @@ -1,3 +1,4 @@ +//! Build script for apx-databricks-sdk. fn main() { let version = std::process::Command::new("rustc") .arg("--version") @@ -7,7 +8,7 @@ fn main() { .and_then(|s| { s.strip_prefix("rustc ") .and_then(|v| v.split_whitespace().next()) - .map(|v| v.to_string()) + .map(ToString::to_string) }) .unwrap_or_else(|| "unknown".to_string()); diff --git a/crates/databricks_sdk/src/api/apps.rs b/crates/databricks_sdk/src/api/apps.rs index 91211d63..3fd4410a 100644 --- a/crates/databricks_sdk/src/api/apps.rs +++ b/crates/databricks_sdk/src/api/apps.rs @@ -14,36 +14,52 @@ use crate::error::{DatabricksError, Result}; // REST types for GET /api/2.0/apps/{name} // --------------------------------------------------------------------------- +/// Metadata for a Databricks App returned by the REST API. #[derive(Debug, Clone, Deserialize)] pub struct App { + /// Application name (unique within the workspace). pub name: String, + /// Public URL of the deployed app, if available. #[serde(default)] pub url: Option, + /// Current compute status of the app. #[serde(default)] pub compute_status: Option, } -#[derive(Debug, Clone, PartialEq, Deserialize)] +/// Lifecycle state of a Databricks App's compute resources. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Deserialize)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum ComputeState { + /// The app is running and serving requests. Active, + /// The app's compute is starting up. Starting, + /// The app's compute is shutting down. Stopping, + /// The app's compute is fully stopped. Stopped, + /// The app is being deleted. Deleting, + /// The app is in an error state. Error, + /// An unrecognized state (forward-compatible). #[serde(other)] Unknown, } impl ComputeState { - pub fn is_terminal(&self) -> bool { + /// Returns `true` if the app is in a terminal state (stopped, deleting, or error). + #[must_use] + pub const fn is_terminal(&self) -> bool { matches!(self, Self::Stopped | Self::Deleting | Self::Error) } } -#[derive(Debug, Clone, Deserialize)] +/// Wrapper carrying the current [`ComputeState`] of an app. +#[derive(Debug, Copy, Clone, Deserialize)] pub struct ComputeStatus { + /// Current lifecycle state. pub state: ComputeState, } @@ -51,10 +67,14 @@ pub struct ComputeStatus { // WebSocket log entry // --------------------------------------------------------------------------- +/// A single log line received over the WebSocket log stream. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LogEntry { + /// Log source (e.g. `"APP"`, `"SYSTEM"`). pub source: String, + /// Unix timestamp in seconds (with fractional milliseconds). pub timestamp: f64, + /// The log message text. pub message: String, } @@ -62,13 +82,20 @@ pub struct LogEntry { // Input parameters // --------------------------------------------------------------------------- +/// Parameters for fetching app logs via WebSocket. #[derive(Debug)] pub struct AppLogsArgs<'a> { + /// Name of the Databricks app. pub app_name: &'a str, + /// Maximum number of log lines to return (ring-buffer). pub tail_lines: usize, + /// Optional search string sent to the log stream. pub search: Option<&'a str>, + /// Optional filter on log sources (e.g. `["APP"]`). pub sources: Option<&'a [String]>, + /// Overall timeout for the WebSocket connection. pub timeout: Duration, + /// Idle timeout after receiving the first log line. pub idle_timeout: Option, } @@ -76,21 +103,32 @@ pub struct AppLogsArgs<'a> { // AppsApi // --------------------------------------------------------------------------- +/// API handle for Databricks Apps operations (metadata + log streaming). +#[derive(Debug)] pub struct AppsApi<'a> { client: &'a DatabricksClient, } impl<'a> AppsApi<'a> { - pub(crate) fn new(client: &'a DatabricksClient) -> Self { + pub(crate) const fn new(client: &'a DatabricksClient) -> Self { Self { client } } /// Fetch app metadata via REST API. + /// + /// # Errors + /// + /// Returns an error if the HTTP request fails or the response cannot be deserialized. pub async fn get(&self, name: &str) -> Result { self.client.get(&format!("/api/2.0/apps/{name}")).await } /// Fetch app logs via WebSocket. + /// + /// # Errors + /// + /// Returns an error if the app is in a terminal state, the WebSocket connection + /// fails, or log frames cannot be read. pub async fn logs(&self, args: &AppLogsArgs<'_>) -> Result> { let app = self.get(args.app_name).await?; let app_url = validate_app(&app)?; @@ -226,9 +264,10 @@ async fn collect_logs(mut stream: WsStream, args: &AppLogsArgs<'_>) -> Result inner?, - Err(_) => debug!("Log stream timed out after {:?}", args.timeout), + if let Ok(inner) = result { + inner?; + } else { + debug!("Log stream timed out after {:?}", args.timeout); } Ok(buffer.into()) @@ -256,9 +295,9 @@ async fn read_frames( received_any = true; parse_and_buffer(text.as_ref(), args, buffer); } - Message::Binary(data) if data.as_ref() == HEARTBEAT => continue, + Message::Binary(data) if data.as_ref() == HEARTBEAT => {} Message::Close(_) => break, - _ => continue, + _ => {} } } Ok(None) => break, // stream ended @@ -269,7 +308,6 @@ async fn read_frames( break; } // Haven't received any logs yet — keep waiting (outer timeout guards) - continue; } } } @@ -304,6 +342,7 @@ fn parse_and_buffer(text: &str, args: &AppLogsArgs<'_>, buffer: &mut VecDeque, + /// Email addresses associated with the user. #[serde(default)] pub emails: Vec, + /// Whether the user account is active. #[serde(default)] pub active: Option, + /// Structured name (given/family). #[serde(default)] pub name: Option, } +/// An email address entry from the SCIM user record. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UserEmail { + /// The email address. pub value: String, + /// Whether this is the primary email. #[serde(default)] pub primary: Option, + /// Email type (e.g. `"work"`). #[serde(rename = "type", default)] pub email_type: Option, } +/// Structured name from the SCIM user record. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UserName { + /// First / given name. #[serde(default)] pub given_name: Option, + /// Last / family name. #[serde(default)] pub family_name: Option, } +/// API handle for current-user (SCIM `/Me`) operations. +#[derive(Debug)] pub struct CurrentUserApi<'a> { client: &'a DatabricksClient, } impl<'a> CurrentUserApi<'a> { - pub(crate) fn new(client: &'a DatabricksClient) -> Self { + pub(crate) const fn new(client: &'a DatabricksClient) -> Self { Self { client } } + /// Fetch the current user's profile. + /// + /// # Errors + /// + /// Returns an error if the HTTP request fails or the response cannot be deserialized. pub async fn me(&self) -> Result { self.client.get("/api/2.0/preview/scim/v2/Me").await } diff --git a/crates/databricks_sdk/src/api/mod.rs b/crates/databricks_sdk/src/api/mod.rs index 91a2e34c..1d3b559a 100644 --- a/crates/databricks_sdk/src/api/mod.rs +++ b/crates/databricks_sdk/src/api/mod.rs @@ -1,2 +1,4 @@ +/// Databricks Apps REST API and WebSocket log streaming. pub mod apps; +/// SCIM current-user (`/Me`) endpoint. pub mod current_user; diff --git a/crates/databricks_sdk/src/auth.rs b/crates/databricks_sdk/src/auth.rs index c62ef2d5..c66bee22 100644 --- a/crates/databricks_sdk/src/auth.rs +++ b/crates/databricks_sdk/src/auth.rs @@ -8,8 +8,6 @@ use crate::error::{DatabricksError, Result}; #[derive(Debug, Clone, Deserialize)] pub(crate) struct CliTokenResponse { pub access_token: String, - #[allow(dead_code)] - pub token_type: String, pub expiry: String, } @@ -23,7 +21,7 @@ pub(crate) struct CachedToken { const STALENESS_BUFFER_SECS: i64 = 40; impl CachedToken { - pub fn is_valid(&self) -> bool { + pub(crate) fn is_valid(&self) -> bool { let buffer = Duration::seconds(STALENESS_BUFFER_SECS); Utc::now() + buffer < self.expires_at } diff --git a/crates/databricks_sdk/src/client.rs b/crates/databricks_sdk/src/client.rs index 9b4a0e43..896db9a5 100644 --- a/crates/databricks_sdk/src/client.rs +++ b/crates/databricks_sdk/src/client.rs @@ -1,6 +1,5 @@ use std::sync::Arc; -use serde::Serialize; use serde::de::DeserializeOwned; use tokio::sync::RwLock; use tracing::debug; @@ -18,6 +17,8 @@ struct Inner { cached_token: RwLock>, } +// Reason: token field is sensitive and must not appear in debug output +#[allow(clippy::missing_fields_in_debug)] impl std::fmt::Debug for Inner { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Inner") @@ -27,6 +28,10 @@ impl std::fmt::Debug for Inner { } } +/// Authenticated HTTP client for the Databricks REST API. +/// +/// Tokens are acquired lazily via the Databricks CLI and cached with +/// automatic refresh before expiry. #[derive(Debug, Clone)] pub struct DatabricksClient { inner: Arc, @@ -35,12 +40,24 @@ pub struct DatabricksClient { impl DatabricksClient { /// Create a new client by resolving the given profile from `~/.databrickscfg`. /// Does not eagerly fetch a token. + /// + /// # Errors + /// + /// Returns an error if the profile cannot be resolved from the config file. + // Reason: async signature required by trait interface + #[allow(clippy::unused_async)] pub async fn new(profile: &str) -> Result { let config = resolve_config(profile)?; Ok(Self::from_config(config)) } /// Create a new client with explicit product info for the User-Agent header. + /// + /// # Errors + /// + /// Returns an error if the profile cannot be resolved from the config file. + // Reason: async signature required by trait interface + #[allow(clippy::unused_async)] pub async fn with_product(profile: &str, product: &str, product_version: &str) -> Result { let mut config = resolve_config(profile)?; config.product = Some(product.to_string()); @@ -49,6 +66,7 @@ impl DatabricksClient { } /// Create a client from an already-resolved config. + #[must_use] pub fn from_config(config: DatabricksConfig) -> Self { let product = config.product.as_deref().unwrap_or("unknown"); let product_version = config.product_version.as_deref().unwrap_or("0.0.0"); @@ -81,20 +99,28 @@ impl DatabricksClient { } } - // Slow path: write lock + acquire - let mut guard = self.inner.cached_token.write().await; - - // Double-check after acquiring write lock - if let Some(ref token) = *guard - && token.is_valid() + // Slow path: check under write lock, but drop it before the async acquire. { - return Ok(token.access_token.clone()); + let guard = self.inner.cached_token.write().await; + // Double-check after acquiring write lock + if let Some(ref token) = *guard + && token.is_valid() + { + return Ok(token.access_token.clone()); + } + // Drop the write lock before the potentially long token acquisition. + drop(guard); } debug!(profile = %self.inner.config.profile, "Token expired or missing, acquiring new token"); let new_token = acquire_token(&self.inner.config.profile).await?; let access_token = new_token.access_token.clone(); - *guard = Some(new_token); + + // Re-acquire write lock to store the new token. + { + let mut guard = self.inner.cached_token.write().await; + *guard = Some(new_token); + } Ok(access_token) } @@ -108,46 +134,36 @@ impl DatabricksClient { handle_response(response).await } - /// Perform an authenticated POST request and deserialize the JSON response. - #[allow(dead_code)] - pub(crate) async fn post( - &self, - path: &str, - body: &B, - ) -> Result { - let token = self.get_token().await?; - let url = format!("{}{}", self.inner.config.host, path); - - let response = self - .inner - .http - .post(&url) - .bearer_auth(&token) - .json(body) - .send() - .await?; - - handle_response(response).await - } - /// Get a raw access token for proxy forwarding. + /// + /// # Errors + /// + /// Returns an error if the token cannot be acquired or refreshed. pub async fn access_token(&self) -> Result { self.get_token().await } + /// The normalized Databricks workspace host URL. + #[must_use] pub fn host(&self) -> &str { &self.inner.config.host } + /// The Databricks CLI profile name used by this client. + #[must_use] pub fn profile(&self) -> &str { &self.inner.config.profile } - pub fn apps(&self) -> AppsApi<'_> { + /// Access the Databricks Apps API. + #[must_use] + pub const fn apps(&self) -> AppsApi<'_> { AppsApi::new(self) } - pub fn current_user(&self) -> CurrentUserApi<'_> { + /// Access the SCIM current-user API. + #[must_use] + pub const fn current_user(&self) -> CurrentUserApi<'_> { CurrentUserApi::new(self) } } diff --git a/crates/databricks_sdk/src/config.rs b/crates/databricks_sdk/src/config.rs index 8f219069..16398042 100644 --- a/crates/databricks_sdk/src/config.rs +++ b/crates/databricks_sdk/src/config.rs @@ -3,11 +3,16 @@ use std::path::PathBuf; use crate::config_parser::ConfigParser; use crate::error::{DatabricksError, Result}; +/// Resolved Databricks workspace configuration for a single profile. #[derive(Debug, Clone)] pub struct DatabricksConfig { + /// Profile name (e.g. `"DEFAULT"`, `"staging"`). pub profile: String, + /// Normalized workspace host URL (e.g. `"https://adb-123.4.azuredatabricks.net"`). pub host: String, + /// Optional product name for the User-Agent header. pub product: Option, + /// Optional product version for the User-Agent header. pub product_version: Option, } @@ -32,6 +37,10 @@ fn normalize_host(host: &str) -> String { } /// List just the profile names (section headers) from `~/.databrickscfg`. +/// +/// # Errors +/// +/// Returns an error if the config file path cannot be determined or the file cannot be parsed. pub fn list_profile_names() -> Result> { let path = config_file_path()?; let config = ConfigParser::parse(&path)?; @@ -44,6 +53,11 @@ pub fn list_profile_names() -> Result> { /// 1. Explicit `profile_name` argument (if non-empty) /// 2. `DATABRICKS_CONFIG_PROFILE` env var /// 3. `"DEFAULT"` +/// +/// # Errors +/// +/// Returns an error if the config file cannot be read, the profile is not found, +/// or the profile has no host configured. pub fn resolve_config(profile_name: &str) -> Result { let profile = if !profile_name.is_empty() { profile_name.to_string() @@ -67,8 +81,7 @@ pub fn resolve_config(profile_name: &str) -> Result { let host = normalize_host(&found.host); if host.is_empty() { return Err(DatabricksError::Config(format!( - "profile '{}' has no host configured", - profile + "profile '{profile}' has no host configured" ))); } diff --git a/crates/databricks_sdk/src/config_parser.rs b/crates/databricks_sdk/src/config_parser.rs index e591b474..344c3eb5 100644 --- a/crates/databricks_sdk/src/config_parser.rs +++ b/crates/databricks_sdk/src/config_parser.rs @@ -14,13 +14,19 @@ pub struct ConfigParser { /// A single `[section]` with its `host = ...` value. #[derive(Debug, Clone)] pub struct ProfileEntry { + /// Section name (e.g. `"DEFAULT"`, `"staging"`). pub name: String, + /// The `host` value from this section. pub host: String, } impl ConfigParser { /// Parse a `.databrickscfg` file at `path`. /// Returns an empty config if the file does not exist. + /// + /// # Errors + /// + /// Returns an error if the file exists but cannot be read. pub fn parse(path: &Path) -> Result { if !path.exists() { return Ok(Self { @@ -36,6 +42,7 @@ impl ConfigParser { } /// Parse from an in-memory string (useful for testing). + #[must_use] pub fn parse_str(content: &str) -> Self { let mut profiles = Vec::new(); let mut current_section: Option = None; @@ -52,8 +59,8 @@ impl ConfigParser { } current_section = Some(trimmed[1..trimmed.len() - 1].to_string()); current_host = None; - } else if let Some((_key, value)) = trimmed.split_once('=') { - let key = _key.trim(); + } else if let Some((key, value)) = trimmed.split_once('=') { + let key = key.trim(); let value = value.trim(); if key == "host" { current_host = Some(value.to_string()); @@ -71,16 +78,19 @@ impl ConfigParser { } /// All parsed profiles with their host values. + #[must_use] pub fn profiles(&self) -> &[ProfileEntry] { &self.profiles } /// Find a profile by name. + #[must_use] pub fn get_profile(&self, name: &str) -> Option<&ProfileEntry> { self.profiles.iter().find(|p| p.name == name) } /// List unique profile names (section headers), plus `DEFAULT` if not already present. + #[must_use] pub fn list_profiles(&self) -> Vec { let mut seen = HashSet::new(); let mut names: Vec = self @@ -104,6 +114,7 @@ impl ConfigParser { } #[cfg(test)] +#[allow(clippy::expect_used)] mod tests { use super::*; @@ -160,7 +171,9 @@ host = https://default.cloud.databricks.com host = https://staging.cloud.databricks.com "; let config = ConfigParser::parse_str(content); - let staging = config.get_profile("staging").unwrap(); + let staging = config + .get_profile("staging") + .expect("staging profile not found"); assert_eq!(staging.host, "https://staging.cloud.databricks.com"); assert!(config.get_profile("nonexistent").is_none()); } diff --git a/crates/databricks_sdk/src/error.rs b/crates/databricks_sdk/src/error.rs index 3586b3df..dd39885c 100644 --- a/crates/databricks_sdk/src/error.rs +++ b/crates/databricks_sdk/src/error.rs @@ -1,27 +1,41 @@ +/// Errors returned by the Databricks SDK. #[derive(Debug, thiserror::Error)] pub enum DatabricksError { + /// Authentication failure (e.g. invalid or expired token). #[error("authentication error: {0}")] Auth(String), + /// Non-success HTTP response from the Databricks API. #[error("API error (HTTP {status}): {message}")] Api { + /// HTTP status code. status: u16, + /// Human-readable error message. message: String, + /// Optional raw response body. body: Option, }, + /// Configuration error (missing profile, missing host, etc.). #[error("configuration error: {0}")] Config(String), + /// Databricks CLI invocation failure. #[error("CLI error: {0}")] Cli(String), + /// WebSocket connection or protocol error. #[error("WebSocket error: {0}")] WebSocket(String), + /// Input validation error. #[error("validation error: {0}")] Validation(String), + /// Underlying HTTP transport error from `reqwest`. #[error("HTTP error: {0}")] Http(#[from] reqwest::Error), + /// File-system or I/O error. #[error("I/O error: {0}")] Io(#[from] std::io::Error), + /// JSON serialization or deserialization error. #[error("JSON error: {0}")] Json(#[from] serde_json::Error), } +/// Convenience alias for `Result`. pub type Result = std::result::Result; diff --git a/crates/databricks_sdk/src/lib.rs b/crates/databricks_sdk/src/lib.rs index 52ed6bd8..109675de 100644 --- a/crates/databricks_sdk/src/lib.rs +++ b/crates/databricks_sdk/src/lib.rs @@ -1,11 +1,21 @@ -#![forbid(unsafe_code)] +//! Databricks SDK for Rust. +//! +//! Provides authenticated access to the Databricks REST API via the Databricks CLI +//! for token management. Configuration is read from `~/.databrickscfg`. +/// Databricks REST API wrappers (apps, current user, etc.). pub mod api; +/// Token acquisition and caching via the Databricks CLI. pub mod auth; +/// HTTP client with automatic token refresh. pub mod client; +/// Configuration resolution from `~/.databrickscfg`. pub mod config; +/// INI-style parser for `.databrickscfg` files. pub mod config_parser; +/// Error types and the crate-level `Result` alias. pub mod error; +/// User-Agent header builder matching the Databricks SDK format. pub mod useragent; pub use api::apps::{App, AppLogsArgs, ComputeState, LogEntry}; @@ -17,6 +27,10 @@ pub use error::{DatabricksError, Result}; /// Validate that the given Databricks profile has working credentials /// by calling the SCIM /Me endpoint. +/// +/// # Errors +/// +/// Returns an error if the profile cannot be resolved or the authentication check fails. pub async fn validate_credentials(profile: &str) -> Result<()> { let client = DatabricksClient::new(profile).await?; client.current_user().me().await?; @@ -25,6 +39,10 @@ pub async fn validate_credentials(profile: &str) -> Result<()> { /// Get the forwarded user header value (`{user_id}@{workspace_id}`) /// for proxying requests to Databricks-hosted apps. +/// +/// # Errors +/// +/// Returns an error if the profile cannot be resolved or the current user cannot be fetched. pub async fn get_forwarded_user_header(profile: &str) -> Result { let client = DatabricksClient::new(profile).await?; let user = client.current_user().me().await?; diff --git a/crates/databricks_sdk/src/useragent.rs b/crates/databricks_sdk/src/useragent.rs index de9278c2..0b5d120e 100644 --- a/crates/databricks_sdk/src/useragent.rs +++ b/crates/databricks_sdk/src/useragent.rs @@ -6,17 +6,23 @@ use std::fmt; /// ```text /// {product}/{product_version} apx-databricks-sdk-rust/{sdk_version} rust/{rust_version} os/{os} auth/{auth_type} [extras...] [upstream/{name}] [upstream-version/{ver}] [runtime/{ver}] [cicd/{provider}] /// ``` +#[derive(Debug)] pub struct UserAgent { + /// Sanitized product name. product: String, + /// Sanitized product version. product_version: String, + /// Optional authentication type tag. auth_type: Option, + /// Additional key/value pairs appended to the header. extras: Vec<(String, String)>, } impl UserAgent { - /// Create a new UserAgent with the given product name and version. + /// Create a new `UserAgent` with the given product name and version. /// /// Both values are sanitized — only `[a-zA-Z0-9_.+-]` chars are kept. + #[must_use] pub fn new(product: &str, product_version: &str) -> Self { Self { product: sanitize(product), @@ -27,17 +33,11 @@ impl UserAgent { } /// Set the authentication type (e.g. "databricks-cli"). + #[must_use] pub fn with_auth(mut self, auth_type: &str) -> Self { self.auth_type = Some(sanitize(auth_type)); self } - - /// Add an extra key/value pair to the User-Agent string. - #[allow(dead_code)] - pub fn with_extra(mut self, key: &str, value: &str) -> Self { - self.extras.push((sanitize(key), sanitize(value))); - self - } } impl fmt::Display for UserAgent { @@ -141,6 +141,16 @@ fn detect_cicd() -> Option<&'static str> { None } +#[cfg(test)] +impl UserAgent { + /// Add an extra key/value pair to the User-Agent string. + #[must_use] + pub fn with_extra(mut self, key: &str, value: &str) -> Self { + self.extras.push((sanitize(key), sanitize(value))); + self + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/db/Cargo.toml b/crates/db/Cargo.toml index 498256e0..85d59a45 100644 --- a/crates/db/Cargo.toml +++ b/crates/db/Cargo.toml @@ -3,6 +3,15 @@ name = "apx-db" version = "0.3.6" edition.workspace = true rust-version.workspace = true +description.workspace = true +repository.workspace = true +license.workspace = true +readme.workspace = true +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true [dependencies] apx-common.workspace = true diff --git a/crates/db/src/dev.rs b/crates/db/src/dev.rs index f43c2cc5..4f4a1248 100644 --- a/crates/db/src/dev.rs +++ b/crates/db/src/dev.rs @@ -1,4 +1,4 @@ -//! Async dev database operations using SQLx. +//! Async dev database operations using `SQLx`. //! //! Provides [`DevDb`] as the connection pool for the dev database at `~/.apx/dev/db`. //! This database holds search indexes (FTS5) and will hold future dev-related tables. @@ -16,12 +16,22 @@ pub struct DevDb { impl DevDb { /// Open or create the dev database at the default location (`~/.apx/dev/db`). + /// + /// # Errors + /// + /// Returns an error if the database path cannot be determined or the database + /// cannot be opened. pub async fn open() -> Result { let path = super::dev_db_path()?; Self::open_at(&path).await } /// Open or create the dev database at a specific path. + /// + /// # Errors + /// + /// Returns an error if the directory cannot be created or the database + /// cannot be opened. pub async fn open_at(path: &Path) -> Result { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) @@ -44,12 +54,17 @@ impl DevDb { } /// Get a reference to the underlying connection pool. - pub fn pool(&self) -> &SqlitePool { + #[must_use] + pub const fn pool(&self) -> &SqlitePool { &self.pool } } /// Check if a table exists in the database. +/// +/// # Errors +/// +/// Returns an error if the existence check query fails. pub async fn table_exists(pool: &SqlitePool, table_name: &str) -> Result { let row: (bool,) = sqlx::query_as("SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)") @@ -61,6 +76,7 @@ pub async fn table_exists(pool: &SqlitePool, table_name: &str) -> Result Self { - self.tokenizer = tokenizer.to_string(); - self - } - /// Get the table name. + #[must_use] pub fn table_name(&self) -> &str { &self.table_name } /// Get a reference to the underlying connection pool. - pub fn pool(&self) -> &SqlitePool { + #[must_use] + pub const fn pool(&self) -> &SqlitePool { &self.pool } /// Check if the table exists in the database. + /// + /// # Errors + /// + /// Returns an error if the existence check query fails. pub async fn exists(&self) -> Result { super::dev::table_exists(&self.pool, &self.table_name).await } /// `DROP TABLE IF EXISTS` + `CREATE VIRTUAL TABLE`. + /// + /// # Errors + /// + /// Returns an error if the drop or create query fails. pub async fn create_or_replace(&self) -> Result<(), String> { let drop_sql = format!("DROP TABLE IF EXISTS \"{}\"", self.table_name); sqlx::query(&drop_sql) @@ -104,6 +112,10 @@ impl Fts5Table { } /// Begin a transaction on the underlying pool. + /// + /// # Errors + /// + /// Returns an error if the transaction cannot be started. pub async fn begin(&self) -> Result, String> { self.pool .begin() @@ -114,6 +126,10 @@ impl Fts5Table { /// Insert a row of string values into the FTS5 table. /// /// `values` must have the same length as the number of columns. + /// + /// # Errors + /// + /// Returns an error if the value count mismatches or the insert fails. pub async fn insert_str( &self, tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, @@ -155,6 +171,11 @@ impl Fts5Table { /// `bm25_weights` must have one entry per **indexed** column (in column /// definition order). Non-indexed columns are automatically assigned /// weight `0.0`. + /// + /// # Errors + /// + /// Returns an error if column names are unknown, weight counts mismatch, + /// or the query fails. pub async fn search_bm25( &self, match_expr: &str, @@ -205,15 +226,23 @@ impl Fts5Table { self.table_name, self.table_name, self.table_name, ); + // Reason: row count fits in i64 + #[allow(clippy::cast_possible_wrap)] + let limit_i64 = limit as i64; + sqlx::query(&sql) .bind(match_expr) - .bind(limit as i64) + .bind(limit_i64) .fetch_all(&self.pool) .await .map_err(|e| format!("Query error: {e}")) } /// FTS5 MATCH search using the built-in `rank` column. + /// + /// # Errors + /// + /// Returns an error if column names are unknown or the query fails. pub async fn search( &self, match_expr: &str, @@ -237,9 +266,13 @@ impl Fts5Table { self.table_name, self.table_name, ); + // Reason: row count fits in i64 + #[allow(clippy::cast_possible_wrap)] + let limit_i64 = limit as i64; + sqlx::query(&sql) .bind(match_expr) - .bind(limit as i64) + .bind(limit_i64) .fetch_all(&self.pool) .await .map_err(|e| format!("Query error: {e}")) @@ -256,6 +289,7 @@ impl Fts5Table { /// (except `_`, `-`, `.`) and wrapped in double quotes. Empty tokens are /// dropped. The caller is responsible for joining the terms (e.g. with /// ` OR `). +#[must_use] pub fn sanitize_fts5_terms(query: &str) -> Vec { query .split_whitespace() @@ -278,6 +312,7 @@ pub fn sanitize_fts5_terms(query: &str) -> Vec { /// Wraps each whitespace-separated term in double quotes for safe literal /// matching. Terms are joined with `OR` so that partial matches are returned — /// FTS5 ranking naturally scores documents with more matching terms higher. +#[must_use] pub fn sanitize_fts5_query(query: &str) -> String { sanitize_fts5_terms(query).join(" OR ") } @@ -287,9 +322,10 @@ pub fn sanitize_fts5_query(query: &str) -> String { /// Accepts individually quoted terms (e.g. `['"GenieAttachment"', '"fields"']`) /// and returns a single FTS5 MATCH expression joined with `OR`. /// -/// - If a term is PascalCase, an extra `entity:` clause is added. +/// - If a term is `PascalCase`, an extra `entity:` clause is added. /// - Hint words like "fields" / "attributes" are stripped (they only guide /// boosting). +#[must_use] pub fn enhance_fts5_query(terms: &[String]) -> String { let hint_words: &[&str] = &["fields", "attributes", "members", "properties"]; @@ -303,10 +339,8 @@ pub fn enhance_fts5_query(terms: &[String]) -> String { } if is_pascal_case(clean) { entity_terms.push(format!("entity:{token}")); - regular_terms.push(token.clone()); - } else { - regular_terms.push(token.clone()); } + regular_terms.push(token.clone()); } let mut parts = entity_terms; @@ -314,7 +348,8 @@ pub fn enhance_fts5_query(terms: &[String]) -> String { parts.join(" OR ") } -/// Check if a string looks like PascalCase (starts with uppercase, contains lowercase). +/// Check if a string looks like `PascalCase` (starts with uppercase, contains lowercase). +#[must_use] pub fn is_pascal_case(s: &str) -> bool { let mut chars = s.chars(); match chars.next() { @@ -339,6 +374,7 @@ fn validate_identifier(name: &str) -> Result<(), String> { } #[cfg(test)] +// Reason: panicking on failure is idiomatic in tests #[allow(clippy::unwrap_used)] mod tests { use super::*; diff --git a/crates/db/src/lib.rs b/crates/db/src/lib.rs index a70be0be..4d4f9282 100644 --- a/crates/db/src/lib.rs +++ b/crates/db/src/lib.rs @@ -1,15 +1,4 @@ -#![forbid(unsafe_code)] -#![deny(warnings, unused_must_use, dead_code, missing_debug_implementations)] -#![deny( - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - clippy::todo, - clippy::unimplemented, - clippy::dbg_macro -)] - -//! Database layer for APX using SQLx with SQLite. +//! Database layer for APX using `SQLx` with `SQLite`. //! //! Provides async connection pools for two databases: //! - **Logs DB** (`~/.apx/logs/db`) — OTLP log storage @@ -27,12 +16,20 @@ pub use sqlx::sqlite::SqlitePool; use std::path::PathBuf; /// Get the logs database path (`~/.apx/logs/db`). +/// +/// # Errors +/// +/// Returns an error if the home directory cannot be determined. pub fn logs_db_path() -> Result { let home = dirs::home_dir().ok_or("Could not determine home directory")?; Ok(home.join(".apx").join("logs").join("db")) } /// Get the dev database path (`~/.apx/dev/db`). +/// +/// # Errors +/// +/// Returns an error if the home directory cannot be determined. pub fn dev_db_path() -> Result { let home = dirs::home_dir().ok_or("Could not determine home directory")?; Ok(home.join(".apx").join("dev").join("db")) diff --git a/crates/db/src/logs.rs b/crates/db/src/logs.rs index 32a975e3..358a3c99 100644 --- a/crates/db/src/logs.rs +++ b/crates/db/src/logs.rs @@ -1,4 +1,4 @@ -//! Async logs database operations using SQLx. +//! Async logs database operations using `SQLx`. //! //! Provides [`LogsDb`] for all CRUD operations on the OTLP logs table //! at `~/.apx/logs/db`. @@ -22,12 +22,22 @@ pub struct LogsDb { impl LogsDb { /// Open or create the database at the default location (`~/.apx/logs/db`). + /// + /// # Errors + /// + /// Returns an error if the database path cannot be determined or the database + /// cannot be opened. pub async fn open() -> Result { let path = super::logs_db_path()?; Self::open_at(&path).await } /// Open or create the database at a specific path. + /// + /// # Errors + /// + /// Returns an error if the directory cannot be created, the database cannot + /// be opened, or schema initialization fails. pub async fn open_at(path: &Path) -> Result { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) @@ -54,7 +64,7 @@ impl LogsDb { /// Initialize the database schema. async fn init_schema(&self) -> Result<(), String> { sqlx::query( - r#"CREATE TABLE IF NOT EXISTS logs ( + r"CREATE TABLE IF NOT EXISTS logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp_ns INTEGER NOT NULL, observed_timestamp_ns INTEGER NOT NULL, @@ -68,7 +78,7 @@ impl LogsDb { trace_id TEXT, span_id TEXT, created_at INTEGER DEFAULT (strftime('%s', 'now')) - )"#, + )", ) .execute(&self.pool) .await @@ -91,6 +101,10 @@ impl LogsDb { } /// Insert a batch of log records. + /// + /// # Errors + /// + /// Returns an error if the transaction fails or any insert fails. pub async fn insert_logs(&self, records: &[LogRecord]) -> Result { if records.is_empty() { return Ok(0); @@ -105,11 +119,11 @@ impl LogsDb { let mut count = 0; for record in records { sqlx::query( - r#"INSERT INTO logs ( + r"INSERT INTO logs ( timestamp_ns, observed_timestamp_ns, severity_number, severity_text, body, service_name, app_path, resource_attributes, log_attributes, trace_id, span_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(record.timestamp_ns) .bind(record.observed_timestamp_ns) @@ -135,6 +149,10 @@ impl LogsDb { } /// Query logs for a specific app path since a given timestamp. + /// + /// # Errors + /// + /// Returns an error if the database query fails. pub async fn query_logs( &self, app_path: Option<&str>, @@ -145,40 +163,40 @@ impl LogsDb { let sql = match (app_path, limit) { (Some(_), Some(lim)) => format!( - r#"SELECT timestamp_ns, observed_timestamp_ns, severity_number, severity_text, + r"SELECT timestamp_ns, observed_timestamp_ns, severity_number, severity_text, body, service_name, app_path, resource_attributes, log_attributes, trace_id, span_id FROM logs WHERE (app_path LIKE ?1 OR ?1 LIKE '%' || app_path || '%') AND {effective_ts} >= ?2 ORDER BY {effective_ts} ASC - LIMIT {lim}"# + LIMIT {lim}" ), (Some(_), None) => format!( - r#"SELECT timestamp_ns, observed_timestamp_ns, severity_number, severity_text, + r"SELECT timestamp_ns, observed_timestamp_ns, severity_number, severity_text, body, service_name, app_path, resource_attributes, log_attributes, trace_id, span_id FROM logs WHERE (app_path LIKE ?1 OR ?1 LIKE '%' || app_path || '%') AND {effective_ts} >= ?2 - ORDER BY {effective_ts} ASC"# + ORDER BY {effective_ts} ASC" ), (None, Some(lim)) => format!( - r#"SELECT timestamp_ns, observed_timestamp_ns, severity_number, severity_text, + r"SELECT timestamp_ns, observed_timestamp_ns, severity_number, severity_text, body, service_name, app_path, resource_attributes, log_attributes, trace_id, span_id FROM logs WHERE {effective_ts} >= ?1 ORDER BY {effective_ts} ASC - LIMIT {lim}"# + LIMIT {lim}" ), (None, None) => format!( - r#"SELECT timestamp_ns, observed_timestamp_ns, severity_number, severity_text, + r"SELECT timestamp_ns, observed_timestamp_ns, severity_number, severity_text, body, service_name, app_path, resource_attributes, log_attributes, trace_id, span_id FROM logs WHERE {effective_ts} >= ?1 - ORDER BY {effective_ts} ASC"# + ORDER BY {effective_ts} ASC" ), }; @@ -199,6 +217,10 @@ impl LogsDb { } /// Get the latest log ID for change detection in follow mode. + /// + /// # Errors + /// + /// Returns an error if the database query fails. pub async fn get_latest_id(&self) -> Result { let row = sqlx::query("SELECT COALESCE(MAX(id), 0) as max_id FROM logs") .fetch_one(&self.pool) @@ -209,6 +231,10 @@ impl LogsDb { } /// Query logs newer than a given ID (for follow mode). + /// + /// # Errors + /// + /// Returns an error if the database query fails. pub async fn query_logs_after_id( &self, app_path: Option<&str>, @@ -219,24 +245,24 @@ impl LogsDb { let (sql, has_app_path) = if app_path.is_some() { ( format!( - r#"SELECT timestamp_ns, observed_timestamp_ns, severity_number, severity_text, + r"SELECT timestamp_ns, observed_timestamp_ns, severity_number, severity_text, body, service_name, app_path, resource_attributes, log_attributes, trace_id, span_id FROM logs WHERE id > ?1 AND (app_path LIKE ?2 OR ?2 LIKE '%' || app_path || '%') - ORDER BY {effective_ts} ASC"# + ORDER BY {effective_ts} ASC" ), true, ) } else { ( format!( - r#"SELECT timestamp_ns, observed_timestamp_ns, severity_number, severity_text, + r"SELECT timestamp_ns, observed_timestamp_ns, severity_number, severity_text, body, service_name, app_path, resource_attributes, log_attributes, trace_id, span_id FROM logs WHERE id > ?1 - ORDER BY {effective_ts} ASC"# + ORDER BY {effective_ts} ASC" ), false, ) @@ -259,10 +285,14 @@ impl LogsDb { } /// Delete logs older than the retention period (7 days). + /// + /// # Errors + /// + /// Returns an error if the delete query fails. pub async fn cleanup_old_logs(&self) -> Result { let cutoff = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64 - RETENTION_SECONDS) + .map(|d| d.as_secs().cast_signed() - RETENTION_SECONDS) .unwrap_or(0); let result = sqlx::query("DELETE FROM logs WHERE created_at < ?") @@ -271,15 +301,19 @@ impl LogsDb { .await .map_err(|e| format!("Delete error: {e}"))?; + // Reason: severity_number is always 0..21 which fits in i32 + #[allow(clippy::cast_possible_truncation)] let deleted = result.rows_affected() as usize; if deleted > 0 { debug!("Cleaned up {} old log records", deleted); } Ok(deleted) } +} +#[cfg(test)] +impl LogsDb { /// Get the total count of logs. - #[allow(dead_code)] pub async fn count_logs(&self) -> Result { let row = sqlx::query("SELECT COUNT(*) as cnt FROM logs") .fetch_one(&self.pool) @@ -290,7 +324,7 @@ impl LogsDb { } } -/// Map a SQLx row to a LogRecord. +/// Map a `SQLx` row to a `LogRecord`. fn row_to_log_record(row: &sqlx::sqlite::SqliteRow) -> LogRecord { LogRecord { timestamp_ns: row.get("timestamp_ns"), @@ -308,6 +342,7 @@ fn row_to_log_record(row: &sqlx::sqlite::SqliteRow) -> LogRecord { } #[cfg(test)] +// Reason: panicking on failure is idiomatic in tests #[allow(clippy::unwrap_used)] mod tests { use super::*; @@ -330,8 +365,8 @@ mod tests { let db = temp_db().await; let record = LogRecord { - timestamp_ns: 1234567890000000000, - observed_timestamp_ns: 1234567890000000000, + timestamp_ns: 1_234_567_890_000_000_000, + observed_timestamp_ns: 1_234_567_890_000_000_000, severity_number: Some(9), severity_text: Some("INFO".to_string()), body: Some("Test log message".to_string()), @@ -355,8 +390,8 @@ mod tests { let db = temp_db().await; let record = LogRecord { - timestamp_ns: 1234567890000000000, - observed_timestamp_ns: 1234567890000000000, + timestamp_ns: 1_234_567_890_000_000_000, + observed_timestamp_ns: 1_234_567_890_000_000_000, severity_number: Some(9), severity_text: Some("INFO".to_string()), body: Some("Test log message".to_string()), @@ -380,8 +415,8 @@ mod tests { let db = temp_db().await; let record = LogRecord { - timestamp_ns: 1234567890000000000, - observed_timestamp_ns: 1234567890000000000, + timestamp_ns: 1_234_567_890_000_000_000, + observed_timestamp_ns: 1_234_567_890_000_000_000, severity_number: Some(9), severity_text: Some("INFO".to_string()), body: Some("First".to_string()), @@ -397,8 +432,8 @@ mod tests { let id = db.get_latest_id().await.unwrap(); let record2 = LogRecord { - timestamp_ns: 1234567891000000000, - observed_timestamp_ns: 1234567891000000000, + timestamp_ns: 1_234_567_891_000_000_000, + observed_timestamp_ns: 1_234_567_891_000_000_000, severity_number: Some(9), severity_text: Some("INFO".to_string()), body: Some("Second".to_string()), diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml index 6d2f767c..30d2b797 100644 --- a/crates/mcp/Cargo.toml +++ b/crates/mcp/Cargo.toml @@ -3,6 +3,15 @@ name = "apx-mcp" version = "0.3.6" edition.workspace = true rust-version.workspace = true +description.workspace = true +repository.workspace = true +license.workspace = true +readme.workspace = true +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true [dependencies] apx-common.workspace = true diff --git a/crates/mcp/src/context.rs b/crates/mcp/src/context.rs index 30eae861..ff025464 100644 --- a/crates/mcp/src/context.rs +++ b/crates/mcp/src/context.rs @@ -7,10 +7,12 @@ use apx_core::search::docs_index::SDKDocsIndex; use apx_db::DevDb; use tokio::sync::{Mutex, Notify, RwLock, broadcast}; -/// Parameters for SDK indexing, pre-computed synchronously to avoid Python GIL issues +/// Parameters for SDK indexing, pre-computed synchronously to avoid Python GIL issues. #[derive(Debug)] pub struct SdkIndexParams { + /// Detected Databricks SDK version string. pub sdk_version: String, + /// Shared handle to the SDK docs index (populated after bootstrap). pub sdk_doc_index: Arc>>, } @@ -39,17 +41,25 @@ impl Default for IndexState { } impl IndexState { + /// Create a new `IndexState` with all indexes marked as not ready. pub fn new() -> Self { Self::default() } } +/// Global application context shared across all MCP handlers. #[derive(Debug)] pub struct AppContext { + /// Development database handle. pub dev_db: DevDb, + /// Shared SDK documentation search index. pub sdk_doc_index: Arc>>, + /// Shared component cache state. pub cache_state: SharedCacheState, + /// Readiness state for background indexes. pub index_state: IndexState, + /// Broadcast channel to signal server shutdown. pub shutdown_tx: broadcast::Sender<()>, + /// Cached Databricks API clients keyed by profile name. pub databricks_clients: RwLock>, } diff --git a/crates/mcp/src/indexing.rs b/crates/mcp/src/indexing.rs index d354ba66..8fe0dde4 100644 --- a/crates/mcp/src/indexing.rs +++ b/crates/mcp/src/indexing.rs @@ -118,7 +118,11 @@ pub fn init_all_indexes( }); } -/// Rebuild the search index from registry.json files (async) +/// Rebuild the component search index from cached registry JSON files. +/// +/// # Errors +/// +/// Returns an error string if index creation or building fails. pub async fn rebuild_search_index(pool: SqlitePool) -> Result<(), String> { let index = ComponentIndex::new(pool)?; index.build_index_from_registries().await @@ -174,20 +178,20 @@ pub async fn wait_for_index_ready( ); // Wait with timeout - match tokio::time::timeout(Duration::from_secs(TIMEOUT_SECS), notified).await { - Ok(_) => { - tracing::debug!("{} index is now ready", index_name); - Ok(false) - } - Err(_) => { - tracing::warn!( - "{} index not ready after {}s timeout", - index_name, - TIMEOUT_SECS - ); - Err(format!( - "{index_name} index is not yet ready, please rerun the query in 5 seconds" - )) - } + if tokio::time::timeout(Duration::from_secs(TIMEOUT_SECS), notified) + .await + .is_ok() + { + tracing::debug!("{} index is now ready", index_name); + Ok(false) + } else { + tracing::warn!( + "{} index not ready after {}s timeout", + index_name, + TIMEOUT_SECS + ); + Err(format!( + "{index_name} index is not yet ready, please rerun the query in 5 seconds" + )) } } diff --git a/crates/mcp/src/info_content.rs b/crates/mcp/src/info_content.rs index a79946e4..7dd8b14b 100644 --- a/crates/mcp/src/info_content.rs +++ b/crates/mcp/src/info_content.rs @@ -1,3 +1,7 @@ +/// Static informational content describing the apx toolkit. +/// +/// Covers project structure, key patterns, and available tools. +/// Served as the `apx://info` resource and included in the server's `instructions` field. pub const APX_INFO_CONTENT: &str = r#" This project uses apx toolkit to build a Databricks app. apx bundles together a set of tools and libraries to help you with the complete app development lifecycle: develop, build and deploy. diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index 917bffcd..66bfd21b 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -1,18 +1,20 @@ -#![forbid(unsafe_code)] -#![deny(warnings, unused_must_use, dead_code, missing_debug_implementations)] -#![deny( - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - clippy::todo, - clippy::unimplemented, - clippy::dbg_macro -)] +//! MCP (Model Context Protocol) server for the apx toolkit. +//! +//! Exposes development tools (start/stop/restart dev server, type checks, +//! OpenAPI regeneration, component search, Databricks logs, SDK docs search) +//! over the MCP protocol via stdio transport. +/// Shared application context passed to every MCP handler. pub mod context; +/// Background index initialization (component search, SDK docs). pub mod indexing; +/// Static informational content embedded in the MCP server. pub mod info_content; +/// MCP resource and resource-template providers. pub mod resources; +/// MCP server setup, tool routing, and `ServerHandler` implementation. pub mod server; +/// Tool handler implementations grouped by domain. pub mod tools; +/// Path validation helpers for MCP tool arguments. pub mod validation; diff --git a/crates/mcp/src/resources.rs b/crates/mcp/src/resources.rs index cf30d2db..6bacd893 100644 --- a/crates/mcp/src/resources.rs +++ b/crates/mcp/src/resources.rs @@ -1,9 +1,13 @@ use crate::info_content::APX_INFO_CONTENT; use crate::tools::openapi::parse_openapi_operations; use crate::validation::validated_app_path; -use rmcp::model::*; +use rmcp::model::{ + AnnotateAble, RawResource, RawResourceTemplate, ReadResourceResult, Resource, ResourceContents, + ResourceTemplate, +}; use serde::Serialize; +/// Return the static list of MCP resources (e.g. `apx://info`). pub fn list_resources() -> Vec { let mut raw = RawResource::new("apx://info", "apx-info".to_string()); raw.description = Some("Information about apx toolkit".to_string()); @@ -11,6 +15,7 @@ pub fn list_resources() -> Vec { vec![raw.no_annotation()] } +/// Return the list of parameterized resource templates (e.g. `apx://project/{app_path}`). pub fn list_resource_templates() -> Vec { let raw = RawResourceTemplate { uri_template: "apx://project/{app_path}".to_string(), @@ -26,6 +31,7 @@ pub fn list_resource_templates() -> Vec { vec![raw.no_annotation()] } +/// Read a static resource by URI. Returns an error if the URI is unknown. pub fn read_resource(uri: &str) -> Result { match uri { "apx://info" => Ok(ReadResourceResult { @@ -58,6 +64,7 @@ struct ProjectContext { configured_registries: Vec, } +/// Read a project-scoped resource, returning routes, components, and metadata as JSON. pub async fn read_project_resource(app_path: &str) -> Result { let path = validated_app_path(app_path).map_err(|e| e.message)?; @@ -187,6 +194,7 @@ fn scan_backend_files(project_root: &std::path::Path, app_slug: &str) -> Vec, tool_router: ToolRouter, } @@ -32,6 +38,7 @@ impl std::fmt::Debug for ApxServer { // Heavy logic is delegated to handler methods in tools/*.rs modules. #[tool_router] impl ApxServer { + /// Create a new MCP server, starting background index initialization. pub fn new(ctx: AppContext, sdk_params: Option) -> Self { // Initialize all indexes in background let shutdown_rx = ctx.shutdown_tx.subscribe(); @@ -53,7 +60,7 @@ impl ApxServer { async fn start( &self, Parameters(args): Parameters, - ) -> Result { + ) -> Result { self.handle_start(args).await } @@ -69,7 +76,7 @@ impl ApxServer { async fn stop( &self, Parameters(args): Parameters, - ) -> Result { + ) -> Result { self.handle_stop(args).await } @@ -81,7 +88,7 @@ impl ApxServer { async fn restart( &self, Parameters(args): Parameters, - ) -> Result { + ) -> Result { self.handle_restart(args).await } @@ -93,7 +100,7 @@ impl ApxServer { async fn logs( &self, Parameters(args): Parameters, - ) -> Result { + ) -> Result { self.handle_logs(args).await } @@ -107,7 +114,7 @@ impl ApxServer { async fn check( &self, Parameters(args): Parameters, - ) -> Result { + ) -> Result { self.handle_check(args).await } @@ -123,7 +130,7 @@ impl ApxServer { async fn refresh_openapi( &self, Parameters(args): Parameters, - ) -> Result { + ) -> Result { self.handle_refresh_openapi(args).await } @@ -135,7 +142,7 @@ impl ApxServer { async fn get_route_info( &self, Parameters(args): Parameters, - ) -> Result { + ) -> Result { self.handle_get_route_info(args).await } @@ -147,7 +154,7 @@ impl ApxServer { async fn routes( &self, Parameters(args): Parameters, - ) -> Result { + ) -> Result { self.handle_routes(args).await } @@ -161,7 +168,7 @@ impl ApxServer { async fn databricks_apps_logs( &self, Parameters(args): Parameters, - ) -> Result { + ) -> Result { self.handle_databricks_apps_logs(args).await } @@ -175,7 +182,7 @@ impl ApxServer { async fn search_registry_components( &self, Parameters(args): Parameters, - ) -> Result { + ) -> Result { self.handle_search_registry_components(args).await } @@ -187,7 +194,7 @@ impl ApxServer { async fn add_component( &self, Parameters(args): Parameters, - ) -> Result { + ) -> Result { self.handle_add_component(args).await } @@ -199,7 +206,7 @@ impl ApxServer { async fn list_registry_components( &self, Parameters(args): Parameters, - ) -> Result { + ) -> Result { self.handle_list_registry_components(args).await } @@ -213,8 +220,8 @@ impl ApxServer { async fn feedback_prepare( &self, Parameters(args): Parameters, - ) -> Result { - self.handle_feedback_prepare(args).await + ) -> Result { + self.handle_feedback_prepare(args) } #[tool( @@ -225,7 +232,7 @@ impl ApxServer { async fn feedback_submit( &self, Parameters(args): Parameters, - ) -> Result { + ) -> Result { self.handle_feedback_submit(args).await } @@ -239,7 +246,7 @@ impl ApxServer { async fn docs( &self, Parameters(args): Parameters, - ) -> Result { + ) -> Result { self.handle_docs(args).await } } @@ -271,7 +278,7 @@ impl ServerHandler for ApxServer { &self, _request: Option, _ctx: RequestContext, - ) -> Result { + ) -> Result { Ok(ListResourcesResult { resources: crate::resources::list_resources(), next_cursor: None, @@ -283,7 +290,7 @@ impl ServerHandler for ApxServer { &self, _request: Option, _ctx: RequestContext, - ) -> Result { + ) -> Result { Ok(ListResourceTemplatesResult { resource_templates: crate::resources::list_resource_templates(), next_cursor: None, @@ -295,23 +302,27 @@ impl ServerHandler for ApxServer { &self, request: ReadResourceRequestParams, _ctx: RequestContext, - ) -> Result { + ) -> Result { let uri = request.uri.as_str(); if let Some(app_path) = uri.strip_prefix("apx://project/") { return crate::resources::read_project_resource(app_path) .await .map_err(|e| { - rmcp::ErrorData::resource_not_found(e, Some(serde_json::json!({ "uri": uri }))) + ErrorData::resource_not_found(e, Some(serde_json::json!({ "uri": uri }))) }); } - crate::resources::read_resource(uri).map_err(|e| { - rmcp::ErrorData::resource_not_found(e, Some(serde_json::json!({ "uri": uri }))) - }) + crate::resources::read_resource(uri) + .map_err(|e| ErrorData::resource_not_found(e, Some(serde_json::json!({ "uri": uri })))) } } +/// Start the MCP server on stdio and block until it shuts down. +/// +/// # Errors +/// +/// Returns an error string if the server fails to initialize or encounters a fatal error. pub async fn run_server(ctx: AppContext, sdk_params: Option) -> Result<(), String> { use rmcp::ServiceExt; diff --git a/crates/mcp/src/tools/databricks.rs b/crates/mcp/src/tools/databricks.rs index 1f8132bb..1dd499ee 100644 --- a/crates/mcp/src/tools/databricks.rs +++ b/crates/mcp/src/tools/databricks.rs @@ -4,13 +4,14 @@ use std::time::Duration; use apx_core::dotenv::DotenvFile; use apx_databricks_sdk::{AppLogsArgs, DatabricksClient, LogEntry}; -use rmcp::model::*; +use rmcp::model::{CallToolResult, ErrorData}; use rmcp::schemars; use crate::server::ApxServer; use crate::tools::{ToolError, ToolResultExt}; use crate::validation::validated_app_path; +/// Arguments for the `databricks_apps_logs` tool. #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct DatabricksAppsLogsArgs { /// Absolute path to the project directory @@ -24,7 +25,7 @@ pub struct DatabricksAppsLogsArgs { /// Search string to filter logs #[serde(default)] pub search: Option, - /// Log sources to include (e.g. ["APP"], ["SYSTEM"], or ["APP", "SYSTEM"]) + /// Log sources to include (e.g. `["APP"]`, `["SYSTEM"]`, or `["APP", "SYSTEM"]`) #[serde(default)] pub source: Option>, /// Databricks CLI profile @@ -44,10 +45,11 @@ fn default_timeout_seconds() -> f64 { } impl ApxServer { + /// Handle the `databricks_apps_logs` tool call. pub async fn handle_databricks_apps_logs( &self, args: DatabricksAppsLogsArgs, - ) -> Result { + ) -> Result { let cwd = validated_app_path(&args.app_path)?; // Load env vars from .env if present @@ -130,10 +132,7 @@ struct ResolvedAppName { from_yml: bool, } -fn resolve_app_name( - args: &DatabricksAppsLogsArgs, - cwd: &Path, -) -> std::result::Result { +fn resolve_app_name(args: &DatabricksAppsLogsArgs, cwd: &Path) -> Result { match args.app_name.as_ref() { Some(name) if !name.trim().is_empty() => Ok(ResolvedAppName { name: name.trim().to_string(), @@ -152,7 +151,7 @@ fn resolve_app_name( async fn get_or_create_client( cache: &tokio::sync::RwLock>, profile: &str, -) -> std::result::Result { +) -> Result { // Fast path: read lock { let clients = cache.read().await; @@ -191,6 +190,7 @@ fn resolve_profile(args: &DatabricksAppsLogsArgs, dotenv_vars: &HashMap String { } impl ApxServer { - pub async fn handle_start(&self, args: AppPathArgs) -> Result { + /// Handle the `start` tool call (start dev server). + pub async fn handle_start(&self, args: AppPathArgs) -> Result { let path = validated_app_path(&args.app_path)?; use apx_core::common::OutputMode; @@ -33,7 +35,8 @@ impl ApxServer { } } - pub async fn handle_stop(&self, args: AppPathArgs) -> Result { + /// Handle the `stop` tool call (stop dev server). + pub async fn handle_stop(&self, args: AppPathArgs) -> Result { let path = validated_app_path(&args.app_path)?; use apx_core::common::OutputMode; @@ -50,10 +53,8 @@ impl ApxServer { } } - pub async fn handle_restart( - &self, - args: AppPathArgs, - ) -> Result { + /// Handle the `restart` tool call (restart dev server). + pub async fn handle_restart(&self, args: AppPathArgs) -> Result { let path = validated_app_path(&args.app_path)?; use apx_core::common::OutputMode; @@ -68,7 +69,8 @@ impl ApxServer { } } - pub async fn handle_logs(&self, args: LogsToolArgs) -> Result { + /// Handle the `logs` tool call (fetch dev server logs). + pub async fn handle_logs(&self, args: LogsToolArgs) -> Result { let path = validated_app_path(&args.app_path)?; use apx_core::ops::logs::fetch_logs_structured; diff --git a/crates/mcp/src/tools/docs.rs b/crates/mcp/src/tools/docs.rs index 93d8e6e3..a53400a0 100644 --- a/crates/mcp/src/tools/docs.rs +++ b/crates/mcp/src/tools/docs.rs @@ -3,10 +3,11 @@ use crate::server::ApxServer; use crate::tools::{ToolError, ToolResultExt}; use apx_core::databricks_sdk_doc::SDKSource; use apx_core::interop::get_databricks_sdk_version; -use rmcp::model::*; +use rmcp::model::{CallToolResult, ErrorData}; use rmcp::schemars; use serde::Serialize; +/// Arguments for the `docs` tool. #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct DocsArgs { /// Documentation source (currently only "databricks-sdk-python" is supported) @@ -26,7 +27,8 @@ fn default_docs_limit() -> usize { } impl ApxServer { - pub async fn handle_docs(&self, args: DocsArgs) -> Result { + /// Handle the `docs` tool call (search SDK documentation). + pub async fn handle_docs(&self, args: DocsArgs) -> Result { let ctx = &self.ctx; // Wait for SDK index to be ready (15 second timeout) @@ -44,15 +46,11 @@ impl ApxServer { // Get the SDK doc index let mut index_guard = ctx.sdk_doc_index.lock().await; - let index = match index_guard.as_mut() { - Some(idx) => idx, - None => { - return ToolError::NotConfigured( - "SDK documentation is not available. The index failed to bootstrap." - .to_string(), - ) - .into_result(); - } + let Some(index) = index_guard.as_mut() else { + return ToolError::NotConfigured( + "SDK documentation is not available. The index failed to bootstrap.".to_string(), + ) + .into_result(); }; // If app_path is provided, detect that project's SDK version and switch if different diff --git a/crates/mcp/src/tools/feedback.rs b/crates/mcp/src/tools/feedback.rs index 4b4b003d..a337cf90 100644 --- a/crates/mcp/src/tools/feedback.rs +++ b/crates/mcp/src/tools/feedback.rs @@ -1,8 +1,9 @@ use crate::server::ApxServer; use crate::tools::{ToolError, ToolResultExt}; -use rmcp::model::*; +use rmcp::model::{CallToolResult, ErrorData}; use rmcp::schemars; +/// Arguments for the `feedback_prepare` tool. #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct FeedbackPrepareArgs { /// The feedback message @@ -18,6 +19,7 @@ pub struct FeedbackPrepareArgs { pub include_metadata: bool, } +/// Arguments for the `feedback_submit` tool. #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct FeedbackSubmitArgs { /// The exact issue title (from feedback_prepare response) @@ -35,10 +37,11 @@ fn default_true() -> bool { } impl ApxServer { - pub async fn handle_feedback_prepare( + /// Handle the `feedback_prepare` tool call. + pub fn handle_feedback_prepare( &self, args: FeedbackPrepareArgs, - ) -> Result { + ) -> Result { if args.message.trim().is_empty() { return ToolError::InvalidInput("Feedback message cannot be empty".to_string()) .into_result(); @@ -74,10 +77,11 @@ impl ApxServer { })) } + /// Handle the `feedback_submit` tool call. pub async fn handle_feedback_submit( &self, args: FeedbackSubmitArgs, - ) -> Result { + ) -> Result { if args.title.trim().is_empty() || args.body.trim().is_empty() { return ToolError::InvalidInput( "Title and body are required. Call feedback_prepare first.".to_string(), diff --git a/crates/mcp/src/tools/mod.rs b/crates/mcp/src/tools/mod.rs index 98f8aeba..e2eac92b 100644 --- a/crates/mcp/src/tools/mod.rs +++ b/crates/mcp/src/tools/mod.rs @@ -35,13 +35,21 @@ macro_rules! tool_response { } // Submodules declared after macro so they can use `tool_response!`. +/// Databricks Apps log-fetching tool handler. pub mod databricks; +/// Dev server lifecycle tools (start, stop, restart, logs). pub mod devserver; +/// SDK documentation search tool handler. pub mod docs; +/// Domain error types for MCP tool handlers. pub mod error; +/// User feedback (prepare + submit) tool handlers. pub mod feedback; +/// OpenAPI spec parsing, route extraction, and code-example generation. pub mod openapi; +/// Project tools (check, routes, route info, OpenAPI refresh). pub mod project; +/// UI component registry tools (search, add, list). pub mod registry; pub use error::ToolError; @@ -59,7 +67,9 @@ pub struct AppPathArgs { /// Extension trait for building `CallToolResult` from serializable values. pub trait ToolResultExt { + /// Build a success result with structured JSON content. fn from_serializable(value: &impl StructuredObject) -> Self; + /// Build an error result with structured JSON content. fn from_serializable_error(value: &impl StructuredObject) -> Self; } diff --git a/crates/mcp/src/tools/openapi.rs b/crates/mcp/src/tools/openapi.rs index 7f2be2d0..ed9ff65f 100644 --- a/crates/mcp/src/tools/openapi.rs +++ b/crates/mcp/src/tools/openapi.rs @@ -335,13 +335,14 @@ fn derive_related_query_key(path: &str) -> String { } #[cfg(test)] +// Reason: panicking on failure is idiomatic in tests #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; use crate::tools::ToolResultExt; use rmcp::model::CallToolResult; - fn parse_test_spec(json: serde_json::Value) -> OpenApiSpec { + fn parse_test_spec(json: Value) -> OpenApiSpec { serde_json::from_value(json).unwrap() } diff --git a/crates/mcp/src/tools/project.rs b/crates/mcp/src/tools/project.rs index 2e90ed1f..d3ba42e2 100644 --- a/crates/mcp/src/tools/project.rs +++ b/crates/mcp/src/tools/project.rs @@ -6,10 +6,11 @@ use crate::tools::openapi::{ use crate::tools::{AppPathArgs, ToolError, ToolResultExt}; use crate::validation::validated_app_path; use apx_core::openapi::spec::OpenApiSpec; -use rmcp::model::*; +use rmcp::model::{CallToolResult, Content, ErrorData}; use rmcp::schemars; use serde_json::Value; +/// Arguments for the `get_route_info` tool. #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct GetRouteInfoArgs { /// Absolute path to the project directory @@ -19,7 +20,8 @@ pub struct GetRouteInfoArgs { } impl ApxServer { - pub async fn handle_check(&self, args: AppPathArgs) -> Result { + /// Handle the `check` tool call (TypeScript + Python type checks). + pub async fn handle_check(&self, args: AppPathArgs) -> Result { let path = validated_app_path(&args.app_path)?; use apx_core::common::OutputMode; @@ -51,10 +53,11 @@ impl ApxServer { } } + /// Handle the `refresh_openapi` tool call. pub async fn handle_refresh_openapi( &self, args: AppPathArgs, - ) -> Result { + ) -> Result { let path = validated_app_path(&args.app_path)?; match apx_core::api_generator::generate_openapi(&path).await { @@ -65,10 +68,11 @@ impl ApxServer { } } + /// Handle the `get_route_info` tool call. pub async fn handle_get_route_info( &self, args: GetRouteInfoArgs, - ) -> Result { + ) -> Result { let path = validated_app_path(&args.app_path)?; use apx_core::common::read_project_metadata; @@ -141,15 +145,12 @@ impl ApxServer { } } - let (route_path, method, parameters, body_schema, resp_schema) = match found { - Some(f) => f, - None => { - return ToolError::OperationFailed(format!( - "Operation ID '{}' not found in OpenAPI schema", - args.operation_id - )) - .into_result(); - } + let Some((route_path, method, parameters, body_schema, resp_schema)) = found else { + return ToolError::OperationFailed(format!( + "Operation ID '{}' not found in OpenAPI schema", + args.operation_id + )) + .into_result(); }; let example = if method == "GET" { @@ -196,10 +197,8 @@ impl ApxServer { Ok(CallToolResult::from_serializable(&response)) } - pub async fn handle_routes( - &self, - args: AppPathArgs, - ) -> Result { + /// Handle the `routes` tool call. + pub async fn handle_routes(&self, args: AppPathArgs) -> Result { let path = validated_app_path(&args.app_path)?; use apx_core::common::read_project_metadata; diff --git a/crates/mcp/src/tools/registry.rs b/crates/mcp/src/tools/registry.rs index b695a595..235c837e 100644 --- a/crates/mcp/src/tools/registry.rs +++ b/crates/mcp/src/tools/registry.rs @@ -8,9 +8,10 @@ use apx_core::components::{ get_all_registry_indexes, needs_registry_refresh, sync_registry_indexes, }; use apx_core::search::ComponentIndex; -use rmcp::model::*; +use rmcp::model::{CallToolResult, ErrorData}; use rmcp::schemars; +/// Arguments for the `search_registry_components` tool. #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct SearchRegistryComponentsArgs { /// Absolute path to the project directory @@ -26,6 +27,7 @@ fn default_search_limit() -> usize { 10 } +/// Arguments for the `add_component` tool. #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct AddComponentArgs { /// Absolute path to the project directory @@ -37,6 +39,7 @@ pub struct AddComponentArgs { pub force: bool, } +/// Arguments for the `list_registry_components` tool. #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct ListRegistryComponentsArgs { /// Absolute path to the project directory @@ -46,10 +49,11 @@ pub struct ListRegistryComponentsArgs { } impl ApxServer { + /// Handle the `search_registry_components` tool call. pub async fn handle_search_registry_components( &self, args: SearchRegistryComponentsArgs, - ) -> Result { + ) -> Result { let path = validated_app_path(&args.app_path)?; let ctx = &self.ctx; @@ -122,10 +126,11 @@ impl ApxServer { Ok(CallToolResult::from_serializable(&response)) } + /// Handle the `add_component` tool call. pub async fn handle_add_component( &self, args: AddComponentArgs, - ) -> Result { + ) -> Result { let path = validated_app_path(&args.app_path)?; use apx_core::components::add::{ComponentInput, add_components}; @@ -186,10 +191,11 @@ impl ApxServer { } } + /// Handle the `list_registry_components` tool call. pub async fn handle_list_registry_components( &self, args: ListRegistryComponentsArgs, - ) -> Result { + ) -> Result { let path = validated_app_path(&args.app_path)?; // Check if registry indexes need refresh @@ -209,16 +215,12 @@ impl ApxServer { _ => "ui".to_string(), }; - let items = match all_indexes.get(®istry_key) { - Some(items) => items, - None => { - let available: Vec<&String> = all_indexes.keys().collect(); - return ToolError::OperationFailed(format!( - "Registry '{}' not found. Available registries: {:?}", - registry_key, available - )) - .into_result(); - } + let Some(items) = all_indexes.get(®istry_key) else { + let available: Vec<&String> = all_indexes.keys().collect(); + return ToolError::OperationFailed(format!( + "Registry '{registry_key}' not found. Available registries: {available:?}", + )) + .into_result(); }; tool_response! { @@ -263,7 +265,7 @@ impl ApxServer { if needs_registry_refresh(&cfg.registries) { tracing::info!("Registry indexes stale, refreshing..."); - if let Ok(true) = sync_registry_indexes(path, false).await { + if sync_registry_indexes(path, false).await == Ok(true) { let pool = self.ctx.dev_db.pool().clone(); if let Err(e) = rebuild_search_index(pool).await { tracing::warn!("Failed to rebuild search index after refresh: {}", e); diff --git a/crates/mcp/src/validation.rs b/crates/mcp/src/validation.rs index d602c8fa..b741c5ad 100644 --- a/crates/mcp/src/validation.rs +++ b/crates/mcp/src/validation.rs @@ -30,6 +30,7 @@ pub fn validated_app_path(s: &str) -> Result { } #[cfg(test)] +// Reason: panicking on failure is idiomatic in tests #[allow(clippy::unwrap_used)] mod tests { use super::*; diff --git a/crates/mcp/tests/mcp_protocol.rs b/crates/mcp/tests/mcp_protocol.rs index d24bbb23..b811f744 100644 --- a/crates/mcp/tests/mcp_protocol.rs +++ b/crates/mcp/tests/mcp_protocol.rs @@ -257,7 +257,9 @@ async fn test_read_info_resource() { assert_eq!(result.contents.len(), 1); let text = match &result.contents[0] { ResourceContents::TextResourceContents { text, .. } => text, - other => panic!("expected text resource, got {other:?}"), + other @ ResourceContents::BlobResourceContents { .. } => { + unreachable!("expected text resource, got {other:?}") + } }; assert!( text.contains("Project Structure"), @@ -285,7 +287,9 @@ async fn test_read_project_resource() { assert_eq!(result.contents.len(), 1); let text = match &result.contents[0] { ResourceContents::TextResourceContents { text, .. } => text, - other => panic!("expected text resource, got {other:?}"), + other @ ResourceContents::BlobResourceContents { .. } => { + unreachable!("expected text resource, got {other:?}") + } }; let json: serde_json::Value = serde_json::from_str(text).expect("project resource should be valid JSON"); diff --git a/crates/studio/Cargo.toml b/crates/studio/Cargo.toml index 95eb211b..bd201cee 100644 --- a/crates/studio/Cargo.toml +++ b/crates/studio/Cargo.toml @@ -3,6 +3,15 @@ name = "apx-studio" version = "0.3.6" edition.workspace = true rust-version.workspace = true +description.workspace = true +repository.workspace = true +license.workspace = true +readme.workspace = true +keywords.workspace = true +categories.workspace = true + +[lints] +workspace = true [[bin]] name = "apx-studio" diff --git a/crates/studio/build.rs b/crates/studio/build.rs index d860e1e6..18cc0c29 100644 --- a/crates/studio/build.rs +++ b/crates/studio/build.rs @@ -1,3 +1,4 @@ +//! Build script for apx-studio. fn main() { tauri_build::build() } diff --git a/crates/studio/src/main.rs b/crates/studio/src/main.rs index 04ef96ec..eb4b212f 100644 --- a/crates/studio/src/main.rs +++ b/crates/studio/src/main.rs @@ -1,24 +1,16 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -#![forbid(unsafe_code)] -#![deny(warnings, unused_must_use, dead_code, missing_debug_implementations)] -#![deny( - clippy::unwrap_used, - clippy::panic, - clippy::todo, - clippy::unimplemented, - clippy::dbg_macro -)] mod commands; mod registry; -#[allow(clippy::expect_used)] -fn main() { +fn main() -> Result<(), Box> { tauri::Builder::default() .invoke_handler(tauri::generate_handler![ commands::get_projects, commands::refresh_projects, ]) .run(tauri::generate_context!()) - .expect("error while running tauri application"); + .map_err(|e| format!("error while running tauri application: {e}"))?; + + Ok(()) }