1use std::path::{Path, PathBuf};
7
8use chrono::{DateTime, SecondsFormat, Timelike, Utc};
9
10use crate::error_bridge::IntoCoreResult;
11use crate::errors::{CoreError, CoreResult};
12use ito_common::fs::StdFs;
13use ito_common::paths;
14use ito_config::types::ItoConfig;
15use ito_domain::changes::{
16 ChangeLifecycleFilter, ChangeRepository as DomainChangeRepository, ChangeStatus, ChangeSummary,
17};
18use ito_domain::modules::ModuleRepository as DomainModuleRepository;
19
20use crate::implementation_readiness::{ReadinessPhase, ReadinessRequest, evaluate_readiness};
21
22#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
23pub struct SubModuleListItem {
25 pub id: String,
27 pub name: String,
29 #[serde(rename = "changeCount")]
30 pub change_count: usize,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
35pub struct ModuleListItem {
37 pub id: String,
39 pub name: String,
41 #[serde(rename = "fullName")]
42 pub full_name: String,
44 #[serde(rename = "changeCount")]
45 pub change_count: usize,
47 #[serde(rename = "subModules")]
49 pub sub_modules: Vec<SubModuleListItem>,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
53pub struct ChangeListItem {
55 pub name: String,
57 #[serde(rename = "completedTasks")]
58 pub completed_tasks: u32,
60 #[serde(rename = "shelvedTasks")]
61 pub shelved_tasks: u32,
63 #[serde(rename = "inProgressTasks")]
64 pub in_progress_tasks: u32,
66 #[serde(rename = "pendingTasks")]
67 pub pending_tasks: u32,
69 #[serde(rename = "totalTasks")]
70 pub total_tasks: u32,
72 #[serde(rename = "lastModified")]
73 pub last_modified: String,
75 pub status: String,
77 #[serde(rename = "workStatus")]
79 pub work_status: String,
80 pub completed: bool,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
85pub struct ArchivedChangeListItem {
87 pub name: String,
89 #[serde(rename = "lastModified")]
90 pub last_modified: String,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum ChangeProgressFilter {
97 All,
99 Ready,
101 Completed,
103 Partial,
105 Pending,
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum ChangeSortOrder {
112 Recent,
114 Name,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub struct ListChangesInput {
121 pub progress_filter: ChangeProgressFilter,
123 pub sort: ChangeSortOrder,
125}
126
127impl Default for ListChangesInput {
128 fn default() -> Self {
129 Self {
130 progress_filter: ChangeProgressFilter::All,
131 sort: ChangeSortOrder::Recent,
132 }
133 }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct ChangeListSummary {
139 pub name: String,
141 pub completed_tasks: u32,
143 pub shelved_tasks: u32,
145 pub in_progress_tasks: u32,
147 pub pending_tasks: u32,
149 pub total_tasks: u32,
151 pub last_modified: DateTime<Utc>,
153 pub status: String,
155 pub work_status: String,
157 pub completed: bool,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
162pub struct SpecListItem {
164 pub id: String,
166 #[serde(rename = "requirementCount")]
167 pub requirement_count: u32,
169}
170
171pub fn list_modules(module_repo: &dyn DomainModuleRepository) -> CoreResult<Vec<ModuleListItem>> {
173 let mut modules: Vec<ModuleListItem> = Vec::new();
174
175 for module in module_repo.list().into_core()? {
176 let full_name = format!("{}_{}", module.id, module.name);
177 let mut sub_modules = Vec::with_capacity(module.sub_modules.len());
178 for sm in &module.sub_modules {
179 sub_modules.push(SubModuleListItem {
180 id: sm.id.clone(),
181 name: sm.name.clone(),
182 change_count: sm.change_count as usize,
183 });
184 }
185 sub_modules.sort_by(|a, b| a.id.cmp(&b.id));
186 modules.push(ModuleListItem {
187 id: module.id,
188 name: module.name,
189 full_name,
190 change_count: module.change_count as usize,
191 sub_modules,
192 });
193 }
194
195 modules.sort_by(|a, b| a.full_name.cmp(&b.full_name));
196 Ok(modules)
197}
198
199pub fn list_change_dirs(ito_path: &Path) -> CoreResult<Vec<PathBuf>> {
201 let fs = StdFs;
202 Ok(ito_domain::discovery::list_change_dir_names(&fs, ito_path)
203 .into_core()?
204 .into_iter()
205 .map(|name| paths::change_dir(ito_path, &name))
206 .collect())
207}
208
209pub fn list_changes(
211 change_repo: &dyn DomainChangeRepository,
212 input: ListChangesInput,
213) -> CoreResult<Vec<ChangeListSummary>> {
214 let mut summaries: Vec<ChangeSummary> = change_repo.list().into_core()?;
215
216 match input.progress_filter {
217 ChangeProgressFilter::All => {}
218 ChangeProgressFilter::Ready => summaries.retain(|s| s.is_ready()),
219 ChangeProgressFilter::Completed => summaries.retain(is_completed),
220 ChangeProgressFilter::Partial => summaries.retain(is_partial),
221 ChangeProgressFilter::Pending => summaries.retain(is_pending),
222 }
223
224 match input.sort {
225 ChangeSortOrder::Name => summaries.sort_by(|a, b| a.id.cmp(&b.id)),
226 ChangeSortOrder::Recent => {
227 summaries.sort_by(|a, b| b.last_modified.cmp(&a.last_modified).then(a.id.cmp(&b.id)))
228 }
229 }
230
231 Ok(summaries
232 .into_iter()
233 .map(|s| {
234 let status = match s.status() {
235 ChangeStatus::NoTasks => "no-tasks",
236 ChangeStatus::InProgress => "in-progress",
237 ChangeStatus::Complete => "complete",
238 };
239 ChangeListSummary {
240 name: s.id.clone(),
241 completed_tasks: s.completed_tasks,
242 shelved_tasks: s.shelved_tasks,
243 in_progress_tasks: s.in_progress_tasks,
244 pending_tasks: s.pending_tasks,
245 total_tasks: s.total_tasks,
246 last_modified: s.last_modified,
247 status: status.to_string(),
248 work_status: s.work_status().to_string(),
249 completed: is_completed(&s),
250 }
251 })
252 .collect())
253}
254
255pub fn list_prepare_ready_changes(
262 change_repo: &dyn DomainChangeRepository,
263 repository_root: &Path,
264 config: &ItoConfig,
265 sort: ChangeSortOrder,
266) -> CoreResult<Vec<ChangeListSummary>> {
267 let summaries = list_changes(
268 change_repo,
269 ListChangesInput {
270 progress_filter: ChangeProgressFilter::All,
271 sort,
272 },
273 )?;
274 Ok(summaries
275 .into_iter()
276 .filter(|summary| {
277 let request =
278 ReadinessRequest::new(&summary.name, ReadinessPhase::Prepare, repository_root);
279 evaluate_readiness(&request, config).ready
280 })
281 .collect())
282}
283
284pub fn list_archived_changes(
290 change_repo: &dyn DomainChangeRepository,
291) -> CoreResult<Vec<ArchivedChangeListItem>> {
292 let mut summaries = change_repo
293 .list_with_filter(ChangeLifecycleFilter::Archived)
294 .into_core()?;
295 summaries.sort_by(|a, b| a.id.cmp(&b.id));
296
297 let mut items = Vec::with_capacity(summaries.len());
298 for s in summaries {
299 items.push(ArchivedChangeListItem {
300 name: s.id,
301 last_modified: to_iso_millis(s.last_modified),
302 });
303 }
304 Ok(items)
305}
306
307pub fn last_modified_recursive(path: &Path) -> CoreResult<DateTime<Utc>> {
309 use std::collections::VecDeque;
310
311 let mut max = std::fs::metadata(path)
312 .map_err(|e| CoreError::io("reading metadata", e))?
313 .modified()
314 .map_err(|e| CoreError::io("getting modification time", std::io::Error::other(e)))?;
315
316 let mut queue: VecDeque<PathBuf> = VecDeque::new();
317 queue.push_back(path.to_path_buf());
318
319 while let Some(p) = queue.pop_front() {
320 let meta = match std::fs::symlink_metadata(&p) {
321 Ok(m) => m,
322 Err(_) => continue,
323 };
324 if let Ok(m) = meta.modified()
325 && m > max
326 {
327 max = m;
328 }
329 if meta.is_dir() {
330 let iter = match std::fs::read_dir(&p) {
331 Ok(i) => i,
332 Err(_) => continue,
333 };
334 for entry in iter {
335 let entry = match entry {
336 Ok(e) => e,
337 Err(_) => continue,
338 };
339 queue.push_back(entry.path());
340 }
341 }
342 }
343
344 let dt: DateTime<Utc> = max.into();
345 Ok(dt)
346}
347
348pub fn to_iso_millis(dt: DateTime<Utc>) -> String {
350 let nanos = dt.timestamp_subsec_nanos();
353 let truncated = dt
354 .with_nanosecond((nanos / 1_000_000) * 1_000_000)
355 .unwrap_or(dt);
356 truncated.to_rfc3339_opts(SecondsFormat::Millis, true)
357}
358
359pub fn list_specs(ito_path: &Path) -> CoreResult<Vec<SpecListItem>> {
361 let mut specs: Vec<SpecListItem> = Vec::new();
362 let specs_dir = paths::specs_dir(ito_path);
363 let fs = StdFs;
364 for id in ito_domain::discovery::list_spec_dir_names(&fs, ito_path).into_core()? {
365 let spec_md = specs_dir.join(&id).join("spec.md");
366 let content = ito_common::io::read_to_string_or_default(&spec_md);
367 let requirement_count = if content.is_empty() {
368 0
369 } else {
370 count_requirements_in_spec_markdown(&content)
371 };
372 specs.push(SpecListItem {
373 id,
374 requirement_count,
375 });
376 }
377
378 specs.sort_by(|a, b| a.id.cmp(&b.id));
379 Ok(specs)
380}
381
382#[cfg(test)]
383fn parse_modular_change_module_id(folder: &str) -> Option<&str> {
384 let bytes = folder.as_bytes();
389 if bytes.len() < 8 {
390 return None;
391 }
392 if !bytes.first()?.is_ascii_digit()
393 || !bytes.get(1)?.is_ascii_digit()
394 || !bytes.get(2)?.is_ascii_digit()
395 {
396 return None;
397 }
398 if *bytes.get(3)? != b'-' {
399 return None;
400 }
401
402 let mut i = 4usize;
404 let mut digit_count = 0usize;
405 while i < bytes.len() {
406 let b = bytes[i];
407 if b == b'_' {
408 break;
409 }
410 if !b.is_ascii_digit() {
411 return None;
412 }
413 digit_count += 1;
414 i += 1;
415 }
416 if i >= bytes.len() || bytes[i] != b'_' {
417 return None;
418 }
419 if digit_count == 0 {
421 return None;
422 }
423
424 let name = &folder[(i + 1)..];
425 let mut chars = name.chars();
426 let first = chars.next()?;
427 if !first.is_ascii_lowercase() {
428 return None;
429 }
430 for c in chars {
431 if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
432 return None;
433 }
434 }
435
436 Some(&folder[0..3])
437}
438
439#[derive(Debug, Clone)]
440struct Section {
441 level: usize,
442 title: String,
443 children: Vec<Section>,
444}
445
446fn count_requirements_in_spec_markdown(content: &str) -> u32 {
447 let sections = parse_sections(content);
448 let purpose = find_section(§ions, "Purpose");
450 let req = find_section(§ions, "Requirements");
451 if purpose.is_none() || req.is_none() {
452 return 0;
453 }
454 req.map(|s| s.children.len() as u32).unwrap_or(0)
455}
456
457fn is_completed(s: &ChangeSummary) -> bool {
458 use ito_domain::changes::ChangeWorkStatus;
459 let status = s.work_status();
460 match status {
461 ChangeWorkStatus::Complete => true,
462 ChangeWorkStatus::Paused => true,
463 ChangeWorkStatus::Draft => false,
464 ChangeWorkStatus::Ready => false,
465 ChangeWorkStatus::InProgress => false,
466 }
467}
468
469fn is_partial(s: &ChangeSummary) -> bool {
470 use ito_domain::changes::ChangeWorkStatus;
471 let in_active_progress_bucket = match s.work_status() {
472 ChangeWorkStatus::Ready => true,
473 ChangeWorkStatus::InProgress => true,
474 ChangeWorkStatus::Draft => false,
475 ChangeWorkStatus::Paused => false,
476 ChangeWorkStatus::Complete => false,
477 };
478
479 in_active_progress_bucket
480 && s.total_tasks > 0
481 && s.completed_tasks > 0
482 && s.completed_tasks < s.total_tasks
483}
484
485fn is_pending(s: &ChangeSummary) -> bool {
486 use ito_domain::changes::ChangeWorkStatus;
487 let in_active_progress_bucket = match s.work_status() {
488 ChangeWorkStatus::Ready => true,
489 ChangeWorkStatus::InProgress => true,
490 ChangeWorkStatus::Draft => false,
491 ChangeWorkStatus::Paused => false,
492 ChangeWorkStatus::Complete => false,
493 };
494
495 in_active_progress_bucket && s.total_tasks > 0 && s.completed_tasks == 0
496}
497
498fn parse_sections(content: &str) -> Vec<Section> {
499 let normalized = content.replace(['\r'], "");
500 let lines: Vec<&str> = normalized.split('\n').collect();
501 let mut sections: Vec<Section> = Vec::new();
502 let mut stack: Vec<Section> = Vec::new();
503
504 for line in lines {
505 let trimmed = line.trim_end();
506 if let Some((level, title)) = parse_header(trimmed) {
507 let section = Section {
508 level,
509 title: title.to_string(),
510 children: Vec::new(),
511 };
512
513 while stack.last().is_some_and(|s| s.level >= level) {
514 let completed = stack.pop().expect("checked");
515 attach_section(&mut sections, &mut stack, completed);
516 }
517
518 stack.push(section);
519 }
520 }
521
522 while let Some(completed) = stack.pop() {
523 attach_section(&mut sections, &mut stack, completed);
524 }
525
526 sections
527}
528
529fn attach_section(sections: &mut Vec<Section>, stack: &mut [Section], section: Section) {
530 if let Some(parent) = stack.last_mut() {
531 parent.children.push(section);
532 } else {
533 sections.push(section);
534 }
535}
536
537fn parse_header(line: &str) -> Option<(usize, &str)> {
538 let bytes = line.as_bytes();
539 if bytes.is_empty() {
540 return None;
541 }
542 let mut i = 0usize;
543 while i < bytes.len() && bytes[i] == b'#' {
544 i += 1;
545 }
546 if i == 0 || i > 6 {
547 return None;
548 }
549 if i >= bytes.len() || !bytes[i].is_ascii_whitespace() {
550 return None;
551 }
552 let title = line[i..].trim();
553 if title.is_empty() {
554 return None;
555 }
556 Some((i, title))
557}
558
559fn find_section<'a>(sections: &'a [Section], title: &str) -> Option<&'a Section> {
560 for s in sections {
561 if s.title.eq_ignore_ascii_case(title) {
562 return Some(s);
563 }
564 if let Some(child) = find_section(&s.children, title) {
565 return Some(child);
566 }
567 }
568 None
569}
570
571#[cfg(test)]
572#[path = "list_tests.rs"]
573mod list_tests;