me-sh 0.2.0

Command-line tools for me.sh.
use crate::prelude::*;

pub(crate) async fn fetch_contacts(
    runtime: &Runtime,
    ids: &[u64],
    concurrency: usize,
) -> Result<Vec<Value>> {
    let mut contacts = Vec::with_capacity(ids.len());
    fetch_contacts_each(runtime, ids, concurrency, |_, value| {
        contacts.push(value);
        Ok(())
    })
    .await?;
    Ok(contacts)
}

pub(crate) async fn fetch_contacts_each<F>(
    runtime: &Runtime,
    ids: &[u64],
    concurrency: usize,
    mut on_contact: F,
) -> Result<()>
where
    F: FnMut(u64, Value) -> Result<()>,
{
    if concurrency == 0 {
        return err("contact fetch concurrency must be greater than zero");
    }
    for chunk in ids.chunks(concurrency) {
        let mut handles = Vec::with_capacity(chunk.len());
        for id in chunk {
            let runtime = runtime.clone();
            let id = *id;
            handles.push((
                id,
                tokio::spawn(async move {
                    runtime
                        .call_tool(route::GET_CONTACT, json!({ "contact_id": id }))
                        .await
                }),
            ));
        }

        let mut chunk_values = Vec::with_capacity(handles.len());
        let mut first_error = None;
        for (id, handle) in handles {
            let result = handle
                .await
                .into_diagnostic()
                .wrap_err_with(|| format!("joining contact fetch task {id}"))
                .and_then(|result| result.wrap_err_with(|| format!("fetching contact {id}")));
            match result {
                Ok(value) => chunk_values.push((id, value)),
                Err(error) => {
                    if first_error.is_none() {
                        first_error = Some(error);
                    }
                }
            }
        }
        if let Some(error) = first_error {
            return Err(error);
        }
        for (id, value) in chunk_values {
            on_contact(id, value)?;
        }
    }
    Ok(())
}

pub(crate) fn normalize_full_contact(id: u64, data: Value) -> Value {
    match data {
        Value::Object(object) if object_contact_id(&object) == Some(id) => Value::Object(object),
        other => json!({
            "id": id,
            "missing": other.is_null(),
            "contact": other,
        }),
    }
}

fn object_contact_id(object: &Map<String, Value>) -> Option<u64> {
    object.get("id").and_then(|value| match value {
        Value::Number(number) => number.as_u64(),
        Value::String(value) => parse_contact_id(value).ok(),
        _ => None,
    })
}

pub(crate) async fn live_contact_records(
    runtime: &Runtime,
    page_size: usize,
    keep_values: bool,
) -> Result<(BTreeMap<String, String>, BTreeMap<String, Value>, usize)> {
    let mut records = BTreeMap::new();
    let mut values = BTreeMap::new();
    let count = export_all_contacts_each(runtime, Map::new(), page_size, |row| {
        insert_record_value(
            "live search contact",
            row,
            &mut records,
            keep_values.then_some(&mut values),
        )
    })
    .await?;
    Ok((records, values, count))
}

pub(crate) async fn live_group_records(
    runtime: &Runtime,
    keep_values: bool,
) -> Result<(BTreeMap<String, String>, BTreeMap<String, Value>, usize)> {
    let data = runtime.call_tool(route::GET_GROUPS, json!({})).await?;
    let mut records = BTreeMap::new();
    let mut values = BTreeMap::new();
    for row in snapshot_group_rows_from_response(&data)? {
        insert_record_value(
            "live group",
            row,
            &mut records,
            keep_values.then_some(&mut values),
        )?;
    }
    let count = records.len();
    Ok((records, values, count))
}

