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