weft_client_shim/lib.rs
1// SPDX-License-Identifier: Apache-2.0
2//! Trait surface separating the OSS Heddle CLI from the closed
3//! heddle-client implementation.
4//!
5//! OSS builds use [`NoopWeftExtensions`], which returns a friendly
6//! "hosted features not enabled" error on every method. Closed builds
7//! ship a real implementation in the `heddle-client` crate and inject
8//! it via Cargo features (today) or `[patch.crates-io]` (post-split).
9//!
10//! Why a separate crate (and not just a trait in `cli`)? When the
11//! repos physically split, the OSS `heddle-cli` crate ships on
12//! crates.io. The closed `heddle-client` crate published in the
13//! private workspace depends on this shim to satisfy `cli`'s trait
14//! bound without `cli` ever knowing about closed-source code. Same
15//! trait surface, two impls, no circular deps.
16//!
17//! Trait methods are intentionally minimal — only the truly
18//! hosted-only commands (`auth`, `support`, `presence`) flow through
19//! here. Hybrid commands like `push`/`pull`/`fetch`/`clone` stay in
20//! `cli` because their git-overlay-without-hosted code paths must
21//! work in OSS-only builds too.
22
23use std::any::Any;
24use std::path::Path;
25
26use anyhow::{Result, anyhow};
27use async_trait::async_trait;
28
29/// Small projection of `cli::Cli` that hosted commands rely on.
30/// Defining the surface here rather than passing `&Cli` lets the
31/// closed `heddle-client` crate compile without depending on `cli` —
32/// breaking what would otherwise be a circular dep (cli optionally
33/// pulls in heddle-client, heddle-client would otherwise need cli for
34/// the `Cli` type).
35///
36/// Keep this trait deliberately small. Every new method is a
37/// permanent contract between the OSS and closed sides; before adding
38/// one, ask whether the hosted command should really need that
39/// context at all, or whether the caller can compute it and pass a
40/// primitive value.
41pub trait CliContext: Send + Sync {
42 /// `--repo` override; `None` means "use the process's current
43 /// directory."
44 fn repo_path(&self) -> Option<&Path>;
45
46 /// `--op-id` override for idempotent gRPC calls. Empty string
47 /// means the caller did not supply one and the server should not
48 /// dedupe.
49 fn operation_id_wire(&self) -> String;
50
51 /// Resolves whether output should be JSON, encapsulating the
52 /// precedence between the `--json` / `--output` cli flags, the
53 /// user's global config, and (when supplied) the repo's
54 /// `output.format` config. Hosted commands typically pass
55 /// `Some(repo.config())` after opening the repo and `None`
56 /// otherwise.
57 fn should_output_json(&self, repo_config: Option<&repo::Config>) -> bool;
58}
59
60/// Hosted-side command implementations. The CLI dispatches through a
61/// `&dyn WeftExtensions` reference; the active impl is selected at
62/// build time by the `heddle-client` Cargo feature.
63///
64/// Implementations take CLI args opaquely (`&dyn Any`) so this shim
65/// crate doesn't need to depend on `cli` for type definitions —
66/// downstream concrete impls downcast to the real types. This avoids
67/// a circular dependency between `cli` (which defines `Cli`,
68/// `AuthCommands`, etc.) and the heddle-client crate.
69#[async_trait]
70pub trait WeftExtensions: Send + Sync {
71 /// `heddle auth <subcommand>` — login, logout, whoami, device
72 /// authorization, service account issuance.
73 async fn auth(
74 &self,
75 ctx: &(dyn CliContext + 'static),
76 command: &(dyn Any + Send + Sync),
77 ) -> Result<()>;
78
79 /// `heddle support <subcommand>` — hosted-side support and
80 /// diagnostic operations.
81 async fn support(
82 &self,
83 ctx: &(dyn CliContext + 'static),
84 command: &(dyn Any + Send + Sync),
85 ) -> Result<()>;
86
87 /// `heddle presence publish` — stream presence/heartbeat over the
88 /// websocket transport to the hosted backend.
89 async fn presence_publish(
90 &self,
91 ctx: &(dyn CliContext + 'static),
92 session: String,
93 interval_secs: u64,
94 ) -> Result<()>;
95}
96
97/// Noop implementation used in OSS builds. Every method returns the
98/// same friendly error pointing the user at the closed-build
99/// installation path.
100pub struct NoopWeftExtensions;
101
102#[async_trait]
103impl WeftExtensions for NoopWeftExtensions {
104 async fn auth(
105 &self,
106 _ctx: &(dyn CliContext + 'static),
107 _command: &(dyn Any + Send + Sync),
108 ) -> Result<()> {
109 Err(anyhow!(not_enabled_error("auth")))
110 }
111
112 async fn support(
113 &self,
114 _ctx: &(dyn CliContext + 'static),
115 _command: &(dyn Any + Send + Sync),
116 ) -> Result<()> {
117 Err(anyhow!(not_enabled_error("support")))
118 }
119
120 async fn presence_publish(
121 &self,
122 _ctx: &(dyn CliContext + 'static),
123 _session: String,
124 _interval_secs: u64,
125 ) -> Result<()> {
126 Err(anyhow!(not_enabled_error("presence publish")))
127 }
128}
129
130fn not_enabled_error(command: &str) -> String {
131 format!(
132 "`heddle {command}` requires the client build of Heddle. \
133 Install it from https://heddleco.com or rebuild the CLI with \
134 `--features client` if you're working from source."
135 )
136}