pub(crate) async fn live_full_contact_records(
    runtime: &Runtime,
    ids: &[u64],
    concurrency: usize,
    keep_values: bool,
) -> Result<(BTreeMap<String, String>, BTreeMap<String, Value>)> {
    let mut records = BTreeMap::new();
    let mut values = BTreeMap::new();
    fetch_contacts_each(runtime, ids, concurrency, |id, contact| {
        let row = normalize_full_contact(id, contact);
        insert_record_value(
            "live full contact",
            row,
            &mut records,
            keep_values.then_some(&mut values),
        )
    })
    .await?;
    Ok((records, values))
}

pub(crate) fn insert_record_value(
    label: &str,
    value: Value,
    records: &mut BTreeMap<String, String>,
    values: Option<&mut BTreeMap<String, Value>>,
) -> Result<()> {
    let id = record_id(&value).ok_or_else(|| miette!("{label} did not include top-level id"))?;
    if records.contains_key(&id) {
        return err(format!("{label} contains duplicate record ID {id}"));
    }
    let hash = record_hash(&value)?;
    records.insert(id.clone(), hash);
    if let Some(values) = values {
        values.insert(id, value);
    }
    Ok(())
}

pub(crate) fn filter_records_by_ids<T: Clone>(
    records: &BTreeMap<String, T>,
    ids: &[u64],
) -> BTreeMap<String, T> {
    ids.iter()
        .filter_map(|id| {
            let key = id.to_string();
            records.get(&key).cloned().map(|value| (key, value))
        })
        .collect()
}

pub(crate) fn append_exclude_contact_ids(
    payload: &mut Map<String, Value>,
    ids: &[u64],
) -> Result<()> {
    if ids.is_empty() {
        return Ok(());
    }
    let mut all = exclude_contact_ids_from_payload(payload.get("exclude_contact_ids"))?;
    all.extend_from_slice(ids);
    all = dedupe_ids(all);
    payload.set("exclude_contact_ids", json!(all));
    Ok(())
}

pub(crate) fn exclude_contact_ids_from_payload(value: Option<&Value>) -> Result<Vec<u64>> {
    let Some(value) = value else {
        return Ok(Vec::new());
    };
    match value {
        Value::Array(items) => items.iter().map(parse_u64_value).collect(),
        other => parse_u64_value(other).map(|id| vec![id]),
    }
}

