1mod repository;
7
8pub use repository::{
9 ChangeLifecycleFilter, ChangeRepository, ChangeTargetResolution, ResolveTargetOptions,
10};
11
12use chrono::{DateTime, Utc};
13use std::path::PathBuf;
14
15use crate::tasks::{ProgressInfo, TasksParseResult};
16
17#[derive(Debug, Clone)]
19pub struct Spec {
20 pub name: String,
22 pub content: String,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ChangeStatus {
29 NoTasks,
31 InProgress,
33 Complete,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum ChangeWorkStatus {
50 Draft,
52 Ready,
54 InProgress,
56 Paused,
60 Complete,
64}
65
66#[derive(Debug, Clone, Default, PartialEq, Eq)]
68pub struct ChangeOrchestrateMetadata {
69 pub depends_on: Vec<String>,
71 pub preferred_gates: Vec<String>,
73}
74
75impl std::fmt::Display for ChangeWorkStatus {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 match self {
78 ChangeWorkStatus::Draft => write!(f, "draft"),
79 ChangeWorkStatus::Ready => write!(f, "ready"),
80 ChangeWorkStatus::InProgress => write!(f, "in-progress"),
81 ChangeWorkStatus::Paused => write!(f, "paused"),
82 ChangeWorkStatus::Complete => write!(f, "complete"),
83 }
84 }
85}
86
87impl std::fmt::Display for ChangeStatus {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 match self {
90 ChangeStatus::NoTasks => write!(f, "no-tasks"),
91 ChangeStatus::InProgress => write!(f, "in-progress"),
92 ChangeStatus::Complete => write!(f, "complete"),
93 }
94 }
95}
96
97#[derive(Debug, Clone)]
99pub struct Change {
100 pub id: String,
102 pub module_id: Option<String>,
104 pub sub_module_id: Option<String>,
108 pub path: PathBuf,
110 pub proposal: Option<String>,
112 pub design: Option<String>,
114 pub specs: Vec<Spec>,
116 pub tasks: TasksParseResult,
118 pub orchestrate: ChangeOrchestrateMetadata,
120 pub last_modified: DateTime<Utc>,
122}
123
124impl Change {
125 pub fn status(&self) -> ChangeStatus {
127 let progress = &self.tasks.progress;
128 if progress.total == 0 {
129 ChangeStatus::NoTasks
130 } else if progress.complete >= progress.total {
131 ChangeStatus::Complete
132 } else {
133 ChangeStatus::InProgress
134 }
135 }
136
137 pub fn work_status(&self) -> ChangeWorkStatus {
139 let ProgressInfo {
140 total,
141 complete,
142 shelved,
143 in_progress,
144 pending,
145 remaining: _,
146 } = self.tasks.progress;
147
148 let has_planning_artifacts = self.proposal.is_some() && !self.specs.is_empty() && total > 0;
150 if !has_planning_artifacts {
151 return ChangeWorkStatus::Draft;
152 }
153
154 if complete == total {
155 return ChangeWorkStatus::Complete;
156 }
157 if in_progress > 0 {
158 return ChangeWorkStatus::InProgress;
159 }
160
161 let done_or_shelved = complete + shelved;
162 if pending == 0 && shelved > 0 && done_or_shelved == total {
163 return ChangeWorkStatus::Paused;
164 }
165
166 ChangeWorkStatus::Ready
167 }
168
169 pub fn artifacts_complete(&self) -> bool {
171 self.proposal.is_some()
172 && self.design.is_some()
173 && !self.specs.is_empty()
174 && self.tasks.progress.total > 0
175 }
176
177 pub fn task_progress(&self) -> (u32, u32) {
179 (
180 self.tasks.progress.complete as u32,
181 self.tasks.progress.total as u32,
182 )
183 }
184
185 pub fn progress(&self) -> &ProgressInfo {
187 &self.tasks.progress
188 }
189}
190
191#[derive(Debug, Clone)]
193pub struct ChangeSummary {
194 pub id: String,
196 pub module_id: Option<String>,
198 pub sub_module_id: Option<String>,
202 pub completed_tasks: u32,
204 pub shelved_tasks: u32,
206 pub in_progress_tasks: u32,
208 pub pending_tasks: u32,
210 pub total_tasks: u32,
212 pub last_modified: DateTime<Utc>,
214 pub has_proposal: bool,
216 pub has_design: bool,
218 pub has_specs: bool,
220 pub has_tasks: bool,
222 pub orchestrate: ChangeOrchestrateMetadata,
224}
225
226impl ChangeSummary {
227 pub fn status(&self) -> ChangeStatus {
229 if self.total_tasks == 0 {
230 ChangeStatus::NoTasks
231 } else if self.completed_tasks >= self.total_tasks {
232 ChangeStatus::Complete
233 } else {
234 ChangeStatus::InProgress
235 }
236 }
237
238 pub fn work_status(&self) -> ChangeWorkStatus {
240 let has_planning_artifacts = self.has_proposal && self.has_specs && self.has_tasks;
241 if !has_planning_artifacts {
242 return ChangeWorkStatus::Draft;
243 }
244
245 if self.total_tasks > 0 && self.completed_tasks == self.total_tasks {
246 return ChangeWorkStatus::Complete;
247 }
248 if self.in_progress_tasks > 0 {
249 return ChangeWorkStatus::InProgress;
250 }
251
252 let done_or_shelved = self.completed_tasks + self.shelved_tasks;
253 if self.pending_tasks == 0 && self.shelved_tasks > 0 && done_or_shelved == self.total_tasks
254 {
255 return ChangeWorkStatus::Paused;
256 }
257
258 ChangeWorkStatus::Ready
259 }
260
261 pub fn is_ready(&self) -> bool {
266 self.work_status() == ChangeWorkStatus::Ready
267 }
268}
269
270pub fn extract_module_id(change_id: &str) -> Option<String> {
280 let parts: Vec<&str> = change_id.split('-').collect();
281 if parts.len() >= 2 {
282 let module_part = parts[0].split('.').next().unwrap_or(parts[0]);
284 Some(normalize_id(module_part, 3))
285 } else {
286 None
287 }
288}
289
290pub fn extract_sub_module_id(change_id: &str) -> Option<String> {
298 let prefix = change_id.split('-').next()?;
300 if !prefix.contains('.') {
301 return None;
302 }
303 ito_common::id::parse_sub_module_id(prefix)
305 .map(|p| p.sub_module_id.as_str().to_string())
306 .ok()
307}
308
309pub fn normalize_id(id: &str, width: usize) -> String {
315 let num: u32 = id.parse().unwrap_or(0);
317 format!("{:0>width$}", num, width = width)
318}
319
320pub fn parse_change_id(input: &str) -> Option<(String, String)> {
329 let id_part = input.split('_').next().unwrap_or(input);
331
332 let parts: Vec<&str> = id_part.split('-').collect();
333 if parts.len() >= 2 {
334 let module_part = parts[0].split('.').next().unwrap_or(parts[0]);
336 let module_id = normalize_id(module_part, 3);
337 let change_num = normalize_id(parts[1], 2);
338 Some((module_id, change_num))
339 } else {
340 None
341 }
342}
343
344pub fn parse_module_id(input: &str) -> String {
352 let id_part = input.split('_').next().unwrap_or(input);
354 normalize_id(id_part, 3)
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360
361 #[test]
362 fn test_normalize_id() {
363 assert_eq!(normalize_id("5", 3), "005");
364 assert_eq!(normalize_id("05", 3), "005");
365 assert_eq!(normalize_id("005", 3), "005");
366 assert_eq!(normalize_id("0005", 3), "005");
367 assert_eq!(normalize_id("1", 2), "01");
368 assert_eq!(normalize_id("01", 2), "01");
369 assert_eq!(normalize_id("001", 2), "01");
370 }
371
372 #[test]
373 fn test_parse_change_id() {
374 assert_eq!(
375 parse_change_id("005-01_my-change"),
376 Some(("005".to_string(), "01".to_string()))
377 );
378 assert_eq!(
379 parse_change_id("5-1_whatever"),
380 Some(("005".to_string(), "01".to_string()))
381 );
382 assert_eq!(
383 parse_change_id("1-2"),
384 Some(("001".to_string(), "02".to_string()))
385 );
386 assert_eq!(
387 parse_change_id("001-000002_foo"),
388 Some(("001".to_string(), "02".to_string()))
389 );
390 assert_eq!(parse_change_id("invalid"), None);
391 }
392
393 #[test]
394 fn test_parse_module_id() {
395 assert_eq!(parse_module_id("005"), "005");
396 assert_eq!(parse_module_id("5"), "005");
397 assert_eq!(parse_module_id("005_dev-tooling"), "005");
398 assert_eq!(parse_module_id("5_dev-tooling"), "005");
399 }
400
401 #[test]
402 fn test_extract_module_id() {
403 assert_eq!(
404 extract_module_id("005-01_my-change"),
405 Some("005".to_string())
406 );
407 assert_eq!(extract_module_id("013-18_cleanup"), Some("013".to_string()));
408 assert_eq!(extract_module_id("5-1_foo"), Some("005".to_string()));
409 assert_eq!(extract_module_id("invalid"), None);
410 assert_eq!(extract_module_id("024.01-03_foo"), Some("024".to_string()));
412 assert_eq!(extract_module_id("5.1-2_bar"), Some("005".to_string()));
413 }
414
415 #[test]
416 fn test_extract_sub_module_id() {
417 assert_eq!(
418 extract_sub_module_id("024.01-03_foo"),
419 Some("024.01".to_string())
420 );
421 assert_eq!(
422 extract_sub_module_id("5.1-2_bar"),
423 Some("005.01".to_string())
424 );
425 assert_eq!(extract_sub_module_id("005-01_my-change"), None);
426 assert_eq!(extract_sub_module_id("invalid"), None);
427 }
428
429 #[test]
430 fn test_parse_change_id_sub_module_format() {
431 assert_eq!(
432 parse_change_id("024.01-03_foo"),
433 Some(("024".to_string(), "03".to_string()))
434 );
435 assert_eq!(
436 parse_change_id("5.1-2_bar"),
437 Some(("005".to_string(), "02".to_string()))
438 );
439 }
440
441 #[test]
442 fn test_change_sub_module_id_field() {
443 let summary = ChangeSummary {
444 id: "005.01-03_my-change".to_string(),
445 module_id: Some("005".to_string()),
446 sub_module_id: Some("005.01".to_string()),
447 completed_tasks: 0,
448 shelved_tasks: 0,
449 in_progress_tasks: 0,
450 pending_tasks: 0,
451 total_tasks: 0,
452 last_modified: Utc::now(),
453 has_proposal: false,
454 has_design: false,
455 has_specs: false,
456 has_tasks: false,
457 orchestrate: ChangeOrchestrateMetadata::default(),
458 };
459
460 assert_eq!(summary.sub_module_id.as_deref(), Some("005.01"));
461 }
462
463 #[test]
464 fn test_change_status_display() {
465 assert_eq!(ChangeStatus::NoTasks.to_string(), "no-tasks");
466 assert_eq!(ChangeStatus::InProgress.to_string(), "in-progress");
467 assert_eq!(ChangeStatus::Complete.to_string(), "complete");
468 }
469
470 #[test]
471 fn test_change_summary_status() {
472 let mut summary = ChangeSummary {
473 id: "test".to_string(),
474 module_id: None,
475 sub_module_id: None,
476 completed_tasks: 0,
477 shelved_tasks: 0,
478 in_progress_tasks: 0,
479 pending_tasks: 0,
480 total_tasks: 0,
481 last_modified: Utc::now(),
482 has_proposal: false,
483 has_design: false,
484 has_specs: false,
485 has_tasks: false,
486 orchestrate: ChangeOrchestrateMetadata::default(),
487 };
488
489 assert_eq!(summary.status(), ChangeStatus::NoTasks);
490
491 summary.total_tasks = 5;
492 summary.completed_tasks = 3;
493 assert_eq!(summary.status(), ChangeStatus::InProgress);
494
495 summary.completed_tasks = 5;
496 assert_eq!(summary.status(), ChangeStatus::Complete);
497 }
498
499 #[test]
500 fn test_change_work_status() {
501 let mut summary = ChangeSummary {
502 id: "test".to_string(),
503 module_id: None,
504 sub_module_id: None,
505 completed_tasks: 0,
506 shelved_tasks: 0,
507 in_progress_tasks: 0,
508 pending_tasks: 0,
509 total_tasks: 0,
510 last_modified: Utc::now(),
511 has_proposal: false,
512 has_design: false,
513 has_specs: false,
514 has_tasks: false,
515 orchestrate: ChangeOrchestrateMetadata::default(),
516 };
517
518 assert_eq!(summary.work_status(), ChangeWorkStatus::Draft);
519
520 summary.has_proposal = true;
521 summary.has_specs = true;
522 summary.has_tasks = true;
523 summary.total_tasks = 3;
524 summary.pending_tasks = 3;
525
526 assert_eq!(summary.work_status(), ChangeWorkStatus::Ready);
527
528 summary.in_progress_tasks = 1;
529 summary.pending_tasks = 2;
530 assert_eq!(summary.work_status(), ChangeWorkStatus::InProgress);
531
532 summary.in_progress_tasks = 0;
533 summary.pending_tasks = 0;
534 summary.shelved_tasks = 1;
535 summary.completed_tasks = 2;
536 assert_eq!(summary.work_status(), ChangeWorkStatus::Paused);
537
538 summary.shelved_tasks = 0;
539 summary.completed_tasks = 3;
540 assert_eq!(summary.work_status(), ChangeWorkStatus::Complete);
541 }
542}