use std::path::{Path, PathBuf};
use chrono::{DateTime, SecondsFormat, Timelike, Utc};
use crate::error_bridge::IntoCoreResult;
use crate::errors::{CoreError, CoreResult};
use ito_common::fs::StdFs;
use ito_common::paths;
use ito_domain::changes::{
ChangeRepository as DomainChangeRepository, ChangeStatus, ChangeSummary,
};
use ito_domain::modules::ModuleRepository as DomainModuleRepository;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct SubModuleListItem {
pub id: String,
pub name: String,
#[serde(rename = "changeCount")]
pub change_count: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct ModuleListItem {
pub id: String,
pub name: String,
#[serde(rename = "fullName")]
pub full_name: String,
#[serde(rename = "changeCount")]
pub change_count: usize,
#[serde(rename = "subModules")]
pub sub_modules: Vec<SubModuleListItem>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct ChangeListItem {
pub name: String,
#[serde(rename = "completedTasks")]
pub completed_tasks: u32,
#[serde(rename = "shelvedTasks")]
pub shelved_tasks: u32,
#[serde(rename = "inProgressTasks")]
pub in_progress_tasks: u32,
#[serde(rename = "pendingTasks")]
pub pending_tasks: u32,
#[serde(rename = "totalTasks")]
pub total_tasks: u32,
#[serde(rename = "lastModified")]
pub last_modified: String,
pub status: String,
#[serde(rename = "workStatus")]
pub work_status: String,
pub completed: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChangeProgressFilter {
All,
Ready,
Completed,
Partial,
Pending,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChangeSortOrder {
Recent,
Name,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ListChangesInput {
pub progress_filter: ChangeProgressFilter,
pub sort: ChangeSortOrder,
}
impl Default for ListChangesInput {
fn default() -> Self {
Self {
progress_filter: ChangeProgressFilter::All,
sort: ChangeSortOrder::Recent,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChangeListSummary {
pub name: String,
pub completed_tasks: u32,
pub shelved_tasks: u32,
pub in_progress_tasks: u32,
pub pending_tasks: u32,
pub total_tasks: u32,
pub last_modified: DateTime<Utc>,
pub status: String,
pub work_status: String,
pub completed: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct SpecListItem {
pub id: String,
#[serde(rename = "requirementCount")]
pub requirement_count: u32,
}
pub fn list_modules(module_repo: &dyn DomainModuleRepository) -> CoreResult<Vec<ModuleListItem>> {
let mut modules: Vec<ModuleListItem> = Vec::new();
for module in module_repo.list().into_core()? {
let full_name = format!("{}_{}", module.id, module.name);
let mut sub_modules = Vec::with_capacity(module.sub_modules.len());
for sm in &module.sub_modules {
sub_modules.push(SubModuleListItem {
id: sm.id.clone(),
name: sm.name.clone(),
change_count: sm.change_count as usize,
});
}
sub_modules.sort_by(|a, b| a.id.cmp(&b.id));
modules.push(ModuleListItem {
id: module.id,
name: module.name,
full_name,
change_count: module.change_count as usize,
sub_modules,
});
}
modules.sort_by(|a, b| a.full_name.cmp(&b.full_name));
Ok(modules)
}
pub fn list_change_dirs(ito_path: &Path) -> CoreResult<Vec<PathBuf>> {
let fs = StdFs;
Ok(ito_domain::discovery::list_change_dir_names(&fs, ito_path)
.into_core()?
.into_iter()
.map(|name| paths::change_dir(ito_path, &name))
.collect())
}
pub fn list_changes(
change_repo: &dyn DomainChangeRepository,
input: ListChangesInput,
) -> CoreResult<Vec<ChangeListSummary>> {
let mut summaries: Vec<ChangeSummary> = change_repo.list().into_core()?;
match input.progress_filter {
ChangeProgressFilter::All => {}
ChangeProgressFilter::Ready => summaries.retain(|s| s.is_ready()),
ChangeProgressFilter::Completed => summaries.retain(is_completed),
ChangeProgressFilter::Partial => summaries.retain(is_partial),
ChangeProgressFilter::Pending => summaries.retain(is_pending),
}
match input.sort {
ChangeSortOrder::Name => summaries.sort_by(|a, b| a.id.cmp(&b.id)),
ChangeSortOrder::Recent => {
summaries.sort_by(|a, b| b.last_modified.cmp(&a.last_modified).then(a.id.cmp(&b.id)))
}
}
Ok(summaries
.into_iter()
.map(|s| {
let status = match s.status() {
ChangeStatus::NoTasks => "no-tasks",
ChangeStatus::InProgress => "in-progress",
ChangeStatus::Complete => "complete",
};
ChangeListSummary {
name: s.id.clone(),
completed_tasks: s.completed_tasks,
shelved_tasks: s.shelved_tasks,
in_progress_tasks: s.in_progress_tasks,
pending_tasks: s.pending_tasks,
total_tasks: s.total_tasks,
last_modified: s.last_modified,
status: status.to_string(),
work_status: s.work_status().to_string(),
completed: is_completed(&s),
}
})
.collect())
}
pub fn last_modified_recursive(path: &Path) -> CoreResult<DateTime<Utc>> {
use std::collections::VecDeque;
let mut max = std::fs::metadata(path)
.map_err(|e| CoreError::io("reading metadata", e))?
.modified()
.map_err(|e| CoreError::io("getting modification time", std::io::Error::other(e)))?;
let mut queue: VecDeque<PathBuf> = VecDeque::new();
queue.push_back(path.to_path_buf());
while let Some(p) = queue.pop_front() {
let meta = match std::fs::symlink_metadata(&p) {
Ok(m) => m,
Err(_) => continue,
};
if let Ok(m) = meta.modified()
&& m > max
{
max = m;
}
if meta.is_dir() {
let iter = match std::fs::read_dir(&p) {
Ok(i) => i,
Err(_) => continue,
};
for entry in iter {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
queue.push_back(entry.path());
}
}
}
let dt: DateTime<Utc> = max.into();
Ok(dt)
}
pub fn to_iso_millis(dt: DateTime<Utc>) -> String {
let nanos = dt.timestamp_subsec_nanos();
let truncated = dt
.with_nanosecond((nanos / 1_000_000) * 1_000_000)
.unwrap_or(dt);
truncated.to_rfc3339_opts(SecondsFormat::Millis, true)
}
pub fn list_specs(ito_path: &Path) -> CoreResult<Vec<SpecListItem>> {
let mut specs: Vec<SpecListItem> = Vec::new();
let specs_dir = paths::specs_dir(ito_path);
let fs = StdFs;
for id in ito_domain::discovery::list_spec_dir_names(&fs, ito_path).into_core()? {
let spec_md = specs_dir.join(&id).join("spec.md");
let content = ito_common::io::read_to_string_or_default(&spec_md);
let requirement_count = if content.is_empty() {
0
} else {
count_requirements_in_spec_markdown(&content)
};
specs.push(SpecListItem {
id,
requirement_count,
});
}
specs.sort_by(|a, b| a.id.cmp(&b.id));
Ok(specs)
}
#[cfg(test)]
fn parse_modular_change_module_id(folder: &str) -> Option<&str> {
let bytes = folder.as_bytes();
if bytes.len() < 8 {
return None;
}
if !bytes.first()?.is_ascii_digit()
|| !bytes.get(1)?.is_ascii_digit()
|| !bytes.get(2)?.is_ascii_digit()
{
return None;
}
if *bytes.get(3)? != b'-' {
return None;
}
let mut i = 4usize;
let mut digit_count = 0usize;
while i < bytes.len() {
let b = bytes[i];
if b == b'_' {
break;
}
if !b.is_ascii_digit() {
return None;
}
digit_count += 1;
i += 1;
}
if i >= bytes.len() || bytes[i] != b'_' {
return None;
}
if digit_count == 0 {
return None;
}
let name = &folder[(i + 1)..];
let mut chars = name.chars();
let first = chars.next()?;
if !first.is_ascii_lowercase() {
return None;
}
for c in chars {
if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
return None;
}
}
Some(&folder[0..3])
}
#[derive(Debug, Clone)]
struct Section {
level: usize,
title: String,
children: Vec<Section>,
}
fn count_requirements_in_spec_markdown(content: &str) -> u32 {
let sections = parse_sections(content);
let purpose = find_section(§ions, "Purpose");
let req = find_section(§ions, "Requirements");
if purpose.is_none() || req.is_none() {
return 0;
}
req.map(|s| s.children.len() as u32).unwrap_or(0)
}
fn is_completed(s: &ChangeSummary) -> bool {
use ito_domain::changes::ChangeWorkStatus;
let status = s.work_status();
match status {
ChangeWorkStatus::Complete => true,
ChangeWorkStatus::Paused => true,
ChangeWorkStatus::Draft => false,
ChangeWorkStatus::Ready => false,
ChangeWorkStatus::InProgress => false,
}
}
fn is_partial(s: &ChangeSummary) -> bool {
use ito_domain::changes::ChangeWorkStatus;
let in_active_progress_bucket = match s.work_status() {
ChangeWorkStatus::Ready => true,
ChangeWorkStatus::InProgress => true,
ChangeWorkStatus::Draft => false,
ChangeWorkStatus::Paused => false,
ChangeWorkStatus::Complete => false,
};
in_active_progress_bucket
&& s.total_tasks > 0
&& s.completed_tasks > 0
&& s.completed_tasks < s.total_tasks
}
fn is_pending(s: &ChangeSummary) -> bool {
use ito_domain::changes::ChangeWorkStatus;
let in_active_progress_bucket = match s.work_status() {
ChangeWorkStatus::Ready => true,
ChangeWorkStatus::InProgress => true,
ChangeWorkStatus::Draft => false,
ChangeWorkStatus::Paused => false,
ChangeWorkStatus::Complete => false,
};
in_active_progress_bucket && s.total_tasks > 0 && s.completed_tasks == 0
}
fn parse_sections(content: &str) -> Vec<Section> {
let normalized = content.replace(['\r'], "");
let lines: Vec<&str> = normalized.split('\n').collect();
let mut sections: Vec<Section> = Vec::new();
let mut stack: Vec<Section> = Vec::new();
for line in lines {
let trimmed = line.trim_end();
if let Some((level, title)) = parse_header(trimmed) {
let section = Section {
level,
title: title.to_string(),
children: Vec::new(),
};
while stack.last().is_some_and(|s| s.level >= level) {
let completed = stack.pop().expect("checked");
attach_section(&mut sections, &mut stack, completed);
}
stack.push(section);
}
}
while let Some(completed) = stack.pop() {
attach_section(&mut sections, &mut stack, completed);
}
sections
}
fn attach_section(sections: &mut Vec<Section>, stack: &mut [Section], section: Section) {
if let Some(parent) = stack.last_mut() {
parent.children.push(section);
} else {
sections.push(section);
}
}
fn parse_header(line: &str) -> Option<(usize, &str)> {
let bytes = line.as_bytes();
if bytes.is_empty() {
return None;
}
let mut i = 0usize;
while i < bytes.len() && bytes[i] == b'#' {
i += 1;
}
if i == 0 || i > 6 {
return None;
}
if i >= bytes.len() || !bytes[i].is_ascii_whitespace() {
return None;
}
let title = line[i..].trim();
if title.is_empty() {
return None;
}
Some((i, title))
}
fn find_section<'a>(sections: &'a [Section], title: &str) -> Option<&'a Section> {
for s in sections {
if s.title.eq_ignore_ascii_case(title) {
return Some(s);
}
if let Some(child) = find_section(&s.children, title) {
return Some(child);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn write(path: impl AsRef<Path>, contents: &str) {
let path = path.as_ref();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("parent dirs should exist");
}
std::fs::write(path, contents).expect("test fixture should write");
}
fn make_change(root: &Path, id: &str, tasks: &str) {
write(
root.join(".ito/changes").join(id).join("proposal.md"),
"## Why\nfixture\n\n## What Changes\n- fixture\n\n## Impact\n- fixture\n",
);
write(root.join(".ito/changes").join(id).join("tasks.md"), tasks);
write(
root.join(".ito/changes")
.join(id)
.join("specs")
.join("alpha")
.join("spec.md"),
"## ADDED Requirements\n\n### Requirement: Fixture\nFixture requirement.\n\n#### Scenario: Works\n- **WHEN** fixture runs\n- **THEN** it is ready\n",
);
}
fn set_mtime_recursive(dir: &Path, time: filetime::FileTime) {
filetime::set_file_mtime(dir, time).expect("set dir mtime");
for entry in std::fs::read_dir(dir).expect("read dir") {
let entry = entry.expect("dir entry");
let path = entry.path();
filetime::set_file_mtime(&path, time).expect("set entry mtime");
if path.is_dir() {
set_mtime_recursive(&path, time);
}
}
}
#[test]
fn counts_requirements_from_headings() {
let md = r#"
# Title
## Purpose
blah
## Requirements
### Requirement: One
foo
### Requirement: Two
bar
"#;
assert_eq!(count_requirements_in_spec_markdown(md), 2);
}
#[test]
fn iso_millis_matches_expected_shape() {
let dt = DateTime::parse_from_rfc3339("2026-01-26T00:00:00.123Z")
.unwrap()
.with_timezone(&Utc);
assert_eq!(to_iso_millis(dt), "2026-01-26T00:00:00.123Z");
}
#[test]
fn parse_modular_change_module_id_allows_overflow_change_numbers() {
assert_eq!(parse_modular_change_module_id("001-02_foo"), Some("001"));
assert_eq!(parse_modular_change_module_id("001-100_foo"), Some("001"));
assert_eq!(parse_modular_change_module_id("001-1234_foo"), Some("001"));
}
#[test]
fn list_changes_filters_by_progress_status() {
let repo = tempfile::tempdir().expect("repo tempdir");
let ito_path = repo.path().join(".ito");
make_change(
repo.path(),
"000-01_pending",
"## 1. Implementation\n- [ ] 1.1 todo\n",
);
make_change(
repo.path(),
"000-02_partial",
"## 1. Implementation\n- [x] 1.1 done\n- [ ] 1.2 todo\n",
);
make_change(
repo.path(),
"000-03_completed",
"## 1. Implementation\n- [x] 1.1 done\n",
);
let change_repo = crate::change_repository::FsChangeRepository::new(&ito_path);
let ready = list_changes(
&change_repo,
ListChangesInput {
progress_filter: ChangeProgressFilter::Ready,
sort: ChangeSortOrder::Name,
},
)
.expect("ready list should succeed");
assert_eq!(ready.len(), 2);
assert_eq!(ready[0].name, "000-01_pending");
assert_eq!(ready[1].name, "000-02_partial");
let pending = list_changes(
&change_repo,
ListChangesInput {
progress_filter: ChangeProgressFilter::Pending,
sort: ChangeSortOrder::Name,
},
)
.expect("pending list should succeed");
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].name, "000-01_pending");
let partial = list_changes(
&change_repo,
ListChangesInput {
progress_filter: ChangeProgressFilter::Partial,
sort: ChangeSortOrder::Name,
},
)
.expect("partial list should succeed");
assert_eq!(partial.len(), 1);
assert_eq!(partial[0].name, "000-02_partial");
let completed = list_changes(
&change_repo,
ListChangesInput {
progress_filter: ChangeProgressFilter::Completed,
sort: ChangeSortOrder::Name,
},
)
.expect("completed list should succeed");
assert_eq!(completed.len(), 1);
assert_eq!(completed[0].name, "000-03_completed");
assert!(completed[0].completed);
}
#[test]
fn list_changes_sorts_by_name_and_recent() {
let repo = tempfile::tempdir().expect("repo tempdir");
let ito_path = repo.path().join(".ito");
make_change(
repo.path(),
"000-01_alpha",
"## 1. Implementation\n- [ ] 1.1 todo\n",
);
make_change(
repo.path(),
"000-02_beta",
"## 1. Implementation\n- [ ] 1.1 todo\n",
);
let alpha_dir = repo.path().join(".ito/changes/000-01_alpha");
let beta_dir = repo.path().join(".ito/changes/000-02_beta");
let earlier = filetime::FileTime::from_unix_time(1_000_000, 0);
let later = filetime::FileTime::from_unix_time(2_000_000, 0);
set_mtime_recursive(&alpha_dir, earlier);
set_mtime_recursive(&beta_dir, later);
let change_repo = crate::change_repository::FsChangeRepository::new(&ito_path);
let by_name = list_changes(
&change_repo,
ListChangesInput {
progress_filter: ChangeProgressFilter::All,
sort: ChangeSortOrder::Name,
},
)
.expect("name sort should succeed");
assert_eq!(by_name[0].name, "000-01_alpha");
assert_eq!(by_name[1].name, "000-02_beta");
let by_recent = list_changes(
&change_repo,
ListChangesInput {
progress_filter: ChangeProgressFilter::All,
sort: ChangeSortOrder::Recent,
},
)
.expect("recent sort should succeed");
assert_eq!(by_recent[0].name, "000-02_beta");
assert_eq!(by_recent[1].name, "000-01_alpha");
}
}