Skip to main content

ito_domain/changes/
mod.rs

1//! Change domain models and repository.
2//!
3//! This module provides domain models for Ito changes and a repository
4//! for loading and querying change data.
5
6mod 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/// A specification within a change.
18#[derive(Debug, Clone)]
19pub struct Spec {
20    /// Spec name (directory name under specs/)
21    pub name: String,
22    /// Spec content (raw markdown)
23    pub content: String,
24}
25
26/// Status of a change based on task completion.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ChangeStatus {
29    /// No tasks defined
30    NoTasks,
31    /// Some tasks incomplete
32    InProgress,
33    /// All tasks complete
34    Complete,
35}
36
37/// Work status of a change.
38///
39/// This is a derived status intended for UX and filtering. It is NOT a persisted
40/// lifecycle state.
41///
42/// Semantics:
43/// - `Draft`: missing required planning artifacts (proposal + specs + tasks)
44/// - `Ready`: planning artifacts exist and there is remaining work, with no in-progress tasks
45/// - `InProgress`: at least one task is in-progress
46/// - `Paused`: no remaining work, but at least one task is shelved (i.e. all tasks are done or shelved)
47/// - `Complete`: all tasks are complete (shelved tasks do NOT count as complete)
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum ChangeWorkStatus {
50    /// Missing required planning artifacts (proposal + specs + tasks).
51    Draft,
52    /// Ready to start work (planning artifacts exist, remaining work, nothing in-progress).
53    Ready,
54    /// At least one task is in-progress.
55    InProgress,
56    /// No remaining work, but at least one task is shelved.
57    ///
58    /// This distinguishes "we're finished but chose to shelve something" from `Complete`.
59    Paused,
60    /// All tasks complete.
61    ///
62    /// Note: shelved tasks do NOT count as complete.
63    Complete,
64}
65
66impl std::fmt::Display for ChangeWorkStatus {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        match self {
69            ChangeWorkStatus::Draft => write!(f, "draft"),
70            ChangeWorkStatus::Ready => write!(f, "ready"),
71            ChangeWorkStatus::InProgress => write!(f, "in-progress"),
72            ChangeWorkStatus::Paused => write!(f, "paused"),
73            ChangeWorkStatus::Complete => write!(f, "complete"),
74        }
75    }
76}
77
78impl std::fmt::Display for ChangeStatus {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        match self {
81            ChangeStatus::NoTasks => write!(f, "no-tasks"),
82            ChangeStatus::InProgress => write!(f, "in-progress"),
83            ChangeStatus::Complete => write!(f, "complete"),
84        }
85    }
86}
87
88/// Full change with all artifacts loaded.
89#[derive(Debug, Clone)]
90pub struct Change {
91    /// Change identifier (e.g., "005-01_my-change")
92    pub id: String,
93    /// Module ID extracted from the change ID (e.g., "005")
94    pub module_id: Option<String>,
95    /// Path to the change directory
96    pub path: PathBuf,
97    /// Proposal content (raw markdown)
98    pub proposal: Option<String>,
99    /// Design content (raw markdown)
100    pub design: Option<String>,
101    /// Specifications
102    pub specs: Vec<Spec>,
103    /// Parsed tasks
104    pub tasks: TasksParseResult,
105    /// Last modification time of any artifact
106    pub last_modified: DateTime<Utc>,
107}
108
109impl Change {
110    /// Get the status of this change based on task completion.
111    pub fn status(&self) -> ChangeStatus {
112        let progress = &self.tasks.progress;
113        if progress.total == 0 {
114            ChangeStatus::NoTasks
115        } else if progress.complete >= progress.total {
116            ChangeStatus::Complete
117        } else {
118            ChangeStatus::InProgress
119        }
120    }
121
122    /// Derived work status for UX and filtering.
123    pub fn work_status(&self) -> ChangeWorkStatus {
124        let ProgressInfo {
125            total,
126            complete,
127            shelved,
128            in_progress,
129            pending,
130            remaining: _,
131        } = self.tasks.progress;
132
133        // Planning artifacts required to start work.
134        let has_planning_artifacts = self.proposal.is_some() && !self.specs.is_empty() && total > 0;
135        if !has_planning_artifacts {
136            return ChangeWorkStatus::Draft;
137        }
138
139        if complete == total {
140            return ChangeWorkStatus::Complete;
141        }
142        if in_progress > 0 {
143            return ChangeWorkStatus::InProgress;
144        }
145
146        let done_or_shelved = complete + shelved;
147        if pending == 0 && shelved > 0 && done_or_shelved == total {
148            return ChangeWorkStatus::Paused;
149        }
150
151        ChangeWorkStatus::Ready
152    }
153
154    /// Check if all required artifacts are present.
155    pub fn artifacts_complete(&self) -> bool {
156        self.proposal.is_some()
157            && self.design.is_some()
158            && !self.specs.is_empty()
159            && self.tasks.progress.total > 0
160    }
161
162    /// Get task progress as (completed, total).
163    pub fn task_progress(&self) -> (u32, u32) {
164        (
165            self.tasks.progress.complete as u32,
166            self.tasks.progress.total as u32,
167        )
168    }
169
170    /// Get the progress info for this change.
171    pub fn progress(&self) -> &ProgressInfo {
172        &self.tasks.progress
173    }
174}
175
176/// Lightweight change summary for listings.
177#[derive(Debug, Clone)]
178pub struct ChangeSummary {
179    /// Change identifier
180    pub id: String,
181    /// Module ID extracted from the change ID
182    pub module_id: Option<String>,
183    /// Number of completed tasks
184    pub completed_tasks: u32,
185    /// Number of shelved tasks (enhanced tasks only)
186    pub shelved_tasks: u32,
187    /// Number of in-progress tasks
188    pub in_progress_tasks: u32,
189    /// Number of pending tasks
190    pub pending_tasks: u32,
191    /// Total number of tasks
192    pub total_tasks: u32,
193    /// Last modification time
194    pub last_modified: DateTime<Utc>,
195    /// Whether proposal.md exists
196    pub has_proposal: bool,
197    /// Whether design.md exists
198    pub has_design: bool,
199    /// Whether specs/ directory has content
200    pub has_specs: bool,
201    /// Whether tasks.md exists and has tasks
202    pub has_tasks: bool,
203}
204
205impl ChangeSummary {
206    /// Get the status of this change based on task counts.
207    pub fn status(&self) -> ChangeStatus {
208        if self.total_tasks == 0 {
209            ChangeStatus::NoTasks
210        } else if self.completed_tasks >= self.total_tasks {
211            ChangeStatus::Complete
212        } else {
213            ChangeStatus::InProgress
214        }
215    }
216
217    /// Derived work status for UX and filtering.
218    pub fn work_status(&self) -> ChangeWorkStatus {
219        let has_planning_artifacts = self.has_proposal && self.has_specs && self.has_tasks;
220        if !has_planning_artifacts {
221            return ChangeWorkStatus::Draft;
222        }
223
224        if self.total_tasks > 0 && self.completed_tasks == self.total_tasks {
225            return ChangeWorkStatus::Complete;
226        }
227        if self.in_progress_tasks > 0 {
228            return ChangeWorkStatus::InProgress;
229        }
230
231        let done_or_shelved = self.completed_tasks + self.shelved_tasks;
232        if self.pending_tasks == 0 && self.shelved_tasks > 0 && done_or_shelved == self.total_tasks
233        {
234            return ChangeWorkStatus::Paused;
235        }
236
237        ChangeWorkStatus::Ready
238    }
239
240    /// Check if this change is ready for implementation.
241    ///
242    /// A change is "ready" when it has all required planning artifacts and has remaining work
243    /// with no in-progress tasks.
244    pub fn is_ready(&self) -> bool {
245        self.work_status() == ChangeWorkStatus::Ready
246    }
247}
248
249/// Extract module ID from a change ID.
250///
251/// Change IDs follow the pattern `NNN-NN_name` where `NNN` is the module ID.
252/// Handles various formats:
253/// - `005-01_my-change` -> `005`
254/// - `5-1_whatever` -> `005`
255/// - `1-000002` -> `001`
256pub fn extract_module_id(change_id: &str) -> Option<String> {
257    let parts: Vec<&str> = change_id.split('-').collect();
258    if parts.len() >= 2 {
259        Some(normalize_id(parts[0], 3))
260    } else {
261        None
262    }
263}
264
265/// Normalize an ID to a fixed width with zero-padding.
266///
267/// - `"5"` with width 3 -> `"005"`
268/// - `"005"` with width 3 -> `"005"`
269/// - `"0005"` with width 3 -> `"005"` (strips leading zeros beyond width)
270pub fn normalize_id(id: &str, width: usize) -> String {
271    // Parse as number to strip leading zeros, then reformat
272    let num: u32 = id.parse().unwrap_or(0);
273    format!("{:0>width$}", num, width = width)
274}
275
276/// Parse a change identifier and return the normalized module ID and change number.
277///
278/// Handles various formats:
279/// - `005-01_my-change` -> `("005", "01")`
280/// - `5-1_whatever` -> `("005", "01")`
281/// - `1-2` -> `("001", "02")`
282/// - `001-000002_foo` -> `("001", "02")`
283pub fn parse_change_id(input: &str) -> Option<(String, String)> {
284    // Remove the name suffix if present (everything after underscore)
285    let id_part = input.split('_').next().unwrap_or(input);
286
287    let parts: Vec<&str> = id_part.split('-').collect();
288    if parts.len() >= 2 {
289        let module_id = normalize_id(parts[0], 3);
290        let change_num = normalize_id(parts[1], 2);
291        Some((module_id, change_num))
292    } else {
293        None
294    }
295}
296
297/// Parse a module identifier and return the normalized module ID.
298///
299/// Handles various formats:
300/// - `005` -> `"005"`
301/// - `5` -> `"005"`
302/// - `005_dev-tooling` -> `"005"`
303/// - `5_dev-tooling` -> `"005"`
304pub fn parse_module_id(input: &str) -> String {
305    // Remove the name suffix if present (everything after underscore)
306    let id_part = input.split('_').next().unwrap_or(input);
307    normalize_id(id_part, 3)
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[test]
315    fn test_normalize_id() {
316        assert_eq!(normalize_id("5", 3), "005");
317        assert_eq!(normalize_id("05", 3), "005");
318        assert_eq!(normalize_id("005", 3), "005");
319        assert_eq!(normalize_id("0005", 3), "005");
320        assert_eq!(normalize_id("1", 2), "01");
321        assert_eq!(normalize_id("01", 2), "01");
322        assert_eq!(normalize_id("001", 2), "01");
323    }
324
325    #[test]
326    fn test_parse_change_id() {
327        assert_eq!(
328            parse_change_id("005-01_my-change"),
329            Some(("005".to_string(), "01".to_string()))
330        );
331        assert_eq!(
332            parse_change_id("5-1_whatever"),
333            Some(("005".to_string(), "01".to_string()))
334        );
335        assert_eq!(
336            parse_change_id("1-2"),
337            Some(("001".to_string(), "02".to_string()))
338        );
339        assert_eq!(
340            parse_change_id("001-000002_foo"),
341            Some(("001".to_string(), "02".to_string()))
342        );
343        assert_eq!(parse_change_id("invalid"), None);
344    }
345
346    #[test]
347    fn test_parse_module_id() {
348        assert_eq!(parse_module_id("005"), "005");
349        assert_eq!(parse_module_id("5"), "005");
350        assert_eq!(parse_module_id("005_dev-tooling"), "005");
351        assert_eq!(parse_module_id("5_dev-tooling"), "005");
352    }
353
354    #[test]
355    fn test_extract_module_id() {
356        assert_eq!(
357            extract_module_id("005-01_my-change"),
358            Some("005".to_string())
359        );
360        assert_eq!(extract_module_id("013-18_cleanup"), Some("013".to_string()));
361        assert_eq!(extract_module_id("5-1_foo"), Some("005".to_string()));
362        assert_eq!(extract_module_id("invalid"), None);
363    }
364
365    #[test]
366    fn test_change_status_display() {
367        assert_eq!(ChangeStatus::NoTasks.to_string(), "no-tasks");
368        assert_eq!(ChangeStatus::InProgress.to_string(), "in-progress");
369        assert_eq!(ChangeStatus::Complete.to_string(), "complete");
370    }
371
372    #[test]
373    fn test_change_summary_status() {
374        let mut summary = ChangeSummary {
375            id: "test".to_string(),
376            module_id: None,
377            completed_tasks: 0,
378            shelved_tasks: 0,
379            in_progress_tasks: 0,
380            pending_tasks: 0,
381            total_tasks: 0,
382            last_modified: Utc::now(),
383            has_proposal: false,
384            has_design: false,
385            has_specs: false,
386            has_tasks: false,
387        };
388
389        assert_eq!(summary.status(), ChangeStatus::NoTasks);
390
391        summary.total_tasks = 5;
392        summary.completed_tasks = 3;
393        assert_eq!(summary.status(), ChangeStatus::InProgress);
394
395        summary.completed_tasks = 5;
396        assert_eq!(summary.status(), ChangeStatus::Complete);
397    }
398
399    #[test]
400    fn test_change_work_status() {
401        let mut summary = ChangeSummary {
402            id: "test".to_string(),
403            module_id: None,
404            completed_tasks: 0,
405            shelved_tasks: 0,
406            in_progress_tasks: 0,
407            pending_tasks: 0,
408            total_tasks: 0,
409            last_modified: Utc::now(),
410            has_proposal: false,
411            has_design: false,
412            has_specs: false,
413            has_tasks: false,
414        };
415
416        assert_eq!(summary.work_status(), ChangeWorkStatus::Draft);
417
418        summary.has_proposal = true;
419        summary.has_specs = true;
420        summary.has_tasks = true;
421        summary.total_tasks = 3;
422        summary.pending_tasks = 3;
423
424        assert_eq!(summary.work_status(), ChangeWorkStatus::Ready);
425
426        summary.in_progress_tasks = 1;
427        summary.pending_tasks = 2;
428        assert_eq!(summary.work_status(), ChangeWorkStatus::InProgress);
429
430        summary.in_progress_tasks = 0;
431        summary.pending_tasks = 0;
432        summary.shelved_tasks = 1;
433        summary.completed_tasks = 2;
434        assert_eq!(summary.work_status(), ChangeWorkStatus::Paused);
435
436        summary.shelved_tasks = 0;
437        summary.completed_tasks = 3;
438        assert_eq!(summary.work_status(), ChangeWorkStatus::Complete);
439    }
440}