force 0.2.0

Production-ready Salesforce Platform API client with REST and Bulk API 2.0 support
Documentation
//! Schema Analyzer for Salesforce SObject Describe metadata.
//!
//! This module provides a utility to analyze `SObjectDescribe` payloads,
//! extracting insights about the health, complexity, and shape of a Salesforce
//! object schema.
//!
//! # Example
//!
//! ```no_run
//! # use force::api::RestOperation;
//! # use force::client::ForceClientBuilder;
//! # use force::schema::analyze_schema;
//! # use force::auth::ClientCredentials;
//! # #[tokio::main]
//! # async fn main() -> anyhow::Result<()> {
//! # let auth = ClientCredentials::new("id", "secret", "url");
//! # let client = ForceClientBuilder::new().authenticate(auth).build().await?;
//! let describe = client.rest().describe("Account").await?;
//!
//! let insights = analyze_schema(&describe);
//!
//! println!("Custom Fields: {}", insights.custom_field_count);
//! println!("Formula Fields: {}", insights.formula_field_count);
//! println!("Relationship Fields: {}", insights.relationship_field_count);
//! println!("Complexity Score: {}", insights.complexity_score);
//! # Ok(())
//! # }
//! ```

use crate::types::describe::{FieldType, SObjectDescribe};

/// Insights generated by `analyze_schema`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SchemaInsights {
    /// Total number of fields on the object.
    pub total_fields: usize,
    /// Number of custom fields (ending in `__c` or marked `custom: true`).
    pub custom_field_count: usize,
    /// Number of standard fields.
    pub standard_field_count: usize,
    /// Number of required fields (not nillable and no default on create).
    pub required_field_count: usize,
    /// Number of formula fields (calculated).
    pub formula_field_count: usize,
    /// Number of relationship fields (lookup/master-detail).
    pub relationship_field_count: usize,
    /// A simple heuristic score representing schema complexity (higher is more complex).
    pub complexity_score: usize,
}

/// Analyzes an `SObjectDescribe` and returns `SchemaInsights`.
#[must_use]
pub fn analyze_schema(describe: &SObjectDescribe) -> SchemaInsights {
    let total_fields = describe.fields.len();
    let mut custom_field_count = 0;
    let mut required_field_count = 0;
    let mut formula_field_count = 0;
    let mut relationship_field_count = 0;

    for field in &describe.fields {
        if field.custom {
            custom_field_count += 1;
        }

        if !field.nillable && !field.defaulted_on_create && field.name != "Id" {
            required_field_count += 1;
        }

        if field.calculated {
            formula_field_count += 1;
        }

        if matches!(field.type_, FieldType::Reference) {
            relationship_field_count += 1;
        }
    }

    let standard_field_count = total_fields - custom_field_count;

    // Complexity heuristic:
    // +1 for every 10 total fields
    // +2 for every custom field (customizations add complexity)
    // +5 for every formula field (impacts query perf)
    // +3 for every relationship field (joins)
    let complexity_score = (total_fields / 10)
        + (custom_field_count * 2)
        + (formula_field_count * 5)
        + (relationship_field_count * 3);

    SchemaInsights {
        total_fields,
        custom_field_count,
        standard_field_count,
        required_field_count,
        formula_field_count,
        relationship_field_count,
        complexity_score,
    }
}

