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