use crate::ActivitySource;
pub const CREATOR_CLIENT_PREFIX: &str = "creator:client=";
pub const CREATOR_VERSION_PREFIX: &str = "creator:version=";
pub const CREATOR_SOURCE_PREFIX: &str = "creator:source=";
pub const CREATOR_CWD_PREFIX: &str = "creator:cwd=";
pub const CREATOR_SESSION_PREFIX: &str = "creator:session=";
pub const X_TRUSTY_CLIENT_NAME: &str = "x-trusty-client-name";
pub const X_TRUSTY_CLIENT_CWD: &str = "x-trusty-client-cwd";
pub const HTTP_DEFAULT_CLIENT: &str = "unknown-http-client";
pub const MCP_CLIENT_NAME: &str = "trusty-memory-mcp";
pub const CLI_CLIENT_NAME: &str = "trusty-memory-cli";
pub const HOOK_CLIENT_NAME: &str = "trusty-memory-hook";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CreatorSource {
Http,
Mcp,
Hook,
Cli,
}
impl CreatorSource {
pub fn as_str(&self) -> &'static str {
match self {
Self::Http => "http",
Self::Mcp => "mcp",
Self::Hook => "hook",
Self::Cli => "cli",
}
}
}
impl From<ActivitySource> for CreatorSource {
fn from(s: ActivitySource) -> Self {
match s {
ActivitySource::Http => Self::Http,
ActivitySource::Mcp => Self::Mcp,
ActivitySource::Hook => Self::Hook,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreatorInfo {
pub client: String,
pub version: String,
pub source: CreatorSource,
pub cwd: Option<String>,
}
impl CreatorInfo {
pub fn new_self(client: impl Into<String>, source: CreatorSource) -> Self {
let cwd = std::env::current_dir()
.ok()
.map(|p| p.to_string_lossy().into_owned());
Self {
client: client.into(),
version: env!("CARGO_PKG_VERSION").to_string(),
source,
cwd,
}
}
pub fn into_tags(self) -> Vec<String> {
let mut out = Vec::with_capacity(4);
out.push(format!("{CREATOR_CLIENT_PREFIX}{}", self.client));
out.push(format!("{CREATOR_VERSION_PREFIX}{}", self.version));
out.push(format!("{CREATOR_SOURCE_PREFIX}{}", self.source.as_str()));
if let Some(cwd) = self.cwd.filter(|c| !c.is_empty()) {
out.push(format!("{CREATOR_CWD_PREFIX}{cwd}"));
}
out
}
pub fn merge_into(self, dst: &mut Vec<String>) {
for tag in self.into_tags() {
dst.push(tag);
}
}
}
pub fn is_creator_tag(tag: &str) -> bool {
tag.starts_with("creator:")
}
pub fn session_tag_from_tags(tags: &[String]) -> Option<String> {
for tag in tags {
if is_creator_tag(tag) {
continue;
}
if let Ok(uuid) = uuid::Uuid::parse_str(tag) {
let simple = uuid.simple().to_string();
let short: String = simple.chars().take(8).collect();
return Some(format!("{CREATOR_SESSION_PREFIX}{short}"));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn creator_info_renders_all_fields() {
let info = CreatorInfo {
client: "qa-curl".into(),
version: "0.1.2".into(),
source: CreatorSource::Http,
cwd: Some("/tmp/proj".into()),
};
let tags = info.into_tags();
assert_eq!(
tags,
vec![
"creator:client=qa-curl".to_string(),
"creator:version=0.1.2".to_string(),
"creator:source=http".to_string(),
"creator:cwd=/tmp/proj".to_string(),
]
);
}
#[test]
fn creator_info_omits_cwd_when_absent() {
let info = CreatorInfo {
client: "mcp".into(),
version: "0.1.0".into(),
source: CreatorSource::Mcp,
cwd: None,
};
assert_eq!(info.into_tags().len(), 3);
let info_empty = CreatorInfo {
client: "mcp".into(),
version: "0.1.0".into(),
source: CreatorSource::Mcp,
cwd: Some(String::new()),
};
assert_eq!(info_empty.into_tags().len(), 3);
}
#[test]
fn creator_info_self_populates_version_and_cwd() {
let info = CreatorInfo::new_self("client", CreatorSource::Cli);
assert!(!info.version.is_empty(), "version must be populated");
assert!(info.cwd.is_some(), "cwd should resolve in tests");
}
#[test]
fn merge_into_appends_creator_tags() {
let mut tags = vec!["user-supplied".to_string()];
CreatorInfo {
client: "x".into(),
version: "1".into(),
source: CreatorSource::Cli,
cwd: None,
}
.merge_into(&mut tags);
assert_eq!(
tags,
vec![
"user-supplied".to_string(),
"creator:client=x".to_string(),
"creator:version=1".to_string(),
"creator:source=cli".to_string(),
]
);
}
#[test]
fn is_creator_tag_detects_namespace() {
assert!(is_creator_tag("creator:client=foo"));
assert!(is_creator_tag("creator:cwd=/tmp"));
assert!(is_creator_tag(CREATOR_VERSION_PREFIX));
assert!(!is_creator_tag("user-tag"));
assert!(!is_creator_tag("msg:v1"));
assert!(!is_creator_tag("creatorx"));
}
#[test]
fn session_tag_from_tags_returns_first_uuid_short() {
let tags = vec![
"user-tag".to_string(),
"01919e90-8a2e-7c1d-9f8b-1234567890ab".to_string(),
"ignored-second-uuid:11111111-2222-3333-4444-555555555555".to_string(),
];
let session = session_tag_from_tags(&tags).expect("session tag");
assert_eq!(session, "creator:session=01919e90");
}
#[test]
fn session_tag_from_tags_skips_non_uuid_entries() {
let tags = vec![
"user-tag".to_string(),
"idx:0".to_string(),
"session-prefix-not-a-uuid".to_string(),
];
assert!(session_tag_from_tags(&tags).is_none());
assert!(session_tag_from_tags(&[]).is_none());
}
#[test]
fn session_tag_from_tags_skips_reserved_namespace() {
let tags = vec![
"creator:cwd=11111111-1111-1111-1111-111111111111".to_string(),
"22222222-2222-2222-2222-222222222222".to_string(),
];
let session = session_tag_from_tags(&tags).expect("session tag");
assert_eq!(session, "creator:session=22222222");
}
#[test]
fn creator_source_from_activity_source() {
assert_eq!(
CreatorSource::from(ActivitySource::Http),
CreatorSource::Http
);
assert_eq!(CreatorSource::from(ActivitySource::Mcp), CreatorSource::Mcp);
assert_eq!(
CreatorSource::from(ActivitySource::Hook),
CreatorSource::Hook
);
}
}