1use std::path::PathBuf;
3
4use chrono::{DateTime, Utc};
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
14pub struct ThreadId(String);
15
16impl ThreadId {
17 pub fn new(value: impl Into<String>) -> Result<Self, ThreadIdError> {
22 let value = value.into();
23 validate_thread_id(&value)?;
24 Ok(Self(value))
25 }
26
27 pub(crate) fn new_unchecked(value: impl Into<String>) -> Self {
32 Self(value.into())
33 }
34
35 pub fn as_str(&self) -> &str {
36 &self.0
37 }
38}
39
40impl std::fmt::Display for ThreadId {
41 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 f.write_str(&self.0)
43 }
44}
45
46impl<'de> Deserialize<'de> for ThreadId {
47 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
48 where
49 D: serde::Deserializer<'de>,
50 {
51 let value = String::deserialize(deserializer)?;
55 Ok(Self::new_unchecked(value))
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct ThreadIdError {
64 input: String,
65 suggestion: String,
66}
67
68impl ThreadIdError {
69 pub fn suggestion(&self) -> &str {
70 &self.suggestion
71 }
72}
73
74impl std::fmt::Display for ThreadIdError {
75 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 if self.input.is_empty() {
77 write!(f, "thread name must not be empty")
78 } else {
79 write!(
80 f,
81 "thread name '{}' is invalid: use only letters, digits, and _ - . / @ : + = \
82 (no spaces, shell metacharacters, '..' path segments, or a leading '/' or '-') — try '{}'",
83 self.input, self.suggestion
84 )
85 }
86 }
87}
88
89impl std::error::Error for ThreadIdError {}
90
91pub fn validate_thread_id(value: &str) -> Result<(), ThreadIdError> {
102 let safe_charset = value.bytes().all(|b| {
103 b.is_ascii_alphanumeric()
104 || matches!(b, b'_' | b'-' | b'.' | b'/' | b'@' | b':' | b'+' | b'=')
105 });
106 let ok = !value.is_empty()
107 && safe_charset
108 && !value.contains("..")
109 && !value.starts_with('/')
110 && !value.starts_with('-')
114 && !crate::object::is_reserved_heddle_namespace(value);
115 if ok {
116 Ok(())
117 } else {
118 Err(ThreadIdError {
119 input: value.to_string(),
120 suggestion: suggest_thread_id(value),
121 })
122 }
123}
124
125fn suggest_thread_id(value: &str) -> String {
129 let value = if crate::object::is_reserved_heddle_namespace(value) {
130 value.split_once('/').map(|(_, rest)| rest).unwrap_or(value)
131 } else {
132 value
133 };
134 let mut slug = String::with_capacity(value.len());
135 for ch in value.chars() {
136 if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.') {
137 slug.push(ch);
138 } else {
139 slug.push('-');
140 }
141 }
142 while slug.contains("--") {
143 slug = slug.replace("--", "-");
144 }
145 while slug.contains("..") {
146 slug = slug.replace("..", "-");
147 }
148 let trimmed = slug.trim_matches(|c| c == '-' || c == '.');
149 if trimmed.is_empty() {
150 "thread".to_string()
151 } else {
152 trimmed.to_string()
153 }
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
179#[serde(rename_all = "snake_case")]
180pub enum ThreadMode {
181 Materialized,
182 Virtualized,
183 Solid,
184}
185
186impl std::fmt::Display for ThreadMode {
187 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188 match self {
189 ThreadMode::Materialized => write!(f, "materialized"),
190 ThreadMode::Virtualized => write!(f, "virtualized"),
191 ThreadMode::Solid => write!(f, "solid"),
192 }
193 }
194}
195
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
197#[serde(rename_all = "snake_case")]
198pub enum ThreadState {
199 Draft,
200 Active,
201 Ready,
202 Blocked,
203 Merged,
204 Abandoned,
205 Promoted,
206}
207
208impl std::fmt::Display for ThreadState {
209 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
210 match self {
211 ThreadState::Draft => write!(f, "draft"),
212 ThreadState::Active => write!(f, "active"),
213 ThreadState::Ready => write!(f, "ready"),
214 ThreadState::Blocked => write!(f, "blocked"),
215 ThreadState::Merged => write!(f, "merged"),
216 ThreadState::Abandoned => write!(f, "abandoned"),
217 ThreadState::Promoted => write!(f, "promoted"),
218 }
219 }
220}
221
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
223#[serde(rename_all = "snake_case")]
224pub enum ThreadFreshness {
225 Current,
226 Stale,
227 Unknown,
228}
229
230impl std::fmt::Display for ThreadFreshness {
231 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
232 match self {
233 ThreadFreshness::Current => write!(f, "current"),
234 ThreadFreshness::Stale => write!(f, "stale"),
235 ThreadFreshness::Unknown => write!(f, "unknown"),
236 }
237 }
238}
239
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
241#[serde(rename_all = "snake_case")]
242pub enum ThreadImpactCategory {
243 DependencyGraph,
244 BuildRuntimeConfig,
245 GeneratedOutputs,
246 RepoWideRefactor,
247 PublicApiSurface,
248}
249
250impl std::fmt::Display for ThreadImpactCategory {
251 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252 match self {
253 ThreadImpactCategory::DependencyGraph => write!(f, "dependency_graph"),
254 ThreadImpactCategory::BuildRuntimeConfig => write!(f, "build_runtime_config"),
255 ThreadImpactCategory::GeneratedOutputs => write!(f, "generated_outputs"),
256 ThreadImpactCategory::RepoWideRefactor => write!(f, "repo_wide_refactor"),
257 ThreadImpactCategory::PublicApiSurface => write!(f, "public_api_surface"),
258 }
259 }
260}
261
262#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
263#[serde(rename_all = "snake_case")]
264pub enum ConfidenceBand {
265 Low,
266 Medium,
267 High,
268}
269
270impl std::fmt::Display for ConfidenceBand {
271 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
272 match self {
273 ConfidenceBand::Low => write!(f, "low"),
274 ConfidenceBand::Medium => write!(f, "medium"),
275 ConfidenceBand::High => write!(f, "high"),
276 }
277 }
278}
279
280#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
281pub struct ThreadVerificationSummary {
282 #[serde(default)]
283 pub tests_passed: Option<bool>,
284 #[serde(default)]
285 pub tests_failed: Option<u32>,
286 #[serde(default)]
287 pub coverage_pct: Option<f32>,
288 #[serde(default)]
289 pub lint_warnings: Option<u32>,
290}
291
292#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
293pub struct ThreadConfidenceSummary {
294 #[serde(default)]
295 pub value: Option<f32>,
296 #[serde(default)]
297 pub band: Option<ConfidenceBand>,
298}
299
300#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
301pub struct ThreadIntegrationPolicy {
302 #[serde(default)]
303 pub status: Option<String>,
304 #[serde(default)]
305 pub reason: Option<String>,
306 #[serde(default)]
307 pub manual_resolution_state: Option<String>,
308 #[serde(default)]
318 pub conflicts_resolved_manually: bool,
319}
320
321impl ThreadIntegrationPolicy {
322 pub fn clear_untrusted_landing_fields(&mut self) {
324 self.manual_resolution_state = None;
325 self.conflicts_resolved_manually = false;
326 }
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct ThreadRecord {
331 pub id: String,
332 pub thread: String,
333 pub target_thread: Option<String>,
334 pub parent_thread: Option<String>,
335 pub mode: ThreadMode,
336 pub state: ThreadState,
337 pub base_state: String,
338 pub base_root: String,
339 pub current_state: Option<String>,
340 pub merged_state: Option<String>,
341 pub task: Option<String>,
342 pub changed_paths: Vec<String>,
343 pub impact_categories: Vec<ThreadImpactCategory>,
344 pub heavy_impact_paths: Vec<String>,
345 pub promotion_suggested: bool,
346 pub freshness: ThreadFreshness,
347 pub verification_summary: ThreadVerificationSummary,
348 pub confidence_summary: ThreadConfidenceSummary,
349 pub integration_policy_result: ThreadIntegrationPolicy,
350 pub created_at: DateTime<Utc>,
351 pub updated_at: DateTime<Utc>,
352 pub ephemeral: Option<EphemeralMarker>,
360
361 pub auto: bool,
369
370 pub shared_target_dir: Option<PathBuf>,
380}
381
382#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
389pub struct EphemeralMarker {
390 pub ttl_seconds: u32,
392 pub created_at: DateTime<Utc>,
396 #[serde(default = "default_auto_collapse")]
401 pub auto_collapse: bool,
402}
403
404fn default_auto_collapse() -> bool {
405 true
406}
407
408impl EphemeralMarker {
409 pub fn new(ttl_seconds: u32) -> Self {
410 Self {
411 ttl_seconds,
412 created_at: Utc::now(),
413 auto_collapse: true,
414 }
415 }
416
417 pub fn expires_at(&self) -> DateTime<Utc> {
419 self.created_at + chrono::Duration::seconds(self.ttl_seconds as i64)
420 }
421
422 pub fn is_expired_at(&self, now: DateTime<Utc>) -> bool {
424 now >= self.expires_at()
425 }
426}
427
428impl ThreadRecord {
429 pub fn thread_id(&self) -> ThreadId {
430 ThreadId::new_unchecked(self.id.clone())
432 }
433}