1use std::collections::HashSet;
2
3use serde::Serialize;
4
5use crate::routing::slug;
6
7use super::probes::{CursorProbeResult, OpenCodeProbeResult, PiProbeResult};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
10#[serde(rename_all = "snake_case")]
11pub enum AvailabilityStatus {
12 Runnable,
13 Unavailable,
14 Unknown,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
18#[serde(rename_all = "snake_case")]
19pub enum AvailabilitySource {
20 HarnessInstalled,
21 UniversalHarness,
22 #[serde(rename = "pi_probe")]
23 PiProbe,
24 #[serde(rename = "pi_probe_negative")]
25 PiProbeNegative,
26 #[serde(rename = "opencode_probe")]
27 OpenCodeProbe,
28 #[serde(rename = "opencode_probe_negative")]
29 OpenCodeProbeNegative,
30 #[serde(rename = "opencode_probe_unknown")]
31 OpenCodeProbeUnknown,
32 #[serde(rename = "cursor_probe")]
33 CursorProbe,
34 #[serde(rename = "cursor_probe_negative")]
35 CursorProbeNegative,
36 #[serde(rename = "cursor_probe_unknown")]
37 CursorProbeUnknown,
38 NoHarness,
39 Offline,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
44pub struct RunnablePath {
45 pub harness: String,
46 pub mars_provider: String,
47 pub harness_model_id: String,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
52pub struct ModelAvailability {
53 pub status: AvailabilityStatus,
54 pub source: AvailabilitySource,
55 pub runnable_paths: Vec<RunnablePath>,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum RunnablePathSource {
60 CachedProbe,
61 ProviderMatch,
62 Passthrough,
63}
64
65impl RunnablePathSource {
66 pub fn label(self) -> &'static str {
67 match self {
68 Self::CachedProbe => "cached-probe",
69 Self::ProviderMatch => "provider-match",
70 Self::Passthrough => "passthrough",
71 }
72 }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum RunnableConfidence {
77 Confirmed,
78 Likely,
79 Unknown,
80}
81
82impl RunnableConfidence {
83 pub fn label(self) -> &'static str {
84 match self {
85 Self::Confirmed => "confirmed",
86 Self::Likely => "likely",
87 Self::Unknown => "unknown",
88 }
89 }
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct ResolvedRunnablePath {
94 pub harness_model_id: String,
95 pub source: RunnablePathSource,
96 pub confidence: RunnableConfidence,
97}
98pub fn classify_for_harness(
100 harness: &str,
101 provider: &str,
102 model_id: &str,
103 installed: &HashSet<String>,
104 probe_result: Option<&OpenCodeProbeResult>,
105 cursor_probe_result: Option<&CursorProbeResult>,
106) -> Option<(AvailabilityStatus, AvailabilitySource, Option<RunnablePath>)> {
107 let harness = harness.to_ascii_lowercase();
108 if !installed.contains(&harness) {
109 return Some((
110 AvailabilityStatus::Unavailable,
111 AvailabilitySource::NoHarness,
112 None,
113 ));
114 }
115
116 let direct_match = match harness.as_str() {
117 "claude" => slug::providers_match(provider, "anthropic"),
118 "codex" => slug::providers_match(provider, "openai"),
119 "opencode" => return classify_opencode(provider, model_id, probe_result),
120 "pi" => return classify_universal_harness(),
121 "cursor" => return classify_cursor(model_id, cursor_probe_result),
122 _ => false,
123 };
124
125 if direct_match {
126 Some((
127 AvailabilityStatus::Runnable,
128 AvailabilitySource::HarnessInstalled,
129 Some(RunnablePath {
130 harness,
131 mars_provider: provider.to_string(),
132 harness_model_id: model_id.to_string(),
133 }),
134 ))
135 } else {
136 Some((
137 AvailabilityStatus::Unavailable,
138 AvailabilitySource::NoHarness,
139 None,
140 ))
141 }
142}
143
144fn classify_universal_harness()
145-> Option<(AvailabilityStatus, AvailabilitySource, Option<RunnablePath>)> {
146 Some((
147 AvailabilityStatus::Unknown,
148 AvailabilitySource::UniversalHarness,
149 None,
150 ))
151}
152
153fn classify_opencode(
154 provider: &str,
155 model_id: &str,
156 probe_result: Option<&OpenCodeProbeResult>,
157) -> Option<(AvailabilityStatus, AvailabilitySource, Option<RunnablePath>)> {
158 let Some(probe) = probe_result else {
159 return Some((
160 AvailabilityStatus::Unknown,
161 AvailabilitySource::OpenCodeProbeUnknown,
162 None,
163 ));
164 };
165
166 if !probe.model_probe_success {
167 return Some((
168 AvailabilityStatus::Unknown,
169 AvailabilitySource::OpenCodeProbeUnknown,
170 None,
171 ));
172 }
173
174 if is_unknown_provider(provider) {
175 return Some((
176 AvailabilityStatus::Unknown,
177 AvailabilitySource::OpenCodeProbeUnknown,
178 None,
179 ));
180 }
181
182 let Some(harness_model_id) = slug::find_exact_match(
183 model_id,
184 provider,
185 probe.model_slugs.iter().map(String::as_str),
186 )
187 .map(|matched| matched.slug) else {
188 return Some((
189 AvailabilityStatus::Unavailable,
190 AvailabilitySource::OpenCodeProbeNegative,
191 None,
192 ));
193 };
194
195 Some((
196 AvailabilityStatus::Runnable,
197 AvailabilitySource::OpenCodeProbe,
198 Some(RunnablePath {
199 harness: "opencode".to_string(),
200 mars_provider: provider.to_string(),
201 harness_model_id,
202 }),
203 ))
204}
205
206fn classify_cursor(
207 model_id: &str,
208 probe_result: Option<&CursorProbeResult>,
209) -> Option<(AvailabilityStatus, AvailabilitySource, Option<RunnablePath>)> {
210 let Some(probe) = probe_result else {
211 return Some((
212 AvailabilityStatus::Unknown,
213 AvailabilitySource::CursorProbeUnknown,
214 None,
215 ));
216 };
217
218 if !probe.model_probe_success {
219 return Some((
220 AvailabilityStatus::Unknown,
221 AvailabilitySource::CursorProbeUnknown,
222 None,
223 ));
224 }
225 if probe.slugs.is_empty() {
226 return Some((
227 AvailabilityStatus::Unknown,
228 AvailabilitySource::CursorProbeUnknown,
229 None,
230 ));
231 }
232
233 let matches = crate::models::probes::cursor::find_cursor_prefix_matches(model_id, &probe.slugs);
234 if matches.is_empty() {
235 return Some((
236 AvailabilityStatus::Unavailable,
237 AvailabilitySource::CursorProbeNegative,
238 None,
239 ));
240 }
241
242 Some((
243 AvailabilityStatus::Runnable,
244 AvailabilitySource::CursorProbe,
245 Some(RunnablePath {
246 harness: "cursor".to_string(),
247 mars_provider: "cursor".to_string(),
248 harness_model_id: model_id.to_string(),
249 }),
250 ))
251}
252
253fn is_unknown_provider(provider: &str) -> bool {
254 let provider = provider.trim();
255 provider.is_empty() || provider.eq_ignore_ascii_case("unknown")
256}
257
258pub fn classify_model(
259 model_id: &str,
260 provider: &str,
261 installed: &HashSet<String>,
262 opencode_probe_result: Option<&OpenCodeProbeResult>,
263 pi_probe_result: Option<&PiProbeResult>,
264 cursor_probe_result: Option<&CursorProbeResult>,
265 offline: bool,
266) -> ModelAvailability {
267 let mut statuses = Vec::new();
268 let mut runnable_paths = Vec::new();
269
270 for harness in ["claude", "codex"] {
271 let Some((status, source, path)) =
272 classify_for_harness(harness, provider, model_id, installed, None, None)
273 else {
274 continue;
275 };
276 if let Some(path) = path {
277 runnable_paths.push(path);
278 }
279 statuses.push((status, source));
280 }
281
282 if let Some((status, source, path)) =
283 classify_pi_for_model(provider, model_id, installed, pi_probe_result, offline)
284 {
285 if let Some(path) = path {
286 runnable_paths.push(path);
287 }
288 statuses.push((status, source));
289 }
290
291 if installed.contains("opencode") {
292 if offline {
293 statuses.push((AvailabilityStatus::Unknown, AvailabilitySource::Offline));
294 } else if let Some(result) = opencode_probe_result {
295 if let Some((status, source, path)) = classify_for_harness(
296 "opencode",
297 provider,
298 model_id,
299 installed,
300 Some(result),
301 None,
302 ) {
303 if let Some(path) = path {
304 runnable_paths.push(path);
305 }
306 statuses.push((status, source));
307 }
308 } else {
309 statuses.push((
310 AvailabilityStatus::Unknown,
311 AvailabilitySource::OpenCodeProbeUnknown,
312 ));
313 }
314 }
315
316 if installed.contains("cursor") {
317 if offline {
318 statuses.push((AvailabilityStatus::Unknown, AvailabilitySource::Offline));
319 } else if let Some((status, source, path)) = classify_cursor(model_id, cursor_probe_result)
320 {
321 if let Some(path) = path {
322 runnable_paths.push(path);
323 }
324 statuses.push((status, source));
325 }
326 }
327
328 aggregate_statuses(statuses, runnable_paths)
329}
330
331fn classify_pi_for_model(
332 provider: &str,
333 model_id: &str,
334 installed: &HashSet<String>,
335 pi_probe_result: Option<&PiProbeResult>,
336 offline: bool,
337) -> Option<(AvailabilityStatus, AvailabilitySource, Option<RunnablePath>)> {
338 if !installed.contains("pi") {
339 return None;
340 }
341
342 if offline || pi_probe_result.is_none() {
343 return classify_universal_harness();
344 }
345
346 let pi_probe_result = pi_probe_result.expect("checked is_some above");
347 if !pi_probe_result.compatible {
348 return Some((
349 AvailabilityStatus::Unavailable,
350 AvailabilitySource::PiProbeNegative,
351 None,
352 ));
353 }
354
355 let Some(harness_model_id) = slug::find_exact_match(
356 model_id,
357 provider,
358 pi_probe_result.model_slugs.iter().map(String::as_str),
359 )
360 .map(|matched| matched.slug) else {
361 return Some((
362 AvailabilityStatus::Unavailable,
363 AvailabilitySource::PiProbeNegative,
364 None,
365 ));
366 };
367
368 Some((
369 AvailabilityStatus::Runnable,
370 AvailabilitySource::PiProbe,
371 Some(RunnablePath {
372 harness: "pi".to_string(),
373 mars_provider: provider.to_string(),
374 harness_model_id,
375 }),
376 ))
377}
378
379fn aggregate_statuses(
380 statuses: Vec<(AvailabilityStatus, AvailabilitySource)>,
381 runnable_paths: Vec<RunnablePath>,
382) -> ModelAvailability {
383 if statuses.is_empty() {
384 return ModelAvailability {
385 status: AvailabilityStatus::Unavailable,
386 source: AvailabilitySource::NoHarness,
387 runnable_paths: Vec::new(),
388 };
389 }
390
391 if statuses
392 .iter()
393 .any(|(status, _)| *status == AvailabilityStatus::Runnable)
394 {
395 return ModelAvailability {
396 status: AvailabilityStatus::Runnable,
397 source: statuses
398 .iter()
399 .find_map(|(status, source)| {
400 (*status == AvailabilityStatus::Runnable).then(|| source.clone())
401 })
402 .expect("runnable status exists"),
403 runnable_paths,
404 };
405 }
406
407 if statuses
408 .iter()
409 .any(|(status, _)| *status == AvailabilityStatus::Unknown)
410 {
411 return ModelAvailability {
412 status: AvailabilityStatus::Unknown,
413 source: statuses
414 .iter()
415 .find_map(|(status, source)| {
416 (*status == AvailabilityStatus::Unknown).then(|| source.clone())
417 })
418 .unwrap_or(AvailabilitySource::OpenCodeProbeUnknown),
419 runnable_paths: Vec::new(),
420 };
421 }
422
423 ModelAvailability {
424 status: AvailabilityStatus::Unavailable,
425 source: statuses
426 .iter()
427 .find_map(|(_, source)| {
428 (*source != AvailabilitySource::NoHarness).then(|| source.clone())
429 })
430 .or_else(|| statuses.first().map(|(_, source)| source.clone()))
431 .unwrap_or(AvailabilitySource::NoHarness),
432 runnable_paths: Vec::new(),
433 }
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439
440 fn installed(names: &[&str]) -> HashSet<String> {
441 names.iter().map(|name| (*name).to_string()).collect()
442 }
443
444 #[test]
445 fn test_classify_claude_anthropic() {
446 let result = classify_for_harness(
447 "claude",
448 "Anthropic",
449 "claude-opus-4-7",
450 &installed(&["claude"]),
451 None,
452 None,
453 )
454 .unwrap();
455 assert_eq!(result.0, AvailabilityStatus::Runnable);
456 assert_eq!(result.1, AvailabilitySource::HarnessInstalled);
457 assert_eq!(
458 result.2.unwrap().harness_model_id,
459 "claude-opus-4-7".to_string()
460 );
461 }
462
463 #[test]
464 fn test_classify_codex_openai() {
465 let result = classify_for_harness(
466 "codex",
467 "OpenAI",
468 "gpt-5.4",
469 &installed(&["codex"]),
470 None,
471 None,
472 )
473 .unwrap();
474 assert_eq!(result.0, AvailabilityStatus::Runnable);
475 assert_eq!(result.1, AvailabilitySource::HarnessInstalled);
476 }
477
478 #[test]
479 fn test_classify_codex_openai_codex_variant_is_runnable() {
480 let result = classify_for_harness(
481 "codex",
482 "openai-codex",
483 "gpt-5.4-mini",
484 &installed(&["codex"]),
485 None,
486 None,
487 )
488 .unwrap();
489 assert_eq!(result.0, AvailabilityStatus::Runnable);
490 assert_eq!(result.1, AvailabilitySource::HarnessInstalled);
491 assert_eq!(
492 result
493 .2
494 .expect("runnable path should be present")
495 .harness_model_id,
496 "gpt-5.4-mini"
497 );
498 }
499
500 #[test]
501 fn test_classify_pi_is_universal_unknown_when_installed() {
502 let result = classify_for_harness(
503 "pi",
504 "OpenAI",
505 "gpt-5.4-mini",
506 &installed(&["pi"]),
507 None,
508 None,
509 )
510 .unwrap();
511 assert_eq!(result.0, AvailabilityStatus::Unknown);
512 assert_eq!(result.1, AvailabilitySource::UniversalHarness);
513 assert!(result.2.is_none());
514 }
515
516 #[test]
517 fn test_classify_cursor_is_universal_unknown_when_installed() {
518 let result = classify_for_harness(
519 "cursor",
520 "Anthropic",
521 "claude-opus-4-7",
522 &installed(&["cursor"]),
523 None,
524 None,
525 )
526 .unwrap();
527 assert_eq!(result.0, AvailabilityStatus::Unknown);
528 assert_eq!(result.1, AvailabilitySource::CursorProbeUnknown);
529 assert!(result.2.is_none());
530 }
531
532 #[test]
533 fn test_classify_cursor_probe_prefix_match_is_runnable() {
534 let cursor_probe = CursorProbeResult {
535 slugs: vec!["gpt-5.5-high".to_string(), "gpt-5.5-low".to_string()],
536 model_probe_success: true,
537 error: None,
538 };
539 let result = classify_model(
540 "gpt-5.5",
541 "OpenAI",
542 &installed(&["cursor"]),
543 None,
544 None,
545 Some(&cursor_probe),
546 false,
547 );
548
549 assert_eq!(result.status, AvailabilityStatus::Runnable);
550 assert_eq!(result.source, AvailabilitySource::CursorProbe);
551 assert_eq!(result.runnable_paths.len(), 1);
552 assert_eq!(result.runnable_paths[0].harness, "cursor");
553 assert_eq!(result.runnable_paths[0].harness_model_id, "gpt-5.5");
554 }
555
556 #[test]
557 fn test_classify_cursor_probe_no_match_is_unavailable() {
558 let cursor_probe = CursorProbeResult {
559 slugs: vec!["claude-opus-4-7-high".to_string()],
560 model_probe_success: true,
561 error: None,
562 };
563 let result = classify_model(
564 "gpt-5.5",
565 "OpenAI",
566 &installed(&["cursor"]),
567 None,
568 None,
569 Some(&cursor_probe),
570 false,
571 );
572
573 assert_eq!(result.status, AvailabilityStatus::Unavailable);
574 assert_eq!(result.source, AvailabilitySource::CursorProbeNegative);
575 assert!(result.runnable_paths.is_empty());
576 }
577
578 #[test]
579 fn test_classify_cursor_probe_empty_catalog_is_unknown() {
580 let cursor_probe = CursorProbeResult {
581 slugs: Vec::new(),
582 model_probe_success: true,
583 error: None,
584 };
585 let result = classify_model(
586 "gpt-5.5",
587 "OpenAI",
588 &installed(&["cursor"]),
589 None,
590 None,
591 Some(&cursor_probe),
592 false,
593 );
594
595 assert_eq!(result.status, AvailabilityStatus::Unknown);
596 assert_eq!(result.source, AvailabilitySource::CursorProbeUnknown);
597 assert!(result.runnable_paths.is_empty());
598 }
599
600 #[test]
601 fn test_classify_no_harness() {
602 let result = classify_for_harness(
603 "claude",
604 "Anthropic",
605 "claude-opus-4-7",
606 &installed(&[]),
607 None,
608 None,
609 )
610 .unwrap();
611 assert_eq!(result.0, AvailabilityStatus::Unavailable);
612 assert_eq!(result.1, AvailabilitySource::NoHarness);
613 assert!(result.2.is_none());
614 }
615
616 #[test]
617 fn test_classify_multi_harness_any_runnable() {
618 let result = classify_model(
619 "claude-opus-4-7",
620 "Anthropic",
621 &installed(&["claude", "codex"]),
622 None,
623 None,
624 None,
625 false,
626 );
627 assert_eq!(result.status, AvailabilityStatus::Runnable);
628 assert_eq!(result.source, AvailabilitySource::HarnessInstalled);
629 assert_eq!(result.runnable_paths.len(), 1);
630 assert_eq!(result.runnable_paths[0].harness, "claude");
631 }
632
633 #[test]
634 fn test_classify_multi_harness_all_unavailable() {
635 let result = classify_model(
636 "custom-model",
637 "Unknown",
638 &installed(&[]),
639 None,
640 None,
641 None,
642 false,
643 );
644 assert_eq!(result.status, AvailabilityStatus::Unavailable);
645 assert_eq!(result.source, AvailabilitySource::NoHarness);
646 assert!(result.runnable_paths.is_empty());
647 }
648
649 #[test]
650 fn test_classify_google_model_with_only_pi_installed_is_unknown_universal() {
651 let result = classify_model(
652 "gemini-2.5-pro",
653 "Google",
654 &installed(&["pi"]),
655 None,
656 None,
657 None,
658 false,
659 );
660 assert_eq!(result.status, AvailabilityStatus::Unknown);
661 assert_eq!(result.source, AvailabilitySource::UniversalHarness);
662 assert!(result.runnable_paths.is_empty());
663 }
664
665 #[test]
666 fn test_classify_pi_probe_compatible_is_runnable() {
667 let pi_probe = PiProbeResult {
668 compatible: true,
669 model_slugs: HashSet::from(["openai/gpt-5.4-mini".to_string()]),
670 ..PiProbeResult::default()
671 };
672
673 let result = classify_model(
674 "gpt-5.4-mini",
675 "OpenAI",
676 &installed(&["pi"]),
677 None,
678 Some(&pi_probe),
679 None,
680 false,
681 );
682
683 assert_eq!(result.status, AvailabilityStatus::Runnable);
684 assert_eq!(result.source, AvailabilitySource::PiProbe);
685 assert_eq!(result.runnable_paths.len(), 1);
686 assert_eq!(result.runnable_paths[0].harness, "pi");
687 assert_eq!(
688 result.runnable_paths[0].harness_model_id,
689 "openai/gpt-5.4-mini"
690 );
691 }
692
693 #[test]
694 fn test_classify_pi_probe_incompatible_is_unavailable_without_other_harnesses() {
695 let pi_probe = PiProbeResult {
696 compatible: false,
697 ..PiProbeResult::default()
698 };
699
700 let result = classify_model(
701 "gpt-5.4-mini",
702 "OpenAI",
703 &installed(&["pi"]),
704 None,
705 Some(&pi_probe),
706 None,
707 false,
708 );
709
710 assert_eq!(result.status, AvailabilityStatus::Unavailable);
711 assert_eq!(result.source, AvailabilitySource::PiProbeNegative);
712 assert!(result.runnable_paths.is_empty());
713 }
714
715 #[test]
716 fn test_classify_pi_probe_incompatible_yields_to_runnable_harness() {
717 let pi_probe = PiProbeResult {
718 compatible: false,
719 ..PiProbeResult::default()
720 };
721
722 let result = classify_model(
723 "gpt-5.4-mini",
724 "OpenAI",
725 &installed(&["pi", "codex"]),
726 None,
727 Some(&pi_probe),
728 None,
729 false,
730 );
731
732 assert_eq!(result.status, AvailabilityStatus::Runnable);
733 assert_eq!(result.source, AvailabilitySource::HarnessInstalled);
734 assert_eq!(result.runnable_paths.len(), 1);
735 assert_eq!(result.runnable_paths[0].harness, "codex");
736 }
737
738 #[test]
739 fn test_classify_pi_probe_missing_model_is_unavailable() {
740 let pi_probe = PiProbeResult {
741 compatible: true,
742 model_slugs: HashSet::from(["openai/gpt-5.4".to_string()]),
743 ..PiProbeResult::default()
744 };
745
746 let result = classify_model(
747 "gpt-5.4-mini",
748 "OpenAI",
749 &installed(&["pi"]),
750 None,
751 Some(&pi_probe),
752 None,
753 false,
754 );
755
756 assert_eq!(result.status, AvailabilityStatus::Unavailable);
757 assert_eq!(result.source, AvailabilitySource::PiProbeNegative);
758 assert!(result.runnable_paths.is_empty());
759 }
760
761 #[test]
762 fn test_classify_offline_mode() {
763 let result = classify_model(
764 "gpt-5.4",
765 "OpenAI",
766 &installed(&["codex"]),
767 None,
768 None,
769 None,
770 true,
771 );
772 assert_eq!(result.status, AvailabilityStatus::Runnable);
773 assert_eq!(result.source, AvailabilitySource::HarnessInstalled);
774 assert_eq!(result.runnable_paths.len(), 1);
775 assert_eq!(result.runnable_paths[0].harness, "codex");
776
777 let result = classify_model(
778 "gpt-5.4",
779 "OpenAI",
780 &installed(&["opencode"]),
781 None,
782 None,
783 None,
784 true,
785 );
786 assert_eq!(result.status, AvailabilityStatus::Unknown);
787 assert_eq!(result.source, AvailabilitySource::Offline);
788 assert!(result.runnable_paths.is_empty());
789 }
790
791 #[test]
792 fn test_classify_opencode_direct_slug() {
793 let probe = OpenCodeProbeResult {
794 model_slugs: vec!["openai/gpt-5.4".to_string()],
795 model_probe_success: true,
796 error: None,
797 };
798
799 let result = classify_model(
800 "gpt-5.4",
801 "OpenAI",
802 &installed(&["opencode"]),
803 Some(&probe),
804 None,
805 None,
806 false,
807 );
808
809 assert_eq!(result.status, AvailabilityStatus::Runnable);
810 assert_eq!(result.source, AvailabilitySource::OpenCodeProbe);
811 assert_eq!(result.runnable_paths.len(), 1);
812 assert_eq!(result.runnable_paths[0].harness, "opencode");
813 assert_eq!(result.runnable_paths[0].harness_model_id, "openai/gpt-5.4");
814 }
815
816 #[test]
817 fn test_classify_opencode_nested_provider_slug_is_not_flattened() {
818 let probe = OpenCodeProbeResult {
819 model_slugs: vec!["openrouter/anthropic/claude-opus-4.7".to_string()],
820 model_probe_success: true,
821 error: None,
822 };
823
824 let result = classify_model(
825 "claude-opus-4-7",
826 "Anthropic",
827 &installed(&["opencode"]),
828 Some(&probe),
829 None,
830 None,
831 false,
832 );
833
834 assert_eq!(result.status, AvailabilityStatus::Unavailable);
835 assert_eq!(result.source, AvailabilitySource::OpenCodeProbeNegative);
836 assert!(result.runnable_paths.is_empty());
837 }
838
839 #[test]
840 fn test_classify_opencode_provider_negative() {
841 let probe = OpenCodeProbeResult {
842 model_slugs: vec!["google/gemini-2.5-pro".to_string()],
843 model_probe_success: true,
844 ..OpenCodeProbeResult::default()
845 };
846
847 let result = classify_model(
848 "gpt-5.4",
849 "OpenAI",
850 &installed(&["opencode"]),
851 Some(&probe),
852 None,
853 None,
854 false,
855 );
856
857 assert_eq!(result.status, AvailabilityStatus::Unavailable);
858 assert_eq!(result.source, AvailabilitySource::OpenCodeProbeNegative);
859 assert!(result.runnable_paths.is_empty());
860 }
861
862 #[test]
863 fn test_classify_opencode_empty_slugs() {
864 let probe = OpenCodeProbeResult {
865 model_slugs: Vec::new(),
866 model_probe_success: true,
867 error: None,
868 };
869
870 let result = classify_model(
871 "claude-opus-4-7",
872 "Anthropic",
873 &installed(&["opencode"]),
874 Some(&probe),
875 None,
876 None,
877 false,
878 );
879
880 assert_eq!(result.status, AvailabilityStatus::Unavailable);
881 assert_eq!(result.source, AvailabilitySource::OpenCodeProbeNegative);
882 assert!(result.runnable_paths.is_empty());
883 }
884
885 #[test]
886 fn test_classify_opencode_no_matching_slug() {
887 let probe = OpenCodeProbeResult {
888 model_slugs: vec!["anthropic/claude-3-5-sonnet".to_string()],
889 model_probe_success: true,
890 error: None,
891 };
892
893 let result = classify_model(
894 "claude-opus-4-7",
895 "Anthropic",
896 &installed(&["opencode"]),
897 Some(&probe),
898 None,
899 None,
900 false,
901 );
902
903 assert_eq!(result.status, AvailabilityStatus::Unavailable);
904 assert_eq!(result.source, AvailabilitySource::OpenCodeProbeNegative);
905 assert!(result.runnable_paths.is_empty());
906 }
907
908 #[test]
909 fn test_classify_opencode_unknown_when_model_probe_fails() {
910 let probe = OpenCodeProbeResult {
911 model_probe_success: false,
912 error: Some("model probe failed: timeout".to_string()),
913 ..OpenCodeProbeResult::default()
914 };
915
916 let result = classify_model(
917 "claude-opus-4-7",
918 "Anthropic",
919 &installed(&["opencode"]),
920 Some(&probe),
921 None,
922 None,
923 false,
924 );
925
926 assert_eq!(result.status, AvailabilityStatus::Unknown);
927 assert_eq!(result.source, AvailabilitySource::OpenCodeProbeUnknown);
928 assert!(result.runnable_paths.is_empty());
929 }
930 #[test]
931 fn test_classify_opencode_unknown_when_probe_fails() {
932 let probe = OpenCodeProbeResult {
933 error: Some("model probe failed: timeout".to_string()),
934 ..OpenCodeProbeResult::default()
935 };
936
937 let result = classify_model(
938 "gpt-5.4",
939 "OpenAI",
940 &installed(&["opencode"]),
941 Some(&probe),
942 None,
943 None,
944 false,
945 );
946
947 assert_eq!(result.status, AvailabilityStatus::Unknown);
948 assert_eq!(result.source, AvailabilitySource::OpenCodeProbeUnknown);
949 assert!(result.runnable_paths.is_empty());
950 }
951
952 #[test]
953 fn test_classify_opencode_unknown_provider_stays_unknown() {
954 let probe = OpenCodeProbeResult {
955 model_slugs: vec!["openai/gpt-5.4".to_string()],
956 model_probe_success: true,
957 ..OpenCodeProbeResult::default()
958 };
959
960 let result = classify_model(
961 "mystery-model",
962 "unknown",
963 &installed(&["opencode"]),
964 Some(&probe),
965 None,
966 None,
967 false,
968 );
969
970 assert_eq!(result.status, AvailabilityStatus::Unknown);
971 assert_eq!(result.source, AvailabilitySource::OpenCodeProbeUnknown);
972 assert!(result.runnable_paths.is_empty());
973 }
974}