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
66/// Per-change orchestration metadata.
67#[derive(Debug, Clone, Default, PartialEq, Eq)]
68pub struct ChangeOrchestrateMetadata {
69    /// Canonical change IDs that must complete before this change is dispatched.
70    pub depends_on: Vec<String>,
71    /// Optional gate order override for this change.
72    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/// Full change with all artifacts loaded.
98#[derive(Debug, Clone)]
99pub struct Change {
100    /// Change identifier (e.g., "005-01_my-change" or "005.01-03_my-change")
101    pub id: String,
102    /// Module ID extracted from the change ID (e.g., "005")
103    pub module_id: Option<String>,
104    /// Sub-module ID in canonical `NNN.SS` form when the change belongs to a sub-module.
105    ///
106    /// `None` for changes that use the legacy `NNN-NN_name` format without a sub-module.
107    pub sub_module_id: Option<String>,
108    /// Path to the change directory
109    pub path: PathBuf,
110    /// Proposal content (raw markdown)
111    pub proposal: Option<String>,
112    /// Design content (raw markdown)
113    pub design: Option<String>,
114    /// Specifications
115    pub specs: Vec<Spec>,
116    /// Parsed tasks
117    pub tasks: TasksParseResult,
118    /// Per-change orchestration metadata.
119    pub orchestrate: ChangeOrchestrateMetadata,
120    /// Last modification time of any artifact
121    pub last_modified: DateTime<Utc>,
122}
123
124impl Change {
125    /// Get the status of this change based on task completion.
126    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    /// Derived work status for UX and filtering.
138    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        // Planning artifacts required to start work.
149        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    /// Check if all required artifacts are present.
170    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    /// Get task progress as (completed, total).
178    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    /// Get the progress info for this change.
186    pub fn progress(&self) -> &ProgressInfo {
187        &self.tasks.progress
188    }
189}
190
191/// Lightweight change summary for listings.
192#[derive(Debug, Clone)]
193pub struct ChangeSummary {
194    /// Change identifier
195    pub id: String,
196    /// Module ID extracted from the change ID
197    pub module_id: Option<String>,
198    /// Sub-module ID in canonical `NNN.SS` form when the change belongs to a sub-module.
199    ///
200    /// `None` for changes that use the legacy `NNN-NN_name` format without a sub-module.
201    pub sub_module_id: Option<String>,
202    /// Number of completed tasks
203    pub completed_tasks: u32,
204    /// Number of shelved tasks (enhanced tasks only)
205    pub shelved_tasks: u32,
206    /// Number of in-progress tasks
207    pub in_progress_tasks: u32,
208    /// Number of pending tasks
209    pub pending_tasks: u32,
210    /// Total number of tasks
211    pub total_tasks: u32,
212    /// Last modification time
213    pub last_modified: DateTime<Utc>,
214    /// Whether proposal.md exists
215    pub has_proposal: bool,
216    /// Whether design.md exists
217    pub has_design: bool,
218    /// Whether specs/ directory has content
219    pub has_specs: bool,
220    /// Whether tasks.md exists and has tasks
221    pub has_tasks: bool,
222    /// Per-change orchestration metadata.
223    pub orchestrate: ChangeOrchestrateMetadata,
224}
225
226impl ChangeSummary {
227    /// Get the status of this change based on task counts.
228    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    /// Derived work status for UX and filtering.
239    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    /// Check if this change is ready for implementation.
262    ///
263    /// A change is "ready" when it has all required planning artifacts and has remaining work
264    /// with no in-progress tasks.
265    pub fn is_ready(&self) -> bool {
266        self.work_status() == ChangeWorkStatus::Ready
267    }
268}
269
270/// Extract module ID from a change ID.
271///
272/// Handles both the legacy `NNN-NN_name` format and the sub-module
273/// `NNN.SS-NN_name` format. Always returns only the parent module number.
274///
275/// - `005-01_my-change` -> `005`
276/// - `5-1_whatever` -> `005`
277/// - `1-000002` -> `001`
278/// - `024.01-03_foo` -> `024`
279pub fn extract_module_id(change_id: &str) -> Option<String> {
280    let parts: Vec<&str> = change_id.split('-').collect();
281    if parts.len() >= 2 {
282        // Strip any sub-module component (e.g., "024.01" -> "024").
283        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
290/// Extract the sub-module ID from a change ID in `NNN.SS-NN_name` format.
291///
292/// Returns `Some("NNN.SS")` for sub-module changes, `None` for legacy
293/// `NNN-NN_name` changes.
294///
295/// - `024.01-03_foo` -> `Some("024.01")`
296/// - `005-01_my-change` -> `None`
297pub fn extract_sub_module_id(change_id: &str) -> Option<String> {
298    // A sub-module change has a dot before the first hyphen.
299    let prefix = change_id.split('-').next()?;
300    if !prefix.contains('.') {
301        return None;
302    }
303    // Normalize: "24.1" -> "024.01" via the common parser.
304    ito_common::id::parse_sub_module_id(prefix)
305        .map(|p| p.sub_module_id.as_str().to_string())
306        .ok()
307}
308
309/// Normalize an ID to a fixed width with zero-padding.
310///
311/// - `"5"` with width 3 -> `"005"`
312/// - `"005"` with width 3 -> `"005"`
313/// - `"0005"` with width 3 -> `"005"` (strips leading zeros beyond width)
314pub fn normalize_id(id: &str, width: usize) -> String {
315    // Parse as number to strip leading zeros, then reformat
316    let num: u32 = id.parse().unwrap_or(0);
317    format!("{:0>width$}", num, width = width)
318}
319
320/// Parse a change identifier and return the normalized module ID and change number.
321///
322/// Handles both legacy and sub-module formats:
323/// - `005-01_my-change` → `("005", "01")`
324/// - `5-1_whatever` → `("005", "01")`
325/// - `1-2` → `("001", "02")`
326/// - `001-000002_foo` → `("001", "02")`
327/// - `024.01-03_foo` → `("024", "03")`
328pub fn parse_change_id(input: &str) -> Option<(String, String)> {
329    // Remove the name suffix if present (everything after underscore)
330    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        // Strip any sub-module component (e.g., "024.01" → "024").
335        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
344/// Parse a module identifier and return the normalized module ID.
345///
346/// Handles various formats:
347/// - `005` -> `"005"`
348/// - `5` -> `"005"`
349/// - `005_dev-tooling` -> `"005"`
350/// - `5_dev-tooling` -> `"005"`
351pub fn parse_module_id(input: &str) -> String {
352    // Remove the name suffix if present (everything after underscore)
353    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        // Sub-module format: strip sub-module component
411        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}