#[cfg(test)]
mod tests {
    #[test]
    fn test_schema_analyzer_exhaustive_mutants() {
        let describe = create_mock_describe(&json!([
            // Standard, Not Required
            mock_field("Id", "id", false, false, true, false), // 1. name=Id (Standard, !req)
            mock_field("Name", "string", false, false, false, false), // 2. name!=Id, Required (!nillable, !default)
            mock_field("Nillable", "string", false, true, false, false), // 3. nillable, !default
            mock_field("Defaulted", "string", false, false, true, false), // 4. !nillable, default
            mock_field("Standard", "string", false, true, true, false), // 5. nillable, default
            // Custom fields (Need exactly 9 to ensure count > 1 and unique)
            mock_field("Custom1__c", "string", true, true, true, false), // 6 (Custom)
            mock_field("Custom2__c", "string", true, true, true, false), // 7 (Custom)
            mock_field("Custom3__c", "string", true, true, true, false), // 8 (Custom)
            mock_field("Custom4__c", "string", true, true, true, false), // 9 (Custom)
            mock_field("Custom5__c", "string", true, true, true, false), // 10 (Custom)
            mock_field("Custom6__c", "string", true, true, true, false), // 11 (Custom)
            mock_field("Custom7__c", "string", true, true, true, false), // 12 (Custom)
            // Formula fields (Need exactly 2 to ensure count > 1 and unique)
            mock_field("Formula1__c", "double", true, true, true, true), // 13 (Custom, Formula)
            mock_field("Formula2__c", "double", true, true, true, true), // 14 (Custom, Formula)
            // Reference fields (Need exactly 4 to ensure count > 1 and unique)
            mock_field("Rel1", "reference", false, true, true, false), // 15 (Reference)
            mock_field("Rel2", "reference", false, true, true, false), // 16 (Reference)
            mock_field("Rel3", "reference", false, true, true, false), // 17 (Reference)
            mock_field("Rel4", "reference", false, true, true, false), // 18 (Reference)
            // Additional required fields to ensure req count > 1 and unique
            mock_field("Req2", "string", false, false, false, false), // 19. Required
            mock_field("Req3", "string", false, false, false, false), // 20. Required
            // Pad to 21 fields (21 / 10 = 2 != 21 % 10 = 1)
            mock_field("Pad1", "string", false, true, true, false) // 21
        ]));

        let insights = analyze_schema(&describe);

        // Explicitly assert every property to catch structural and arithmetic mutants.
        assert_eq!(insights.total_fields, 21, "total_fields mismatch");
        assert_eq!(
            insights.custom_field_count, 9,
            "custom_field_count mismatch"
        );
        assert_eq!(
            insights.formula_field_count, 2,
            "formula_field_count mismatch"
        );
        assert_eq!(
            insights.relationship_field_count, 4,
            "relationship_field_count mismatch"
        );
        assert_eq!(
            insights.required_field_count, 3,
            "required_field_count mismatch"
        ); // 2, 19, 20
        assert_eq!(
            insights.standard_field_count, 12,
            "standard_field_count mismatch"
        );

        // Score: (21/10) + (9*2) + (2*5) + (4*3) = 2 + 18 + 10 + 12 = 42
        assert_eq!(insights.complexity_score, 42, "complexity_score mismatch");

        // Now test all mutants via full struct equality match.
        // This stops variable swap mutants.
        let expected = SchemaInsights {
            total_fields: 21,
            custom_field_count: 9,
            standard_field_count: 12,
            required_field_count: 3,
            formula_field_count: 2,
            relationship_field_count: 4,
            complexity_score: 42,
        };
        assert_eq!(insights, expected, "Struct equality failed");

        let empty_describe = create_mock_describe(&json!([]));
        let empty_insights = analyze_schema(&empty_describe);

        let empty_expected = SchemaInsights {
            total_fields: 0,
            custom_field_count: 0,
            standard_field_count: 0,
            required_field_count: 0,
            formula_field_count: 0,
            relationship_field_count: 0,
            complexity_score: 0,
        };
        assert_eq!(
            empty_insights, empty_expected,
            "Empty struct equality failed"
        );
    }

    use super::*;
    use crate::test_support::Must;
    use serde_json::json;

    fn create_mock_describe(fields_json: &serde_json::Value) -> SObjectDescribe {
        let describe_json = json!({
            "name": "Account",
            "label": "Account",
            "custom": false,
            "queryable": true,
            "activateable": false, "createable": true, "customSetting": false, "deletable": true,
            "deprecatedAndHidden": false, "feedEnabled": true, "hasSubtypes": false,
            "isSubtype": false, "keyPrefix": "001", "labelPlural": "Accounts", "layoutable": true,
            "mergeable": true, "mruEnabled": true, "replicateable": true, "retrieveable": true,
            "searchable": true, "triggerable": true, "undeletable": true, "updateable": true,
            "urls": {}, "childRelationships": [], "recordTypeInfos": [],
            "fields": fields_json.clone()
        });
        serde_json::from_value(describe_json).must()
    }

