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    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, Copy, PartialEq, Eq)]
82/// Progress filter for the `ito list` default changes path.
83pub enum ChangeProgressFilter {
84    /// Return all changes.
85    All,
86    /// Return only ready changes.
87    Ready,
88    /// Return only completed (including paused) changes.
89    Completed,
90    /// Return only partially complete changes.
91    Partial,
92    /// Return only pending changes.
93    Pending,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97/// Sort order for the `ito list` default changes path.
98pub enum ChangeSortOrder {
99    /// Sort by most-recent first.
100    Recent,
101    /// Sort by change name.
102    Name,
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106/// Input arguments for the default `ito list` changes use-case.
107pub struct ListChangesInput {
108    /// Progress filter to apply before sorting.
109    pub progress_filter: ChangeProgressFilter,
110    /// Sort order applied to filtered changes.
111    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)]
124/// Stable typed summary returned to adapters for `ito list` changes.
125pub struct ChangeListSummary {
126    /// Change folder name.
127    pub name: String,
128    /// Number of completed tasks.
129    pub completed_tasks: u32,
130    /// Number of shelved tasks.
131    pub shelved_tasks: u32,
132    /// Number of in-progress tasks.
133    pub in_progress_tasks: u32,
134    /// Number of pending tasks.
135    pub pending_tasks: u32,
136    /// Total number of tasks.
137    pub total_tasks: u32,
138    /// Last modified time for the change directory.
139    pub last_modified: DateTime<Utc>,
140    /// Legacy status field for backward compatibility.
141    pub status: String,
142    /// Work status: draft, ready, in-progress, paused, complete.
143    pub work_status: String,
144    /// True when no remaining work (complete or paused).
145    pub completed: bool,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
149/// Spec entry returned by `ito list specs`.
150pub struct SpecListItem {
151    /// Spec id.
152    pub id: String,
153    #[serde(rename = "requirementCount")]
154    /// Count of requirements in `spec.md`.
155    pub requirement_count: u32,
156}
157
158/// List modules under `{ito_path}/modules`.
159pub 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
186/// List change directories under `{ito_path}/changes`.
187pub 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
196/// List active changes using typed summaries for adapter rendering.
197pub 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
242/// Compute the most-recent modification time under `path`.
243pub 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
283/// Render a UTC timestamp as ISO-8601 with millisecond precision.
284pub fn to_iso_millis(dt: DateTime<Utc>) -> String {
285    // JS Date.toISOString() is millisecond-precision. Truncate to millis to avoid
286    // platform-specific sub-ms differences.
287    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
294/// List specs under `{ito_path}/specs`.
295pub 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    // Accept canonical folder names like:
320    // - "NNN-NN_name" (2+ digit change number)
321    // - "NNN-100_name" (overflow change number)
322    // NOTE: This is a fast path for listing; full canonicalization lives in `parse_change_id`.
323    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    // Scan digits until '_'
338    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    // Canonical change numbers are at least 2 digits ("01"), but be permissive.
355    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    // Match TS MarkdownParser.parseSpec: requires Purpose and Requirements.
384    let purpose = find_section(&sections, "Purpose");
385    let req = find_section(&sections, "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    /// Create a minimal change fixture under the repository root for the given change id.
519    ///
520    /// This writes three files for a change named `id` beneath `root/.ito/changes/<id>`:
521    /// - `proposal.md` with a simple proposal template,
522    /// - `tasks.md` containing the provided `tasks` text,
523    /// - `specs/alpha/spec.md` containing a small example requirement.
524    ///
525    /// # Parameters
526    ///
527    /// - `root`: Path to the repository root where the `.ito` directory will be created.
528    /// - `id`: Folder name for the change (used as the change identifier directory).
529    /// - `tasks`: Text to write into `tasks.md`.
530    ///
531    /// # Examples
532    ///
533    /// ```
534    /// let tmp = tempfile::tempdir().unwrap();
535    /// make_change(tmp.path(), "000-01_alpha", "- [ ] task1");
536    /// assert!(tmp.path().join(".ito/changes/000-01_alpha/tasks.md").exists());
537    /// ```
538    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    /// Recursively sets the filesystem modification time for a directory and all entries within it.
555    ///
556    /// Traverses `dir`, sets the mtime of `dir` itself and every file and subdirectory it contains to `time`.
557    ///
558    /// # Examples
559    ///
560    /// ```
561    /// use std::fs::{create_dir_all, File};
562    /// use tempfile::tempdir;
563    /// use filetime::FileTime;
564    ///
565    /// let td = tempdir().unwrap();
566    /// let nested = td.path().join("a/b");
567    /// create_dir_all(&nested).unwrap();
568    /// File::create(nested.join("f.txt")).unwrap();
569    /// let ft = FileTime::from_unix_time(1_600_000_000, 0);
570    /// set_mtime_recursive(td.path(), ft);
571    /// ```
572    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        // Set explicit mtimes recursively so sort-by-recent is deterministic without sleeping.
702        // last_modified_recursive() walks all files, so we must set mtime on every entry.
703        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}