1mod mutations;
7mod repository;
8
9pub use mutations::{
10 ChangeArtifactKind, ChangeArtifactMutationError, ChangeArtifactMutationResult,
11 ChangeArtifactMutationService, ChangeArtifactMutationServiceResult, ChangeArtifactRef,
12};
13pub use repository::{
14 ChangeLifecycleFilter, ChangeRepository, ChangeTargetResolution, ResolveTargetOptions,
15};
16
17use chrono::{DateTime, Utc};
18use std::path::PathBuf;
19
20use crate::tasks::{ProgressInfo, TasksParseResult};
21
22#[derive(Debug, Clone)]
24pub struct Spec {
25 pub name: String,
27 pub content: String,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum ChangeStatus {
34 NoTasks,
36 InProgress,
38 Complete,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum ChangeWorkStatus {
55 Draft,
57 Ready,
59 InProgress,
61 Paused,
65 Complete,
69}
70
71#[derive(Debug, Clone, Default, PartialEq, Eq)]
73pub struct ChangeOrchestrateMetadata {
74 pub depends_on: Vec<String>,
76 pub preferred_gates: Vec<String>,
78}
79
80impl std::fmt::Display for ChangeWorkStatus {
81 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82 match self {
83 ChangeWorkStatus::Draft => write!(f, "draft"),
84 ChangeWorkStatus::Ready => write!(f, "ready"),
85 ChangeWorkStatus::InProgress => write!(f, "in-progress"),
86 ChangeWorkStatus::Paused => write!(f, "paused"),
87 ChangeWorkStatus::Complete => write!(f, "complete"),
88 }
89 }
90}
91
92impl std::fmt::Display for ChangeStatus {
93 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94 match self {
95 ChangeStatus::NoTasks => write!(f, "no-tasks"),
96 ChangeStatus::InProgress => write!(f, "in-progress"),
97 ChangeStatus::Complete => write!(f, "complete"),
98 }
99 }
100}
101
102#[derive(Debug, Clone)]
104pub struct Change {
105 pub id: String,
107 pub module_id: Option<String>,
109 pub sub_module_id: Option<String>,
113 pub path: PathBuf,
115 pub proposal: Option<String>,
117 pub design: Option<String>,
119 pub specs: Vec<Spec>,
121 pub tasks: TasksParseResult,
123 pub orchestrate: ChangeOrchestrateMetadata,
125 pub last_modified: DateTime<Utc>,
127}
128
129impl Change {
130 pub fn status(&self) -> ChangeStatus {
132 let progress = &self.tasks.progress;
133 if progress.total == 0 {
134 ChangeStatus::NoTasks
135 } else if progress.complete >= progress.total {
136 ChangeStatus::Complete
137 } else {
138 ChangeStatus::InProgress
139 }
140 }
141
142 pub fn work_status(&self) -> ChangeWorkStatus {
144 let ProgressInfo {
145 total,
146 complete,
147 shelved,
148 in_progress,
149 pending,
150 remaining: _,
151 } = self.tasks.progress;
152
153 let has_planning_artifacts = self.proposal.is_some() && !self.specs.is_empty() && total > 0;
155 if !has_planning_artifacts {
156 return ChangeWorkStatus::Draft;
157 }
158
159 if complete == total {
160 return ChangeWorkStatus::Complete;
161 }
162 if in_progress > 0 {
163 return ChangeWorkStatus::InProgress;
164 }
165
166 let done_or_shelved = complete + shelved;
167 if pending == 0 && shelved > 0 && done_or_shelved == total {
168 return ChangeWorkStatus::Paused;
169 }
170
171 ChangeWorkStatus::Ready
172 }
173
174 pub fn artifacts_complete(&self) -> bool {
176 self.proposal.is_some()
177 && self.design.is_some()
178 && !self.specs.is_empty()
179 && self.tasks.progress.total > 0
180 }
181
182 pub fn task_progress(&self) -> (u32, u32) {
184 (
185 self.tasks.progress.complete as u32,
186 self.tasks.progress.total as u32,
187 )
188 }
189
190 pub fn progress(&self) -> &ProgressInfo {
192 &self.tasks.progress
193 }
194}
195
196#[derive(Debug, Clone)]
198pub struct ChangeSummary {
199 pub id: String,
201 pub module_id: Option<String>,
203 pub sub_module_id: Option<String>,
207 pub completed_tasks: u32,
209 pub shelved_tasks: u32,
211 pub in_progress_tasks: u32,
213 pub pending_tasks: u32,
215 pub total_tasks: u32,
217 pub last_modified: DateTime<Utc>,
219 pub has_proposal: bool,
221 pub has_design: bool,
223 pub has_specs: bool,
225 pub has_tasks: bool,
227 pub orchestrate: ChangeOrchestrateMetadata,
229}
230
231impl ChangeSummary {
232 pub fn status(&self) -> ChangeStatus {
234 if self.total_tasks == 0 {
235 ChangeStatus::NoTasks
236 } else if self.completed_tasks >= self.total_tasks {
237 ChangeStatus::Complete
238 } else {
239 ChangeStatus::InProgress
240 }
241 }
242
243 pub fn work_status(&self) -> ChangeWorkStatus {
245 let has_planning_artifacts = self.has_proposal && self.has_specs && self.has_tasks;
246 if !has_planning_artifacts {
247 return ChangeWorkStatus::Draft;
248 }
249
250 if self.total_tasks > 0 && self.completed_tasks == self.total_tasks {
251 return ChangeWorkStatus::Complete;
252 }
253 if self.in_progress_tasks > 0 {
254 return ChangeWorkStatus::InProgress;
255 }
256
257 let done_or_shelved = self.completed_tasks + self.shelved_tasks;
258 if self.pending_tasks == 0 && self.shelved_tasks > 0 && done_or_shelved == self.total_tasks
259 {
260 return ChangeWorkStatus::Paused;
261 }
262
263 ChangeWorkStatus::Ready
264 }
265
266 pub fn is_ready(&self) -> bool {
271 self.work_status() == ChangeWorkStatus::Ready
272 }
273}
274
275pub fn extract_module_id(change_id: &str) -> Option<String> {
285 let parts: Vec<&str> = change_id.split('-').collect();
286 if parts.len() >= 2 {
287 let module_part = parts[0].split('.').next().unwrap_or(parts[0]);
289 Some(normalize_id(module_part, 3))
290 } else {
291 None
292 }
293}
294
295pub fn extract_sub_module_id(change_id: &str) -> Option<String> {
303 let prefix = change_id.split('-').next()?;
305 if !prefix.contains('.') {
306 return None;
307 }
308 ito_common::id::parse_sub_module_id(prefix)
310 .map(|p| p.sub_module_id.as_str().to_string())
311 .ok()
312}
313
314pub fn normalize_id(id: &str, width: usize) -> String {
320 let num: u32 = id.parse().unwrap_or(0);
322 format!("{:0>width$}", num, width = width)
323}
324
325pub fn parse_change_id(input: &str) -> Option<(String, String)> {
334 let id_part = input.split('_').next().unwrap_or(input);
336
337 let parts: Vec<&str> = id_part.split('-').collect();
338 if parts.len() >= 2 {
339 let module_part = parts[0].split('.').next().unwrap_or(parts[0]);
341 let module_id = normalize_id(module_part, 3);
342 let change_num = normalize_id(parts[1], 2);
343 Some((module_id, change_num))
344 } else {
345 None
346 }
347}
348
349pub fn parse_module_id(input: &str) -> String {
357 let id_part = input.split('_').next().unwrap_or(input);
359 normalize_id(id_part, 3)
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 #[test]
367 fn test_normalize_id() {
368 assert_eq!(normalize_id("5", 3), "005");
369 assert_eq!(normalize_id("05", 3), "005");
370 assert_eq!(normalize_id("005", 3), "005");
371 assert_eq!(normalize_id("0005", 3), "005");
372 assert_eq!(normalize_id("1", 2), "01");
373 assert_eq!(normalize_id("01", 2), "01");
374 assert_eq!(normalize_id("001", 2), "01");
375 }
376
377 #[test]
378 fn test_parse_change_id() {
379 assert_eq!(
380 parse_change_id("005-01_my-change"),
381 Some(("005".to_string(), "01".to_string()))
382 );
383 assert_eq!(
384 parse_change_id("5-1_whatever"),
385 Some(("005".to_string(), "01".to_string()))
386 );
387 assert_eq!(
388 parse_change_id("1-2"),
389 Some(("001".to_string(), "02".to_string()))
390 );
391 assert_eq!(
392 parse_change_id("001-000002_foo"),
393 Some(("001".to_string(), "02".to_string()))
394 );
395 assert_eq!(parse_change_id("invalid"), None);
396 }
397
398 #[test]
399 fn test_parse_module_id() {
400 assert_eq!(parse_module_id("005"), "005");
401 assert_eq!(parse_module_id("5"), "005");
402 assert_eq!(parse_module_id("005_dev-tooling"), "005");
403 assert_eq!(parse_module_id("5_dev-tooling"), "005");
404 }
405
406 #[test]
407 fn test_extract_module_id() {
408 assert_eq!(
409 extract_module_id("005-01_my-change"),
410 Some("005".to_string())
411 );
412 assert_eq!(extract_module_id("013-18_cleanup"), Some("013".to_string()));
413 assert_eq!(extract_module_id("5-1_foo"), Some("005".to_string()));
414 assert_eq!(extract_module_id("invalid"), None);
415 assert_eq!(extract_module_id("024.01-03_foo"), Some("024".to_string()));
417 assert_eq!(extract_module_id("5.1-2_bar"), Some("005".to_string()));
418 }
419
420 #[test]
421 fn test_extract_sub_module_id() {
422 assert_eq!(
423 extract_sub_module_id("024.01-03_foo"),
424 Some("024.01".to_string())
425 );
426 assert_eq!(
427 extract_sub_module_id("5.1-2_bar"),
428 Some("005.01".to_string())
429 );
430 assert_eq!(extract_sub_module_id("005-01_my-change"), None);
431 assert_eq!(extract_sub_module_id("invalid"), None);
432 }
433
434 #[test]
435 fn test_parse_change_id_sub_module_format() {
436 assert_eq!(
437 parse_change_id("024.01-03_foo"),
438 Some(("024".to_string(), "03".to_string()))
439 );
440 assert_eq!(
441 parse_change_id("5.1-2_bar"),
442 Some(("005".to_string(), "02".to_string()))
443 );
444 }
445
446 #[test]
447 fn test_change_sub_module_id_field() {
448 let summary = ChangeSummary {
449 id: "005.01-03_my-change".to_string(),
450 module_id: Some("005".to_string()),
451 sub_module_id: Some("005.01".to_string()),
452 completed_tasks: 0,
453 shelved_tasks: 0,
454 in_progress_tasks: 0,
455 pending_tasks: 0,
456 total_tasks: 0,
457 last_modified: Utc::now(),
458 has_proposal: false,
459 has_design: false,
460 has_specs: false,
461 has_tasks: false,
462 orchestrate: ChangeOrchestrateMetadata::default(),
463 };
464
465 assert_eq!(summary.sub_module_id.as_deref(), Some("005.01"));
466 }
467
468 #[test]
469 fn test_change_status_display() {
470 assert_eq!(ChangeStatus::NoTasks.to_string(), "no-tasks");
471 assert_eq!(ChangeStatus::InProgress.to_string(), "in-progress");
472 assert_eq!(ChangeStatus::Complete.to_string(), "complete");
473 }
474
475 #[test]
476 fn test_change_summary_status() {
477 let mut summary = ChangeSummary {
478 id: "test".to_string(),
479 module_id: None,
480 sub_module_id: None,
481 completed_tasks: 0,
482 shelved_tasks: 0,
483 in_progress_tasks: 0,
484 pending_tasks: 0,
485 total_tasks: 0,
486 last_modified: Utc::now(),
487 has_proposal: false,
488 has_design: false,
489 has_specs: false,
490 has_tasks: false,
491 orchestrate: ChangeOrchestrateMetadata::default(),
492 };
493
494 assert_eq!(summary.status(), ChangeStatus::NoTasks);
495
496 summary.total_tasks = 5;
497 summary.completed_tasks = 3;
498 assert_eq!(summary.status(), ChangeStatus::InProgress);
499
500 summary.completed_tasks = 5;
501 assert_eq!(summary.status(), ChangeStatus::Complete);
502 }
503
504 #[test]
505 fn test_change_work_status() {
506 let mut summary = ChangeSummary {
507 id: "test".to_string(),
508 module_id: None,
509 sub_module_id: None,
510 completed_tasks: 0,
511 shelved_tasks: 0,
512 in_progress_tasks: 0,
513 pending_tasks: 0,
514 total_tasks: 0,
515 last_modified: Utc::now(),
516 has_proposal: false,
517 has_design: false,
518 has_specs: false,
519 has_tasks: false,
520 orchestrate: ChangeOrchestrateMetadata::default(),
521 };
522
523 assert_eq!(summary.work_status(), ChangeWorkStatus::Draft);
524
525 summary.has_proposal = true;
526 summary.has_specs = true;
527 summary.has_tasks = true;
528 summary.total_tasks = 3;
529 summary.pending_tasks = 3;
530
531 assert_eq!(summary.work_status(), ChangeWorkStatus::Ready);
532
533 summary.in_progress_tasks = 1;
534 summary.pending_tasks = 2;
535 assert_eq!(summary.work_status(), ChangeWorkStatus::InProgress);
536
537 summary.in_progress_tasks = 0;
538 summary.pending_tasks = 0;
539 summary.shelved_tasks = 1;
540 summary.completed_tasks = 2;
541 assert_eq!(summary.work_status(), ChangeWorkStatus::Paused);
542
543 summary.shelved_tasks = 0;
544 summary.completed_tasks = 3;
545 assert_eq!(summary.work_status(), ChangeWorkStatus::Complete);
546 }
547}