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) if !value.trim().is_empty() => {
158            tokens.insert(value.clone());
159        }
160        Value::Array(items) => {
161            for item in items {
162                collect_feature_tokens(item, tokens);
163            }
164        }
165        Value::Object(map) => {
166            for (key, value) in map {
167                if let Value::Bool(true) = value {
168                    tokens.insert(key.clone());
169                }
170                collect_feature_tokens(value, tokens);
171            }
172        }
173        _ => {}
174    }
175}
176
177pub(super) fn parse_features_from_text(output: &str) -> CodexFeatureFlags {
178    let mut flags = CodexFeatureFlags::default();
179    let lower = output.to_ascii_lowercase();
180    if lower.contains("features list") {
181        flags.supports_features_list = true;
182    }
183    if lower.contains("--output-schema") || lower.contains("output schema") {
184        flags.supports_output_schema = true;
185    }
186    if lower.contains("add-dir") || lower.contains("add dir") {
187        flags.supports_add_dir = true;
188    }
189    if lower.contains("login --mcp") || lower.contains("mcp login") {
190        flags.supports_mcp_login = true;
191    }
192    if lower.contains("login") && lower.contains("mcp") {
193        flags.supports_mcp_login = true;
194    }
195
196    for token in lower
197        .split(|c: char| c.is_ascii_whitespace() || c == ',' || c == ';' || c == '|')
198        .filter(|token| !token.is_empty())
199    {
200        apply_feature_token(&mut flags, token);
201    }
202    flags
203}
204
205pub(super) fn parse_help_output(output: &str) -> CodexFeatureFlags {
206    let mut flags = parse_features_from_text(output);
207    let lower = output.to_ascii_lowercase();
208    if lower.contains("features list") {
209        flags.supports_features_list = true;
210    }
211    flags
212}
213
214pub(super) fn merge_feature_flags(target: &mut CodexFeatureFlags, update: CodexFeatureFlags) {
215    target.supports_features_list |= update.supports_features_list;
216    target.supports_output_schema |= update.supports_output_schema;
217    target.supports_add_dir |= update.supports_add_dir;
218    target.supports_mcp_login |= update.supports_mcp_login;
219}
220
221pub(super) fn detected_feature_flags(flags: &CodexFeatureFlags) -> bool {
222    flags.supports_output_schema || flags.supports_add_dir || flags.supports_mcp_login
223}
224
225pub(super) fn should_run_help_fallback(flags: &CodexFeatureFlags) -> bool {
226    !flags.supports_features_list
227        || !flags.supports_output_schema
228        || !flags.supports_add_dir
229        || !flags.supports_mcp_login
230}
231
232fn normalize_feature_token(token: &str) -> String {
233    token
234        .chars()
235        .map(|c| {
236            if c.is_ascii_alphanumeric() {
237                c.to_ascii_lowercase()
238            } else {
239                '_'
240            }
241        })
242        .collect()
243}
244
245fn apply_feature_token(flags: &mut CodexFeatureFlags, token: &str) {
246    let normalized = normalize_feature_token(token);
247    let compact = normalized.replace('_', "");
248    if normalized.contains("features_list") || compact.contains("featureslist") {
249        flags.supports_features_list = true;
250    }
251    if normalized.contains("output_schema") || compact.contains("outputschema") {
252        flags.supports_output_schema = true;
253    }
254    if normalized.contains("add_dir") || compact.contains("adddir") {
255        flags.supports_add_dir = true;
256    }
257    if normalized.contains("mcp_login")
258        || (normalized.contains("login") && normalized.contains("mcp"))
259    {
260        flags.supports_mcp_login = true;
261    }
262}
263
264pub(super) fn parse_feature_list_output(
265    stdout: &str,
266    prefer_json: bool,
267) -> Result<(Vec<CodexFeature>, FeaturesListFormat), String> {
268    let trimmed = stdout.trim();
269    if trimmed.is_empty() {
270        return Err("features list output was empty".to_string());
271    }
272
273    if prefer_json {
274        if let Some(features) = parse_feature_list_json(trimmed) {
275            if !features.is_empty() {
276                return Ok((features, FeaturesListFormat::Json));
277            }
278        }
279        if let Some(features) = parse_feature_list_text(trimmed) {
280            if !features.is_empty() {
281                return Ok((features, FeaturesListFormat::Text));
282            }
283        }
284    } else {
285        if let Some(features) = parse_feature_list_text(trimmed) {
286            if !features.is_empty() {
287                return Ok((features, FeaturesListFormat::Text));
288            }
289        }
290        if let Some(features) = parse_feature_list_json(trimmed) {
291            if !features.is_empty() {
292                return Ok((features, FeaturesListFormat::Json));
293            }
294        }
295    }
296
297    Err("could not parse JSON or text feature rows".to_string())
298}
299
300fn parse_feature_list_json(output: &str) -> Option<Vec<CodexFeature>> {
301    let parsed: Value = serde_json::from_str(output).ok()?;
302    parse_feature_list_json_value(&parsed)
303}
304
305fn parse_feature_list_json_value(value: &Value) -> Option<Vec<CodexFeature>> {
306    match value {
307        Value::Array(items) => Some(
308            items
309                .iter()
310                .filter_map(|item| match item {
311                    Value::Object(map) => feature_from_json_fields(None, map),
312                    Value::String(name) => Some(CodexFeature {
313                        name: name.clone(),
314                        stage: None,
315                        enabled: true,
316                        extra: BTreeMap::new(),
317                    }),
318                    _ => None,
319                })
320                .collect(),
321        ),
322        Value::Object(map) => {
323            if let Some(features) = map.get("features") {
324                return parse_feature_list_json_value(features);
325            }
326            if map.contains_key("name") || map.contains_key("enabled") || map.contains_key("stage")
327            {
328                return feature_from_json_fields(None, map).map(|feature| vec![feature]);
329            }
330            Some(
331                map.iter()
332                    .filter_map(|(name, value)| match value {
333                        Value::Object(inner) => {
334                            feature_from_json_fields(Some(name.as_str()), inner)
335                        }
336                        Value::Bool(flag) => Some(CodexFeature {
337                            name: name.clone(),
338                            stage: None,
339                            enabled: *flag,
340                            extra: BTreeMap::new(),
341                        }),
342                        Value::String(flag) => parse_feature_enabled_str(flag)
343                            .map(|enabled| CodexFeature {
344                                name: name.clone(),
345                                stage: None,
346                                enabled,
347                                extra: BTreeMap::new(),
348                            })
349                            .or_else(|| {
350                                Some(CodexFeature {
351                                    name: name.clone(),
352                                    stage: Some(CodexFeatureStage::parse(flag)),
353                                    enabled: true,
354                                    extra: BTreeMap::new(),
355                                })
356                            }),
357                        _ => None,
358                    })
359                    .collect(),
360            )
361        }
362        _ => None,
363    }
364}
365
366fn parse_feature_list_text(output: &str) -> Option<Vec<CodexFeature>> {
367    let mut features = Vec::new();
368    for line in output.lines() {
369        let trimmed = line.trim();
370        if trimmed.is_empty() {
371            continue;
372        }
373        if trimmed
374            .chars()
375            .all(|c| matches!(c, '-' | '=' | '+' | '*' | '|'))
376        {
377            continue;
378        }
379
380        let tokens: Vec<&str> = trimmed.split_whitespace().collect();
381        if tokens.len() < 3 {
382            continue;
383        }
384        if tokens[0].eq_ignore_ascii_case("feature")
385            && tokens[1].eq_ignore_ascii_case("stage")
386            && tokens[2].eq_ignore_ascii_case("enabled")
387        {
388            continue;
389        }
390
391        let enabled_token = tokens.last().copied().unwrap_or_default();
392        let enabled = match parse_feature_enabled_str(enabled_token) {
393            Some(value) => value,
394            None => continue,
395        };
396        let stage_token = tokens.get(tokens.len() - 2).copied().unwrap_or_default();
397        let name = tokens[..tokens.len() - 2].join(" ");
398        if name.is_empty() {
399            continue;
400        }
401        let stage = (!stage_token.is_empty()).then(|| CodexFeatureStage::parse(stage_token));
402        features.push(CodexFeature {
403            name,
404            stage,
405            enabled,
406            extra: BTreeMap::new(),
407        });
408    }
409
410    if features.is_empty() {
411        None
412    } else {
413        Some(features)
414    }
415}
416
417fn parse_feature_enabled_value(value: &Value) -> Option<bool> {
418    match value {
419        Value::Bool(flag) => Some(*flag),
420        Value::String(raw) => parse_feature_enabled_str(raw),
421        _ => None,
422    }
423}
424
425fn parse_feature_enabled_str(raw: &str) -> Option<bool> {
426    match raw.trim().to_ascii_lowercase().as_str() {
427        "true" | "yes" | "y" | "on" | "1" | "enabled" => Some(true),
428        "false" | "no" | "n" | "off" | "0" | "disabled" => Some(false),
429        _ => None,
430    }
431}
432
433fn feature_from_json_fields(
434    name_hint: Option<&str>,
435    map: &serde_json::Map<String, Value>,
436) -> Option<CodexFeature> {
437    let name = map
438        .get("name")
439        .and_then(Value::as_str)
440        .map(str::to_string)
441        .or_else(|| name_hint.map(str::to_string))?;
442    let enabled = map
443        .get("enabled")
444        .and_then(parse_feature_enabled_value)
445        .or_else(|| map.get("value").and_then(parse_feature_enabled_value))?;
446    let stage = map
447        .get("stage")
448        .or_else(|| map.get("status"))
449        .and_then(Value::as_str)
450        .map(CodexFeatureStage::parse);
451
452    let mut extra = BTreeMap::new();
453    for (key, value) in map {
454        if matches!(
455            key.as_str(),
456            "name" | "stage" | "status" | "enabled" | "value"
457        ) {
458            continue;
459        }
460        extra.insert(key.clone(), value.clone());
461    }
462
463    Some(CodexFeature {
464        name,
465        stage,
466        enabled,
467        extra,
468    })
469}
470
471/// Computes an update advisory for a previously probed binary.
472///
473/// Callers that already have a [`CodexCapabilities`] snapshot can use this
474/// helper to avoid re-running `codex --version`. Provide a [`CodexLatestReleases`]
475/// table sourced from your preferred distribution channel.
476pub fn update_advisory_from_capabilities(
477    capabilities: &CodexCapabilities,
478    latest_releases: &CodexLatestReleases,
479) -> CodexUpdateAdvisory {
480    let local_release = capabilities
481        .version
482        .as_ref()
483        .and_then(codex_release_from_info);
484    let preferred_channel = local_release
485        .as_ref()
486        .map(|release| release.channel)
487        .unwrap_or(CodexReleaseChannel::Stable);
488    let (latest_release, comparison_channel, fell_back) =
489        latest_releases.select_for_channel(preferred_channel);
490    let mut notes = Vec::new();
491
492    if fell_back {
493        notes.push(format!(
494            "No latest {preferred_channel} release provided; comparing against {comparison_channel}."
495        ));
496    }
497
498    let status = match (local_release.as_ref(), latest_release.as_ref()) {
499        (None, None) => CodexUpdateStatus::UnknownLatestVersion,
500        (None, Some(_)) => CodexUpdateStatus::UnknownLocalVersion,
501        (Some(_), None) => CodexUpdateStatus::UnknownLatestVersion,
502        (Some(local), Some(latest)) => {
503            if local.version < latest.version {
504                CodexUpdateStatus::UpdateRecommended
505            } else if local.version > latest.version {
506                CodexUpdateStatus::LocalNewerThanKnown
507            } else {
508                CodexUpdateStatus::UpToDate
509            }
510        }
511    };
512
513    match status {
514        CodexUpdateStatus::UpdateRecommended => {
515            if let (Some(local), Some(latest)) = (local_release.as_ref(), latest_release.as_ref()) {
516                notes.push(format!(
517                    "Local codex {local_version} is behind latest {comparison_channel} {latest_version}.",
518                    local_version = local.version,
519                    latest_version = latest.version
520                ));
521            }
522        }
523        CodexUpdateStatus::LocalNewerThanKnown => {
524            if let Some(local) = local_release.as_ref() {
525                let known = latest_release
526                    .as_ref()
527                    .map(|release| release.version.to_string())
528                    .unwrap_or_else(|| "unknown".to_string());
529                notes.push(format!(
530                    "Local codex {local_version} is newer than provided {comparison_channel} metadata (latest table: {known}).",
531                    local_version = local.version
532                ));
533            }
534        }
535        CodexUpdateStatus::UnknownLocalVersion => {
536            if let Some(latest) = latest_release.as_ref() {
537                notes.push(format!(
538                    "Latest known {comparison_channel} release is {latest_version}; local version could not be parsed.",
539                    latest_version = latest.version
540                ));
541            } else {
542                notes.push(
543                    "Local version could not be parsed and no latest release was provided."
544                        .to_string(),
545                );
546            }
547        }
548        CodexUpdateStatus::UnknownLatestVersion => notes.push(
549            "No latest Codex release information provided; update advisory unavailable."
550                .to_string(),
551        ),
552        CodexUpdateStatus::UpToDate => {
553            if let Some(latest) = latest_release.as_ref() {
554                notes.push(format!(
555                    "Local codex matches latest {comparison_channel} release {latest_version}.",
556                    latest_version = latest.version
557                ));
558            }
559        }
560    }
561
562    CodexUpdateAdvisory {
563        local_release,
564        latest_release,
565        comparison_channel,
566        status,
567        notes,
568    }
569}