    #[allow(clippy::fn_params_excessive_bools)]
    fn mock_field(
        name: &str,
        field_type: &str,
        custom: bool,
        nillable: bool,
        defaulted: bool,
        calculated: bool,
    ) -> serde_json::Value {
        json!({
            "name": name,
            "type": field_type,
            "label": format!("{} Label", name),
            "referenceTo": if field_type == "reference" { vec!["Account"] } else { vec![] },
            "custom": custom,
            "nillable": nillable,
            "defaultedOnCreate": defaulted,
            "calculated": calculated,
            // Mandatory fields filler
            "createable": true, "autoNumber": false, "aggregatable": true, "byteLength": 18,
            "cascadeDelete": false, "caseSensitive": false,
            "dependentPicklist": false, "deprecatedAndHidden": false,
            "digits": 0, "displayLocationInDecimal": false, "encrypted": false, "externalId": false,
            "filterable": true, "groupable": true, "highScaleNumber": false, "htmlFormatted": false,
            "idLookup": true, "length": 18, "nameField": false, "namePointing": false,
            "permissionable": false, "polymorphicForeignKey": false, "precision": 0, "queryByDistance": false,
            "restrictedDelete": false, "restrictedPicklist": false, "scale": 0, "soapType": "tns:ID",
            "sortable": true, "unique": false, "updateable": false, "writeRequiresMasterRead": false
        })
    }

    #[test]
    fn test_schema_analyzer() {
        let describe = create_mock_describe(&json!([
            // Id: Standard, Not required (because it's Id, handled by SFDC)
            mock_field("Id", "id", false, false, true, false),
            // Name: Standard, Required (not nillable, no default)
            mock_field("Name", "string", false, false, false, false),
            // CustomField__c: Custom, Not required (nillable)
            mock_field("CustomField__c", "string", true, true, false, false),
            // Formula__c: Custom, Formula
            mock_field("Formula__c", "double", true, true, false, true),
            // ParentId: Standard, Lookup
            mock_field("ParentId", "reference", false, true, false, false),
            // CustomLookup__c: Custom, Required Lookup
            mock_field("CustomLookup__c", "reference", true, false, false, false)
        ]));

        let insights = analyze_schema(&describe);

        assert_eq!(insights.total_fields, 6);
        assert_eq!(insights.custom_field_count, 3);
        assert_eq!(insights.standard_field_count, 3);
        // Required: Name, CustomLookup__c (Id is excluded by logic)
        assert_eq!(insights.required_field_count, 2);
        assert_eq!(insights.formula_field_count, 1);
        assert_eq!(insights.relationship_field_count, 2);

        // Score logic:
        // (total/10) = 0
        // (custom*2) = 6
        // (formula*5) = 5
        // (relationship*3) = 6
        // Total = 17
        assert_eq!(insights.complexity_score, 17);
    }

    #[test]
    fn test_schema_analyzer_complexity_math() {
        let describe = create_mock_describe(&json!([
            mock_field("Id", "id", false, false, true, false),
            mock_field("Name", "string", false, false, false, false),
            mock_field("Custom1__c", "string", true, true, false, false),
            mock_field("Custom2__c", "string", true, true, false, false),
            mock_field("Custom3__c", "string", true, true, false, false),
            mock_field("Formula1__c", "double", true, true, false, true),
            mock_field("Formula2__c", "double", true, true, false, true),
            mock_field("Rel1__c", "reference", true, true, false, false),
            mock_field("Rel2__c", "reference", true, true, false, false),
            mock_field("Standard1", "string", false, true, false, false),
            mock_field("Standard2", "string", false, true, false, false),
            mock_field("Standard3", "string", false, true, false, false)
        ]));

        let insights = analyze_schema(&describe);

        assert_eq!(insights.total_fields, 12);
        assert_eq!(insights.custom_field_count, 7);
        assert_eq!(insights.formula_field_count, 2);
        assert_eq!(insights.relationship_field_count, 2);

        // Score logic: 1 + 14 + 10 + 6 = 31
        assert_eq!(insights.complexity_score, 31);
    }
}