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
471pub 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}