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" or "005.01-03_my-change")
92    pub id: String,
93    /// Module ID extracted from the change ID (e.g., "005")
94    pub module_id: Option<String>,
95    /// Sub-module ID in canonical `NNN.SS` form when the change belongs to a sub-module.
96    ///
97    /// `None` for changes that use the legacy `NNN-NN_name` format without a sub-module.
98    pub sub_module_id: Option<String>,
99    /// Path to the change directory
100    pub path: PathBuf,
101    /// Proposal content (raw markdown)
102    pub proposal: Option<String>,
103    /// Design content (raw markdown)
104    pub design: Option<String>,
105    /// Specifications
106    pub specs: Vec<Spec>,
107    /// Parsed tasks
108    pub tasks: TasksParseResult,
109    /// Last modification time of any artifact
110    pub last_modified: DateTime<Utc>,
111}
112
113impl Change {
114    /// Get the status of this change based on task completion.
115    pub fn status(&self) -> ChangeStatus {
116        let progress = &self.tasks.progress;
117        if progress.total == 0 {
118            ChangeStatus::NoTasks
119        } else if progress.complete >= progress.total {
120            ChangeStatus::Complete
121        } else {
122            ChangeStatus::InProgress
123        }
124    }
125
126    /// Derived work status for UX and filtering.
127    pub fn work_status(&self) -> ChangeWorkStatus {
128        let ProgressInfo {
129            total,
130            complete,
131            shelved,
132            in_progress,
133            pending,
134            remaining: _,
135        } = self.tasks.progress;
136
137        // Planning artifacts required to start work.
138        let has_planning_artifacts = self.proposal.is_some() && !self.specs.is_empty() && total > 0;
139        if !has_planning_artifacts {
140            return ChangeWorkStatus::Draft;
141        }
142
143        if complete == total {
144            return ChangeWorkStatus::Complete;
145        }
146        if in_progress > 0 {
147            return ChangeWorkStatus::InProgress;
148        }
149
150        let done_or_shelved = complete + shelved;
151        if pending == 0 && shelved > 0 && done_or_shelved == total {
152            return ChangeWorkStatus::Paused;
153        }
154
155        ChangeWorkStatus::Ready
156    }
157
158    /// Check if all required artifacts are present.
159    pub fn artifacts_complete(&self) -> bool {
160        self.proposal.is_some()
161            && self.design.is_some()
162            && !self.specs.is_empty()
163            && self.tasks.progress.total > 0
164    }
165
166    /// Get task progress as (completed, total).
167    pub fn task_progress(&self) -> (u32, u32) {
168        (
169            self.tasks.progress.complete as u32,
170            self.tasks.progress.total as u32,
171        )
172    }
173
174    /// Get the progress info for this change.
175    pub fn progress(&self) -> &ProgressInfo {
176        &self.tasks.progress
177    }
178}
179
180/// Lightweight change summary for listings.
181#[derive(Debug, Clone)]
182pub struct ChangeSummary {
183    /// Change identifier
184    pub id: String,
185    /// Module ID extracted from the change ID
186    pub module_id: Option<String>,
187    /// Sub-module ID in canonical `NNN.SS` form when the change belongs to a sub-module.
188    ///
189    /// `None` for changes that use the legacy `NNN-NN_name` format without a sub-module.
190    pub sub_module_id: Option<String>,
191    /// Number of completed tasks
192    pub completed_tasks: u32,
193    /// Number of shelved tasks (enhanced tasks only)
194    pub shelved_tasks: u32,
195    /// Number of in-progress tasks
196    pub in_progress_tasks: u32,
197    /// Number of pending tasks
198    pub pending_tasks: u32,
199    /// Total number of tasks
200    pub total_tasks: u32,
201    /// Last modification time
202    pub last_modified: DateTime<Utc>,
203    /// Whether proposal.md exists
204    pub has_proposal: bool,
205    /// Whether design.md exists
206    pub has_design: bool,
207    /// Whether specs/ directory has content
208    pub has_specs: bool,
209    /// Whether tasks.md exists and has tasks
210    pub has_tasks: bool,
211}
212
213impl ChangeSummary {
214    /// Get the status of this change based on task counts.
215    pub fn status(&self) -> ChangeStatus {
216        if self.total_tasks == 0 {
217            ChangeStatus::NoTasks
218        } else if self.completed_tasks >= self.total_tasks {
219            ChangeStatus::Complete
220        } else {
221            ChangeStatus::InProgress
222        }
223    }
224
225    /// Derived work status for UX and filtering.
226    pub fn work_status(&self) -> ChangeWorkStatus {
227        let has_planning_artifacts = self.has_proposal && self.has_specs && self.has_tasks;
228        if !has_planning_artifacts {
229            return ChangeWorkStatus::Draft;
230        }
231
232        if self.total_tasks > 0 && self.completed_tasks == self.total_tasks {
233            return ChangeWorkStatus::Complete;
234        }
235        if self.in_progress_tasks > 0 {
236            return ChangeWorkStatus::InProgress;
237        }
238
239        let done_or_shelved = self.completed_tasks + self.shelved_tasks;
240        if self.pending_tasks == 0 && self.shelved_tasks > 0 && done_or_shelved == self.total_tasks
241        {
242            return ChangeWorkStatus::Paused;
243        }
244
245        ChangeWorkStatus::Ready
246    }
247
248    /// Check if this change is ready for implementation.
249    ///
250    /// A change is "ready" when it has all required planning artifacts and has remaining work
251    /// with no in-progress tasks.
252    pub fn is_ready(&self) -> bool {
253        self.work_status() == ChangeWorkStatus::Ready
254    }
255}
256
257/// Extract module ID from a change ID.
258///
259/// Handles both the legacy `NNN-NN_name` format and the sub-module
260/// `NNN.SS-NN_name` format. Always returns only the parent module number.
261///
262/// - `005-01_my-change` -> `005`
263/// - `5-1_whatever` -> `005`
264/// - `1-000002` -> `001`
265/// - `024.01-03_foo` -> `024`
266pub fn extract_module_id(change_id: &str) -> Option<String> {
267    let parts: Vec<&str> = change_id.split('-').collect();
268    if parts.len() >= 2 {
269        // Strip any sub-module component (e.g., "024.01" -> "024").
270        let module_part = parts[0].split('.').next().unwrap_or(parts[0]);
271        Some(normalize_id(module_part, 3))
272    } else {
273        None
274    }
275}
276
277/// Extract the sub-module ID from a change ID in `NNN.SS-NN_name` format.
278///
279/// Returns `Some("NNN.SS")` for sub-module changes, `None` for legacy
280/// `NNN-NN_name` changes.
281///
282/// - `024.01-03_foo` -> `Some("024.01")`
283/// - `005-01_my-change` -> `None`
284pub fn extract_sub_module_id(change_id: &str) -> Option<String> {
285    // A sub-module change has a dot before the first hyphen.
286    let prefix = change_id.split('-').next()?;
287    if !prefix.contains('.') {
288        return None;
289    }
290    // Normalize: "24.1" -> "024.01" via the common parser.
291    ito_common::id::parse_sub_module_id(prefix)
292        .map(|p| p.sub_module_id.as_str().to_string())
293        .ok()
294}
295
296/// Normalize an ID to a fixed width with zero-padding.
297///
298/// - `"5"` with width 3 -> `"005"`
299/// - `"005"` with width 3 -> `"005"`
300/// - `"0005"` with width 3 -> `"005"` (strips leading zeros beyond width)
301pub fn normalize_id(id: &str, width: usize) -> String {
302    // Parse as number to strip leading zeros, then reformat
303    let num: u32 = id.parse().unwrap_or(0);
304    format!("{:0>width$}", num, width = width)
305}
306
307/// Parse a change identifier and return the normalized module ID and change number.
308///
309/// Handles both legacy and sub-module formats:
310/// - `005-01_my-change` → `("005", "01")`
311/// - `5-1_whatever` → `("005", "01")`
312/// - `1-2` → `("001", "02")`
313/// - `001-000002_foo` → `("001", "02")`
314/// - `024.01-03_foo` → `("024", "03")`
315pub fn parse_change_id(input: &str) -> Option<(String, String)> {
316    // Remove the name suffix if present (everything after underscore)
317    let id_part = input.split('_').next().unwrap_or(input);
318
319    let parts: Vec<&str> = id_part.split('-').collect();
320    if parts.len() >= 2 {
321        // Strip any sub-module component (e.g., "024.01" → "024").
322        let module_part = parts[0].split('.').next().unwrap_or(parts[0]);
323        let module_id = normalize_id(module_part, 3);
324        let change_num = normalize_id(parts[1], 2);
325        Some((module_id, change_num))
326    } else {
327        None
328    }
329}
330
331/// Parse a module identifier and return the normalized module ID.
332///
333/// Handles various formats:
334/// - `005` -> `"005"`
335/// - `5` -> `"005"`
336/// - `005_dev-tooling` -> `"005"`
337/// - `5_dev-tooling` -> `"005"`
338pub fn parse_module_id(input: &str) -> String {
339    // Remove the name suffix if present (everything after underscore)
340    let id_part = input.split('_').next().unwrap_or(input);
341    normalize_id(id_part, 3)
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn test_normalize_id() {
350        assert_eq!(normalize_id("5", 3), "005");
351        assert_eq!(normalize_id("05", 3), "005");
352        assert_eq!(normalize_id("005", 3), "005");
353        assert_eq!(normalize_id("0005", 3), "005");
354        assert_eq!(normalize_id("1", 2), "01");
355        assert_eq!(normalize_id("01", 2), "01");
356        assert_eq!(normalize_id("001", 2), "01");
357    }
358
359    #[test]
360    fn test_parse_change_id() {
361        assert_eq!(
362            parse_change_id("005-01_my-change"),
363            Some(("005".to_string(), "01".to_string()))
364        );
365        assert_eq!(
366            parse_change_id("5-1_whatever"),
367            Some(("005".to_string(), "01".to_string()))
368        );
369        assert_eq!(
370            parse_change_id("1-2"),
371            Some(("001".to_string(), "02".to_string()))
372        );
373        assert_eq!(
374            parse_change_id("001-000002_foo"),
375            Some(("001".to_string(), "02".to_string()))
376        );
377        assert_eq!(parse_change_id("invalid"), None);
378    }
379
380    #[test]
381    fn test_parse_module_id() {
382        assert_eq!(parse_module_id("005"), "005");
383        assert_eq!(parse_module_id("5"), "005");
384        assert_eq!(parse_module_id("005_dev-tooling"), "005");
385        assert_eq!(parse_module_id("5_dev-tooling"), "005");
386    }
387
388    #[test]
389    fn test_extract_module_id() {
390        assert_eq!(
391            extract_module_id("005-01_my-change"),
392            Some("005".to_string())
393        );
394        assert_eq!(extract_module_id("013-18_cleanup"), Some("013".to_string()));
395        assert_eq!(extract_module_id("5-1_foo"), Some("005".to_string()));
396        assert_eq!(extract_module_id("invalid"), None);
397        // Sub-module format: strip sub-module component
398        assert_eq!(extract_module_id("024.01-03_foo"), Some("024".to_string()));
399        assert_eq!(extract_module_id("5.1-2_bar"), Some("005".to_string()));
400    }
401
402    #[test]
403    fn test_extract_sub_module_id() {
404        assert_eq!(
405            extract_sub_module_id("024.01-03_foo"),
406            Some("024.01".to_string())
407        );
408        assert_eq!(
409            extract_sub_module_id("5.1-2_bar"),
410            Some("005.01".to_string())
411        );
412        assert_eq!(extract_sub_module_id("005-01_my-change"), None);
413        assert_eq!(extract_sub_module_id("invalid"), None);
414    }
415
416    #[test]
417    fn test_parse_change_id_sub_module_format() {
418        assert_eq!(
419            parse_change_id("024.01-03_foo"),
420            Some(("024".to_string(), "03".to_string()))
421        );
422        assert_eq!(
423            parse_change_id("5.1-2_bar"),
424            Some(("005".to_string(), "02".to_string()))
425        );
426    }
427
428    #[test]
429    fn test_change_sub_module_id_field() {
430        let summary = ChangeSummary {
431            id: "005.01-03_my-change".to_string(),
432            module_id: Some("005".to_string()),
433            sub_module_id: Some("005.01".to_string()),
434            completed_tasks: 0,
435            shelved_tasks: 0,
436            in_progress_tasks: 0,
437            pending_tasks: 0,
438            total_tasks: 0,
439            last_modified: Utc::now(),
440            has_proposal: false,
441            has_design: false,
442            has_specs: false,
443            has_tasks: false,
444        };
445
446        assert_eq!(summary.sub_module_id.as_deref(), Some("005.01"));
447    }
448
449    #[test]
450    fn test_change_status_display() {
451        assert_eq!(ChangeStatus::NoTasks.to_string(), "no-tasks");
452        assert_eq!(ChangeStatus::InProgress.to_string(), "in-progress");
453        assert_eq!(ChangeStatus::Complete.to_string(), "complete");
454    }
455
456    #[test]
457    fn test_change_summary_status() {
458        let mut summary = ChangeSummary {
459            id: "test".to_string(),
460            module_id: None,
461            sub_module_id: None,
462            completed_tasks: 0,
463            shelved_tasks: 0,
464            in_progress_tasks: 0,
465            pending_tasks: 0,
466            total_tasks: 0,
467            last_modified: Utc::now(),
468            has_proposal: false,
469            has_design: false,
470            has_specs: false,
471            has_tasks: false,
472        };
473
474        assert_eq!(summary.status(), ChangeStatus::NoTasks);
475
476        summary.total_tasks = 5;
477        summary.completed_tasks = 3;
478        assert_eq!(summary.status(), ChangeStatus::InProgress);
479
480        summary.completed_tasks = 5;
481        assert_eq!(summary.status(), ChangeStatus::Complete);
482    }
483
484    #[test]
485    fn test_change_work_status() {
486        let mut summary = ChangeSummary {
487            id: "test".to_string(),
488            module_id: None,
489            sub_module_id: None,
490            completed_tasks: 0,
491            shelved_tasks: 0,
492            in_progress_tasks: 0,
493            pending_tasks: 0,
494            total_tasks: 0,
495            last_modified: Utc::now(),
496            has_proposal: false,
497            has_design: false,
498            has_specs: false,
499            has_tasks: false,
500        };
501
502        assert_eq!(summary.work_status(), ChangeWorkStatus::Draft);
503
504        summary.has_proposal = true;
505        summary.has_specs = true;
506        summary.has_tasks = true;
507        summary.total_tasks = 3;
508        summary.pending_tasks = 3;
509
510        assert_eq!(summary.work_status(), ChangeWorkStatus::Ready);
511
512        summary.in_progress_tasks = 1;
513        summary.pending_tasks = 2;
514        assert_eq!(summary.work_status(), ChangeWorkStatus::InProgress);
515
516        summary.in_progress_tasks = 0;
517        summary.pending_tasks = 0;
518        summary.shelved_tasks = 1;
519        summary.completed_tasks = 2;
520        assert_eq!(summary.work_status(), ChangeWorkStatus::Paused);
521
522        summary.shelved_tasks = 0;
523        summary.completed_tasks = 3;
524        assert_eq!(summary.work_status(), ChangeWorkStatus::Complete);
525    }
526}