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