Skip to main content

socket_patch_cli/
args.rs

1//! Shared CLI arguments flattened into every subcommand.
2//!
3//! `GlobalArgs` defines the flags that apply uniformly across every
4//! `socket-patch` subcommand. Each subcommand `#[command(flatten)]`s this
5//! struct into its own `Args` struct so the surface stays consistent.
6//!
7//! Subcommands that don't actually use a given global flag still accept it
8//! silently (no-op). See `CLI_CONTRACT.md` for the full contract.
9//!
10//! Precedence for every flag: CLI arg > env var > default.
11//!
12//! All env-var names use the `SOCKET_*` prefix. Three legacy `SOCKET_PATCH_*`
13//! names are still read at runtime (via `socket_patch_core::env_compat`) with
14//! a one-shot deprecation warning; they will be removed in the next major.
15
16use std::path::PathBuf;
17
18use clap::Args;
19
20use socket_patch_core::api::client::ApiClientEnvOverrides;
21use socket_patch_core::constants::{
22    DEFAULT_PATCH_API_PROXY_URL, DEFAULT_PATCH_MANIFEST_PATH, DEFAULT_SOCKET_API_URL,
23};
24
25/// Arguments inherited by every subcommand via `#[command(flatten)]`.
26///
27/// **Every** global flag is parseable on **every** subcommand. Commands that
28/// don't use a given flag ignore it silently — e.g. `list --global` parses
29/// fine and the `global` field is unused at runtime.
30#[derive(Args, Debug, Clone)]
31pub struct GlobalArgs {
32    /// Working directory.
33    #[arg(long, env = "SOCKET_CWD", default_value = ".")]
34    pub cwd: PathBuf,
35
36    /// Path to patch manifest file (resolved relative to --cwd).
37    #[arg(
38        long = "manifest-path",
39        env = "SOCKET_MANIFEST_PATH",
40        default_value = DEFAULT_PATCH_MANIFEST_PATH,
41    )]
42    pub manifest_path: String,
43
44    /// Socket API URL (authenticated endpoint).
45    #[arg(
46        long = "api-url",
47        env = "SOCKET_API_URL",
48        default_value = DEFAULT_SOCKET_API_URL,
49    )]
50    pub api_url: String,
51
52    /// Socket API token. Absence selects the public patch proxy.
53    #[arg(long = "api-token", env = "SOCKET_API_TOKEN")]
54    pub api_token: Option<String>,
55
56    /// Organization slug. Auto-resolved when omitted and a token is set.
57    #[arg(long = "org", short = 'o', env = "SOCKET_ORG_SLUG")]
58    pub org: Option<String>,
59
60    /// Public proxy URL used when no API token is set.
61    #[arg(
62        long = "proxy-url",
63        env = "SOCKET_PROXY_URL",
64        default_value = DEFAULT_PATCH_API_PROXY_URL,
65    )]
66    pub proxy_url: String,
67
68    /// Restrict to these ecosystems (comma-separated).
69    #[arg(
70        long = "ecosystems",
71        short = 'e',
72        env = "SOCKET_ECOSYSTEMS",
73        value_delimiter = ',',
74    )]
75    pub ecosystems: Option<Vec<String>>,
76
77    /// Which kind of patch artifact to download when local files are missing.
78    /// `diff` (default) fetches the smallest delta archive; `package` fetches
79    /// a full per-package tarball; `file` falls back to legacy per-file blobs.
80    #[arg(
81        long = "download-mode",
82        env = "SOCKET_DOWNLOAD_MODE",
83        default_value = "diff",
84    )]
85    pub download_mode: String,
86
87    /// Strict airgap: never contact the network. Operations that need remote
88    /// data fail loudly when this is set.
89    #[arg(
90        long,
91        env = "SOCKET_OFFLINE",
92        default_value_t = false,
93        value_parser = clap::builder::BoolishValueParser::new(),
94    )]
95    pub offline: bool,
96
97    /// Operate on globally-installed packages.
98    #[arg(
99        long = "global",
100        short = 'g',
101        env = "SOCKET_GLOBAL",
102        default_value_t = false,
103        value_parser = clap::builder::BoolishValueParser::new(),
104    )]
105    pub global: bool,
106
107    /// Override the path used to discover globally-installed packages.
108    #[arg(long = "global-prefix", env = "SOCKET_GLOBAL_PREFIX")]
109    pub global_prefix: Option<PathBuf>,
110
111    /// Emit machine-readable JSON output.
112    #[arg(
113        long = "json",
114        short = 'j',
115        env = "SOCKET_JSON",
116        default_value_t = false,
117        value_parser = clap::builder::BoolishValueParser::new(),
118    )]
119    pub json: bool,
120
121    /// Show extra detail in human-readable output.
122    #[arg(
123        long = "verbose",
124        short = 'v',
125        env = "SOCKET_VERBOSE",
126        default_value_t = false,
127        value_parser = clap::builder::BoolishValueParser::new(),
128    )]
129    pub verbose: bool,
130
131    /// Suppress non-error output.
132    #[arg(
133        long = "silent",
134        short = 's',
135        env = "SOCKET_SILENT",
136        default_value_t = false,
137        value_parser = clap::builder::BoolishValueParser::new(),
138    )]
139    pub silent: bool,
140
141    /// Preview the operation without making any mutations.
142    #[arg(
143        long = "dry-run",
144        env = "SOCKET_DRY_RUN",
145        default_value_t = false,
146        value_parser = clap::builder::BoolishValueParser::new(),
147    )]
148    pub dry_run: bool,
149
150    /// Skip interactive prompts.
151    #[arg(
152        long = "yes",
153        short = 'y',
154        env = "SOCKET_YES",
155        default_value_t = false,
156        value_parser = clap::builder::BoolishValueParser::new(),
157    )]
158    pub yes: bool,
159
160    /// Seconds to wait for `<.socket>/apply.lock` before giving up.
161    /// Default (`None`) and `0` both mean a single non-blocking try
162    /// — failing immediately if another process holds the lock. A
163    /// positive value retries with a 100 ms backoff until the lock
164    /// frees or the budget elapses. Only meaningful for the mutating
165    /// subcommands (`apply`, `rollback`, `repair`, `remove`); other
166    /// commands accept it silently.
167    #[arg(long = "lock-timeout", env = "SOCKET_LOCK_TIMEOUT")]
168    pub lock_timeout: Option<u64>,
169
170    /// Force-remove `<.socket>/apply.lock` before attempting
171    /// acquisition. Use when you are certain no other socket-patch
172    /// process is running (e.g. a previous run crashed in a way that
173    /// stripped the OS lock but left the file). Emits a
174    /// `lock_broken` warning event in the JSON envelope so the
175    /// action is auditable. Only meaningful for mutating
176    /// subcommands; other commands accept it silently.
177    #[arg(
178        long = "break-lock",
179        env = "SOCKET_BREAK_LOCK",
180        default_value_t = false,
181        value_parser = clap::builder::BoolishValueParser::new(),
182    )]
183    pub break_lock: bool,
184
185    /// Emit verbose debug logs to stderr.
186    #[arg(
187        long = "debug",
188        env = "SOCKET_DEBUG",
189        default_value_t = false,
190        value_parser = clap::builder::BoolishValueParser::new(),
191    )]
192    pub debug: bool,
193
194    /// Disable anonymous usage telemetry.
195    #[arg(
196        long = "no-telemetry",
197        env = "SOCKET_TELEMETRY_DISABLED",
198        default_value_t = false,
199        value_parser = clap::builder::BoolishValueParser::new(),
200    )]
201    pub no_telemetry: bool,
202}
203
204impl GlobalArgs {
205    /// Resolve `manifest_path` against `cwd`. See
206    /// `socket_patch_core::manifest::operations::resolve_manifest_path`.
207    pub fn resolved_manifest_path(&self) -> PathBuf {
208        socket_patch_core::manifest::operations::resolve_manifest_path(
209            &self.cwd,
210            &self.manifest_path,
211        )
212    }
213
214    /// Build [`ApiClientEnvOverrides`] from the CLI flags.
215    ///
216    /// `api_token` and `org` are forwarded as `Some(_)` only when set.
217    /// `api_url` and `proxy_url` are forwarded only when non-empty;
218    /// `GlobalArgs::default()` leaves both empty so integration tests
219    /// that mutate env vars *after* constructing args still get env-var
220    /// resolution from `get_api_client_with_overrides`. In production
221    /// clap always populates them with either the CLI value, the env
222    /// value, or the clap-declared default — all non-empty — so the
223    /// resolved value still flows through.
224    pub fn api_client_overrides(&self) -> ApiClientEnvOverrides {
225        ApiClientEnvOverrides {
226            api_url: Some(self.api_url.clone()).filter(|s| !s.is_empty()),
227            api_token: self.api_token.clone().filter(|s| !s.is_empty()),
228            org_slug: self.org.clone().filter(|s| !s.is_empty()),
229            proxy_url: Some(self.proxy_url.clone()).filter(|s| !s.is_empty()),
230        }
231    }
232}
233
234/// Apply CLI-flag toggles for env-driven knobs by mirroring them into env
235/// vars. This is how `--debug` / `--no-telemetry` reach core code that
236/// reads `SOCKET_DEBUG` / `SOCKET_TELEMETRY_DISABLED` directly. Idempotent
237/// and a no-op when the flags are off.
238pub fn apply_env_toggles(common: &GlobalArgs) {
239    if common.debug {
240        std::env::set_var("SOCKET_DEBUG", "1");
241    }
242    if common.no_telemetry {
243        std::env::set_var("SOCKET_TELEMETRY_DISABLED", "1");
244    }
245}
246
247impl Default for GlobalArgs {
248    /// Defaults intended for **test struct literals** (e.g. `..GlobalArgs::default()`).
249    ///
250    /// In production every field is populated by clap (with the
251    /// `default_value = ".."` attribute providing the documented defaults
252    /// when neither CLI flag nor env var is set), so this `Default` is
253    /// only reached from tests building `GlobalArgs` directly.
254    ///
255    /// `api_url` and `proxy_url` are intentionally **empty** here (not
256    /// the production default URLs). That lets tests set
257    /// `SOCKET_API_URL` / `SOCKET_PROXY_URL` via `std::env::set_var`
258    /// *after* constructing the args struct and have those env vars
259    /// flow through to the API client — `api_client_overrides` skips
260    /// empty values so the underlying `get_api_client_with_overrides`
261    /// falls back to env-var resolution.
262    fn default() -> Self {
263        Self {
264            cwd: PathBuf::from("."),
265            manifest_path: DEFAULT_PATCH_MANIFEST_PATH.to_string(),
266            api_url: String::new(),
267            api_token: None,
268            org: None,
269            proxy_url: String::new(),
270            ecosystems: None,
271            download_mode: "diff".to_string(),
272            offline: false,
273            global: false,
274            global_prefix: None,
275            json: false,
276            verbose: false,
277            silent: false,
278            dry_run: false,
279            yes: false,
280            lock_timeout: None,
281            break_lock: false,
282            debug: false,
283            no_telemetry: false,
284        }
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    /// `api_client_overrides` must forward every populated value verbatim.
293    #[test]
294    fn api_client_overrides_forwards_set_values() {
295        let args = GlobalArgs {
296            api_url: "https://api.example.com".to_string(),
297            api_token: Some("tok123".to_string()),
298            org: Some("acme".to_string()),
299            proxy_url: "https://proxy.example.com".to_string(),
300            ..GlobalArgs::default()
301        };
302        let o = args.api_client_overrides();
303        assert_eq!(o.api_url.as_deref(), Some("https://api.example.com"));
304        assert_eq!(o.api_token.as_deref(), Some("tok123"));
305        assert_eq!(o.org_slug.as_deref(), Some("acme"));
306        assert_eq!(o.proxy_url.as_deref(), Some("https://proxy.example.com"));
307    }
308
309    /// `GlobalArgs::default()` leaves `api_url`/`proxy_url` empty and the
310    /// optional fields `None`, so every override must come back `None` —
311    /// this is what lets integration tests set `SOCKET_*` env vars *after*
312    /// constructing args and still have env-var resolution win downstream.
313    #[test]
314    fn api_client_overrides_default_is_all_none() {
315        let o = GlobalArgs::default().api_client_overrides();
316        assert!(o.api_url.is_none(), "empty api_url must not be forwarded");
317        assert!(o.proxy_url.is_none(), "empty proxy_url must not be forwarded");
318        assert!(o.api_token.is_none());
319        assert!(o.org_slug.is_none());
320    }
321
322    /// Empty strings for url/token/org are filtered out, not forwarded as
323    /// `Some("")` — otherwise an empty CLI value would mask env-var fallback.
324    #[test]
325    fn api_client_overrides_filters_empty_strings() {
326        let args = GlobalArgs {
327            api_url: String::new(),
328            api_token: Some(String::new()),
329            org: Some(String::new()),
330            proxy_url: String::new(),
331            ..GlobalArgs::default()
332        };
333        let o = args.api_client_overrides();
334        assert!(o.api_url.is_none());
335        assert!(o.api_token.is_none());
336        assert!(o.org_slug.is_none());
337        assert!(o.proxy_url.is_none());
338    }
339
340    /// A relative `manifest_path` is resolved against `cwd`.
341    #[test]
342    fn resolved_manifest_path_joins_relative_against_cwd() {
343        let args = GlobalArgs {
344            cwd: PathBuf::from("/work/project"),
345            manifest_path: ".socket/manifest.json".to_string(),
346            ..GlobalArgs::default()
347        };
348        assert_eq!(
349            args.resolved_manifest_path(),
350            PathBuf::from("/work/project/.socket/manifest.json"),
351        );
352    }
353
354    /// An absolute `manifest_path` ignores `cwd` and passes through unchanged.
355    #[test]
356    fn resolved_manifest_path_passes_absolute_through() {
357        let args = GlobalArgs {
358            cwd: PathBuf::from("/work/project"),
359            manifest_path: "/etc/socket/manifest.json".to_string(),
360            ..GlobalArgs::default()
361        };
362        assert_eq!(
363            args.resolved_manifest_path(),
364            PathBuf::from("/etc/socket/manifest.json"),
365        );
366    }
367
368    /// `apply_env_toggles` mirrors `--debug` / `--no-telemetry` into the env
369    /// vars core code reads directly, and is a no-op when the flags are off.
370    /// `#[serial]` because it mutates process-global env state.
371    #[test]
372    #[serial_test::serial]
373    fn apply_env_toggles_mirrors_flags_into_env() {
374        let saved_debug = std::env::var("SOCKET_DEBUG").ok();
375        let saved_telemetry = std::env::var("SOCKET_TELEMETRY_DISABLED").ok();
376        std::env::remove_var("SOCKET_DEBUG");
377        std::env::remove_var("SOCKET_TELEMETRY_DISABLED");
378
379        // Flags off: no-op, env stays unset.
380        apply_env_toggles(&GlobalArgs::default());
381        assert!(std::env::var("SOCKET_DEBUG").is_err());
382        assert!(std::env::var("SOCKET_TELEMETRY_DISABLED").is_err());
383
384        // Flags on: mirrored into the env.
385        let args = GlobalArgs {
386            debug: true,
387            no_telemetry: true,
388            ..GlobalArgs::default()
389        };
390        apply_env_toggles(&args);
391        assert_eq!(std::env::var("SOCKET_DEBUG").as_deref(), Ok("1"));
392        assert_eq!(std::env::var("SOCKET_TELEMETRY_DISABLED").as_deref(), Ok("1"));
393
394        match saved_debug {
395            Some(v) => std::env::set_var("SOCKET_DEBUG", v),
396            None => std::env::remove_var("SOCKET_DEBUG"),
397        }
398        match saved_telemetry {
399            Some(v) => std::env::set_var("SOCKET_TELEMETRY_DISABLED", v),
400            None => std::env::remove_var("SOCKET_TELEMETRY_DISABLED"),
401        }
402    }
403}