Skip to main content

ito_core/
list.rs

1//! Listing helpers for modules, changes, and specs.
2//!
3//! These functions are used by the CLI to produce stable, JSON-friendly
4//! summaries of on-disk Ito state.
5
6use 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)]
20/// Sub-module entry nested inside a [`ModuleListItem`].
21pub struct SubModuleListItem {
22    /// Canonical sub-module id (e.g., `"024.01"`).
23    pub id: String,
24    /// Sub-module name (slug).
25    pub name: String,
26    #[serde(rename = "changeCount")]
27    /// Number of changes in this sub-module.
28    pub change_count: usize,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
32/// Module entry returned by `ito list modules`.
33pub struct ModuleListItem {
34    /// 3-digit module id.
35    pub id: String,
36    /// Module name (slug).
37    pub name: String,
38    #[serde(rename = "fullName")]
39    /// Folder name (`NNN_name`).
40    pub full_name: String,
41    #[serde(rename = "changeCount")]
42    /// Number of changes currently associated with the module.
43    pub change_count: usize,
44    /// Sub-modules belonging to this module.
45    #[serde(rename = "subModules")]
46    pub sub_modules: Vec<SubModuleListItem>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
50/// Change entry returned by `ito list changes`.
51pub struct ChangeListItem {
52    /// Change folder name.
53    pub name: String,
54    #[serde(rename = "completedTasks")]
55    /// Number of completed tasks.
56    pub completed_tasks: u32,
57    #[serde(rename = "shelvedTasks")]
58    /// Number of shelved tasks.
59    pub shelved_tasks: u32,
60    #[serde(rename = "inProgressTasks")]
61    /// Number of in-progress tasks.
62    pub in_progress_tasks: u32,
63    #[serde(rename = "pendingTasks")]
64    /// Number of pending tasks.
65    pub pending_tasks: u32,
66    #[serde(rename = "totalTasks")]
67    /// Total number of tasks.
68    pub total_tasks: u32,
69    #[serde(rename = "lastModified")]
70    /// Last modified time for the change directory.
71    pub last_modified: String,
72    /// Legacy status field for backward compatibility
73    pub status: String,
74    /// Work status: draft, ready, in-progress, paused, complete
75    #[serde(rename = "workStatus")]
76    pub work_status: String,
77    /// True when no remaining work (complete or paused)
78    pub completed: bool,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
82/// Archived change entry returned by `ito list-archive`.
83pub struct ArchivedChangeListItem {
84    /// Canonical change id, without the archive date prefix.
85    pub name: String,
86    #[serde(rename = "lastModified")]
87    /// Last modified time for the archived change directory.
88    pub last_modified: String,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92/// Progress filter for the `ito list` default changes path.
93pub enum ChangeProgressFilter {
94    /// Return all changes.
95    All,
96    /// Return only ready changes.
97    Ready,
98    /// Return only completed (including paused) changes.
99    Completed,
100    /// Return only partially complete changes.
101    Partial,
102    /// Return only pending changes.
103    Pending,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107/// Sort order for the `ito list` default changes path.
108pub enum ChangeSortOrder {
109    /// Sort by most-recent first.
110    Recent,
111    /// Sort by change name.
112    Name,
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116/// Input arguments for the default `ito list` changes use-case.
117pub struct ListChangesInput {
118    /// Progress filter to apply before sorting.
119    pub progress_filter: ChangeProgressFilter,
120    /// Sort order applied to filtered changes.
121    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)]
134/// Stable typed summary returned to adapters for `ito list` changes.
135pub struct ChangeListSummary {
136    /// Change folder name.
137    pub name: String,
138    /// Number of completed tasks.
139    pub completed_tasks: u32,
140    /// Number of shelved tasks.
141    pub shelved_tasks: u32,
142    /// Number of in-progress tasks.
143    pub in_progress_tasks: u32,
144    /// Number of pending tasks.
145    pub pending_tasks: u32,
146    /// Total number of tasks.
147    pub total_tasks: u32,
148    /// Last modified time for the change directory.
149    pub last_modified: DateTime<Utc>,
150    /// Legacy status field for backward compatibility.
151    pub status: String,
152    /// Work status: draft, ready, in-progress, paused, complete.
153    pub work_status: String,
154    /// True when no remaining work (complete or paused).
155    pub completed: bool,
156}
157
158#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
159/// Spec entry returned by `ito list specs`.
160pub struct SpecListItem {
161    /// Spec id.
162    pub id: String,
163    #[serde(rename = "requirementCount")]
164    /// Count of requirements in `spec.md`.
165    pub requirement_count: u32,
166}
167
168/// List modules under `{ito_path}/modules`.
169pub 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
196/// List change directories under `{ito_path}/changes`.
197pub 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
206/// List active changes using typed summaries for adapter rendering.
207pub 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
252/// List archived changes as serializable items, sorted by canonical change id.
253///
254/// Each item carries the change id (without any archive date prefix) and the
255/// recursive last-modified timestamp formatted via [`to_iso_millis`]. Adapters
256/// should not reformat the timestamp.
257pub 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
275/// Compute the most-recent modification time under `path`.
276pub 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
316/// Render a UTC timestamp as ISO-8601 with millisecond precision.
317pub fn to_iso_millis(dt: DateTime<Utc>) -> String {
318    // JS Date.toISOString() is millisecond-precision. Truncate to millis to avoid
319    // platform-specific sub-ms differences.
320    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
327/// List specs under `{ito_path}/specs`.
328pub 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    // Accept canonical folder names like:
353    // - "NNN-NN_name" (2+ digit change number)
354    // - "NNN-100_name" (overflow change number)
355    // NOTE: This is a fast path for listing; full canonicalization lives in `parse_change_id`.
356    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    // Scan digits until '_'
371    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    // Canonical change numbers are at least 2 digits ("01"), but be permissive.
388    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    // Match TS MarkdownParser.parseSpec: requires Purpose and Requirements.
417    let purpose = find_section(&sections, "Purpose");
418    let req = find_section(&sections, "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    /// Create a minimal change fixture under the repository root for the given change id.
552    ///
553    /// This writes three files for a change named `id` beneath `root/.ito/changes/<id>`:
554    /// - `proposal.md` with a simple proposal template,
555    /// - `tasks.md` containing the provided `tasks` text,
556    /// - `specs/alpha/spec.md` containing a small example requirement.
557    ///
558    /// # Parameters
559    ///
560    /// - `root`: Path to the repository root where the `.ito` directory will be created.
561    /// - `id`: Folder name for the change (used as the change identifier directory).
562    /// - `tasks`: Text to write into `tasks.md`.
563    ///
564    /// # Examples
565    ///
566    /// ```
567    /// let tmp = tempfile::tempdir().unwrap();
568    /// make_change(tmp.path(), "000-01_alpha", "- [ ] task1");
569    /// assert!(tmp.path().join(".ito/changes/000-01_alpha/tasks.md").exists());
570    /// ```
571    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    /// Recursively sets the filesystem modification time for a directory and all entries within it.
588    ///
589    /// Traverses `dir`, sets the mtime of `dir` itself and every file and subdirectory it contains to `time`.
590    ///
591    /// # Examples
592    ///
593    /// ```
594    /// use std::fs::{create_dir_all, File};
595    /// use tempfile::tempdir;
596    /// use filetime::FileTime;
597    ///
598    /// let td = tempdir().unwrap();
599    /// let nested = td.path().join("a/b");
600    /// create_dir_all(&nested).unwrap();
601    /// File::create(nested.join("f.txt")).unwrap();
602    /// let ft = FileTime::from_unix_time(1_600_000_000, 0);
603    /// set_mtime_recursive(td.path(), ft);
604    /// ```
605    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        // Set explicit mtimes recursively so sort-by-recent is deterministic without sleeping.
735        // last_modified_recursive() walks all files, so we must set mtime on every entry.
736        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}