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 CREATOR_WORKSTREAM_PREFIX: &str = "creator:workstream=";
pub const WORKSTREAM_TAG_PREFIX: &str = "ws:";
pub const WORKSTREAM_NAME_ENV: &str = "TM_WORKSTREAM_NAME";
pub const X_TRUSTY_CLIENT_NAME: &str = "x-trusty-client-name";
pub const X_TRUSTY_CLIENT_CWD: &str = "x-trusty-client-cwd";
pub const X_TRUSTY_CLIENT_WORKSTREAM: &str = "x-trusty-client-workstream";
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>,
pub workstream: 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());
let workstream = resolve_own_workstream_name(cwd.as_deref());
Self {
client: client.into(),
version: env!("CARGO_PKG_VERSION").to_string(),
source,
cwd,
workstream,
}
}
pub fn new_for_caller(
client: impl Into<String>,
source: CreatorSource,
caller_cwd: Option<&str>,
caller_workstream: Option<&str>,
) -> Self {
let cwd = caller_cwd.map(str::to_string).filter(|c| !c.is_empty());
let workstream = match caller_workstream.filter(|w| !w.is_empty()) {
Some(w) => is_valid_workstream_name(w).then(|| w.to_string()),
None => resolve_workstream_name(cwd.as_deref()),
};
Self {
client: client.into(),
version: env!("CARGO_PKG_VERSION").to_string(),
source,
cwd,
workstream,
}
}
pub fn into_tags(self) -> Vec<String> {
let mut out = Vec::with_capacity(6);
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}"));
}
if let Some(ws) = self.workstream.filter(|w| is_valid_workstream_name(w)) {
out.push(format!("{CREATOR_WORKSTREAM_PREFIX}{ws}"));
out.push(format!("{WORKSTREAM_TAG_PREFIX}{ws}"));
}
out
}
pub fn merge_into(self, dst: &mut Vec<String>) {
for tag in self.into_tags() {
dst.push(tag);
}
}
pub fn merge_into_deduped(self, dst: &mut Vec<String>) {
for tag in self.into_tags() {
if !dst.contains(&tag) {
dst.push(tag);
}
}
}
}
pub fn is_creator_tag(tag: &str) -> bool {
tag.starts_with("creator:")
}
pub fn resolve_workstream_name(cwd: Option<&str>) -> Option<String> {
let cwd = cwd?;
let mut components = cwd.split('/');
while let Some(part) = components.next() {
if part == ".worktrees" {
let candidate = components.next()?;
return is_valid_workstream_name(candidate).then(|| candidate.to_string());
}
}
None
}
pub(crate) fn resolve_own_workstream_name(cwd: Option<&str>) -> Option<String> {
if let Ok(name) = std::env::var(WORKSTREAM_NAME_ENV) {
return is_valid_workstream_name(&name).then_some(name);
}
resolve_workstream_name(cwd)
}
pub fn is_valid_workstream_name(name: &str) -> bool {
if name.is_empty() || name.len() > 64 {
return false;
}
if uuid::Uuid::parse_str(name).is_ok() {
return false;
}
let mut chars = name.chars();
let Some(first) = chars.next() else {
return false;
};
if !first.is_ascii_alphanumeric() {
return false;
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-')
}
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()),
workstream: None,
};
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,
workstream: 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()),
workstream: None,
};
assert_eq!(info_empty.into_tags().len(), 3);
}
#[tokio::test]
async fn creator_info_self_populates_version_and_cwd() {
let _guard = crate::commands::env_test_lock().lock().await;
unsafe {
std::env::remove_var(WORKSTREAM_NAME_ENV);
}
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,
workstream: 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
);
}
#[test]
fn creator_info_renders_workstream_tags_when_resolvable() {
let info = CreatorInfo {
client: "mcp".into(),
version: "0.1.0".into(),
source: CreatorSource::Mcp,
cwd: Some("/tmp/proj".into()),
workstream: Some("feat-ws-memory-claims".into()),
};
let tags = info.into_tags();
assert_eq!(
tags,
vec![
"creator:client=mcp".to_string(),
"creator:version=0.1.0".to_string(),
"creator:source=mcp".to_string(),
"creator:cwd=/tmp/proj".to_string(),
"creator:workstream=feat-ws-memory-claims".to_string(),
"ws:feat-ws-memory-claims".to_string(),
]
);
}
#[test]
fn creator_info_omits_workstream_tags_when_absent_or_invalid() {
let absent = CreatorInfo {
client: "mcp".into(),
version: "0.1.0".into(),
source: CreatorSource::Mcp,
cwd: None,
workstream: None,
};
assert_eq!(absent.into_tags().len(), 3);
let invalid = CreatorInfo {
client: "mcp".into(),
version: "0.1.0".into(),
source: CreatorSource::Mcp,
cwd: None,
workstream: Some("11111111-1111-1111-1111-111111111111".into()),
};
assert_eq!(invalid.into_tags().len(), 3);
}
#[test]
fn new_for_caller_prefers_explicit_workstream_over_cwd() {
let info = CreatorInfo::new_for_caller(
"trusty-memory-mcp",
CreatorSource::Mcp,
Some("/x/.worktrees/cwd-derived-name"),
Some("explicit-ws"),
);
assert_eq!(info.workstream.as_deref(), Some("explicit-ws"));
assert_eq!(info.cwd.as_deref(), Some("/x/.worktrees/cwd-derived-name"));
}
#[test]
fn new_for_caller_falls_back_to_cwd_when_workstream_absent() {
let info = CreatorInfo::new_for_caller(
"trusty-memory-mcp",
CreatorSource::Mcp,
Some("/x/.worktrees/cwd-derived-name"),
None,
);
assert_eq!(info.workstream.as_deref(), Some("cwd-derived-name"));
}
#[test]
fn new_for_caller_omits_workstream_when_neither_resolvable() {
let info = CreatorInfo::new_for_caller("trusty-memory-mcp", CreatorSource::Mcp, None, None);
assert_eq!(info.workstream, None);
assert_eq!(info.cwd, None);
}
#[test]
fn new_for_caller_invalid_explicit_workstream_returns_none_not_cwd_fallback() {
let info = CreatorInfo::new_for_caller(
"trusty-memory-mcp",
CreatorSource::Mcp,
Some("/x/.worktrees/cwd-derived-name"),
Some("not a valid name!"),
);
assert_eq!(info.workstream, None);
}
#[test]
fn merge_into_deduped_skips_tags_already_present() {
let mut tags = vec!["user-tag".to_string(), "ws:feat-x".to_string()];
CreatorInfo {
client: "trusty-memory-mcp".into(),
version: "0.1.0".into(),
source: CreatorSource::Mcp,
cwd: None,
workstream: Some("feat-x".into()),
}
.merge_into_deduped(&mut tags);
assert_eq!(
tags.iter().filter(|t| *t == "ws:feat-x").count(),
1,
"ws:feat-x must not be duplicated; got {tags:?}"
);
assert!(
tags.contains(&"creator:client=trusty-memory-mcp".to_string()),
"non-overlapping tags must still be appended; got {tags:?}"
);
assert!(
tags.contains(&"creator:workstream=feat-x".to_string()),
"creator:workstream= must still be appended (only ws: overlapped); got {tags:?}"
);
}
#[test]
fn merge_into_deduped_appends_when_no_overlap() {
let mut tags = vec!["unrelated".to_string()];
CreatorInfo {
client: "trusty-memory-mcp".into(),
version: "0.1.0".into(),
source: CreatorSource::Mcp,
cwd: None,
workstream: Some("feat-x".into()),
}
.merge_into_deduped(&mut tags);
assert_eq!(
tags,
vec![
"unrelated".to_string(),
"creator:client=trusty-memory-mcp".to_string(),
"creator:version=0.1.0".to_string(),
"creator:source=mcp".to_string(),
"creator:workstream=feat-x".to_string(),
"ws:feat-x".to_string(),
]
);
}
#[tokio::test]
async fn resolve_workstream_name_prefers_env_var() {
let _guard = crate::commands::env_test_lock().lock().await;
unsafe {
std::env::set_var(WORKSTREAM_NAME_ENV, "explicit-ws");
}
let resolved = resolve_own_workstream_name(Some("/x/.worktrees/other-name"));
unsafe {
std::env::remove_var(WORKSTREAM_NAME_ENV);
}
assert_eq!(resolved, Some("explicit-ws".to_string()));
}
#[tokio::test]
async fn resolve_workstream_name_invalid_env_var_returns_none() {
let _guard = crate::commands::env_test_lock().lock().await;
unsafe {
std::env::set_var(WORKSTREAM_NAME_ENV, "not a valid name!");
}
let resolved = resolve_own_workstream_name(Some("/x/.worktrees/other-name"));
unsafe {
std::env::remove_var(WORKSTREAM_NAME_ENV);
}
assert_eq!(resolved, None);
}
#[test]
fn resolve_workstream_name_falls_back_to_worktrees_cwd_segment() {
let resolved = resolve_workstream_name(Some(
"/Users/bob/trusty-tools/.base/.worktrees/feat-ws-memory-claims",
));
assert_eq!(resolved, Some("feat-ws-memory-claims".to_string()));
}
#[test]
fn resolve_workstream_name_rejects_uuid_shaped_cwd_segment() {
let resolved = resolve_workstream_name(Some(
"/Users/bob/trusty-tools/.base/.worktrees/2eb72dca-de08-481b-8dfa-22ab7f81b1f9",
));
assert_eq!(resolved, None);
}
#[test]
fn resolve_workstream_name_none_when_unresolvable() {
assert_eq!(resolve_workstream_name(None), None);
assert_eq!(
resolve_workstream_name(Some("/Users/bob/some/other/project")),
None
);
}
#[test]
fn is_valid_workstream_name_accepts_slugs() {
for name in [
"feat-ws-memory-claims",
"tm_search_eviction_01",
"a",
"release.0.20.0",
] {
assert!(is_valid_workstream_name(name), "expected valid: {name}");
}
}
#[test]
fn is_valid_workstream_name_rejects_uuid_and_unsafe_names() {
for name in [
"",
"2eb72dca-de08-481b-8dfa-22ab7f81b1f9",
"-leading-dash",
"has space",
"has/slash",
"has;semicolon",
&"x".repeat(65),
] {
assert!(!is_valid_workstream_name(name), "expected invalid: {name}");
}
}
}