im-core 0.1.0

Rust IM SDK for Awiki clients built on Agent Network Protocol (ANP)
Documentation
use serde_json::Value;

use super::records::ContactRecord;
use crate::internal::local_state::owner_scope::OwnerScope;

pub(crate) fn record_from_save_request(
    client: &crate::core::ImClient,
    request: &crate::directory::SaveContactRequest,
    did: crate::ids::Did,
) -> crate::ImResult<ContactRecord> {
    let scope = OwnerScope::for_client(client)?;
    Ok(ContactRecord {
        owner_identity_id: scope.owner_identity_id.clone(),
        owner_did: scope.owner_did.clone(),
        did: did.as_str().to_string(),
        name: request.display_name.clone().unwrap_or_default(),
        handle: request
            .handle
            .as_ref()
            .map(|handle| handle.as_str().to_string())
            .unwrap_or_else(|| {
                if request.peer.as_str().starts_with("did:") {
                    String::new()
                } else {
                    request.peer.as_str().to_string()
                }
            }),
        relationship: request.relationship.clone().unwrap_or_default(),
        note: request.note.clone().unwrap_or_default(),
        source_type: "directory.save_contact".to_string(),
        credential_name: credential_name(&scope),
        ..ContactRecord::default()
    })
}

pub(crate) fn record_from_profile(
    client: &crate::core::ImClient,
    profile: &crate::identity::Profile,
    source_type: &str,
) -> crate::ImResult<ContactRecord> {
    let scope = OwnerScope::for_client(client)?;
    Ok(ContactRecord {
        owner_identity_id: scope.owner_identity_id.clone(),
        owner_did: scope.owner_did.clone(),
        did: profile.subject.as_str().to_string(),
        name: profile.display_name.clone().unwrap_or_default(),
        handle: profile
            .handle
            .as_ref()
            .map(|handle| handle.as_str().to_string())
            .unwrap_or_default(),
        bio: profile
            .bio
            .clone()
            .or_else(|| profile.description.clone())
            .unwrap_or_default(),
        profile_md: profile.markdown.clone().unwrap_or_default(),
        tags: profile.tags.join(","),
        source_type: source_type.to_string(),
        last_seen_at: profile.updated_at.clone().unwrap_or_default(),
        metadata: metadata_json(profile),
        credential_name: credential_name(&scope),
        ..ContactRecord::default()
    })
}

#[cfg(any(feature = "blocking", test))]
pub(crate) fn project_directory_resolution(
    client: &crate::core::ImClient,
    resolution: &crate::directory::DirectoryResolution,
) {
    let mut connection = match super::open_writable(client) {
        Ok(connection) => connection,
        Err(_) => return,
    };
    let record = match record_from_directory_resolution(client, resolution) {
        Ok(record) => record,
        Err(_) => return,
    };
    let _ = super::records::upsert_contact(&mut connection, record);
}

#[cfg(not(any(feature = "blocking", test)))]
pub(crate) fn project_directory_resolution(
    _client: &crate::core::ImClient,
    _resolution: &crate::directory::DirectoryResolution,
) {
}

pub(crate) async fn project_directory_resolution_async(
    client: &crate::core::ImClient,
    resolution: &crate::directory::DirectoryResolution,
) -> crate::ImResult<()> {
    let record = record_from_directory_resolution(client, resolution)?;
    let db = client.core_inner().local_state_db().await?;
    db.upsert_contact(record).await
}

fn record_from_directory_resolution(
    client: &crate::core::ImClient,
    resolution: &crate::directory::DirectoryResolution,
) -> crate::ImResult<ContactRecord> {
    let scope = OwnerScope::for_client(client)?;
    let credential_name = credential_name(&scope);
    let record = resolution
        .profile
        .as_ref()
        .map(|profile| record_from_profile(client, profile, "directory.profile_projection"))
        .transpose()?
        .unwrap_or_else(|| ContactRecord {
            owner_identity_id: scope.owner_identity_id,
            owner_did: scope.owner_did,
            did: resolution.did.as_str().to_string(),
            handle: resolution
                .handle
                .as_ref()
                .map(|handle| handle.as_str().to_string())
                .unwrap_or_default(),
            source_type: "directory.resolve_peer".to_string(),
            credential_name,
            ..ContactRecord::default()
        });
    Ok(record)
}

fn metadata_json(profile: &crate::identity::Profile) -> String {
    let mut object: serde_json::Map<String, Value> = profile
        .metadata
        .iter()
        .map(|attribute| {
            (
                attribute.key.clone(),
                Value::String(attribute.value.clone()),
            )
        })
        .collect();
    if let Some(avatar_uri) = profile.effective_avatar_uri() {
        object.insert("avatar_uri".to_string(), Value::String(avatar_uri.clone()));
        object
            .entry("avatar_url".to_string())
            .or_insert_with(|| Value::String(avatar_uri.clone()));
    }
    if let Some(profile_uri) = profile.profile_uri.as_ref() {
        object.insert(
            "profile_uri".to_string(),
            Value::String(profile_uri.clone()),
        );
    }
    if let Some(subject_type) = profile.subject_type.as_ref() {
        object.insert(
            "subject_type".to_string(),
            Value::String(subject_type.clone()),
        );
    }
    if let Some(version_id) = profile.version_id.as_ref() {
        object.insert("versionId".to_string(), Value::String(version_id.clone()));
    }
    if let Some(ttl) = profile.ttl {
        object.insert("ttl".to_string(), serde_json::json!(ttl));
    }
    if object.is_empty() {
        return String::new();
    }
    let value = Value::Object(object);
    value.to_string()
}

