malvin 0.2.5

Non-interactive research and coding agent
use super::ModelListPage;

pub(super) fn parse_model_list_page(value: &serde_json::Value) -> Result<ModelListPage, String> {
    reject_model_list_error(value)?;
    let result = model_list_result(value)?;
    Ok(ModelListPage {
        models: parse_model_rows(result)?,
        next_cursor: parse_next_cursor(result),
    })
}

fn reject_model_list_error(value: &serde_json::Value) -> Result<(), String> {
    if let Some(error) = value.get("error") {
        return Err(format!("codex model/list: {error}"));
    }
    Ok(())
}

fn model_list_result(value: &serde_json::Value) -> Result<&serde_json::Value, String> {
    value
        .get("result")
        .ok_or_else(|| "codex model/list response missing result".into())
}

fn parse_model_rows(result: &serde_json::Value) -> Result<Vec<(String, String)>, String> {
    let rows = result
        .get("data")
        .and_then(serde_json::Value::as_array)
        .ok_or("codex model/list response missing data")?;
    Ok(rows.iter().filter_map(parse_model_row).collect())
}

fn parse_model_row(row: &serde_json::Value) -> Option<(String, String)> {
    Some((row.get("id")?.as_str()?.to_owned(), model_row_label(row)))
}

fn model_row_label(row: &serde_json::Value) -> String {
    let name = row
        .get("displayName")
        .and_then(serde_json::Value::as_str)
        .unwrap_or("");
    let extras = codex_listing_extras(row);
    if extras.is_empty() {
        name.to_owned()
    } else if name.is_empty() {
        extras
    } else {
        format!("{name}\t{extras}")
    }
}

fn codex_listing_extras(row: &serde_json::Value) -> String {
    let mut parts = Vec::new();
    if let Some(values) = joined_ids(row.get("supportedReasoningEfforts"), "reasoningEffort") {
        parts.push(format!("thinking={values}"));
    }
    if let Some(values) = joined_ids(row.get("serviceTiers"), "id") {
        parts.push(format!("service={values}"));
    }
    if row.get("hidden").and_then(serde_json::Value::as_bool) == Some(true) {
        parts.push("hidden".into());
    }
    if row.get("isDefault").and_then(serde_json::Value::as_bool) == Some(true) {
        parts.push("default".into());
    }
    parts.join(" ")
}

fn joined_ids(field: Option<&serde_json::Value>, key: &str) -> Option<String> {
    let ids: Vec<&str> = field?
        .as_array()?
        .iter()
        .filter_map(|value| value.get(key).and_then(serde_json::Value::as_str))
        .collect();
    if ids.is_empty() {
        None
    } else {
        Some(ids.join("|"))
    }
}

fn parse_next_cursor(result: &serde_json::Value) -> Option<String> {
    result
        .get("nextCursor")
        .and_then(serde_json::Value::as_str)
        .map(str::to_owned)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn model_list_page_type_fields_are_constructible() {
        let page = ModelListPage {
            models: vec![("gpt-test".into(), "Test".into())],
            next_cursor: Some("next".into()),
        };
        assert_eq!(page.models.len(), 1);
        assert_eq!(page.next_cursor.as_deref(), Some("next"));
    }
    #[test]
    fn model_list_page_reads_next_cursor() {
        let value = serde_json::json!({
            "result": {
                "data": [{"id": "gpt-test", "displayName": "Test"}],
                "nextCursor": "page-2"
            }
        });
        let page = parse_model_list_page(&value).expect("page");
        assert_eq!(page.models, vec![("gpt-test".into(), "Test".into())]);
        assert_eq!(page.next_cursor.as_deref(), Some("page-2"));
    }

    #[test]
    fn model_list_page_without_cursor_finishes() {
        let value = serde_json::json!({
            "result": {"data": [{"id": "gpt-test"}]}
        });
        let page = parse_model_list_page(&value).expect("page");
        assert!(page.next_cursor.is_none());
        assert_eq!(page.models[0].0, "gpt-test");
    }

    #[test]
    fn model_list_page_propagates_error() {
        let value = serde_json::json!({"error": {"message": "bad"}});
        let err = parse_model_list_page(&value).expect_err("error");
        assert!(err.contains("bad"));
    }

    #[test]
    fn model_list_page_rejects_missing_result_and_data() {
        let missing_result = serde_json::json!({});
        assert!(
            parse_model_list_page(&missing_result)
                .expect_err("missing result")
                .contains("missing result")
        );

        let missing_data = serde_json::json!({"result": {}});
        assert!(
            parse_model_list_page(&missing_data)
                .expect_err("missing data")
                .contains("missing data")
        );
    }

    #[test]
    fn model_list_page_skips_rows_without_string_id() {
        let value = serde_json::json!({
            "result": {
                "data": [
                    {"displayName": "Missing id"},
                    {"id": 42, "displayName": "Wrong type"},
                    {"id": "valid"}
                ]
            }
        });
        let page = parse_model_list_page(&value).expect("page");
        assert_eq!(page.models, vec![("valid".into(), String::new())]);
    }

    #[test]
    fn model_list_row_requires_id_and_defaults_display_name() {
        assert!(parse_model_row(&serde_json::json!({})).is_none());
        assert_eq!(
            parse_model_row(&serde_json::json!({"id": "valid"})),
            Some(("valid".into(), String::new()))
        );
    }

    #[test]
    fn model_list_row_appends_thinking_service_and_flags() {
        let row = serde_json::json!({
            "id": "gpt-terra",
            "displayName": "Terra",
            "hidden": true,
            "isDefault": true,
            "supportedReasoningEfforts": [
                {"reasoningEffort": "low"},
                {"reasoningEffort": "high"}
            ],
            "serviceTiers": [{"id": "priority"}]
        });
        assert_eq!(
            parse_model_row(&row),
            Some((
                "gpt-terra".into(),
                "Terra\tthinking=low|high service=priority hidden default".into()
            ))
        );
        assert!(joined_ids(Some(&serde_json::json!([])), "id").is_none());
        assert!(codex_listing_extras(&serde_json::json!({"id": "x"})).is_empty());
    }
}