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)]
10pub struct ThreadId(String);
11
12impl ThreadId {
13 pub fn new(value: impl Into<String>) -> Result<Self, ThreadIdError> {
15 let value = value.into();
16 validate_thread_id(&value)?;
17 Ok(Self(value))
18 }
19
20 pub(crate) fn new_unchecked(value: impl Into<String>) -> Self {
24 Self(value.into())
25 }
26
27 pub fn as_str(&self) -> &str {
28 &self.0
29 }
30}
31
32impl std::fmt::Display for ThreadId {
33 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34 f.write_str(&self.0)
35 }
36}
37
38impl<'de> Deserialize<'de> for ThreadId {
39 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
40 where
41 D: serde::Deserializer<'de>,
42 {
43 let value = String::deserialize(deserializer)?;
44 Self::new(value).map_err(serde::de::Error::custom)
45 }
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct ThreadIdError {
53 input: String,
54 suggestion: String,
55}
56
57impl ThreadIdError {
58 pub fn suggestion(&self) -> &str {
59 &self.suggestion
60 }
61}
62
63impl std::fmt::Display for ThreadIdError {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 if self.input.is_empty() {
66 write!(f, "thread name must not be empty")
67 } else {
68 write!(
69 f,
70 "thread name '{}' is invalid: use a Git branch name other than HEAD or the reserved heddle/ namespace or a noncanonical git% prefix (full ref at most 1024 UTF-8 bytes) — try '{}'",
71 self.input, self.suggestion
72 )
73 }
74 }
75}
76
77impl std::error::Error for ThreadIdError {}
78
79pub fn validate_thread_id(value: &str) -> Result<(), ThreadIdError> {
81 let git_name = crate::name_encoding::git_name(value);
82 if git_name != "HEAD"
83 && git_name.len() + "refs/heads/".len() <= 1024
84 && sley_refs::BranchRefNameBuf::from_branch_name(&git_name).is_ok()
85 && !crate::object::is_reserved_heddle_namespace(value)
86 && (!value.starts_with("git%") || crate::name_encoding::native_git_name(&git_name) == value)
87 {
88 Ok(())
89 } else {
90 Err(ThreadIdError {
91 input: value.to_string(),
92 suggestion: suggest_thread_id(value),
93 })
94 }
95}
96
97#[cfg(test)]
98mod refname_tests {
99 use super::validate_thread_id;
100
101 #[test]
102 fn native_git_prefix_requires_canonical_mapping() {
103 assert!(validate_thread_id("git%foo").is_err());
104 for git in ["git%foo", "heddle/foo", "git%n-heddle%2Ffoo"] {
105 let native = crate::name_encoding::native_git_name(git);
106 assert!(validate_thread_id(&native).is_ok());
107 assert_eq!(crate::name_encoding::git_name(&native), git);
108 }
109 }
110}
111
112fn suggest_thread_id(value: &str) -> String {
116 let value = if crate::object::is_reserved_heddle_namespace(value) {
117 value.split_once('/').map(|(_, rest)| rest).unwrap_or(value)
118 } else {
119 value
120 };
121 let mut slug = String::with_capacity(value.len());
122 for ch in value.chars() {
123 if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.') {
124 slug.push(ch);
125 } else {
126 slug.push('-');
127 }
128 }
129 while slug.contains("--") {
130 slug = slug.replace("--", "-");
131 }
132 while slug.contains("..") {
133 slug = slug.replace("..", "-");
134 }
135 let trimmed = slug.trim_matches(|c| c == '-' || c == '.');
136 if trimmed.is_empty() {
137 "thread".to_string()
138 } else {
139 let mut suggestion = trimmed[..trimmed.len().min(1000)]
140 .trim_end_matches('.')
141 .to_string();
142 if suggestion == "HEAD" || suggestion.ends_with(".lock") {
143 suggestion.push_str("-thread");
144 }
145 suggestion
146 }
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
172#[serde(rename_all = "snake_case")]
173pub enum ThreadMode {
174 Materialized,
175 Virtualized,
176 Solid,
177}
178
179impl std::fmt::Display for ThreadMode {
180 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
181 match self {
182 ThreadMode::Materialized => write!(f, "materialized"),
183 ThreadMode::Virtualized => write!(f, "virtualized"),
184 ThreadMode::Solid => write!(f, "solid"),
185 }
186 }
187}
188
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
190#[serde(rename_all = "snake_case")]
191pub enum ThreadState {
192 Draft,
193 Active,
194 Ready,
195 Blocked,
196 Merged,
197 Abandoned,
198 Promoted,
199}
200
201impl std::fmt::Display for ThreadState {
202 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203 match self {
204 ThreadState::Draft => write!(f, "draft"),
205 ThreadState::Active => write!(f, "active"),
206 ThreadState::Ready => write!(f, "ready"),
207 ThreadState::Blocked => write!(f, "blocked"),
208 ThreadState::Merged => write!(f, "merged"),
209 ThreadState::Abandoned => write!(f, "abandoned"),
210 ThreadState::Promoted => write!(f, "promoted"),
211 }
212 }
213}
214
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
216#[serde(rename_all = "snake_case")]
217pub enum ThreadFreshness {
218 Current,
219 Stale,
220 Unknown,
221}
222
223impl std::fmt::Display for ThreadFreshness {
224 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225 match self {
226 ThreadFreshness::Current => write!(f, "current"),
227 ThreadFreshness::Stale => write!(f, "stale"),
228 ThreadFreshness::Unknown => write!(f, "unknown"),
229 }
230 }
231}
232
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
234#[serde(rename_all = "snake_case")]
235pub enum ThreadImpactCategory {
236 DependencyGraph,
237 BuildRuntimeConfig,
238 GeneratedOutputs,
239 RepoWideRefactor,
240 PublicApiSurface,
241}
242
243impl std::fmt::Display for ThreadImpactCategory {
244 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245 match self {
246 ThreadImpactCategory::DependencyGraph => write!(f, "dependency_graph"),
247 ThreadImpactCategory::BuildRuntimeConfig => write!(f, "build_runtime_config"),
248 ThreadImpactCategory::GeneratedOutputs => write!(f, "generated_outputs"),
249 ThreadImpactCategory::RepoWideRefactor => write!(f, "repo_wide_refactor"),
250 ThreadImpactCategory::PublicApiSurface => write!(f, "public_api_surface"),
251 }
252 }
253}
254
255#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
256#[serde(rename_all = "snake_case")]
257pub enum ConfidenceBand {
258 Low,
259 Medium,
260 High,
261}
262
263impl std::fmt::Display for ConfidenceBand {
264 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265 match self {
266 ConfidenceBand::Low => write!(f, "low"),
267 ConfidenceBand::Medium => write!(f, "medium"),
268 ConfidenceBand::High => write!(f, "high"),
269 }
270 }
271}
272
273#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
274pub struct ThreadVerificationSummary {
275 #[serde(default)]
276 pub tests_passed: Option<bool>,
277 #[serde(default)]
278 pub tests_failed: Option<u32>,
279 #[serde(default)]
280 pub coverage_pct: Option<f32>,
281 #[serde(default)]
282 pub lint_warnings: Option<u32>,
283}
284
285#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
286pub struct ThreadConfidenceSummary {
287 #[serde(default)]
288 pub value: Option<f32>,
289 #[serde(default)]
290 pub band: Option<ConfidenceBand>,
291}
292
293#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
294pub struct ThreadIntegrationPolicy {
295 #[serde(default)]
296 pub status: Option<String>,
297 #[serde(default)]
298 pub reason: Option<String>,
299 #[serde(default)]
300 pub manual_resolution_state: Option<String>,
301 #[serde(default)]
311 pub conflicts_resolved_manually: bool,
312}
313
314impl ThreadIntegrationPolicy {
315 pub fn clear_untrusted_landing_fields(&mut self) {
317 self.manual_resolution_state = None;
318 self.conflicts_resolved_manually = false;
319 }
320}
321
322#[derive(Debug, Clone, Serialize, Deserialize)]
323pub struct ThreadRecord {
324 pub id: String,
325 pub thread: String,
326 pub target_thread: Option<String>,
327 pub parent_thread: Option<String>,
328 pub mode: ThreadMode,
329 pub state: ThreadState,
330 pub base_state: String,
331 pub base_root: String,
332 pub current_state: Option<String>,
333 pub merged_state: Option<String>,
334 pub task: Option<String>,
335 pub changed_paths: Vec<String>,
336 pub impact_categories: Vec<ThreadImpactCategory>,
337 pub heavy_impact_paths: Vec<String>,
338 pub promotion_suggested: bool,
339 pub freshness: ThreadFreshness,
340 pub verification_summary: ThreadVerificationSummary,
341 pub confidence_summary: ThreadConfidenceSummary,
342 pub integration_policy_result: ThreadIntegrationPolicy,
343 pub created_at: DateTime<Utc>,
344 pub updated_at: DateTime<Utc>,
345 pub ephemeral: Option<EphemeralMarker>,
353
354 pub auto: bool,
362
363 pub shared_target_dir: Option<PathBuf>,
373}
374
375#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
382pub struct EphemeralMarker {
383 pub ttl_seconds: u32,
385 pub created_at: DateTime<Utc>,
389 #[serde(default = "default_auto_collapse")]
394 pub auto_collapse: bool,
395}
396
397fn default_auto_collapse() -> bool {
398 true
399}
400
401impl EphemeralMarker {
402 pub fn new(ttl_seconds: u32) -> Self {
403 Self {
404 ttl_seconds,
405 created_at: Utc::now(),
406 auto_collapse: true,
407 }
408 }
409
410 pub fn expires_at(&self) -> DateTime<Utc> {
412 self.created_at + chrono::Duration::seconds(self.ttl_seconds as i64)
413 }
414
415 pub fn is_expired_at(&self, now: DateTime<Utc>) -> bool {
417 now >= self.expires_at()
418 }
419}
420
421impl ThreadRecord {
422 pub fn thread_id(&self) -> ThreadId {
423 ThreadId::new_unchecked(self.id.clone())
425 }
426}