use std::path::Path;
use crate::crypto::SignerId;
use crate::git::git_config_get;
use crate::model::{ActorId, id_prefix};
use crate::session::event::{Writer, WriterProducer};
pub(crate) const SHORE_ACTOR_ID_ENV: &str = "SHORE_ACTOR_ID";
pub(crate) fn writer_from_options(repo: &Path, explicit: Option<&ActorId>) -> Writer {
Writer {
actor_id: actor_id_for_repo(explicit.map(ActorId::as_str), repo),
producer: shore_producer(),
}
}
pub fn resolve_writer_actor_id(repo: &Path, explicit: Option<&ActorId>) -> ActorId {
actor_id_for_repo(explicit.map(ActorId::as_str), repo)
}
fn actor_id_for_repo(explicit: Option<&str>, repo: &Path) -> ActorId {
resolve_actor_id(
explicit,
std::env::var(SHORE_ACTOR_ID_ENV).ok().as_deref(),
repo,
)
}
fn resolve_actor_id(explicit: Option<&str>, env: Option<&str>, repo: &Path) -> ActorId {
for value in [explicit, env].into_iter().flatten() {
let value = value.trim();
if is_valid_actor_id(value) {
return ActorId::new(value.to_owned());
}
}
actor_id_from_git_config(repo)
}
pub fn is_valid_actor_id(value: &str) -> bool {
value.len() <= 256 && {
value.strip_prefix("actor:").is_some_and(|rest| {
!rest.is_empty() && rest.chars().all(|c| !c.is_whitespace() && !c.is_control())
}) || SignerId::parse(value).is_ok()
}
}
pub(crate) fn is_valid_principal_actor_id(value: &str) -> bool {
value.len() <= 256 && {
value
.strip_prefix("actor:")
.is_some_and(|rest| !rest.trim().is_empty() && rest.chars().all(|c| !c.is_control()))
|| SignerId::parse(value).is_ok()
}
}
pub fn is_agent_actor_id(value: &str) -> bool {
value
.strip_prefix("actor:agent:")
.is_some_and(|rest| !rest.is_empty())
}
fn shore_producer() -> WriterProducer {
WriterProducer {
name: "shore".to_owned(),
version: env!("CARGO_PKG_VERSION").to_owned(),
}
}
fn actor_id_from_git_config(repo: &Path) -> ActorId {
git_config_get(repo, "user.email")
.map(|email| ActorId::new(format!("{}:git-email:{email}", id_prefix::ACTOR)))
.or_else(|| {
git_config_get(repo, "user.name")
.map(|name| ActorId::new(format!("{}:git-name:{name}", id_prefix::ACTOR)))
})
.unwrap_or_else(|| ActorId::new(format!("{}:local", id_prefix::ACTOR)))
}
#[cfg(test)]
mod tests {
use std::process::Command;
#[test]
fn writer_from_options_uses_git_identity_and_shore_producer() {
let repo = tempfile::tempdir().unwrap();
Command::new("git")
.args(["init"])
.current_dir(repo.path())
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "author@example.com"])
.current_dir(repo.path())
.output()
.unwrap();
let writer = super::writer_from_options(repo.path(), None);
assert_eq!(
writer.actor_id.as_str(),
"actor:git-email:author@example.com"
);
assert_eq!(writer.producer.name, "shore");
}
#[test]
fn writer_from_options_uses_email_then_name_then_actor_local() {
let email_repo = tempfile::tempdir().unwrap();
Command::new("git")
.args(["init"])
.current_dir(email_repo.path())
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "reviewer@example.com"])
.current_dir(email_repo.path())
.output()
.unwrap();
let email_writer = super::writer_from_options(email_repo.path(), None);
assert_eq!(
email_writer.actor_id.as_str(),
"actor:git-email:reviewer@example.com"
);
let name_repo = tempfile::tempdir().unwrap();
Command::new("git")
.args(["init"])
.current_dir(name_repo.path())
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", ""])
.current_dir(name_repo.path())
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "reviewer-name"])
.current_dir(name_repo.path())
.output()
.unwrap();
let name_writer = super::writer_from_options(name_repo.path(), None);
assert_eq!(
name_writer.actor_id.as_str(),
"actor:git-name:reviewer-name"
);
let local_repo = tempfile::tempdir().unwrap();
Command::new("git")
.args(["init"])
.current_dir(local_repo.path())
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", ""])
.current_dir(local_repo.path())
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", ""])
.current_dir(local_repo.path())
.output()
.unwrap();
let local_writer = super::writer_from_options(local_repo.path(), None);
assert_eq!(local_writer.actor_id.as_str(), "actor:local");
}
#[test]
fn writer_falls_back_to_name_when_email_is_blank() {
let repo = tempfile::tempdir().unwrap();
Command::new("git")
.args(["init"])
.current_dir(repo.path())
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", ""])
.current_dir(repo.path())
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "reviewer-name"])
.current_dir(repo.path())
.output()
.unwrap();
let writer = super::writer_from_options(repo.path(), None);
assert_eq!(writer.actor_id.as_str(), "actor:git-name:reviewer-name");
}
fn git_repo_with_email(email: &str) -> tempfile::TempDir {
let repo = tempfile::tempdir().unwrap();
Command::new("git")
.args(["init"])
.current_dir(repo.path())
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", email])
.current_dir(repo.path())
.output()
.unwrap();
repo
}
#[test]
fn explicit_actor_id_overrides_env_and_git_identity() {
let repo = git_repo_with_email("host@example.com");
let actor = super::resolve_actor_id(
Some("actor:agent:remote-reviewer"),
Some("actor:env:from-env"),
repo.path(),
);
assert_eq!(actor.as_str(), "actor:agent:remote-reviewer");
}
#[test]
fn env_actor_id_overrides_git_identity_when_no_explicit() {
let repo = git_repo_with_email("host@example.com");
let actor = super::resolve_actor_id(None, Some("actor:env:from-env"), repo.path());
assert_eq!(actor.as_str(), "actor:env:from-env");
}
#[test]
fn invalid_explicit_actor_id_falls_through_to_valid_env() {
let repo = git_repo_with_email("host@example.com");
let actor = super::resolve_actor_id(
Some("not-an-actor"),
Some("actor:env:from-env"),
repo.path(),
);
assert_eq!(
actor.as_str(),
"actor:env:from-env",
"a malformed explicit override must fall through to the valid env value"
);
}
#[test]
fn invalid_explicit_and_env_actor_ids_fall_back_to_git_identity() {
let repo = git_repo_with_email("host@example.com");
for bad in [
"",
"no-prefix",
"actor:",
"actor:has space",
"actor:line\nbreak",
] {
let actor = super::resolve_actor_id(Some(bad), Some("also bad"), repo.path());
assert_eq!(
actor.as_str(),
"actor:git-email:host@example.com",
"invalid override {bad:?} and invalid env should fall back to the Git identity"
);
}
}
#[test]
fn missing_explicit_and_env_actor_id_uses_git_identity() {
let repo = git_repo_with_email("host@example.com");
let actor = super::resolve_actor_id(None, None, repo.path());
assert_eq!(actor.as_str(), "actor:git-email:host@example.com");
}
#[test]
fn is_agent_actor_id_matches_agent_scheme_only() {
assert!(super::is_agent_actor_id("actor:agent:claude-code"));
assert!(!super::is_agent_actor_id(
"actor:git-email:kevin@swiber.dev"
));
assert!(!super::is_agent_actor_id(
"did:key:z6MkehRgf7yJbgaGfYsdoAsKdBPE3dj2CYhowQdcjqSJgvVd"
));
assert!(!super::is_agent_actor_id("actor:agentx:foo"));
assert!(!super::is_agent_actor_id("actor:agent:"));
}
#[test]
fn actor_validation_accepts_actor_or_did_key_but_does_not_alias_them() {
let did_key = "did:key:z6MkehRgf7yJbgaGfYsdoAsKdBPE3dj2CYhowQdcjqSJgvVd";
assert!(super::is_valid_actor_id(
"actor:git-email:alice@example.com"
));
assert!(super::is_valid_actor_id(did_key));
assert_ne!(
crate::model::ActorId::new(did_key),
crate::model::ActorId::new("actor:git-email:alice@example.com")
);
}
}