fn credential_name(scope: &OwnerScope) -> String {
    scope
        .credential_name
        .clone()
        .unwrap_or_else(|| scope.owner_identity_id.clone())
}

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

    #[test]
    fn contacts_profile_projection_maps_profile_to_contact_record() {
        let client = fixture_client();
        let mut profile =
            crate::identity::Profile::new(crate::ids::Did::parse("did:example:bob").unwrap());
        profile.handle = Some(crate::ids::Handle::parse("bob.awiki.test", "").unwrap());
        profile.display_name = Some("Bob".to_string());
        profile.bio = Some("Builder".to_string());
        profile.tags = vec!["rust".to_string(), "sdk".to_string()];
        profile.markdown = Some("## Bob".to_string());
        profile.avatar_uri = Some("https://cdn.test/bob.png".to_string());
        profile.profile_uri = Some("https://bob.awiki.test/".to_string());
        profile.subject_type = Some("person".to_string());
        profile.updated_at = Some("2026-05-21T00:00:00Z".to_string());
        profile.metadata = vec![crate::identity::ProfileAttribute {
            key: "source".to_string(),
            value: "profile".to_string(),
        }];

        let record = record_from_profile(&client, &profile, "directory.profile_projection")
            .expect("profile projection record");

        assert_eq!(record.owner_identity_id, "alice-id");
        assert_eq!(record.owner_did, "did:example:alice");
        assert_eq!(record.did, "did:example:bob");
        assert_eq!(record.handle, "bob.awiki.test");
        assert_eq!(record.name, "Bob");
        assert_eq!(record.tags, "rust,sdk");
        assert_eq!(record.profile_md, "## Bob");
        assert_eq!(record.last_seen_at, "2026-05-21T00:00:00Z");
        let metadata: serde_json::Value = serde_json::from_str(&record.metadata).unwrap();
        assert_eq!(metadata["source"], "profile");
        assert_eq!(metadata["avatar_uri"], "https://cdn.test/bob.png");
        assert_eq!(metadata["avatar_url"], "https://cdn.test/bob.png");
        assert_eq!(metadata["profile_uri"], "https://bob.awiki.test/");
        assert_eq!(metadata["subject_type"], "person");
        assert_eq!(record.credential_name, "alice");
    }

    fn fixture_client() -> crate::core::ImClient {
        let root = tempfile::TempDir::new().unwrap();
        let identities = root.path().join("identities");
        std::fs::create_dir_all(identities.join("alice")).unwrap();
        std::fs::write(identities.join("default"), "alice\n").unwrap();
        std::fs::write(
            identities.join("registry.json"),
            r#"{
              "default_identity": "alice",
              "identities": [{
                "id": "alice-id",
                "did": "did:example:alice",
                "handle": "alice.awiki.test",
                "display_name": "Alice",
                "local_alias": "alice",
                "ready_for_auth": true,
                "ready_for_messaging": true,
                "missing": []
              }]
            }"#,
        )
        .unwrap();
        std::fs::write(
            identities.join("alice").join("did.json"),
            r#"{"id":"did:example:alice","controller":"did:example:alice"}"#,
        )
        .unwrap();
        std::fs::write(identities.join("alice").join("private.key"), "key\n").unwrap();
        std::fs::write(
            identities.join("alice").join("auth.json"),
            r#"{"jwt_token":"token"}"#,
        )
        .unwrap();
        let core = crate::core::ImCore::new(
            crate::config::ImCoreConfig {
                service_base_url: crate::config::ServiceEndpoint::parse("https://example.test")
                    .unwrap(),
                did_domain: "awiki.test".to_string(),
                user_service_endpoint: None,
                message_service_endpoint: None,
                mail_service_endpoint: None,
                anp_service_endpoint: None,
                anp_service_did: None,
                ca_bundle: None,
                transport_policy: crate::config::MessageTransportPolicy::HttpOnly,
            },
            crate::paths::ImCorePaths {
                identities: crate::paths::IdentityRegistryPaths {
                    identity_root_dir: identities.clone(),
                    registry_path: identities.join("registry.json"),
                    default_identity_path: Some(identities.join("default")),
                },
                local_state: crate::paths::LocalStatePaths {
                    sqlite_path: root.path().join("local").join("im.sqlite"),
                },
                runtime: crate::paths::RuntimePaths {
                    cache_dir: root.path().join("cache"),
                    temp_dir: root.path().join("tmp"),
                },
            },
        )
        .unwrap();
        core.client(crate::identity::IdentitySelector::LocalAlias(
            "alice".to_string(),
        ))
        .unwrap()
    }
}