pub(crate) fn total_from_search(data: &Value) -> Value {
    data.get("total")
        .cloned()
        .or_else(|| data.get("count").cloned())
        .unwrap_or(Value::Null)
}

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

    #[test]
    fn normalize_full_contact_keeps_objects_with_ids() {
        let contact = json!({"id": 7, "name": "Ada"});

        assert_eq!(normalize_full_contact(7, contact.clone()), contact);
        assert_eq!(
            normalize_full_contact(42, json!({"id": "42", "name": "Ada"})),
            json!({"id": "42", "name": "Ada"})
        );
    }

    #[test]
    fn normalize_full_contact_wraps_missing_or_null_contacts() {
        assert_eq!(
            normalize_full_contact(42, Value::Null),
            json!({"id": 42, "missing": true, "contact": null})
        );
        assert_eq!(
            normalize_full_contact(42, json!({"name": "Ada"})),
            json!({"id": 42, "missing": false, "contact": {"name": "Ada"}})
        );
    }

    #[test]
    fn normalize_full_contact_wraps_contacts_with_unusable_ids() {
        assert_eq!(
            normalize_full_contact(42, json!({"id": null, "name": "Ada"})),
            json!({"id": 42, "missing": false, "contact": {"id": null, "name": "Ada"}})
        );
        assert_eq!(
            normalize_full_contact(42, json!({"id": true, "name": "Ada"})),
            json!({"id": 42, "missing": false, "contact": {"id": true, "name": "Ada"}})
        );
    }

    #[test]
    fn normalize_full_contact_wraps_contacts_for_other_ids() {
        assert_eq!(
            normalize_full_contact(42, json!({"id": 7, "name": "Grace"})),
            json!({"id": 42, "missing": false, "contact": {"id": 7, "name": "Grace"}})
        );
    }

    #[test]
    fn insert_record_value_stores_hash_and_optional_value() -> Result<()> {
        let value = json!({"id": "c-1", "name": "Ada"});
        let expected_hash = record_hash(&value)?;
        let mut records = BTreeMap::new();
        let mut values = BTreeMap::new();

        insert_record_value("contact", value.clone(), &mut records, Some(&mut values))?;

        assert_eq!(records.get("c-1"), Some(&expected_hash));
        assert_eq!(values.get("c-1"), Some(&value));
        Ok(())
    }

    #[test]
    fn insert_record_value_requires_top_level_id() {
        let error = insert_record_value(
            "contact",
            json!({"name": "Ada"}),
            &mut BTreeMap::new(),
            None,
        )
        .unwrap_err()
        .to_string();

        assert!(error.contains("contact did not include top-level id"));
    }

    #[test]
    fn insert_record_value_rejects_duplicate_ids() -> Result<()> {
        let first = json!({"id": 1, "name": "first"});
        let second = json!({"id": 1, "name": "second"});
        let mut records = BTreeMap::new();
        let mut values = BTreeMap::new();

        insert_record_value(
            "live contact",
            first.clone(),
            &mut records,
            Some(&mut values),
        )?;
        let error = insert_record_value("live contact", second, &mut records, Some(&mut values))
            .expect_err("duplicate live IDs should not be collapsed");

        assert!(error.to_string().contains("duplicate record ID 1"));
        assert_eq!(records.get("1"), Some(&record_hash(&first)?));
        assert_eq!(values.get("1"), Some(&first));
        Ok(())
    }

    #[test]
    fn filter_records_by_ids_keeps_matching_numeric_string_keys() {
        let records = BTreeMap::from([
            ("1".to_string(), "one".to_string()),
            ("2".to_string(), "two".to_string()),
            ("3".to_string(), "three".to_string()),
        ]);

        assert_eq!(
            filter_records_by_ids(&records, &[3, 99, 1]),
            BTreeMap::from([
                ("1".to_string(), "one".to_string()),
                ("3".to_string(), "three".to_string())
            ])
        );
    }

    #[test]
    fn append_exclude_contact_ids_merges_and_dedupes_ids() -> Result<()> {
        let mut payload = Map::from_iter([("exclude_contact_ids".to_string(), json!([2, 1]))]);

        append_exclude_contact_ids(&mut payload, &[1, 3, 2])?;

        assert_eq!(payload.get("exclude_contact_ids"), Some(&json!([2, 1, 3])));
        Ok(())
    }

    #[test]
    fn append_exclude_contact_ids_leaves_payload_unchanged_for_empty_input() -> Result<()> {
        let mut payload = Map::from_iter([("exclude_contact_ids".to_string(), json!("bad"))]);

        append_exclude_contact_ids(&mut payload, &[])?;

        assert_eq!(payload.get("exclude_contact_ids"), Some(&json!("bad")));
        Ok(())
    }

    #[test]
    fn exclude_contact_ids_from_payload_accepts_scalar_and_array_values() -> Result<()> {
        assert_eq!(exclude_contact_ids_from_payload(None)?, Vec::<u64>::new());
        assert_eq!(exclude_contact_ids_from_payload(Some(&json!(7)))?, vec![7]);
        assert_eq!(
            exclude_contact_ids_from_payload(Some(&json!(["7", 8])))?,
            vec![7, 8]
        );
        Ok(())
    }

    #[test]
    fn exclude_contact_ids_from_payload_rejects_invalid_values() {
        let error = exclude_contact_ids_from_payload(Some(&json!([1, null])))
            .unwrap_err()
            .to_string();

        assert!(error.contains("contact ID values must be positive integers"));
    }

    #[test]
    fn total_from_search_prefers_total_then_count() {
        assert_eq!(
            total_from_search(&json!({"total": 5, "count": 3})),
            json!(5)
        );
        assert_eq!(total_from_search(&json!({"count": 3})), json!(3));
        assert_eq!(total_from_search(&json!({"items": []})), Value::Null);
    }
}