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_domain::changes::{
15 ChangeLifecycleFilter, ChangeRepository as DomainChangeRepository, ChangeStatus, ChangeSummary,
16};
17use ito_domain::modules::ModuleRepository as DomainModuleRepository;
18
19#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
20pub struct SubModuleListItem {
22 pub id: String,
24 pub name: String,
26 #[serde(rename = "changeCount")]
27 pub change_count: usize,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
32pub struct ModuleListItem {
34 pub id: String,
36 pub name: String,
38 #[serde(rename = "fullName")]
39 pub full_name: String,
41 #[serde(rename = "changeCount")]
42 pub change_count: usize,
44 #[serde(rename = "subModules")]
46 pub sub_modules: Vec<SubModuleListItem>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
50pub struct ChangeListItem {
52 pub name: String,
54 #[serde(rename = "completedTasks")]
55 pub completed_tasks: u32,
57 #[serde(rename = "shelvedTasks")]
58 pub shelved_tasks: u32,
60 #[serde(rename = "inProgressTasks")]
61 pub in_progress_tasks: u32,
63 #[serde(rename = "pendingTasks")]
64 pub pending_tasks: u32,
66 #[serde(rename = "totalTasks")]
67 pub total_tasks: u32,
69 #[serde(rename = "lastModified")]
70 pub last_modified: String,
72 pub status: String,
74 #[serde(rename = "workStatus")]
76 pub work_status: String,
77 pub completed: bool,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
82pub struct ArchivedChangeListItem {
84 pub name: String,
86 #[serde(rename = "lastModified")]
87 pub last_modified: String,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum ChangeProgressFilter {
94 All,
96 Ready,
98 Completed,
100 Partial,
102 Pending,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum ChangeSortOrder {
109 Recent,
111 Name,
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub struct ListChangesInput {
118 pub progress_filter: ChangeProgressFilter,
120 pub sort: ChangeSortOrder,
122}
123
124impl Default for ListChangesInput {
125 fn default() -> Self {
126 Self {
127 progress_filter: ChangeProgressFilter::All,
128 sort: ChangeSortOrder::Recent,
129 }
130 }
131}
132
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct ChangeListSummary {
136 pub name: String,
138 pub completed_tasks: u32,
140 pub shelved_tasks: u32,
142 pub in_progress_tasks: u32,
144 pub pending_tasks: u32,
146 pub total_tasks: u32,
148 pub last_modified: DateTime<Utc>,
150 pub status: String,
152 pub work_status: String,
154 pub completed: bool,
156}
157
158#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
159pub struct SpecListItem {
161 pub id: String,
163 #[serde(rename = "requirementCount")]
164 pub requirement_count: u32,
166}
167
168pub fn list_modules(module_repo: &dyn DomainModuleRepository) -> CoreResult<Vec<ModuleListItem>> {
170 let mut modules: Vec<ModuleListItem> = Vec::new();
171
172 for module in module_repo.list().into_core()? {
173 let full_name = format!("{}_{}", module.id, module.name);
174 let mut sub_modules = Vec::with_capacity(module.sub_modules.len());
175 for sm in &module.sub_modules {
176 sub_modules.push(SubModuleListItem {
177 id: sm.id.clone(),
178 name: sm.name.clone(),
179 change_count: sm.change_count as usize,
180 });
181 }
182 sub_modules.sort_by(|a, b| a.id.cmp(&b.id));
183 modules.push(ModuleListItem {
184 id: module.id,
185 name: module.name,
186 full_name,
187 change_count: module.change_count as usize,
188 sub_modules,
189 });
190 }
191
192 modules.sort_by(|a, b| a.full_name.cmp(&b.full_name));
193 Ok(modules)
194}
195
196pub fn list_change_dirs(ito_path: &Path) -> CoreResult<Vec<PathBuf>> {
198 let fs = StdFs;
199 Ok(ito_domain::discovery::list_change_dir_names(&fs, ito_path)
200 .into_core()?
201 .into_iter()
202 .map(|name| paths::change_dir(ito_path, &name))
203 .collect())
204}
205
206pub fn list_changes(
208 change_repo: &dyn DomainChangeRepository,
209 input: ListChangesInput,
210) -> CoreResult<Vec<ChangeListSummary>> {
211 let mut summaries: Vec<ChangeSummary> = change_repo.list().into_core()?;
212
213 match input.progress_filter {
214 ChangeProgressFilter::All => {}
215 ChangeProgressFilter::Ready => summaries.retain(|s| s.is_ready()),
216 ChangeProgressFilter::Completed => summaries.retain(is_completed),
217 ChangeProgressFilter::Partial => summaries.retain(is_partial),
218 ChangeProgressFilter::Pending => summaries.retain(is_pending),
219 }
220
221 match input.sort {
222 ChangeSortOrder::Name => summaries.sort_by(|a, b| a.id.cmp(&b.id)),
223 ChangeSortOrder::Recent => {
224 summaries.sort_by(|a, b| b.last_modified.cmp(&a.last_modified).then(a.id.cmp(&b.id)))
225 }
226 }
227
228 Ok(summaries
229 .into_iter()
230 .map(|s| {
231 let status = match s.status() {
232 ChangeStatus::NoTasks => "no-tasks",
233 ChangeStatus::InProgress => "in-progress",
234 ChangeStatus::Complete => "complete",
235 };
236 ChangeListSummary {
237 name: s.id.clone(),
238 completed_tasks: s.completed_tasks,
239 shelved_tasks: s.shelved_tasks,
240 in_progress_tasks: s.in_progress_tasks,
241 pending_tasks: s.pending_tasks,
242 total_tasks: s.total_tasks,
243 last_modified: s.last_modified,
244 status: status.to_string(),
245 work_status: s.work_status().to_string(),
246 completed: is_completed(&s),
247 }
248 })
249 .collect())
250}
251
252pub fn list_archived_changes(
258 change_repo: &dyn DomainChangeRepository,
259) -> CoreResult<Vec<ArchivedChangeListItem>> {
260 let mut summaries = change_repo
261 .list_with_filter(ChangeLifecycleFilter::Archived)
262 .into_core()?;
263 summaries.sort_by(|a, b| a.id.cmp(&b.id));
264
265 let mut items = Vec::with_capacity(summaries.len());
266 for s in summaries {
267 items.push(ArchivedChangeListItem {
268 name: s.id,
269 last_modified: to_iso_millis(s.last_modified),
270 });
271 }
272 Ok(items)
273}
274
275pub fn last_modified_recursive(path: &Path) -> CoreResult<DateTime<Utc>> {
277 use std::collections::VecDeque;
278
279 let mut max = std::fs::metadata(path)
280 .map_err(|e| CoreError::io("reading metadata", e))?
281 .modified()
282 .map_err(|e| CoreError::io("getting modification time", std::io::Error::other(e)))?;
283
284 let mut queue: VecDeque<PathBuf> = VecDeque::new();
285 queue.push_back(path.to_path_buf());
286
287 while let Some(p) = queue.pop_front() {
288 let meta = match std::fs::symlink_metadata(&p) {
289 Ok(m) => m,
290 Err(_) => continue,
291 };
292 if let Ok(m) = meta.modified()
293 && m > max
294 {
295 max = m;
296 }
297 if meta.is_dir() {
298 let iter = match std::fs::read_dir(&p) {
299 Ok(i) => i,
300 Err(_) => continue,
301 };
302 for entry in iter {
303 let entry = match entry {
304 Ok(e) => e,
305 Err(_) => continue,
306 };
307 queue.push_back(entry.path());
308 }
309 }
310 }
311
312 let dt: DateTime<Utc> = max.into();
313 Ok(dt)
314}
315
316pub fn to_iso_millis(dt: DateTime<Utc>) -> String {
318 let nanos = dt.timestamp_subsec_nanos();
321 let truncated = dt
322 .with_nanosecond((nanos / 1_000_000) * 1_000_000)
323 .unwrap_or(dt);
324 truncated.to_rfc3339_opts(SecondsFormat::Millis, true)
325}
326
327pub fn list_specs(ito_path: &Path) -> CoreResult<Vec<SpecListItem>> {
329 let mut specs: Vec<SpecListItem> = Vec::new();
330 let specs_dir = paths::specs_dir(ito_path);
331 let fs = StdFs;
332 for id in ito_domain::discovery::list_spec_dir_names(&fs, ito_path).into_core()? {
333 let spec_md = specs_dir.join(&id).join("spec.md");
334 let content = ito_common::io::read_to_string_or_default(&spec_md);
335 let requirement_count = if content.is_empty() {
336 0
337 } else {
338 count_requirements_in_spec_markdown(&content)
339 };
340 specs.push(SpecListItem {
341 id,
342 requirement_count,
343 });
344 }
345
346 specs.sort_by(|a, b| a.id.cmp(&b.id));
347 Ok(specs)
348}
349
350#[cfg(test)]
351fn parse_modular_change_module_id(folder: &str) -> Option<&str> {
352 let bytes = folder.as_bytes();
357 if bytes.len() < 8 {
358 return None;
359 }
360 if !bytes.first()?.is_ascii_digit()
361 || !bytes.get(1)?.is_ascii_digit()
362 || !bytes.get(2)?.is_ascii_digit()
363 {
364 return None;
365 }
366 if *bytes.get(3)? != b'-' {
367 return None;
368 }
369
370 let mut i = 4usize;
372 let mut digit_count = 0usize;
373 while i < bytes.len() {
374 let b = bytes[i];
375 if b == b'_' {
376 break;
377 }
378 if !b.is_ascii_digit() {
379 return None;
380 }
381 digit_count += 1;
382 i += 1;
383 }
384 if i >= bytes.len() || bytes[i] != b'_' {
385 return None;
386 }
387 if digit_count == 0 {
389 return None;
390 }
391
392 let name = &folder[(i + 1)..];
393 let mut chars = name.chars();
394 let first = chars.next()?;
395 if !first.is_ascii_lowercase() {
396 return None;
397 }
398 for c in chars {
399 if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
400 return None;
401 }
402 }
403
404 Some(&folder[0..3])
405}
406
407#[derive(Debug, Clone)]
408struct Section {
409 level: usize,
410 title: String,
411 children: Vec<Section>,
412}
413
414fn count_requirements_in_spec_markdown(content: &str) -> u32 {
415 let sections = parse_sections(content);
416 let purpose = find_section(§ions, "Purpose");
418 let req = find_section(§ions, "Requirements");
419 if purpose.is_none() || req.is_none() {
420 return 0;
421 }
422 req.map(|s| s.children.len() as u32).unwrap_or(0)
423}
424
425fn is_completed(s: &ChangeSummary) -> bool {
426 use ito_domain::changes::ChangeWorkStatus;
427 let status = s.work_status();
428 match status {
429 ChangeWorkStatus::Complete => true,
430 ChangeWorkStatus::Paused => true,
431 ChangeWorkStatus::Draft => false,
432 ChangeWorkStatus::Ready => false,
433 ChangeWorkStatus::InProgress => false,
434 }
435}
436
437fn is_partial(s: &ChangeSummary) -> bool {
438 use ito_domain::changes::ChangeWorkStatus;
439 let in_active_progress_bucket = match s.work_status() {
440 ChangeWorkStatus::Ready => true,
441 ChangeWorkStatus::InProgress => true,
442 ChangeWorkStatus::Draft => false,
443 ChangeWorkStatus::Paused => false,
444 ChangeWorkStatus::Complete => false,
445 };
446
447 in_active_progress_bucket
448 && s.total_tasks > 0
449 && s.completed_tasks > 0
450 && s.completed_tasks < s.total_tasks
451}
452
453fn is_pending(s: &ChangeSummary) -> bool {
454 use ito_domain::changes::ChangeWorkStatus;
455 let in_active_progress_bucket = match s.work_status() {
456 ChangeWorkStatus::Ready => true,
457 ChangeWorkStatus::InProgress => true,
458 ChangeWorkStatus::Draft => false,
459 ChangeWorkStatus::Paused => false,
460 ChangeWorkStatus::Complete => false,
461 };
462
463 in_active_progress_bucket && s.total_tasks > 0 && s.completed_tasks == 0
464}
465
466fn parse_sections(content: &str) -> Vec<Section> {
467 let normalized = content.replace(['\r'], "");
468 let lines: Vec<&str> = normalized.split('\n').collect();
469 let mut sections: Vec<Section> = Vec::new();
470 let mut stack: Vec<Section> = Vec::new();
471
472 for line in lines {
473 let trimmed = line.trim_end();
474 if let Some((level, title)) = parse_header(trimmed) {
475 let section = Section {
476 level,
477 title: title.to_string(),
478 children: Vec::new(),
479 };
480
481 while stack.last().is_some_and(|s| s.level >= level) {
482 let completed = stack.pop().expect("checked");
483 attach_section(&mut sections, &mut stack, completed);
484 }
485
486 stack.push(section);
487 }
488 }
489
490 while let Some(completed) = stack.pop() {
491 attach_section(&mut sections, &mut stack, completed);
492 }
493
494 sections
495}
496
497fn attach_section(sections: &mut Vec<Section>, stack: &mut [Section], section: Section) {
498 if let Some(parent) = stack.last_mut() {
499 parent.children.push(section);
500 } else {
501 sections.push(section);
502 }
503}
504
505fn parse_header(line: &str) -> Option<(usize, &str)> {
506 let bytes = line.as_bytes();
507 if bytes.is_empty() {
508 return None;
509 }
510 let mut i = 0usize;
511 while i < bytes.len() && bytes[i] == b'#' {
512 i += 1;
513 }
514 if i == 0 || i > 6 {
515 return None;
516 }
517 if i >= bytes.len() || !bytes[i].is_ascii_whitespace() {
518 return None;
519 }
520 let title = line[i..].trim();
521 if title.is_empty() {
522 return None;
523 }
524 Some((i, title))
525}
526
527fn find_section<'a>(sections: &'a [Section], title: &str) -> Option<&'a Section> {
528 for s in sections {
529 if s.title.eq_ignore_ascii_case(title) {
530 return Some(s);
531 }
532 if let Some(child) = find_section(&s.children, title) {
533 return Some(child);
534 }
535 }
536 None
537}
538
539#[cfg(test)]
540mod tests {
541 use super::*;
542
543 fn write(path: impl AsRef<Path>, contents: &str) {
544 let path = path.as_ref();
545 if let Some(parent) = path.parent() {
546 std::fs::create_dir_all(parent).expect("parent dirs should exist");
547 }
548 std::fs::write(path, contents).expect("test fixture should write");
549 }
550
551 fn make_change(root: &Path, id: &str, tasks: &str) {
572 write(
573 root.join(".ito/changes").join(id).join("proposal.md"),
574 "## Why\nfixture\n\n## What Changes\n- fixture\n\n## Impact\n- fixture\n",
575 );
576 write(root.join(".ito/changes").join(id).join("tasks.md"), tasks);
577 write(
578 root.join(".ito/changes")
579 .join(id)
580 .join("specs")
581 .join("alpha")
582 .join("spec.md"),
583 "## ADDED Requirements\n\n### Requirement: Fixture\nFixture requirement.\n\n#### Scenario: Works\n- **WHEN** fixture runs\n- **THEN** it is ready\n",
584 );
585 }
586
587 fn set_mtime_recursive(dir: &Path, time: filetime::FileTime) {
606 filetime::set_file_mtime(dir, time).expect("set dir mtime");
607 for entry in std::fs::read_dir(dir).expect("read dir") {
608 let entry = entry.expect("dir entry");
609 let path = entry.path();
610 filetime::set_file_mtime(&path, time).expect("set entry mtime");
611 if path.is_dir() {
612 set_mtime_recursive(&path, time);
613 }
614 }
615 }
616
617 #[test]
618 fn counts_requirements_from_headings() {
619 let md = r#"
620# Title
621
622## Purpose
623blah
624
625## Requirements
626
627### Requirement: One
628foo
629
630### Requirement: Two
631bar
632"#;
633 assert_eq!(count_requirements_in_spec_markdown(md), 2);
634 }
635
636 #[test]
637 fn iso_millis_matches_expected_shape() {
638 let dt = DateTime::parse_from_rfc3339("2026-01-26T00:00:00.123Z")
639 .unwrap()
640 .with_timezone(&Utc);
641 assert_eq!(to_iso_millis(dt), "2026-01-26T00:00:00.123Z");
642 }
643
644 #[test]
645 fn parse_modular_change_module_id_allows_overflow_change_numbers() {
646 assert_eq!(parse_modular_change_module_id("001-02_foo"), Some("001"));
647 assert_eq!(parse_modular_change_module_id("001-100_foo"), Some("001"));
648 assert_eq!(parse_modular_change_module_id("001-1234_foo"), Some("001"));
649 }
650
651 #[test]
652 fn list_changes_filters_by_progress_status() {
653 let repo = tempfile::tempdir().expect("repo tempdir");
654 let ito_path = repo.path().join(".ito");
655 make_change(
656 repo.path(),
657 "000-01_pending",
658 "## 1. Implementation\n- [ ] 1.1 todo\n",
659 );
660 make_change(
661 repo.path(),
662 "000-02_partial",
663 "## 1. Implementation\n- [x] 1.1 done\n- [ ] 1.2 todo\n",
664 );
665 make_change(
666 repo.path(),
667 "000-03_completed",
668 "## 1. Implementation\n- [x] 1.1 done\n",
669 );
670
671 let change_repo = crate::change_repository::FsChangeRepository::new(&ito_path);
672
673 let ready = list_changes(
674 &change_repo,
675 ListChangesInput {
676 progress_filter: ChangeProgressFilter::Ready,
677 sort: ChangeSortOrder::Name,
678 },
679 )
680 .expect("ready list should succeed");
681 assert_eq!(ready.len(), 2);
682 assert_eq!(ready[0].name, "000-01_pending");
683 assert_eq!(ready[1].name, "000-02_partial");
684
685 let pending = list_changes(
686 &change_repo,
687 ListChangesInput {
688 progress_filter: ChangeProgressFilter::Pending,
689 sort: ChangeSortOrder::Name,
690 },
691 )
692 .expect("pending list should succeed");
693 assert_eq!(pending.len(), 1);
694 assert_eq!(pending[0].name, "000-01_pending");
695
696 let partial = list_changes(
697 &change_repo,
698 ListChangesInput {
699 progress_filter: ChangeProgressFilter::Partial,
700 sort: ChangeSortOrder::Name,
701 },
702 )
703 .expect("partial list should succeed");
704 assert_eq!(partial.len(), 1);
705 assert_eq!(partial[0].name, "000-02_partial");
706
707 let completed = list_changes(
708 &change_repo,
709 ListChangesInput {
710 progress_filter: ChangeProgressFilter::Completed,
711 sort: ChangeSortOrder::Name,
712 },
713 )
714 .expect("completed list should succeed");
715 assert_eq!(completed.len(), 1);
716 assert_eq!(completed[0].name, "000-03_completed");
717 assert!(completed[0].completed);
718 }
719
720 #[test]
721 fn list_changes_sorts_by_name_and_recent() {
722 let repo = tempfile::tempdir().expect("repo tempdir");
723 let ito_path = repo.path().join(".ito");
724 make_change(
725 repo.path(),
726 "000-01_alpha",
727 "## 1. Implementation\n- [ ] 1.1 todo\n",
728 );
729 make_change(
730 repo.path(),
731 "000-02_beta",
732 "## 1. Implementation\n- [ ] 1.1 todo\n",
733 );
734 let alpha_dir = repo.path().join(".ito/changes/000-01_alpha");
737 let beta_dir = repo.path().join(".ito/changes/000-02_beta");
738 let earlier = filetime::FileTime::from_unix_time(1_000_000, 0);
739 let later = filetime::FileTime::from_unix_time(2_000_000, 0);
740 set_mtime_recursive(&alpha_dir, earlier);
741 set_mtime_recursive(&beta_dir, later);
742
743 let change_repo = crate::change_repository::FsChangeRepository::new(&ito_path);
744
745 let by_name = list_changes(
746 &change_repo,
747 ListChangesInput {
748 progress_filter: ChangeProgressFilter::All,
749 sort: ChangeSortOrder::Name,
750 },
751 )
752 .expect("name sort should succeed");
753 assert_eq!(by_name[0].name, "000-01_alpha");
754 assert_eq!(by_name[1].name, "000-02_beta");
755
756 let by_recent = list_changes(
757 &change_repo,
758 ListChangesInput {
759 progress_filter: ChangeProgressFilter::All,
760 sort: ChangeSortOrder::Recent,
761 },
762 )
763 .expect("recent sort should succeed");
764 assert_eq!(by_recent[0].name, "000-02_beta");
765 assert_eq!(by_recent[1].name, "000-01_alpha");
766 }
767}