Skip to main content

codex/capabilities/
types.rs

1use semver::Version;
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4use std::time::SystemTime;
5use thiserror::Error;
6
7use super::{BinaryFingerprint, CapabilityCacheKey, CapabilityProbePlan};
8
9/// Snapshot of Codex CLI capabilities derived from probing a specific binary.
10///
11/// Instances of this type are intended to be cached per binary path so callers can
12/// gate optional flags (like `--output-schema`) without repeatedly spawning the CLI.
13/// A process-wide `HashMap<CapabilityCacheKey, CodexCapabilities>` (behind a mutex/once)
14/// keeps probes cheap; entries should use canonical binary paths where possible and
15/// ship a [`BinaryFingerprint`] so we can invalidate stale snapshots when the binary
16/// on disk changes.
17#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
18pub struct CodexCapabilities {
19    /// Canonical path used as the cache key.
20    pub cache_key: CapabilityCacheKey,
21    /// File metadata used to detect when a cached entry is stale.
22    pub fingerprint: Option<BinaryFingerprint>,
23    /// Parsed output from `codex --version`; `None` when the command fails.
24    pub version: Option<CodexVersionInfo>,
25    /// Known feature toggles; fields default to `false` when detection fails.
26    pub features: CodexFeatureFlags,
27    /// Steps attempted while interrogating the binary (version, features, help).
28    pub probe_plan: CapabilityProbePlan,
29    /// Timestamp of when the probe finished.
30    pub collected_at: SystemTime,
31}
32
33/// Parsed version details emitted by `codex --version`.
34#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
35pub struct CodexVersionInfo {
36    /// Raw stdout from `codex --version` so we do not lose channel/build metadata.
37    pub raw: String,
38    /// Parsed `major.minor.patch` triplet when the output contains a semantic version.
39    pub semantic: Option<(u64, u64, u64)>,
40    /// Optional commit hash or build identifier printed by pre-release builds.
41    pub commit: Option<String>,
42    /// Release channel inferred from the version string suffix (e.g., `-beta`).
43    pub channel: CodexReleaseChannel,
44}
45
46/// Release channel segments inferred from the Codex version string.
47#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
48pub enum CodexReleaseChannel {
49    Stable,
50    Beta,
51    Nightly,
52    /// Fallback for bespoke or vendor-patched builds.
53    Custom,
54}
55
56impl std::fmt::Display for CodexReleaseChannel {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        let label = match self {
59            CodexReleaseChannel::Stable => "stable",
60            CodexReleaseChannel::Beta => "beta",
61            CodexReleaseChannel::Nightly => "nightly",
62            CodexReleaseChannel::Custom => "custom",
63        };
64        write!(f, "{label}")
65    }
66}
67
68/// Release metadata for a specific Codex build channel.
69#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
70pub struct CodexRelease {
71    /// Release channel (stable/beta/nightly/custom).
72    pub channel: CodexReleaseChannel,
73    /// Parsed semantic version for comparison.
74    pub version: Version,
75}
76
77/// Caller-supplied table of known latest Codex releases.
78///
79/// The crate intentionally avoids network requests; hosts should populate this
80/// with data from their preferred distribution channel (e.g. `npm view
81/// @openai/codex version`, `brew info codex --json`, or the GitHub releases
82/// API) before requesting an update advisory.
83#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
84pub struct CodexLatestReleases {
85    /// Latest stable release version.
86    pub stable: Option<Version>,
87    /// Latest beta pre-release when available.
88    pub beta: Option<Version>,
89    /// Latest nightly build when available.
90    pub nightly: Option<Version>,
91}
92
93impl CodexLatestReleases {
94    /// Returns the most appropriate latest release for the given channel,
95    /// falling back to a more stable track when channel-specific data is
96    /// missing.
97    pub fn select_for_channel(
98        &self,
99        channel: CodexReleaseChannel,
100    ) -> (Option<CodexRelease>, CodexReleaseChannel, bool) {
101        if let Some(release) = self.release_for_channel(channel) {
102            return (Some(release), channel, false);
103        }
104
105        let fallback = self
106            .stable
107            .as_ref()
108            .map(|version| CodexRelease {
109                channel: CodexReleaseChannel::Stable,
110                version: version.clone(),
111            })
112            .or_else(|| {
113                self.beta.as_ref().map(|version| CodexRelease {
114                    channel: CodexReleaseChannel::Beta,
115                    version: version.clone(),
116                })
117            })
118            .or_else(|| {
119                self.nightly.as_ref().map(|version| CodexRelease {
120                    channel: CodexReleaseChannel::Nightly,
121                    version: version.clone(),
122                })
123            });
124
125        let fallback_channel = fallback
126            .as_ref()
127            .map(|release| release.channel)
128            .unwrap_or(channel);
129        let fell_back = fallback_channel != channel;
130        (fallback, fallback_channel, fell_back)
131    }
132
133    fn release_for_channel(&self, channel: CodexReleaseChannel) -> Option<CodexRelease> {
134        match channel {
135            CodexReleaseChannel::Stable => self.stable.as_ref().map(|version| CodexRelease {
136                channel,
137                version: version.clone(),
138            }),
139            CodexReleaseChannel::Beta => self.beta.as_ref().map(|version| CodexRelease {
140                channel,
141                version: version.clone(),
142            }),
143            CodexReleaseChannel::Nightly => self.nightly.as_ref().map(|version| CodexRelease {
144                channel,
145                version: version.clone(),
146            }),
147            CodexReleaseChannel::Custom => None,
148        }
149    }
150}
151
152/// Update guidance derived from comparing local and latest Codex versions.
153#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
154pub struct CodexUpdateAdvisory {
155    /// Local release as parsed from `codex --version`.
156    pub local_release: Option<CodexRelease>,
157    /// Latest release used for comparison (may be a fallback track).
158    pub latest_release: Option<CodexRelease>,
159    /// Channel chosen for comparison (local channel when available, otherwise stable).
160    pub comparison_channel: CodexReleaseChannel,
161    /// High-level outcome to drive host UX.
162    pub status: CodexUpdateStatus,
163    /// Human-readable hints callers can log or display.
164    pub notes: Vec<String>,
165}
166
167impl CodexUpdateAdvisory {
168    /// True when the host should prompt for or attempt an update.
169    pub fn is_update_recommended(&self) -> bool {
170        matches!(
171            self.status,
172            CodexUpdateStatus::UpdateRecommended | CodexUpdateStatus::UnknownLocalVersion
173        )
174    }
175}
176
177/// Enum summarizing whether an update is needed based on provided release data.
178#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
179pub enum CodexUpdateStatus {
180    /// Local binary matches the latest known release for the comparison channel.
181    UpToDate,
182    /// A newer release exists for the comparison channel.
183    UpdateRecommended,
184    /// Local binary appears newer than the provided release table (e.g., dev build).
185    LocalNewerThanKnown,
186    /// No local version data was available (probe failure).
187    UnknownLocalVersion,
188    /// Caller did not provide a comparable latest release.
189    UnknownLatestVersion,
190}
191
192/// Feature gates for Codex CLI flags.
193///
194/// All fields default to `false` so callers can conservatively avoid passing flags
195/// unless probes prove that the binary understands them.
196#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
197pub struct CodexFeatureFlags {
198    /// True when `codex features list` is available.
199    pub supports_features_list: bool,
200    /// True when `--output-schema` is accepted by `codex exec`.
201    pub supports_output_schema: bool,
202    /// True when `codex add-dir` is available for recursive prompting.
203    pub supports_add_dir: bool,
204    /// True when `codex login --mcp` is recognized for MCP integration.
205    pub supports_mcp_login: bool,
206}
207
208/// Optional overrides for feature detection that can be layered onto probe results.
209#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
210pub struct CapabilityFeatureOverrides {
211    /// Override for `codex features list` support; `None` defers to probes.
212    pub supports_features_list: Option<bool>,
213    /// Override for `--output-schema` support; `None` defers to probes.
214    pub supports_output_schema: Option<bool>,
215    /// Override for `codex add-dir` support; `None` defers to probes.
216    pub supports_add_dir: Option<bool>,
217    /// Override for `codex login --mcp` support; `None` defers to probes.
218    pub supports_mcp_login: Option<bool>,
219}
220
221impl CapabilityFeatureOverrides {
222    /// Returns true when no overrides are set.
223    pub fn is_empty(&self) -> bool {
224        self.supports_features_list.is_none()
225            && self.supports_output_schema.is_none()
226            && self.supports_add_dir.is_none()
227            && self.supports_mcp_login.is_none()
228    }
229
230    /// Builds overrides that mirror every provided feature flag, including false values.
231    pub fn from_flags(flags: CodexFeatureFlags) -> Self {
232        CapabilityFeatureOverrides {
233            supports_features_list: Some(flags.supports_features_list),
234            supports_output_schema: Some(flags.supports_output_schema),
235            supports_add_dir: Some(flags.supports_add_dir),
236            supports_mcp_login: Some(flags.supports_mcp_login),
237        }
238    }
239
240    /// Builds overrides that only force-enable flags that are true in the input set.
241    pub fn enabling(flags: CodexFeatureFlags) -> Self {
242        CapabilityFeatureOverrides {
243            supports_features_list: flags.supports_features_list.then_some(true),
244            supports_output_schema: flags.supports_output_schema.then_some(true),
245            supports_add_dir: flags.supports_add_dir.then_some(true),
246            supports_mcp_login: flags.supports_mcp_login.then_some(true),
247        }
248    }
249}
250
251/// Caller-supplied capability data that can short-circuit or adjust probing.
252/// Manual snapshots override cached/probed data, and feature/version overrides
253/// apply on top of whichever snapshot is returned.
254#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
255pub struct CapabilityOverrides {
256    /// Manual snapshot to return instead of probing when present (after applying feature/version overrides).
257    pub snapshot: Option<CodexCapabilities>,
258    /// Version override applied after probing.
259    pub version: Option<CodexVersionInfo>,
260    /// Feature-level overrides merged into probed or manual capabilities.
261    pub features: CapabilityFeatureOverrides,
262}
263
264impl CapabilityOverrides {
265    /// Returns true when no override data is present.
266    pub fn is_empty(&self) -> bool {
267        self.snapshot.is_none() && self.version.is_none() && self.features.is_empty()
268    }
269}
270
271/// Supported serialization formats for capability snapshots and overrides.
272#[derive(Clone, Copy, Debug, Eq, PartialEq)]
273pub enum CapabilitySnapshotFormat {
274    Json,
275    Toml,
276}
277
278impl CapabilitySnapshotFormat {
279    pub(crate) fn from_path(path: &Path) -> Option<Self> {
280        let ext = path.extension()?.to_string_lossy().to_lowercase();
281        match ext.as_str() {
282            "json" => Some(Self::Json),
283            "toml" => Some(Self::Toml),
284            _ => None,
285        }
286    }
287}
288
289/// Errors encountered while saving or loading capability snapshots.
290#[derive(Debug, Error)]
291pub enum CapabilitySnapshotError {
292    #[error("failed to read capability snapshot from `{path}`: {source}")]
293    ReadSnapshot {
294        path: PathBuf,
295        #[source]
296        source: std::io::Error,
297    },
298    #[error("failed to write capability snapshot to `{path}`: {source}")]
299    WriteSnapshot {
300        path: PathBuf,
301        #[source]
302        source: std::io::Error,
303    },
304    #[error("failed to decode capability snapshot from JSON: {source}")]
305    JsonDecode {
306        #[source]
307        source: serde_json::Error,
308    },
309    #[error("failed to encode capability snapshot to JSON: {source}")]
310    JsonEncode {
311        #[source]
312        source: serde_json::Error,
313    },
314    #[error("failed to decode capability snapshot from TOML: {source}")]
315    TomlDecode {
316        #[source]
317        source: toml::de::Error,
318    },
319    #[error("failed to encode capability snapshot to TOML: {source}")]
320    TomlEncode {
321        #[source]
322        source: toml::ser::Error,
323    },
324    #[error("unsupported capability snapshot format for `{path}`; use .json/.toml or supply a format explicitly")]
325    UnsupportedFormat { path: PathBuf },
326}