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}