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