Skip to main content

codex/
version.rs

1use std::collections::{BTreeMap, HashSet};
2
3use semver::{Prerelease, Version};
4use serde_json::Value;
5
6use crate::{
7    CodexCapabilities, CodexFeature, CodexFeatureFlags, CodexFeatureStage, CodexLatestReleases,
8    CodexRelease, CodexReleaseChannel, CodexUpdateAdvisory, CodexUpdateStatus, CodexVersionInfo,
9    FeaturesListFormat,
10};
11
12fn parse_semver_from_raw(raw: &str) -> Option<Version> {
13    for token in raw.split_whitespace() {
14        let candidate = token
15            .trim_matches(|c: char| matches!(c, '(' | ')' | ',' | ';'))
16            .trim_start_matches('v');
17        if let Ok(version) = Version::parse(candidate) {
18            return Some(version);
19        }
20    }
21    None
22}
23
24pub(super) fn parse_version_output(output: &str) -> CodexVersionInfo {
25    let raw = output.trim().to_string();
26    let parsed_version = parse_semver_from_raw(&raw);
27    let semantic = parsed_version
28        .as_ref()
29        .map(|version| (version.major, version.minor, version.patch));
30    let mut commit = extract_commit_hash(&raw);
31    if commit.is_none() {
32        for token in raw.split_whitespace() {
33            let candidate = token
34                .trim_matches(|c: char| matches!(c, '(' | ')' | ',' | ';'))
35                .trim_start_matches('v');
36            if let Some(cleaned) = cleaned_hex(candidate) {
37                commit = Some(cleaned);
38                break;
39            }
40        }
41    }
42    let channel = parsed_version
43        .as_ref()
44        .map(release_channel_for_version)
45        .unwrap_or_else(|| infer_release_channel(&raw));
46
47    CodexVersionInfo {
48        raw,
49        semantic,
50        commit,
51        channel,
52    }
53}
54
55fn release_channel_for_version(version: &Version) -> CodexReleaseChannel {
56    if version.pre.is_empty() {
57        CodexReleaseChannel::Stable
58    } else {
59        let prerelease = version.pre.as_str().to_ascii_lowercase();
60        if prerelease.contains("beta") {
61            CodexReleaseChannel::Beta
62        } else if prerelease.contains("nightly") {
63            CodexReleaseChannel::Nightly
64        } else {
65            CodexReleaseChannel::Custom
66        }
67    }
68}
69
70fn infer_release_channel(raw: &str) -> CodexReleaseChannel {
71    let lower = raw.to_ascii_lowercase();
72    if lower.contains("beta") {
73        CodexReleaseChannel::Beta
74    } else if lower.contains("nightly") {
75        CodexReleaseChannel::Nightly
76    } else {
77        CodexReleaseChannel::Custom
78    }
79}
80
81fn codex_semver(info: &CodexVersionInfo) -> Option<Version> {
82    if let Some(parsed) = parse_semver_from_raw(&info.raw) {
83        return Some(parsed);
84    }
85    let (major, minor, patch) = info.semantic?;
86    let mut version = Version::new(major, minor, patch);
87    if version.pre.is_empty() {
88        match info.channel {
89            CodexReleaseChannel::Beta => {
90                version.pre = Prerelease::new("beta").ok()?;
91            }
92            CodexReleaseChannel::Nightly => {
93                version.pre = Prerelease::new("nightly").ok()?;
94            }
95            CodexReleaseChannel::Stable | CodexReleaseChannel::Custom => {}
96        }
97    }
98    Some(version)
99}
100
101fn codex_release_from_info(info: &CodexVersionInfo) -> Option<CodexRelease> {
102    let version = codex_semver(info)?;
103    Some(CodexRelease {
104        channel: info.channel,
105        version,
106    })
107}
108
109fn extract_commit_hash(raw: &str) -> Option<String> {
110    let tokens: Vec<&str> = raw.split_whitespace().collect();
111    for window in tokens.windows(2) {
112        if window[0].eq_ignore_ascii_case("commit") {
113            if let Some(cleaned) = cleaned_hex(window[1]) {
114                return Some(cleaned);
115            }
116        }
117    }
118
119    for token in tokens {
120        if let Some(cleaned) = cleaned_hex(token) {
121            return Some(cleaned);
122        }
123    }
124    None
125}
126
127fn cleaned_hex(token: &str) -> Option<String> {
128    let trimmed = token
129        .trim_matches(|c: char| matches!(c, '(' | ')' | ',' | ';'))
130        .trim_start_matches("commit")
131        .trim_start_matches(':')
132        .trim_start_matches('g');
133    if trimmed.len() >= 7 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
134        Some(trimmed.to_string())
135    } else {
136        None
137    }
138}
139
140pub(super) fn parse_features_from_json(output: &str) -> Option<CodexFeatureFlags> {
141    let parsed: Value = serde_json::from_str(output).ok()?;
142    let mut tokens = HashSet::new();
143    collect_feature_tokens(&parsed, &mut tokens);
144    if tokens.is_empty() {
145        return None;
146    }
147
148    let mut flags = CodexFeatureFlags::default();
149    for token in tokens {
150        apply_feature_token(&mut flags, &token);
151    }
152    Some(flags)
153}
154
155fn collect_feature_tokens(value: &Value, tokens: &mut HashSet<String>) {
156    match value {
157        Value::String(value) => {
158            if !value.trim().is_empty() {
159                tokens.insert(value.clone());
160            }
161        }
162        Value::Array(items) => {
163            for item in items {
164                collect_feature_tokens(item, tokens);
165            }
166        }
167        Value::Object(map) => {
168            for (key, value) in map {
169                if let Value::Bool(true) = value {
170                    tokens.insert(key.clone());
171                }
172                collect_feature_tokens(value, tokens);
173            }
174        }
175        _ => {}
176    }
177}
178
179pub(super) fn parse_features_from_text(output: &str) -> CodexFeatureFlags {
180    let mut flags = CodexFeatureFlags::default();
181    let lower = output.to_ascii_lowercase();
182    if lower.contains("features list") {
183        flags.supports_features_list = true;
184    }
185    if lower.contains("--output-schema") || lower.contains("output schema") {
186        flags.supports_output_schema = true;
187    }
188    if lower.contains("add-dir") || lower.contains("add dir") {
189        flags.supports_add_dir = true;
190    }
191    if lower.contains("login --mcp") || lower.contains("mcp login") {
192        flags.supports_mcp_login = true;
193    }
194    if lower.contains("login") && lower.contains("mcp") {
195        flags.supports_mcp_login = true;
196    }
197
198    for token in lower
199        .split(|c: char| c.is_ascii_whitespace() || c == ',' || c == ';' || c == '|')
200        .filter(|token| !token.is_empty())
201    {
202        apply_feature_token(&mut flags, token);
203    }
204    flags
205}
206
207pub(super) fn parse_help_output(output: &str) -> CodexFeatureFlags {
208    let mut flags = parse_features_from_text(output);
209    let lower = output.to_ascii_lowercase();
210    if lower.contains("features list") {
211        flags.supports_features_list = true;
212    }
213    flags
214}
215
216pub(super) fn merge_feature_flags(target: &mut CodexFeatureFlags, update: CodexFeatureFlags) {
217    target.supports_features_list |= update.supports_features_list;
218    target.supports_output_schema |= update.supports_output_schema;
219    target.supports_add_dir |= update.supports_add_dir;
220    target.supports_mcp_login |= update.supports_mcp_login;
221}
222
223pub(super) fn detected_feature_flags(flags: &CodexFeatureFlags) -> bool {
224    flags.supports_output_schema || flags.supports_add_dir || flags.supports_mcp_login
225}
226
227pub(super) fn should_run_help_fallback(flags: &CodexFeatureFlags) -> bool {
228    !flags.supports_features_list
229        || !flags.supports_output_schema
230        || !flags.supports_add_dir
231        || !flags.supports_mcp_login
232}
233
234fn normalize_feature_token(token: &str) -> String {
235    token
236        .chars()
237        .map(|c| {
238            if c.is_ascii_alphanumeric() {
239                c.to_ascii_lowercase()
240            } else {
241                '_'
242            }
243        })
244        .collect()
245}
246
247fn apply_feature_token(flags: &mut CodexFeatureFlags, token: &str) {
248    let normalized = normalize_feature_token(token);
249    let compact = normalized.replace('_', "");
250    if normalized.contains("features_list") || compact.contains("featureslist") {
251        flags.supports_features_list = true;
252    }
253    if normalized.contains("output_schema") || compact.contains("outputschema") {
254        flags.supports_output_schema = true;
255    }
256    if normalized.contains("add_dir") || compact.contains("adddir") {
257        flags.supports_add_dir = true;
258    }
259    if normalized.contains("mcp_login")
260        || (normalized.contains("login") && normalized.contains("mcp"))
261    {
262        flags.supports_mcp_login = true;
263    }
264}
265
266pub(super) fn parse_feature_list_output(
267    stdout: &str,
268    prefer_json: bool,
269) -> Result<(Vec<CodexFeature>, FeaturesListFormat), String> {
270    let trimmed = stdout.trim();
271    if trimmed.is_empty() {
272        return Err("features list output was empty".to_string());
273    }
274
275    if prefer_json {
276        if let Some(features) = parse_feature_list_json(trimmed) {
277            if !features.is_empty() {
278                return Ok((features, FeaturesListFormat::Json));
279            }
280        }
281        if let Some(features) = parse_feature_list_text(trimmed) {
282            if !features.is_empty() {
283                return Ok((features, FeaturesListFormat::Text));
284            }
285        }
286    } else {
287        if let Some(features) = parse_feature_list_text(trimmed) {
288            if !features.is_empty() {
289                return Ok((features, FeaturesListFormat::Text));
290            }
291        }
292        if let Some(features) = parse_feature_list_json(trimmed) {
293            if !features.is_empty() {
294                return Ok((features, FeaturesListFormat::Json));
295            }
296        }
297    }
298
299    Err("could not parse JSON or text feature rows".to_string())
300}
301
302fn parse_feature_list_json(output: &str) -> Option<Vec<CodexFeature>> {
303    let parsed: Value = serde_json::from_str(output).ok()?;
304    parse_feature_list_json_value(&parsed)
305}
306
307fn parse_feature_list_json_value(value: &Value) -> Option<Vec<CodexFeature>> {
308    match value {
309        Value::Array(items) => Some(
310            items
311                .iter()
312                .filter_map(|item| match item {
313                    Value::Object(map) => feature_from_json_fields(None, map),
314                    Value::String(name) => Some(CodexFeature {
315                        name: name.clone(),
316                        stage: None,
317                        enabled: true,
318                        extra: BTreeMap::new(),
319                    }),
320                    _ => None,
321                })
322                .collect(),
323        ),
324        Value::Object(map) => {
325            if let Some(features) = map.get("features") {
326                return parse_feature_list_json_value(features);
327            }
328            if map.contains_key("name") || map.contains_key("enabled") || map.contains_key("stage")
329            {
330                return feature_from_json_fields(None, map).map(|feature| vec![feature]);
331            }
332            Some(
333                map.iter()
334                    .filter_map(|(name, value)| match value {
335                        Value::Object(inner) => {
336                            feature_from_json_fields(Some(name.as_str()), inner)
337                        }
338                        Value::Bool(flag) => Some(CodexFeature {
339                            name: name.clone(),
340                            stage: None,
341                            enabled: *flag,
342                            extra: BTreeMap::new(),
343                        }),
344                        Value::String(flag) => parse_feature_enabled_str(flag)
345                            .map(|enabled| CodexFeature {
346                                name: name.clone(),
347                                stage: None,
348                                enabled,
349                                extra: BTreeMap::new(),
350                            })
351                            .or_else(|| {
352                                Some(CodexFeature {
353                                    name: name.clone(),
354                                    stage: Some(CodexFeatureStage::parse(flag)),
355                                    enabled: true,
356                                    extra: BTreeMap::new(),
357                                })
358                            }),
359                        _ => None,
360                    })
361                    .collect(),
362            )
363        }
364        _ => None,
365    }
366}
367
368fn parse_feature_list_text(output: &str) -> Option<Vec<CodexFeature>> {
369    let mut features = Vec::new();
370    for line in output.lines() {
371        let trimmed = line.trim();
372        if trimmed.is_empty() {
373            continue;
374        }
375        if trimmed
376            .chars()
377            .all(|c| matches!(c, '-' | '=' | '+' | '*' | '|'))
378        {
379            continue;
380        }
381
382        let tokens: Vec<&str> = trimmed.split_whitespace().collect();
383        if tokens.len() < 3 {
384            continue;
385        }
386        if tokens[0].eq_ignore_ascii_case("feature")
387            && tokens[1].eq_ignore_ascii_case("stage")
388            && tokens[2].eq_ignore_ascii_case("enabled")
389        {
390            continue;
391        }
392
393        let enabled_token = tokens.last().copied().unwrap_or_default();
394        let enabled = match parse_feature_enabled_str(enabled_token) {
395            Some(value) => value,
396            None => continue,
397        };
398        let stage_token = tokens.get(tokens.len() - 2).copied().unwrap_or_default();
399        let name = tokens[..tokens.len() - 2].join(" ");
400        if name.is_empty() {
401            continue;
402        }
403        let stage = (!stage_token.is_empty()).then(|| CodexFeatureStage::parse(stage_token));
404        features.push(CodexFeature {
405            name,
406            stage,
407            enabled,
408            extra: BTreeMap::new(),
409        });
410    }
411
412    if features.is_empty() {
413        None
414    } else {
415        Some(features)
416    }
417}
418
419fn parse_feature_enabled_value(value: &Value) -> Option<bool> {
420    match value {
421        Value::Bool(flag) => Some(*flag),
422        Value::String(raw) => parse_feature_enabled_str(raw),
423        _ => None,
424    }
425}
426
427fn parse_feature_enabled_str(raw: &str) -> Option<bool> {
428    match raw.trim().to_ascii_lowercase().as_str() {
429        "true" | "yes" | "y" | "on" | "1" | "enabled" => Some(true),
430        "false" | "no" | "n" | "off" | "0" | "disabled" => Some(false),
431        _ => None,
432    }
433}
434
435fn feature_from_json_fields(
436    name_hint: Option<&str>,
437    map: &serde_json::Map<String, Value>,
438) -> Option<CodexFeature> {
439    let name = map
440        .get("name")
441        .and_then(Value::as_str)
442        .map(str::to_string)
443        .or_else(|| name_hint.map(str::to_string))?;
444    let enabled = map
445        .get("enabled")
446        .and_then(parse_feature_enabled_value)
447        .or_else(|| map.get("value").and_then(parse_feature_enabled_value))?;
448    let stage = map
449        .get("stage")
450        .or_else(|| map.get("status"))
451        .and_then(Value::as_str)
452        .map(CodexFeatureStage::parse);
453
454    let mut extra = BTreeMap::new();
455    for (key, value) in map {
456        if matches!(
457            key.as_str(),
458            "name" | "stage" | "status" | "enabled" | "value"
459        ) {
460            continue;
461        }
462        extra.insert(key.clone(), value.clone());
463    }
464
465    Some(CodexFeature {
466        name,
467        stage,
468        enabled,
469        extra,
470    })
471}
472
473/// Computes an update advisory for a previously probed binary.
474///
475/// Callers that already have a [`CodexCapabilities`] snapshot can use this
476/// helper to avoid re-running `codex --version`. Provide a [`CodexLatestReleases`]
477/// table sourced from your preferred distribution channel.
478pub fn update_advisory_from_capabilities(
479    capabilities: &CodexCapabilities,
480    latest_releases: &CodexLatestReleases,
481) -> CodexUpdateAdvisory {
482    let local_release = capabilities
483        .version
484        .as_ref()
485        .and_then(codex_release_from_info);
486    let preferred_channel = local_release
487        .as_ref()
488        .map(|release| release.channel)
489        .unwrap_or(CodexReleaseChannel::Stable);
490    let (latest_release, comparison_channel, fell_back) =
491        latest_releases.select_for_channel(preferred_channel);
492    let mut notes = Vec::new();
493
494    if fell_back {
495        notes.push(format!(
496            "No latest {preferred_channel} release provided; comparing against {comparison_channel}."
497        ));
498    }
499
500    let status = match (local_release.as_ref(), latest_release.as_ref()) {
501        (None, None) => CodexUpdateStatus::UnknownLatestVersion,
502        (None, Some(_)) => CodexUpdateStatus::UnknownLocalVersion,
503        (Some(_), None) => CodexUpdateStatus::UnknownLatestVersion,
504        (Some(local), Some(latest)) => {
505            if local.version < latest.version {
506                CodexUpdateStatus::UpdateRecommended
507            } else if local.version > latest.version {
508                CodexUpdateStatus::LocalNewerThanKnown
509            } else {
510                CodexUpdateStatus::UpToDate
511            }
512        }
513    };
514
515    match status {
516        CodexUpdateStatus::UpdateRecommended => {
517            if let (Some(local), Some(latest)) = (local_release.as_ref(), latest_release.as_ref()) {
518                notes.push(format!(
519                    "Local codex {local_version} is behind latest {comparison_channel} {latest_version}.",
520                    local_version = local.version,
521                    latest_version = latest.version
522                ));
523            }
524        }
525        CodexUpdateStatus::LocalNewerThanKnown => {
526            if let Some(local) = local_release.as_ref() {
527                let known = latest_release
528                    .as_ref()
529                    .map(|release| release.version.to_string())
530                    .unwrap_or_else(|| "unknown".to_string());
531                notes.push(format!(
532                    "Local codex {local_version} is newer than provided {comparison_channel} metadata (latest table: {known}).",
533                    local_version = local.version
534                ));
535            }
536        }
537        CodexUpdateStatus::UnknownLocalVersion => {
538            if let Some(latest) = latest_release.as_ref() {
539                notes.push(format!(
540                    "Latest known {comparison_channel} release is {latest_version}; local version could not be parsed.",
541                    latest_version = latest.version
542                ));
543            } else {
544                notes.push(
545                    "Local version could not be parsed and no latest release was provided."
546                        .to_string(),
547                );
548            }
549        }
550        CodexUpdateStatus::UnknownLatestVersion => notes.push(
551            "No latest Codex release information provided; update advisory unavailable."
552                .to_string(),
553        ),
554        CodexUpdateStatus::UpToDate => {
555            if let Some(latest) = latest_release.as_ref() {
556                notes.push(format!(
557                    "Local codex matches latest {comparison_channel} release {latest_version}.",
558                    latest_version = latest.version
559                ));
560            }
561        }
562    }
563
564    CodexUpdateAdvisory {
565        local_release,
566        latest_release,
567        comparison_channel,
568        status,
569        notes,
570    }
571}