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;