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