kiromi-ai-memory 0.2.2

Local-first multi-tenant memory store engine: Markdown/text content on object storage, metadata in SQLite, plugin-shaped embedder/storage/metadata, hybrid text+vector search.
Documentation
// SPDX-License-Identifier: Apache-2.0 OR MIT
//! Tenant identity newtype with strict charset validation.

use std::fmt;

use serde::{Deserialize, Serialize};

/// Tenant identifier — strict charset `[A-Za-z0-9_-]{1..=64}`.
#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct TenantId(String);

impl TenantId {
    /// Construct a tenant id, validating the charset and length.
    pub fn new(s: impl Into<String>) -> Result<Self, InvalidTenantId> {
        let s = s.into();
        if s.is_empty() {
            return Err(InvalidTenantId::Empty);
        }
        if s.len() > 64 {
            return Err(InvalidTenantId::TooLong { len: s.len() });
        }
        if let Some(c) = s.chars().find(|c| !is_valid_tenant_char(*c)) {
            return Err(InvalidTenantId::BadChar { ch: c });
        }
        Ok(TenantId(s))
    }

    /// Borrow as `&str`.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

fn is_valid_tenant_char(c: char) -> bool {
    c.is_ascii_alphanumeric() || c == '_' || c == '-'
}

impl fmt::Display for TenantId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.0)
    }
}

impl From<TenantId> for String {
    fn from(t: TenantId) -> String {
        t.0
    }
}

impl TryFrom<String> for TenantId {
    type Error = InvalidTenantId;
    fn try_from(s: String) -> Result<Self, Self::Error> {
        Self::new(s)
    }
}

/// Reasons a tenant id can be rejected.
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum InvalidTenantId {
    /// Tenant id was empty.
    #[error("tenant id is empty")]
    Empty,
    /// Tenant id exceeded 64 chars.
    #[error("tenant id too long ({len} > 64)")]
    TooLong {
        /// The actual length.
        len: usize,
    },
    /// Tenant id contained a disallowed character.
    #[error("tenant id contains disallowed character {ch:?}")]
    BadChar {
        /// The first offending character.
        ch: char,
    },
}

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

    #[test]
    fn rejects_empty() {
        assert_eq!(TenantId::new("").unwrap_err(), InvalidTenantId::Empty);
    }

    #[test]
    fn rejects_too_long() {
        let s: String = "a".repeat(65);
        assert!(matches!(
            TenantId::new(s).unwrap_err(),
            InvalidTenantId::TooLong { len: 65 }
        ));
    }

    #[test]
    fn rejects_disallowed_chars() {
        for c in ['/', ' ', '.', '\u{00e9}', '\n', '*'] {
            let s = format!("a{c}b");
            assert!(matches!(
                TenantId::new(&s).unwrap_err(),
                InvalidTenantId::BadChar { ch } if ch == c
            ));
        }
    }

    #[test]
    fn accepts_valid() {
        for s in ["a", "ABC_123", "x-y_z", "Z9"] {
            let t = TenantId::new(s).unwrap();
            assert_eq!(t.as_str(), s);
        }
    }

    #[test]
    fn round_trips_through_serde() {
        let t = TenantId::new("acme_42").unwrap();
        let j = serde_json::to_string(&t).unwrap();
        assert_eq!(j, "\"acme_42\"");
        let back: TenantId = serde_json::from_str(&j).unwrap();
        assert_eq!(back, t);
    }
}