diff --git a/CHANGELOG.md b/CHANGELOG.md index dc2e84d..6d205ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ All notable changes to Orbit will be documented in this file. - **`orbit presets` subcommand**: Displays available profiles with a comparison table - **Transfer shorthand subcommands**: `orbit sync `, `orbit backup `, `orbit mirror ` — all global CLI flags (compression, retry, quiet, etc.) are fully supported alongside shorthands - **`orbit doctor` subcommand**: Validates configuration, probes hardware, lists compiled features, and checks environment variables — a one-stop diagnostic tool +- **`orbit doctor --target ` live backend probes**: Repeatable flag that runs a real `list` call against any backend URI (`s3://`, `ssh://`, `smb://`, `azblob://`, `gs://`, or a local path), reporting connection latency on success and a one-line actionable suggestion on failure (e.g., "set AWS_ACCESS_KEY_ID", "ssh-add your key", "check IAM/ACL"). If `ORBIT_BACKEND_TYPE` is set, that backend is auto-probed too. Informational only — exits 0 regardless; a `--strict` mode for CI is tracked in `ROADMAP-v0.7.md` - **`--quiet` / `-q` flag**: Suppresses all non-essential output (progress, stats, guidance notices) - **`--raw` flag**: Disables human-readable formatting (outputs raw byte counts instead of "1.5 GiB") - **`--no-stat` flag**: Disables the end-of-run execution statistics summary diff --git a/ROADMAP-v0.7.md b/ROADMAP-v0.7.md new file mode 100644 index 0000000..1c778c6 --- /dev/null +++ b/ROADMAP-v0.7.md @@ -0,0 +1,115 @@ +# Orbit v0.7 Roadmap + +Tracking the v0.7 stabilization plan: ship a rock-solid core, gate experimental V3 features behind a clear promotion path, and tighten the observability + testing surface. + +Status taxonomy: **Planned** → **In progress** → **Done**. Each item has a one-line acceptance criterion so we know when it's shippable. + +--- + +## Theme 1 — Core transfer (mostly done, see audit) + +Already solid: zero-copy, resume/checkpoint, parallel workers, LZ4/Zstd, BLAKE3+SHA-256, Disk Guardian, error classification. No work tracked here unless a regression appears. + +### 1.1 Progress throughput / ETA polish — Planned +- **Why:** `indicatif` is wired but throughput and ETA aren't surfaced consistently across copy paths. +- **Accept:** `orbit cp ` shows current MB/s and ETA that update at least once per second, on all backends. + +### 1.2 `print_error` "did you mean" suggestions — Planned +- **Why:** Error formatting exists but lacks pattern-based hints (typo'd flag, common misconfigs). +- **Accept:** At least 10 common failure modes map to a one-line suggestion; covered by a regression test. + +### 1.3 `parse_uri` prefix duplication cleanup — Planned +- **Why:** `parse_uri` returns `(BackendConfig, PathBuf)` where for `s3://`/`azblob://`/`gs://`/`smb://` URIs the path segment is stored twice — as `prefix` inside the config *and* as the returned `PathBuf`. Anything that hands the returned path back to `Backend::list` double-prefixes silently. `orbit doctor` already works around this with `probe_path_for` (see `src/commands/doctor.rs`), but every future consumer of `parse_uri` will hit the same trap. +- **Accept:** `parse_uri` returns a config that uniquely owns the prefix; the returned `PathBuf` is either empty or represents a sub-path relative to that prefix (decide which, document it). All existing call sites audited and updated; doctor's `probe_path_for` shim becomes a one-liner or is removed. Regression test in the `backend::config` test module pins the chosen contract. + +--- + +## Theme 2 — Promote or cut experimental V3 features + +The V3 crates (`core-cdc`, `core-semantic`) compile but are not exposed via any CLI flag. They are dead weight until promoted or removed. + +### 2.1 `--smart` flag + alpha labelling — Planned +- **Why:** Users currently have no way to opt into CDC + semantic prioritization. Without a gate, the crates are unreachable; without a label, users will assume they're stable. +- **Accept:** `orbit cp --smart ` enables CDC + semantic prioritization end-to-end; `--help` text marks the flag `[preview]`; doc note explains the alpha contract. +- **Alternative:** if the feature isn't promotable in this cycle, remove the crates from the default build and gate them behind a `experimental` cargo feature. + +### 2.2 Global dedup — Deferred +- Reference counting + chunk store are not implemented. Track as a separate v0.8 effort, not v0.7. + +### 2.3 Delta manifests (rusqlite) — Already gated +- Behind `delta-manifest` feature. No work needed unless promoted to default. + +### Promotion criteria for any V3 feature + +Before a `--smart`-class feature is moved out of `[preview]`, it must satisfy all of: + +1. **Correctness:** round-trip test on at least three real-world corpora (mixed text, large binaries, deeply nested trees) shows zero content divergence. +2. **Performance:** no regression vs. the non-smart path on a copy of ≥ 10 GB at the 95th percentile. +3. **Stability:** at least one minor release with the flag exposed and zero open correctness issues against it. +4. **Docs:** user-facing `--help` text and a section in the README explaining when to use it and when not to. + +--- + +## Theme 3 — S3 / Remote UX + +### 3.1 `orbit s3 sync` — Planned +- **Why:** CLI has cp/ls/du/rm/mv/mb/rb but no `sync`. Users wanting `aws s3 sync` parity have to script multipart resume themselves. +- **Accept:** `orbit s3 sync ` mirrors a local tree to S3 (and the reverse) with: ETag-based change detection, multipart resume, `--delete` flag for orphan removal, dry-run via `--dry-run`. Built on `object_store` (the `s3-native` path) to keep it dep-light. + +### 3.2 `orbit doctor` `--strict` mode — Planned (after 4.1 lands) +- **Why:** v0.7 ships doctor live probes as informational only. A `--strict` flag makes it usable in CI. +- **Accept:** `orbit doctor --strict` exits non-zero if any probe fails; documented in the doctor help text. + +--- + +## Theme 4 — `orbit doctor` (Theme 4.1 is in progress) + +### 4.1 Live backend connectivity checks — In progress +- **Why:** Current doctor reports static state (platform, features, hardware) but doesn't tell users whether their actual backend config works. Debugging is blind. +- **Accept:** `orbit doctor --target ` probes any configured backend with a real list call; `ORBIT_BACKEND_TYPE`-style env vars are auto-probed; failures map to a one-line actionable suggestion. + +--- + +## Theme 5 — Observability + +### 5.1 Prometheus `/metrics` endpoint — Planned +- **Why:** `metrics_port` config field already exists but no exporter is implemented. This is the lightweight default that pairs with OTel being opt-in. +- **Accept:** When `metrics_port` is set, Orbit serves OpenMetrics on `/metrics` with at minimum: bytes transferred, files completed, errors by category, current throughput. No new heavy deps (use `prometheus` or hand-rolled text format). + +### 5.2 OTel — Already gated +- Behind `opentelemetry` feature. No work unless we hear demand for default-on. + +--- + +## Theme 6 — Testing & CI + +### 6.1 proptest scaffolding — Planned +- **Why:** No property-based tests for path handling, filter system, or resume logic — all of which have combinatorial input spaces. +- **Accept:** `proptest` in dev-deps; at least three property tests covering: (a) path normalization roundtrips, (b) filter inclusion/exclusion under random pattern sets, (c) resume manifest survives random checkpoint truncations. + +### 6.2 localstack / mock S3 integration tests — Planned +- **Why:** Current S3 integration tests rely on real AWS or skip — protocol semantics are untested in CI. +- **Accept:** New integration test layer spins up localstack (or `aws-smithy-mocks`) in CI and runs: upload, multipart upload, multipart resume, list, delete, sync (once 3.1 lands). + +### 6.3 Fuzz targets for CDC — Planned +- **Why:** Gear-hash boundary logic has subtle correctness properties (see `cdc-details.md` in memory). No fuzzing exists. +- **Accept:** `cargo-fuzz` target in `fuzz/` that asserts: chunk boundaries are deterministic for a given input; concatenating chunks reproduces the input byte-for-byte; chunks respect min/max size bounds. + +### 6.4 CI — Already in good shape +- `cargo deny`, `cargo audit`, `cargo fmt`, `clippy -D warnings`, multi-OS, multi-feature matrix all green. No work unless something breaks. + +--- + +## Out of scope for v0.7 + +- Global dedup (Theme 2.2) — v0.8. +- Mandatory OTel — opt-in is correct for v0.7. +- Rewriting the manifest schema — stable enough; only touch on correctness fixes. + +--- + +## How to use this doc + +- Pick a theme. Move its item from **Planned** → **In progress** in the same PR that starts work. +- Close out an item by moving to **Done** with the PR/commit reference. +- New gaps surfaced during v0.7 work get appended under the appropriate theme — don't start a separate doc. diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index f0aa0fd..69bd6c2 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -233,6 +233,10 @@ orbit /data /backup -R --error-mode skip # Validate config and check environment orbit doctor +# Live-probe one or more backends (auth, reachability, list permission) +orbit doctor --target s3://my-bucket +orbit doctor --target ssh://user@host/path --target azblob://my-container + # View transfer history orbit history orbit history --limit 50 --json diff --git a/docs/guides/quickstart_guide.md b/docs/guides/quickstart_guide.md index 8ab1ff7..4ffb848 100644 --- a/docs/guides/quickstart_guide.md +++ b/docs/guides/quickstart_guide.md @@ -29,6 +29,9 @@ orbit init # Or check your system and config health orbit doctor + +# Confirm a remote backend is actually reachable (auth + permissions) +orbit doctor --target s3://my-bucket ``` `orbit init` scans your hardware, asks about your use case, sets up exclusion patterns, offers shell completion installation, and generates an optimized `~/.orbit/orbit.toml`. All subsequent commands use these settings automatically. @@ -346,7 +349,7 @@ TEST_LOG=llm-debug RUST_LOG=debug \ | `orbit sync ` | Sync mode (recursive, metadata) | `orbit sync /data /backup` | | `orbit backup ` | Backup profile (checksums + Zstd) | `orbit backup /data /backup` | | `orbit mirror ` | Mirror mode (exact replica) | `orbit mirror /data /replica` | -| `orbit doctor` | Validate config and probe system | `orbit doctor` | +| `orbit doctor` | Validate config, probe system, and live-test backends | `orbit doctor --target s3://my-bucket` | | `orbit init` | Interactive setup wizard | `orbit init` | | `orbit cp ` | Copy alias (same as bare `orbit`) | `orbit cp /data /backup` | | `orbit explain ` | Preview transfer plan (no files touched) | `orbit explain /data /backup -R` | diff --git a/src/backend/config.rs b/src/backend/config.rs index 8284ea8..bf4b225 100644 --- a/src/backend/config.rs +++ b/src/backend/config.rs @@ -3,6 +3,15 @@ //! Provides configuration structures and URI parsing for backend initialization. use super::error::{BackendError, BackendResult}; +// `HashMap` is only used inside provider-gated arms of `parse_uri` (collecting +// URI query parameters). Without any provider feature, it would be unused. +#[cfg(any( + feature = "ssh-backend", + feature = "s3-native", + feature = "smb-native", + feature = "azure-native", + feature = "gcs-native", +))] use std::collections::HashMap; use std::path::PathBuf; diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 182ccdd..ab6ed8f 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -107,7 +107,7 @@ pub use azure::AzureBackend; pub use gcs::GcsBackend; #[cfg(feature = "backend-abstraction")] -pub use config::{parse_uri, BackendConfig}; +pub use config::{from_env, parse_uri, BackendConfig}; #[cfg(all(feature = "backend-abstraction", feature = "s3-native"))] pub use config::S3BackendConfig; diff --git a/src/backend/registry.rs b/src/backend/registry.rs index c2466c2..8d23db9 100644 --- a/src/backend/registry.rs +++ b/src/backend/registry.rs @@ -97,6 +97,11 @@ impl BackendRegistry { }; Ok(Box::new(backend) as Box) } + // Defensive guard for when other BackendConfig variants + // are compiled in; unreachable under + // `--features backend-abstraction` alone, where Local + // is the only variant. + #[allow(unreachable_patterns)] _ => Err(BackendError::InvalidConfig { backend: "local".to_string(), message: "Invalid configuration for local backend".to_string(), diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs new file mode 100644 index 0000000..5dba281 --- /dev/null +++ b/src/commands/doctor.rs @@ -0,0 +1,574 @@ +//! Doctor command — diagnose configuration and probe backend connectivity. +//! +//! Doctor is informational. It never mutates state and currently always exits +//! with code 0 regardless of probe failures; a `--strict` mode is tracked in +//! [ROADMAP-v0.7.md]. + +use crate::cli_style::{self, section_header, Icons, Theme}; +use crate::config::CopyConfig; +use crate::get_zero_copy_capabilities; + +/// Entry point for `orbit doctor [--target ...]`. +pub fn run_doctor(targets: &[String]) { + cli_style::print_banner(); + section_header(&format!("{} Orbit Doctor", Icons::WRENCH)); + println!(); + + print_config_section(); + print_platform_section(); + print_hardware_section(); + print_features_section(); + print_env_section(); + print_backend_probes_section(targets); + + println!(" {}", Theme::success("Doctor check complete.")); + println!(); +} + +fn print_config_section() { + let home = dirs::home_dir(); + let path = home.as_ref().map(|h| h.join(".orbit").join("orbit.toml")); + let exists = path.as_ref().map(|p| p.exists()).unwrap_or(false); + + if let (Some(path), true) = (path.as_ref(), exists) { + println!( + " {} {} {}", + Icons::SUCCESS, + Theme::muted("Config file:"), + Theme::success(path.display()) + ); + match CopyConfig::from_file(path) { + Ok(_) => println!( + " {} {}", + Icons::SUCCESS, + Theme::success("Config file is valid TOML") + ), + Err(e) => println!( + " {} {} {}", + Icons::ERROR, + Theme::error("Config parse error:"), + e + ), + } + } else { + println!( + " {} {} {}", + Icons::WARNING, + Theme::warning("No config file found."), + Theme::muted("Run 'orbit init' to create one.") + ); + } + println!(); +} + +fn print_platform_section() { + section_header(&format!("{} Platform", Icons::GEAR)); + println!( + " {} {} {} / {}", + Icons::BULLET, + Theme::muted("OS:"), + Theme::value(std::env::consts::OS), + Theme::muted(std::env::consts::ARCH) + ); + + let caps = get_zero_copy_capabilities(); + println!( + " {} {} {}", + if caps.available { + Icons::SUCCESS + } else { + Icons::WARNING + }, + Theme::muted("Zero-copy:"), + if caps.available { + Theme::success(caps.method) + } else { + Theme::warning("unavailable") + } + ); + println!(); +} + +fn print_hardware_section() { + section_header(&format!("{} Hardware", Icons::LIGHTNING)); + match crate::core::probe::Probe::scan(&std::env::current_dir().unwrap_or_default()) { + Ok(profile) => { + println!( + " {} {} {}", + Icons::BULLET, + Theme::muted("CPU cores:"), + Theme::value(profile.logical_cores) + ); + println!( + " {} {} {} GB", + Icons::BULLET, + Theme::muted("RAM:"), + Theme::value(profile.available_ram_gb) + ); + println!( + " {} {} ~{:.0} MB/s", + Icons::BULLET, + Theme::muted("I/O throughput:"), + profile.estimated_io_throughput + ); + } + Err(e) => { + println!( + " {} {} {}", + Icons::WARNING, + Theme::warning("Probe failed:"), + e + ); + } + } + println!(); +} + +fn print_features_section() { + section_header(&format!("{} Compiled Features", Icons::GEAR)); + let features: Vec<(&str, bool)> = vec![ + ("s3-native", cfg!(feature = "s3-native")), + ("s3-cli", cfg!(feature = "s3-cli")), + ("smb-native", cfg!(feature = "smb-native")), + ("ssh-backend", cfg!(feature = "ssh-backend")), + ("azure-native", cfg!(feature = "azure-native")), + ("gcs-native", cfg!(feature = "gcs-native")), + ("backend-abstraction", cfg!(feature = "backend-abstraction")), + ("opentelemetry", cfg!(feature = "opentelemetry")), + ]; + for (name, enabled) in &features { + println!( + " {} {}", + if *enabled { + Icons::SUCCESS + } else { + Icons::BULLET + }, + if *enabled { + Theme::success(*name).to_string() + } else { + Theme::muted(*name).to_string() + } + ); + } + println!(); +} + +fn print_env_section() { + section_header(&format!("{} Environment", Icons::GLOBE)); + let jwt_set = std::env::var("ORBIT_JWT_SECRET").is_ok(); + println!( + " {} {} {}", + if jwt_set { + Icons::SUCCESS + } else { + Icons::BULLET + }, + Theme::muted("ORBIT_JWT_SECRET:"), + if jwt_set { + Theme::success("set") + } else { + Theme::muted("not set (dashboard auth disabled)") + } + ); + + let stats_env = std::env::var("ORBIT_STATS").unwrap_or_else(|_| "on".to_string()); + println!( + " {} {} {}", + Icons::BULLET, + Theme::muted("ORBIT_STATS:"), + Theme::value(&stats_env) + ); + println!(); +} + +// --------------------------------------------------------------------------- +// Backend connectivity probes +// --------------------------------------------------------------------------- + +#[cfg(not(feature = "backend-abstraction"))] +fn print_backend_probes_section(targets: &[String]) { + section_header(&format!("{} Backend Connectivity", Icons::SATELLITE)); + if targets.is_empty() { + println!( + " {} {}", + Icons::BULLET, + Theme::muted("No backend-abstraction feature compiled — probes unavailable.") + ); + } else { + println!( + " {} {} {}", + Icons::WARNING, + Theme::warning("Probe targets ignored:"), + Theme::muted("rebuild with --features backend-abstraction (or a backend feature)") + ); + for t in targets { + println!(" {} {}", Icons::BULLET, Theme::muted(t)); + } + } + println!(); +} + +#[cfg(feature = "backend-abstraction")] +fn print_backend_probes_section(targets: &[String]) { + use crate::backend::{from_env, BackendRegistry}; + + let env_type = std::env::var("ORBIT_BACKEND_TYPE") + .ok() + .map(|s| s.to_lowercase()); + let env_probe_enabled = env_type + .as_deref() + .map(|t| t != "local") // skip local — nothing to probe + .unwrap_or(false); + + if targets.is_empty() && !env_probe_enabled { + section_header(&format!("{} Backend Connectivity", Icons::SATELLITE)); + println!( + " {} {}", + Icons::BULLET, + Theme::muted("No probe targets. Pass --target or set ORBIT_BACKEND_TYPE.") + ); + println!(); + return; + } + + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + section_header(&format!("{} Backend Connectivity", Icons::SATELLITE)); + println!( + " {} {} {}", + Icons::ERROR, + Theme::error("Could not start runtime:"), + e + ); + println!(); + return; + } + }; + + section_header(&format!("{} Backend Connectivity", Icons::SATELLITE)); + let registry = BackendRegistry::new(); + + for target in targets { + runtime.block_on(probe_uri(®istry, target)); + } + + if env_probe_enabled { + match from_env() { + Ok(config) => { + let label = format!("env:{}", env_type.as_deref().unwrap_or("?")); + runtime.block_on(probe_config(®istry, &label, &config, None)); + } + Err(e) => { + println!( + " {} {} {}", + Icons::ERROR, + Theme::error("env config error:"), + e + ); + println!( + " {} {}", + Icons::ARROW_RIGHT, + Theme::muted( + "set the required ORBIT_* / AWS_* / AZURE_* / GOOGLE_* variables for this backend" + ) + ); + } + } + } + + println!(); +} + +#[cfg(feature = "backend-abstraction")] +async fn probe_uri(registry: &crate::backend::BackendRegistry, uri: &str) { + use crate::backend::parse_uri; + + let (config, path) = match parse_uri(uri) { + Ok(v) => v, + Err(e) => { + println!( + " {} {} {} {}", + Icons::ERROR, + Theme::error(uri), + Theme::muted("→ URI parse error:"), + e + ); + println!( + " {} {}", + Icons::ARROW_RIGHT, + Theme::muted("check the URI scheme and query parameters") + ); + return; + } + }; + probe_config(registry, uri, &config, Some(&path)).await; +} + +/// Choose what path to pass to `Backend::list` when probing. +/// +/// `parse_uri` returns a `(config, path)` pair where `path` is the URI's +/// path segment. For backends that already encode that segment as a `prefix` +/// inside the config (S3, Azure, GCS, SMB), passing it again to `list` would +/// double-prefix the request — e.g. `s3://bucket/foo` would end up listing +/// `foo/foo`. Those backends should be probed at their root. Local and SSH +/// don't bake a prefix into config, so the parsed path is the real probe +/// target. +#[cfg(feature = "backend-abstraction")] +fn probe_path_for( + config: &crate::backend::BackendConfig, + list_path: Option<&std::path::Path>, +) -> std::path::PathBuf { + // Import only when at least one provider arm below is enabled; otherwise + // `BackendConfig` is unused inside this function under + // `--features backend-abstraction` alone and `-D unused-imports` fails. + // When adding a new backend variant, extend both the `cfg(any(...))` here + // and add a matching arm. + #[cfg(any( + feature = "s3-native", + feature = "azure-native", + feature = "gcs-native", + feature = "smb-native", + ))] + use crate::backend::BackendConfig; + match config { + #[cfg(feature = "s3-native")] + BackendConfig::S3 { .. } => std::path::PathBuf::new(), + #[cfg(feature = "azure-native")] + BackendConfig::Azure { .. } => std::path::PathBuf::new(), + #[cfg(feature = "gcs-native")] + BackendConfig::Gcs { .. } => std::path::PathBuf::new(), + #[cfg(feature = "smb-native")] + BackendConfig::Smb(_) => std::path::PathBuf::new(), + // Local and SSH: the parsed path is the actual probe target. + _ => list_path + .map(std::path::Path::to_path_buf) + .unwrap_or_default(), + } +} + +#[cfg(feature = "backend-abstraction")] +async fn probe_config( + registry: &crate::backend::BackendRegistry, + label: &str, + config: &crate::backend::BackendConfig, + list_path: Option<&std::path::Path>, +) { + use crate::backend::types::ListOptions; + use futures::StreamExt; + use std::time::Instant; + + let started = Instant::now(); + let backend = match registry.create(config).await { + Ok(b) => b, + Err(e) => { + println!( + " {} {} {} {}", + Icons::ERROR, + Theme::error(label), + Theme::muted("→"), + e + ); + print_suggestion(&e); + return; + } + }; + + let probe_path = probe_path_for(config, list_path); + let opts = ListOptions { + max_entries: Some(1), + ..Default::default() + }; + + match backend.list(&probe_path, opts).await { + Ok(mut stream) => { + let first = stream.next().await; + let elapsed = started.elapsed(); + match first { + Some(Ok(_)) | None => { + println!( + " {} {} {} {}ms", + Icons::SUCCESS, + Theme::success(label), + Theme::muted("→ connected in"), + elapsed.as_millis() + ); + } + Some(Err(e)) => { + println!( + " {} {} {} {}", + Icons::ERROR, + Theme::error(label), + Theme::muted("→ list error:"), + e + ); + print_suggestion(&e); + } + } + } + Err(e) => { + println!( + " {} {} {} {}", + Icons::ERROR, + Theme::error(label), + Theme::muted("→ list error:"), + e + ); + print_suggestion(&e); + } + } +} + +#[cfg(feature = "backend-abstraction")] +fn print_suggestion(err: &crate::backend::BackendError) { + if let Some(hint) = suggest_for_error(err) { + println!(" {} {}", Icons::ARROW_RIGHT, Theme::muted(hint)); + } +} + +#[cfg(feature = "backend-abstraction")] +fn suggest_for_error(err: &crate::backend::BackendError) -> Option<&'static str> { + use crate::backend::BackendError as E; + match err { + E::AuthenticationFailed { backend, .. } => Some(match backend.as_str() { + "s3" => "check AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (or run `aws configure`)", + "ssh" => "verify the SSH key path, or run `ssh-add ` for agent auth", + "smb" => "check ORBIT_SMB_USER and ORBIT_SMB_PASSWORD", + "azure" => { + "set AZURE_STORAGE_CONNECTION_STRING (or AZURE_STORAGE_ACCOUNT + AZURE_STORAGE_KEY)" + } + "gcs" => "set GOOGLE_SERVICE_ACCOUNT_KEY (path to JSON credentials)", + _ => "check backend credentials", + }), + E::ConnectionFailed { backend, .. } => Some(match backend.as_str() { + "s3" => "check endpoint + region; confirm the bucket exists and DNS resolves", + "ssh" => "confirm the host is reachable on the configured port", + "smb" => "confirm the SMB share is reachable and the port is open", + "azure" | "gcs" => "confirm network reachability to the storage endpoint", + _ => "verify network reachability to the endpoint", + }), + E::NotFound { backend, .. } => Some(match backend.as_str() { + "s3" => "bucket or prefix not found (or missing ListBucket permission)", + "azure" => "container or prefix not found (or missing list permission)", + "gcs" => "bucket or prefix not found (or missing storage.objects.list)", + _ => "the path does not exist on the backend", + }), + E::PermissionDenied { .. } => { + Some("backend rejected the request — check IAM / ACL / share permissions") + } + E::InvalidConfig { .. } => Some("review the URI / env vars for typos or missing fields"), + E::Timeout { .. } => Some("network or backend is slow — try again, or check firewall"), + E::Network { .. } => Some("network error — check connectivity, DNS, and proxy settings"), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[cfg(feature = "backend-abstraction")] + fn local_uri_probes_succeed() { + // Smoke test: running doctor against a local temp dir should not panic. + let tmp = tempfile::tempdir().unwrap(); + let uri = tmp.path().to_string_lossy().to_string(); + run_doctor(&[uri]); + } + + #[test] + fn run_doctor_with_no_targets_does_not_panic() { + run_doctor(&[]); + } + + #[test] + #[cfg(feature = "backend-abstraction")] + fn suggest_for_auth_failure_returns_hint() { + use crate::backend::BackendError; + let err = BackendError::AuthenticationFailed { + backend: "s3".to_string(), + message: "no creds".to_string(), + }; + assert!(suggest_for_error(&err).is_some()); + } + + #[test] + #[cfg(feature = "backend-abstraction")] + fn suggest_for_unknown_backend_falls_back() { + use crate::backend::BackendError; + let err = BackendError::AuthenticationFailed { + backend: "weird".to_string(), + message: "x".to_string(), + }; + assert_eq!(suggest_for_error(&err), Some("check backend credentials")); + } + + // ----- probe_path_for: keep `parse_uri` + Backend::list from double-prefixing ----- + + #[test] + #[cfg(feature = "backend-abstraction")] + fn probe_path_for_local_uses_parsed_path() { + use crate::backend::parse_uri; + use std::path::PathBuf; + let (config, path) = parse_uri("/tmp/data").unwrap(); + assert_eq!( + probe_path_for(&config, Some(&path)), + PathBuf::from("/tmp/data") + ); + } + + /// Regression: `s3://bucket/prefix` would otherwise double-prefix to `prefix/prefix` + /// because `parse_uri` stores `prefix` in `BackendConfig::S3` AND returns it as + /// the parsed path, and `S3Backend::path_to_key` concatenates the two. + #[test] + #[cfg(all(feature = "backend-abstraction", feature = "s3-native"))] + fn probe_path_for_s3_with_prefix_uses_root() { + use crate::backend::parse_uri; + use std::path::PathBuf; + let (config, path) = parse_uri("s3://my-bucket/some/prefix").unwrap(); + assert_eq!(probe_path_for(&config, Some(&path)), PathBuf::new()); + } + + #[test] + #[cfg(all(feature = "backend-abstraction", feature = "azure-native"))] + fn probe_path_for_azure_with_prefix_uses_root() { + use crate::backend::parse_uri; + use std::path::PathBuf; + let (config, path) = parse_uri("azblob://my-container/some/prefix").unwrap(); + assert_eq!(probe_path_for(&config, Some(&path)), PathBuf::new()); + } + + #[test] + #[cfg(all(feature = "backend-abstraction", feature = "gcs-native"))] + fn probe_path_for_gcs_with_prefix_uses_root() { + use crate::backend::parse_uri; + use std::path::PathBuf; + let (config, path) = parse_uri("gs://my-bucket/some/prefix").unwrap(); + assert_eq!(probe_path_for(&config, Some(&path)), PathBuf::new()); + } + + #[test] + #[cfg(all(feature = "backend-abstraction", feature = "smb-native"))] + fn probe_path_for_smb_with_subpath_uses_root() { + use crate::backend::parse_uri; + use std::path::PathBuf; + let (config, path) = parse_uri("smb://server/share/some/sub").unwrap(); + assert_eq!(probe_path_for(&config, Some(&path)), PathBuf::new()); + } + + #[test] + #[cfg(all(feature = "backend-abstraction", feature = "ssh-backend"))] + fn probe_path_for_ssh_uses_parsed_path() { + use crate::backend::parse_uri; + use std::path::PathBuf; + let (config, path) = parse_uri("ssh://user@host/remote/dir").unwrap(); + assert_eq!( + probe_path_for(&config, Some(&path)), + PathBuf::from("/remote/dir") + ); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 27df13e..140e58c 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -5,6 +5,7 @@ */ pub mod batch; +pub mod doctor; pub mod explain; pub mod history; pub mod init; diff --git a/src/main.rs b/src/main.rs index 8b4dd80..8a35453 100644 --- a/src/main.rs +++ b/src/main.rs @@ -680,7 +680,15 @@ enum Commands { Capabilities, /// Diagnose common issues (config, permissions, connectivity) - Doctor, + Doctor { + /// Probe a backend URI for live connectivity (repeatable). + /// + /// Examples: `--target s3://my-bucket`, `--target ssh://user@host/path`, + /// `--target azblob://container`. If `ORBIT_BACKEND_TYPE` is set, that + /// backend is also auto-probed. + #[arg(long = "target", value_name = "URI")] + target: Vec, + }, /// Generate shell completions Completions { @@ -1715,8 +1723,8 @@ fn handle_subcommand(command: Commands, json_output: bool) -> Result<()> { print_capabilities(); Ok(()) } - Commands::Doctor => { - run_doctor(); + Commands::Doctor { target } => { + orbit::commands::doctor::run_doctor(&target); Ok(()) } Commands::Completions { shell } => { @@ -1789,168 +1797,6 @@ fn handle_subcommand(command: Commands, json_output: bool) -> Result<()> { } } -fn run_doctor() { - cli_style::print_banner(); - section_header(&format!("{} Orbit Doctor", Icons::WRENCH)); - println!(); - - // 1. Config file - let config_exists = default_config_exists(); - if config_exists { - let home = dirs::home_dir().unwrap(); - let path = home.join(".orbit").join("orbit.toml"); - println!( - " {} {} {}", - Icons::SUCCESS, - Theme::muted("Config file:"), - Theme::success(path.display()) - ); - // Try to parse it - match CopyConfig::from_file(&path) { - Ok(_) => println!( - " {} {}", - Icons::SUCCESS, - Theme::success("Config file is valid TOML") - ), - Err(e) => println!( - " {} {} {}", - Icons::ERROR, - Theme::error("Config parse error:"), - e - ), - } - } else { - println!( - " {} {} {}", - Icons::WARNING, - Theme::warning("No config file found."), - Theme::muted("Run 'orbit init' to create one.") - ); - } - println!(); - - // 2. Platform capabilities - section_header(&format!("{} Platform", Icons::GEAR)); - println!( - " {} {} {} / {}", - Icons::BULLET, - Theme::muted("OS:"), - Theme::value(std::env::consts::OS), - Theme::muted(std::env::consts::ARCH) - ); - - let caps = get_zero_copy_capabilities(); - println!( - " {} {} {}", - if caps.available { - Icons::SUCCESS - } else { - Icons::WARNING - }, - Theme::muted("Zero-copy:"), - if caps.available { - Theme::success(caps.method) - } else { - Theme::warning("unavailable") - } - ); - println!(); - - // 3. System probe - section_header(&format!("{} Hardware", Icons::LIGHTNING)); - match orbit::core::probe::Probe::scan(&std::env::current_dir().unwrap_or_default()) { - Ok(profile) => { - println!( - " {} {} {}", - Icons::BULLET, - Theme::muted("CPU cores:"), - Theme::value(profile.logical_cores) - ); - println!( - " {} {} {} GB", - Icons::BULLET, - Theme::muted("RAM:"), - Theme::value(profile.available_ram_gb) - ); - println!( - " {} {} ~{:.0} MB/s", - Icons::BULLET, - Theme::muted("I/O throughput:"), - profile.estimated_io_throughput - ); - } - Err(e) => { - println!( - " {} {} {}", - Icons::WARNING, - Theme::warning("Probe failed:"), - e - ); - } - } - println!(); - - // 4. Feature flags - section_header(&format!("{} Compiled Features", Icons::GEAR)); - let features: Vec<(&str, bool)> = vec![ - ("s3-native", cfg!(feature = "s3-native")), - ("s3-cli", cfg!(feature = "s3-cli")), - ("smb-native", cfg!(feature = "smb-native")), - ("ssh-backend", cfg!(feature = "ssh-backend")), - ("azure-native", cfg!(feature = "azure-native")), - ("gcs-native", cfg!(feature = "gcs-native")), - ]; - for (name, enabled) in &features { - println!( - " {} {}", - if *enabled { - Icons::SUCCESS - } else { - Icons::BULLET - }, - if *enabled { - Theme::success(*name).to_string() - } else { - Theme::muted(*name).to_string() - } - ); - } - println!(); - - // 5. Environment - section_header(&format!("{} Environment", Icons::GLOBE)); - let jwt_set = std::env::var("ORBIT_JWT_SECRET").is_ok(); - println!( - " {} {} {}", - if jwt_set { - Icons::SUCCESS - } else { - Icons::BULLET - }, - Theme::muted("ORBIT_JWT_SECRET:"), - if jwt_set { - Theme::success("set") - } else { - Theme::muted("not set (dashboard auth disabled)") - } - ); - - let stats_env = std::env::var("ORBIT_STATS").unwrap_or_else(|_| "on".to_string()); - println!( - " {} {} {}", - Icons::BULLET, - Theme::muted("ORBIT_STATS:"), - Theme::value(&stats_env) - ); - println!(); - - println!( - " {}", - Theme::success("Doctor check complete. No critical issues found.") - ); - println!(); -} - fn print_presets() { cli_style::print_banner(); section_header(&format!("{} Configuration Presets", Icons::GEAR)); @@ -2689,7 +2535,51 @@ mod tests { #[test] fn test_doctor_subcommand() { let cli = Cli::try_parse_from(["orbit", "doctor"]).unwrap(); - assert!(matches!(cli.command, Some(Commands::Doctor))); + assert!(matches!( + cli.command, + Some(Commands::Doctor { ref target }) if target.is_empty() + )); + } + + #[test] + fn test_doctor_with_target() { + let cli = Cli::try_parse_from(["orbit", "doctor", "--target", "s3://my-bucket"]).unwrap(); + match cli.command { + Some(Commands::Doctor { target }) => { + assert_eq!(target, vec!["s3://my-bucket".to_string()]); + } + _ => panic!("Expected Doctor subcommand with target"), + } + } + + #[test] + fn test_doctor_with_repeated_targets() { + let cli = Cli::try_parse_from([ + "orbit", + "doctor", + "--target", + "s3://a", + "--target", + "ssh://b/c", + ]) + .unwrap(); + match cli.command { + Some(Commands::Doctor { target }) => { + assert_eq!(target, vec!["s3://a".to_string(), "ssh://b/c".to_string()]); + } + _ => panic!("Expected Doctor subcommand with repeated targets"), + } + } + + /// Regression: `--target` with no URI must be a parse error, not a + /// silent no-op. Previously `num_args = 0..` allowed empty values. + #[test] + fn test_doctor_target_without_value_errors() { + let result = Cli::try_parse_from(["orbit", "doctor", "--target"]); + assert!( + result.is_err(), + "expected `doctor --target` (no URI) to be a parse error" + ); } #[test]