Skip to main content

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}