sniff-cli 0.1.4

An exhaustive LLM-backed slop finder for codebases
Documentation
use super::ResponseSchema;

pub(super) fn schema_description(schema: ResponseSchema) -> &'static str {
    match schema {
        ResponseSchema::MethodReview => {
            "Required fields: smelly (bool), tier (string), evidence (string), reason (string)."
        }
        ResponseSchema::SemanticMethodReview => {
            "Required fields: smelly (bool), tier (string), pattern (string), intent (string), necessity_check (string), evidence (array of objects with start_line (number), end_line (number), quote (string)). reason is a string when present and is required for non-clean results; clean results may omit it."
        }
        ResponseSchema::FileReview => {
            "Required fields: smelly (bool), tier (string), evidence (string), cohesive (bool), name_accurate (bool), reason (string)."
        }
        ResponseSchema::RoleClassification => "Required fields: role (string), reason (string).",
    }
}

pub(super) fn validate_schema(
    value: &serde_json::Value,
    schema: ResponseSchema,
) -> Result<(), String> {
    let Some(obj) = value.as_object() else {
        return Err("response was not a JSON object".to_string());
    };

    let mut missing = Vec::new();
    let mut wrong_type = Vec::new();

    fn check_bool(
        obj: &serde_json::Map<String, serde_json::Value>,
        name: &str,
        missing: &mut Vec<String>,
        wrong_type: &mut Vec<String>,
    ) {
        match obj.get(name) {
            Some(v) if v.is_boolean() => {}
            Some(_) => wrong_type.push(name.to_string()),
            None => missing.push(name.to_string()),
        }
    }

    fn check_string(
        obj: &serde_json::Map<String, serde_json::Value>,
        name: &str,
        missing: &mut Vec<String>,
        wrong_type: &mut Vec<String>,
    ) {
        match obj.get(name) {
            Some(v) if v.is_string() => {}
            Some(_) => wrong_type.push(name.to_string()),
            None => missing.push(name.to_string()),
        }
    }

    fn check_optional_string(
        obj: &serde_json::Map<String, serde_json::Value>,
        name: &str,
        wrong_type: &mut Vec<String>,
    ) {
        if let Some(value) = obj.get(name)
            && !value.is_string()
        {
            wrong_type.push(name.to_string());
        }
    }

    fn check_number(
        obj: &serde_json::Map<String, serde_json::Value>,
        name: &str,
        missing: &mut Vec<String>,
        wrong_type: &mut Vec<String>,
    ) {
        match obj.get(name) {
            Some(v) if v.is_u64() || v.is_i64() => {}
            Some(_) => wrong_type.push(name.to_string()),
            None => missing.push(name.to_string()),
        }
    }

    fn check_semantic_evidence(
        obj: &serde_json::Map<String, serde_json::Value>,
        missing: &mut Vec<String>,
        wrong_type: &mut Vec<String>,
    ) {
        let Some(value) = obj.get("evidence") else {
            missing.push("evidence".to_string());
            return;
        };
        let Some(entries) = value.as_array() else {
            wrong_type.push("evidence".to_string());
            return;
        };
        for (index, entry) in entries.iter().enumerate() {
            let Some(entry) = entry.as_object() else {
                wrong_type.push(format!("evidence[{index}]"));
                continue;
            };
            check_number(entry, "start_line", missing, wrong_type);
            check_number(entry, "end_line", missing, wrong_type);
            check_string(entry, "quote", missing, wrong_type);
        }
    }

    match schema {
        ResponseSchema::MethodReview => {
            check_bool(obj, "smelly", &mut missing, &mut wrong_type);
            check_string(obj, "tier", &mut missing, &mut wrong_type);
            check_string(obj, "evidence", &mut missing, &mut wrong_type);
            check_string(obj, "reason", &mut missing, &mut wrong_type);
        }
        ResponseSchema::SemanticMethodReview => {
            check_bool(obj, "smelly", &mut missing, &mut wrong_type);
            check_string(obj, "tier", &mut missing, &mut wrong_type);
            check_string(obj, "pattern", &mut missing, &mut wrong_type);
            check_string(obj, "intent", &mut missing, &mut wrong_type);
            check_optional_string(obj, "reason", &mut wrong_type);
            check_string(obj, "necessity_check", &mut missing, &mut wrong_type);
            check_semantic_evidence(obj, &mut missing, &mut wrong_type);
        }
        ResponseSchema::FileReview => {
            check_bool(obj, "smelly", &mut missing, &mut wrong_type);
            check_string(obj, "tier", &mut missing, &mut wrong_type);
            check_string(obj, "evidence", &mut missing, &mut wrong_type);
            check_string(obj, "reason", &mut missing, &mut wrong_type);
            check_bool(obj, "cohesive", &mut missing, &mut wrong_type);
            check_bool(obj, "name_accurate", &mut missing, &mut wrong_type);
        }
        ResponseSchema::RoleClassification => {
            check_string(obj, "role", &mut missing, &mut wrong_type);
            check_string(obj, "reason", &mut missing, &mut wrong_type);
        }
    }

    if missing.is_empty() && wrong_type.is_empty() {
        Ok(())
    } else {
        let mut parts = Vec::new();
        if !missing.is_empty() {
            parts.push(format!("missing fields: {}", missing.join(", ")));
        }
        if !wrong_type.is_empty() {
            parts.push(format!("wrong field types: {}", wrong_type.join(", ")));
        }
        Err(parts.join("; "))
    }
}

#[cfg(test)]
mod tests {
    use super::{ResponseSchema, validate_schema};

    #[test]
    fn clean_semantic_review_may_omit_its_explanation() {
        let value = serde_json::json!({
            "smelly": false,
            "tier": "clean",
            "pattern": "none",
            "intent": "Return a configured value.",
            "necessity_check": "The implementation is direct.",
            "evidence": []
        });

        assert!(validate_schema(&value, ResponseSchema::SemanticMethodReview).is_ok());
    }
}