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