bear-cli 0.3.3

A native Rust CLI for Bear.app on macOS — SQLite for reads, CloudKit REST API for writes
Documentation
use std::collections::HashMap;

use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;

#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ZoneId {
    pub zone_name: String,
}

impl Default for ZoneId {
    fn default() -> Self {
        Self::notes()
    }
}

impl ZoneId {
    pub fn notes() -> Self {
        Self {
            zone_name: "Notes".into(),
        }
    }

    pub fn default_zone() -> Self {
        Self {
            zone_name: "_defaultZone".into(),
        }
    }
}

/// A CloudKit field value with type tag and optional encryption flag.
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct CkField {
    #[serde(rename = "type")]
    pub kind: String,
    pub value: JsonValue,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_encrypted: Option<bool>,
}

impl CkField {
    pub fn string(v: impl Into<String>) -> Self {
        Self {
            kind: "STRING".into(),
            value: JsonValue::String(v.into()),
            is_encrypted: None,
        }
    }

    pub fn string_encrypted(v: impl Into<String>) -> Self {
        Self {
            kind: "STRING".into(),
            value: JsonValue::String(v.into()),
            is_encrypted: Some(true),
        }
    }

    pub fn string_null() -> Self {
        Self {
            kind: "STRING".into(),
            value: JsonValue::Null,
            is_encrypted: None,
        }
    }

    pub fn string_list(v: Vec<String>) -> Self {
        Self {
            kind: "STRING_LIST".into(),
            value: serde_json::to_value(v).unwrap(),
            is_encrypted: None,
        }
    }

    pub fn string_list_null() -> Self {
        Self {
            kind: "STRING_LIST".into(),
            value: JsonValue::Null,
            is_encrypted: None,
        }
    }

    pub fn int64(v: i64) -> Self {
        Self {
            kind: "INT64".into(),
            value: JsonValue::Number(v.into()),
            is_encrypted: None,
        }
    }

    pub fn timestamp(ms: i64) -> Self {
        Self {
            kind: "TIMESTAMP".into(),
            value: JsonValue::Number(ms.into()),
            is_encrypted: None,
        }
    }

    pub fn timestamp_null() -> Self {
        Self {
            kind: "TIMESTAMP".into(),
            value: JsonValue::Null,
            is_encrypted: None,
        }
    }

    pub fn bytes(b64: impl Into<String>) -> Self {
        Self {
            kind: "BYTES".into(),
            value: JsonValue::String(b64.into()),
            is_encrypted: None,
        }
    }

    pub fn asset_id(receipt: AssetReceipt) -> Self {
        Self {
            kind: "ASSETID".into(),
            value: serde_json::to_value(receipt).unwrap(),
            is_encrypted: None,
        }
    }
}

#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct AssetToken {
    pub record_type: String,
    pub record_name: String,
    pub field_name: String,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AssetUploadRequest {
    #[serde(rename = "zoneID")]
    pub zone_id: ZoneId,
    pub tokens: Vec<AssetToken>,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AssetUploadResponse {
    pub tokens: Vec<AssetUploadToken>,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AssetUploadToken {
    pub record_name: String,
    pub field_name: String,
    pub url: String,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct AssetReceipt {
    pub file_checksum: String,
    pub receipt: String,
    pub size: i64,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub wrapping_key: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reference_checksum: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AssetUploadResult {
    pub single_file: AssetReceipt,
}

pub type Fields = HashMap<String, CkField>;

#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct CkRecord {
    pub record_name: String,
    #[serde(default)]
    pub record_type: String,
    #[serde(rename = "zoneID", skip_serializing_if = "Option::is_none")]
    pub zone_id: Option<ZoneId>,
    #[serde(default)]
    pub fields: Fields,
    #[serde(default)]
    pub plugin_fields: HashMap<String, JsonValue>,
    pub record_change_tag: Option<String>,
    pub created: Option<JsonValue>,
    pub modified: Option<JsonValue>,
    #[serde(default)]
    pub deleted: bool,
    // Present on error responses:
    #[serde(skip_serializing_if = "Option::is_none")]
    pub server_error_code: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reason: Option<String>,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ModifyOperation {
    pub operation_type: String, // "create" | "update" | "delete"
    pub record: CkRecord,
    pub record_type: String,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ModifyRequest {
    pub operations: Vec<ModifyOperation>,
    #[serde(rename = "zoneID")]
    pub zone_id: ZoneId,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModifyResponse {
    pub records: Vec<CkRecord>,
}

#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct CkQuery {
    pub record_type: String,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub filter_by: Vec<CkFilter>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub sort_by: Vec<CkSort>,
}

#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct CkFilter {
    pub field_name: String,
    pub comparator: String,
    pub field_value: CkFilterValue,
}

#[derive(Debug, Serialize, Clone)]
pub struct CkFilterValue {
    pub value: JsonValue,
    #[serde(rename = "type")]
    pub kind: String,
}

#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct CkSort {
    pub field_name: String,
    pub ascending: bool,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct QueryRequest {
    #[serde(rename = "zoneID")]
    pub zone_id: ZoneId,
    pub query: CkQuery,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub results_limit: Option<usize>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub desired_keys: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub continuation_marker: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct QueryResponse {
    pub records: Vec<CkRecord>,
    #[serde(default)]
    pub continuation_marker: Option<String>,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LookupRequest {
    pub records: Vec<LookupRecord>,
    #[serde(rename = "zoneID")]
    pub zone_id: ZoneId,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LookupRecord {
    pub record_name: String,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LookupResponse {
    pub records: Vec<CkRecord>,
}

impl CkRecord {
    pub fn str_field(&self, name: &str) -> Option<&str> {
        self.fields.get(name)?.value.as_str()
    }

    pub fn i64_field(&self, name: &str) -> Option<i64> {
        self.fields.get(name)?.value.as_i64()
    }

    pub fn bool_field(&self, name: &str) -> Option<bool> {
        self.i64_field(name).map(|v| v != 0)
    }

    pub fn string_list_field(&self, name: &str) -> Vec<String> {
        self.fields
            .get(name)
            .and_then(|f| f.value.as_array())
            .map(|arr| {
                arr.iter()
                    .filter_map(|v| v.as_str().map(str::to_string))
                    .collect()
            })
            .unwrap_or_default()
    }
}

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

    #[test]
    fn modify_request_serializes_zone_id_exactly() {
        let json = serde_json::to_value(ModifyRequest {
            operations: vec![],
            zone_id: ZoneId::notes(),
        })
        .unwrap();

        assert!(json.get("zoneID").is_some());
        assert!(json.get("zoneId").is_none());
    }

    #[test]
    fn query_request_serializes_zone_id_exactly() {
        let json = serde_json::to_value(QueryRequest {
            zone_id: ZoneId::notes(),
            query: CkQuery {
                record_type: "SFNote".into(),
                filter_by: vec![],
                sort_by: vec![],
            },
            results_limit: Some(1),
            desired_keys: None,
            continuation_marker: None,
        })
        .unwrap();

        assert!(json.get("zoneID").is_some());
        assert!(json.get("zoneId").is_none());
    }
}