use std::path::PathBuf;
use chrono::{DateTime, Utc};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
pub struct ThreadId(String);
impl ThreadId {
pub fn new(value: impl Into<String>) -> Result<Self, ThreadIdError> {
let value = value.into();
validate_thread_id(&value)?;
Ok(Self(value))
}
pub(crate) fn new_unchecked(value: impl Into<String>) -> Self {
Self(value.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for ThreadId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl<'de> Deserialize<'de> for ThreadId {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Ok(Self::new_unchecked(value))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ThreadIdError {
input: String,
suggestion: String,
}
impl ThreadIdError {
pub fn suggestion(&self) -> &str {
&self.suggestion
}
}
impl std::fmt::Display for ThreadIdError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.input.is_empty() {
write!(f, "thread name must not be empty")
} else {
write!(
f,
"thread name '{}' is invalid: use only letters, digits, and _ - . / @ : + = \
(no spaces, shell metacharacters, '..' path segments, or a leading '/' or '-') — try '{}'",
self.input, self.suggestion
)
}
}
}
impl std::error::Error for ThreadIdError {}
pub fn validate_thread_id(value: &str) -> Result<(), ThreadIdError> {
let safe_charset = value.bytes().all(|b| {
b.is_ascii_alphanumeric()
|| matches!(b, b'_' | b'-' | b'.' | b'/' | b'@' | b':' | b'+' | b'=')
});
let ok = !value.is_empty()
&& safe_charset
&& !value.contains("..")
&& !value.starts_with('/')
&& !value.starts_with('-')
&& !crate::object::is_reserved_heddle_namespace(value);
if ok {
Ok(())
} else {
Err(ThreadIdError {
input: value.to_string(),
suggestion: suggest_thread_id(value),
})
}
}
fn suggest_thread_id(value: &str) -> String {
let value = if crate::object::is_reserved_heddle_namespace(value) {
value.split_once('/').map(|(_, rest)| rest).unwrap_or(value)
} else {
value
};
let mut slug = String::with_capacity(value.len());
for ch in value.chars() {
if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.') {
slug.push(ch);
} else {
slug.push('-');
}
}
while slug.contains("--") {
slug = slug.replace("--", "-");
}
while slug.contains("..") {
slug = slug.replace("..", "-");
}
let trimmed = slug.trim_matches(|c| c == '-' || c == '.');
if trimmed.is_empty() {
"thread".to_string()
} else {
trimmed.to_string()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ThreadMode {
Materialized,
Virtualized,
Solid,
}
impl std::fmt::Display for ThreadMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ThreadMode::Materialized => write!(f, "materialized"),
ThreadMode::Virtualized => write!(f, "virtualized"),
ThreadMode::Solid => write!(f, "solid"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ThreadState {
Draft,
Active,
Ready,
Blocked,
Merged,
Abandoned,
Promoted,
}
impl std::fmt::Display for ThreadState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ThreadState::Draft => write!(f, "draft"),
ThreadState::Active => write!(f, "active"),
ThreadState::Ready => write!(f, "ready"),
ThreadState::Blocked => write!(f, "blocked"),
ThreadState::Merged => write!(f, "merged"),
ThreadState::Abandoned => write!(f, "abandoned"),
ThreadState::Promoted => write!(f, "promoted"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ThreadFreshness {
Current,
Stale,
Unknown,
}
impl std::fmt::Display for ThreadFreshness {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ThreadFreshness::Current => write!(f, "current"),
ThreadFreshness::Stale => write!(f, "stale"),
ThreadFreshness::Unknown => write!(f, "unknown"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ThreadImpactCategory {
DependencyGraph,
BuildRuntimeConfig,
GeneratedOutputs,
RepoWideRefactor,
PublicApiSurface,
}
impl std::fmt::Display for ThreadImpactCategory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ThreadImpactCategory::DependencyGraph => write!(f, "dependency_graph"),
ThreadImpactCategory::BuildRuntimeConfig => write!(f, "build_runtime_config"),
ThreadImpactCategory::GeneratedOutputs => write!(f, "generated_outputs"),
ThreadImpactCategory::RepoWideRefactor => write!(f, "repo_wide_refactor"),
ThreadImpactCategory::PublicApiSurface => write!(f, "public_api_surface"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ConfidenceBand {
Low,
Medium,
High,
}
impl std::fmt::Display for ConfidenceBand {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConfidenceBand::Low => write!(f, "low"),
ConfidenceBand::Medium => write!(f, "medium"),
ConfidenceBand::High => write!(f, "high"),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct ThreadVerificationSummary {
#[serde(default)]
pub tests_passed: Option<bool>,
#[serde(default)]
pub tests_failed: Option<u32>,
#[serde(default)]
pub coverage_pct: Option<f32>,
#[serde(default)]
pub lint_warnings: Option<u32>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct ThreadConfidenceSummary {
#[serde(default)]
pub value: Option<f32>,
#[serde(default)]
pub band: Option<ConfidenceBand>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct ThreadIntegrationPolicy {
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub reason: Option<String>,
#[serde(default)]
pub manual_resolution_state: Option<String>,
#[serde(default)]
pub conflicts_resolved_manually: bool,
}
impl ThreadIntegrationPolicy {
pub fn clear_untrusted_landing_fields(&mut self) {
self.manual_resolution_state = None;
self.conflicts_resolved_manually = false;
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThreadRecord {
pub id: String,
pub thread: String,
pub target_thread: Option<String>,
pub parent_thread: Option<String>,
pub mode: ThreadMode,
pub state: ThreadState,
pub base_state: String,
pub base_root: String,
pub current_state: Option<String>,
pub merged_state: Option<String>,
pub task: Option<String>,
pub changed_paths: Vec<String>,
pub impact_categories: Vec<ThreadImpactCategory>,
pub heavy_impact_paths: Vec<String>,
pub promotion_suggested: bool,
pub freshness: ThreadFreshness,
pub verification_summary: ThreadVerificationSummary,
pub confidence_summary: ThreadConfidenceSummary,
pub integration_policy_result: ThreadIntegrationPolicy,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub ephemeral: Option<EphemeralMarker>,
pub auto: bool,
pub shared_target_dir: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct EphemeralMarker {
pub ttl_seconds: u32,
pub created_at: DateTime<Utc>,
#[serde(default = "default_auto_collapse")]
pub auto_collapse: bool,
}
fn default_auto_collapse() -> bool {
true
}
impl EphemeralMarker {
pub fn new(ttl_seconds: u32) -> Self {
Self {
ttl_seconds,
created_at: Utc::now(),
auto_collapse: true,
}
}
pub fn expires_at(&self) -> DateTime<Utc> {
self.created_at + chrono::Duration::seconds(self.ttl_seconds as i64)
}
pub fn is_expired_at(&self, now: DateTime<Utc>) -> bool {
now >= self.expires_at()
}
}
impl ThreadRecord {
pub fn thread_id(&self) -> ThreadId {
ThreadId::new_unchecked(self.id.clone())
}
}