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/// Module entry returned by `ito list modules`.
21pub struct ModuleListItem {
22    /// 3-digit module id.
23    pub id: String,
24    /// Module name (slug).
25    pub name: String,
26    #[serde(rename = "fullName")]
27    /// Folder name (`NNN_name`).
28    pub full_name: String,
29    #[serde(rename = "changeCount")]
30    /// Number of changes currently associated with the module.
31    pub change_count: usize,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
35/// Change entry returned by `ito list changes`.
36pub struct ChangeListItem {
37    /// Change folder name.
38    pub name: String,
39    #[serde(rename = "completedTasks")]
40    /// Number of completed tasks.
41    pub completed_tasks: u32,
42    #[serde(rename = "shelvedTasks")]
43    /// Number of shelved tasks.
44    pub shelved_tasks: u32,
45    #[serde(rename = "inProgressTasks")]
46    /// Number of in-progress tasks.
47    pub in_progress_tasks: u32,
48    #[serde(rename = "pendingTasks")]
49    /// Number of pending tasks.
50    pub pending_tasks: u32,
51    #[serde(rename = "totalTasks")]
52    /// Total number of tasks.
53    pub total_tasks: u32,
54    #[serde(rename = "lastModified")]
55    /// Last modified time for the change directory.
56    pub last_modified: String,
57    /// Legacy status field for backward compatibility
58    pub status: String,
59    /// Work status: draft, ready, in-progress, paused, complete
60    #[serde(rename = "workStatus")]
61    pub work_status: String,
62    /// True when no remaining work (complete or paused)
63    pub completed: bool,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67/// Progress filter for the `ito list` default changes path.
68pub enum ChangeProgressFilter {
69    /// Return all changes.
70    All,
71    /// Return only ready changes.
72    Ready,
73    /// Return only completed (including paused) changes.
74    Completed,
75    /// Return only partially complete changes.
76    Partial,
77    /// Return only pending changes.
78    Pending,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82/// Sort order for the `ito list` default changes path.
83pub enum ChangeSortOrder {
84    /// Sort by most-recent first.
85    Recent,
86    /// Sort by change name.
87    Name,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91/// Input arguments for the default `ito list` changes use-case.
92pub struct ListChangesInput {
93    /// Progress filter to apply before sorting.
94    pub progress_filter: ChangeProgressFilter,
95    /// Sort order applied to filtered changes.
96    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)]
109/// Stable typed summary returned to adapters for `ito list` changes.
110pub struct ChangeListSummary {
111    /// Change folder name.
112    pub name: String,
113    /// Number of completed tasks.
114    pub completed_tasks: u32,
115    /// Number of shelved tasks.
116    pub shelved_tasks: u32,
117    /// Number of in-progress tasks.
118    pub in_progress_tasks: u32,
119    /// Number of pending tasks.
120    pub pending_tasks: u32,
121    /// Total number of tasks.
122    pub total_tasks: u32,
123    /// Last modified time for the change directory.
124    pub last_modified: DateTime<Utc>,
125    /// Legacy status field for backward compatibility.
126    pub status: String,
127    /// Work status: draft, ready, in-progress, paused, complete.
128    pub work_status: String,
129    /// True when no remaining work (complete or paused).
130    pub completed: bool,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
134/// Spec entry returned by `ito list specs`.
135pub struct SpecListItem {
136    /// Spec id.
137    pub id: String,
138    #[serde(rename = "requirementCount")]
139    /// Count of requirements in `spec.md`.
140    pub requirement_count: u32,
141}
142
143/// List modules under `{ito_path}/modules`.
144pub 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
161/// List change directories under `{ito_path}/changes`.
162pub 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
171/// List active changes using typed summaries for adapter rendering.
172pub 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 => summaries.sort_by(|a, b| b.last_modified.cmp(&a.last_modified)),
189    }
190
191    Ok(summaries
192        .into_iter()
193        .map(|s| {
194            let status = match s.status() {
195                ChangeStatus::NoTasks => "no-tasks",
196                ChangeStatus::InProgress => "in-progress",
197                ChangeStatus::Complete => "complete",
198            };
199            ChangeListSummary {
200                name: s.id.clone(),
201                completed_tasks: s.completed_tasks,
202                shelved_tasks: s.shelved_tasks,
203                in_progress_tasks: s.in_progress_tasks,
204                pending_tasks: s.pending_tasks,
205                total_tasks: s.total_tasks,
206                last_modified: s.last_modified,
207                status: status.to_string(),
208                work_status: s.work_status().to_string(),
209                completed: is_completed(&s),
210            }
211        })
212        .collect())
213}
214
215/// Compute the most-recent modification time under `path`.
216pub fn last_modified_recursive(path: &Path) -> CoreResult<DateTime<Utc>> {
217    use std::collections::VecDeque;
218
219    let mut max = std::fs::metadata(path)
220        .map_err(|e| CoreError::io("reading metadata", e))?
221        .modified()
222        .map_err(|e| CoreError::io("getting modification time", std::io::Error::other(e)))?;
223
224    let mut queue: VecDeque<PathBuf> = VecDeque::new();
225    queue.push_back(path.to_path_buf());
226
227    while let Some(p) = queue.pop_front() {
228        let meta = match std::fs::symlink_metadata(&p) {
229            Ok(m) => m,
230            Err(_) => continue,
231        };
232        if let Ok(m) = meta.modified()
233            && m > max
234        {
235            max = m;
236        }
237        if meta.is_dir() {
238            let iter = match std::fs::read_dir(&p) {
239                Ok(i) => i,
240                Err(_) => continue,
241            };
242            for entry in iter {
243                let entry = match entry {
244                    Ok(e) => e,
245                    Err(_) => continue,
246                };
247                queue.push_back(entry.path());
248            }
249        }
250    }
251
252    let dt: DateTime<Utc> = max.into();
253    Ok(dt)
254}
255
256/// Render a UTC timestamp as ISO-8601 with millisecond precision.
257pub fn to_iso_millis(dt: DateTime<Utc>) -> String {
258    // JS Date.toISOString() is millisecond-precision. Truncate to millis to avoid
259    // platform-specific sub-ms differences.
260    let nanos = dt.timestamp_subsec_nanos();
261    let truncated = dt
262        .with_nanosecond((nanos / 1_000_000) * 1_000_000)
263        .unwrap_or(dt);
264    truncated.to_rfc3339_opts(SecondsFormat::Millis, true)
265}
266
267/// List specs under `{ito_path}/specs`.
268pub fn list_specs(ito_path: &Path) -> CoreResult<Vec<SpecListItem>> {
269    let mut specs: Vec<SpecListItem> = Vec::new();
270    let specs_dir = paths::specs_dir(ito_path);
271    let fs = StdFs;
272    for id in ito_domain::discovery::list_spec_dir_names(&fs, ito_path).into_core()? {
273        let spec_md = specs_dir.join(&id).join("spec.md");
274        let content = ito_common::io::read_to_string_or_default(&spec_md);
275        let requirement_count = if content.is_empty() {
276            0
277        } else {
278            count_requirements_in_spec_markdown(&content)
279        };
280        specs.push(SpecListItem {
281            id,
282            requirement_count,
283        });
284    }
285
286    specs.sort_by(|a, b| a.id.cmp(&b.id));
287    Ok(specs)
288}
289
290#[cfg(test)]
291fn parse_modular_change_module_id(folder: &str) -> Option<&str> {
292    // Accept canonical folder names like:
293    // - "NNN-NN_name" (2+ digit change number)
294    // - "NNN-100_name" (overflow change number)
295    // NOTE: This is a fast path for listing; full canonicalization lives in `parse_change_id`.
296    let bytes = folder.as_bytes();
297    if bytes.len() < 8 {
298        return None;
299    }
300    if !bytes.first()?.is_ascii_digit()
301        || !bytes.get(1)?.is_ascii_digit()
302        || !bytes.get(2)?.is_ascii_digit()
303    {
304        return None;
305    }
306    if *bytes.get(3)? != b'-' {
307        return None;
308    }
309
310    // Scan digits until '_'
311    let mut i = 4usize;
312    let mut digit_count = 0usize;
313    while i < bytes.len() {
314        let b = bytes[i];
315        if b == b'_' {
316            break;
317        }
318        if !b.is_ascii_digit() {
319            return None;
320        }
321        digit_count += 1;
322        i += 1;
323    }
324    if i >= bytes.len() || bytes[i] != b'_' {
325        return None;
326    }
327    // Canonical change numbers are at least 2 digits ("01"), but be permissive.
328    if digit_count == 0 {
329        return None;
330    }
331
332    let name = &folder[(i + 1)..];
333    let mut chars = name.chars();
334    let first = chars.next()?;
335    if !first.is_ascii_lowercase() {
336        return None;
337    }
338    for c in chars {
339        if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
340            return None;
341        }
342    }
343
344    Some(&folder[0..3])
345}
346
347#[derive(Debug, Clone)]
348struct Section {
349    level: usize,
350    title: String,
351    children: Vec<Section>,
352}
353
354fn count_requirements_in_spec_markdown(content: &str) -> u32 {
355    let sections = parse_sections(content);
356    // Match TS MarkdownParser.parseSpec: requires Purpose and Requirements.
357    let purpose = find_section(&sections, "Purpose");
358    let req = find_section(&sections, "Requirements");
359    if purpose.is_none() || req.is_none() {
360        return 0;
361    }
362    req.map(|s| s.children.len() as u32).unwrap_or(0)
363}
364
365fn is_completed(s: &ChangeSummary) -> bool {
366    use ito_domain::changes::ChangeWorkStatus;
367    let status = s.work_status();
368    match status {
369        ChangeWorkStatus::Complete => true,
370        ChangeWorkStatus::Paused => true,
371        ChangeWorkStatus::Draft => false,
372        ChangeWorkStatus::Ready => false,
373        ChangeWorkStatus::InProgress => false,
374    }
375}
376
377fn is_partial(s: &ChangeSummary) -> bool {
378    use ito_domain::changes::ChangeWorkStatus;
379    let in_active_progress_bucket = match s.work_status() {
380        ChangeWorkStatus::Ready => true,
381        ChangeWorkStatus::InProgress => true,
382        ChangeWorkStatus::Draft => false,
383        ChangeWorkStatus::Paused => false,
384        ChangeWorkStatus::Complete => false,
385    };
386
387    in_active_progress_bucket
388        && s.total_tasks > 0
389        && s.completed_tasks > 0
390        && s.completed_tasks < s.total_tasks
391}
392
393fn is_pending(s: &ChangeSummary) -> bool {
394    use ito_domain::changes::ChangeWorkStatus;
395    let in_active_progress_bucket = match s.work_status() {
396        ChangeWorkStatus::Ready => true,
397        ChangeWorkStatus::InProgress => true,
398        ChangeWorkStatus::Draft => false,
399        ChangeWorkStatus::Paused => false,
400        ChangeWorkStatus::Complete => false,
401    };
402
403    in_active_progress_bucket && s.total_tasks > 0 && s.completed_tasks == 0
404}
405
406fn parse_sections(content: &str) -> Vec<Section> {
407    let normalized = content.replace(['\r'], "");
408    let lines: Vec<&str> = normalized.split('\n').collect();
409    let mut sections: Vec<Section> = Vec::new();
410    let mut stack: Vec<Section> = Vec::new();
411
412    for line in lines {
413        let trimmed = line.trim_end();
414        if let Some((level, title)) = parse_header(trimmed) {
415            let section = Section {
416                level,
417                title: title.to_string(),
418                children: Vec::new(),
419            };
420
421            while stack.last().is_some_and(|s| s.level >= level) {
422                let completed = stack.pop().expect("checked");
423                attach_section(&mut sections, &mut stack, completed);
424            }
425
426            stack.push(section);
427        }
428    }
429
430    while let Some(completed) = stack.pop() {
431        attach_section(&mut sections, &mut stack, completed);
432    }
433
434    sections
435}
436
437fn attach_section(sections: &mut Vec<Section>, stack: &mut [Section], section: Section) {
438    if let Some(parent) = stack.last_mut() {
439        parent.children.push(section);
440    } else {
441        sections.push(section);
442    }
443}
444
445fn parse_header(line: &str) -> Option<(usize, &str)> {
446    let bytes = line.as_bytes();
447    if bytes.is_empty() {
448        return None;
449    }
450    let mut i = 0usize;
451    while i < bytes.len() && bytes[i] == b'#' {
452        i += 1;
453    }
454    if i == 0 || i > 6 {
455        return None;
456    }
457    if i >= bytes.len() || !bytes[i].is_ascii_whitespace() {
458        return None;
459    }
460    let title = line[i..].trim();
461    if title.is_empty() {
462        return None;
463    }
464    Some((i, title))
465}
466
467fn find_section<'a>(sections: &'a [Section], title: &str) -> Option<&'a Section> {
468    for s in sections {
469        if s.title.eq_ignore_ascii_case(title) {
470            return Some(s);
471        }
472        if let Some(child) = find_section(&s.children, title) {
473            return Some(child);
474        }
475    }
476    None
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    fn write(path: impl AsRef<Path>, contents: &str) {
484        let path = path.as_ref();
485        if let Some(parent) = path.parent() {
486            std::fs::create_dir_all(parent).expect("parent dirs should exist");
487        }
488        std::fs::write(path, contents).expect("test fixture should write");
489    }
490
491    /// Create a minimal change fixture under the repository root for the given change id.
492    ///
493    /// This writes three files for a change named `id` beneath `root/.ito/changes/<id>`:
494    /// - `proposal.md` with a simple proposal template,
495    /// - `tasks.md` containing the provided `tasks` text,
496    /// - `specs/alpha/spec.md` containing a small example requirement.
497    ///
498    /// # Parameters
499    ///
500    /// - `root`: Path to the repository root where the `.ito` directory will be created.
501    /// - `id`: Folder name for the change (used as the change identifier directory).
502    /// - `tasks`: Text to write into `tasks.md`.
503    ///
504    /// # Examples
505    ///
506    /// ```
507    /// let tmp = tempfile::tempdir().unwrap();
508    /// make_change(tmp.path(), "000-01_alpha", "- [ ] task1");
509    /// assert!(tmp.path().join(".ito/changes/000-01_alpha/tasks.md").exists());
510    /// ```
511    fn make_change(root: &Path, id: &str, tasks: &str) {
512        write(
513            root.join(".ito/changes").join(id).join("proposal.md"),
514            "## Why\nfixture\n\n## What Changes\n- fixture\n\n## Impact\n- fixture\n",
515        );
516        write(root.join(".ito/changes").join(id).join("tasks.md"), tasks);
517        write(
518            root.join(".ito/changes")
519                .join(id)
520                .join("specs")
521                .join("alpha")
522                .join("spec.md"),
523            "## ADDED Requirements\n\n### Requirement: Fixture\nFixture requirement.\n\n#### Scenario: Works\n- **WHEN** fixture runs\n- **THEN** it is ready\n",
524        );
525    }
526
527    /// Recursively sets the filesystem modification time for a directory and all entries within it.
528    ///
529    /// Traverses `dir`, sets the mtime of `dir` itself and every file and subdirectory it contains to `time`.
530    ///
531    /// # Examples
532    ///
533    /// ```
534    /// use std::fs::{create_dir_all, File};
535    /// use tempfile::tempdir;
536    /// use filetime::FileTime;
537    ///
538    /// let td = tempdir().unwrap();
539    /// let nested = td.path().join("a/b");
540    /// create_dir_all(&nested).unwrap();
541    /// File::create(nested.join("f.txt")).unwrap();
542    /// let ft = FileTime::from_unix_time(1_600_000_000, 0);
543    /// set_mtime_recursive(td.path(), ft);
544    /// ```
545    fn set_mtime_recursive(dir: &Path, time: filetime::FileTime) {
546        filetime::set_file_mtime(dir, time).expect("set dir mtime");
547        for entry in std::fs::read_dir(dir).expect("read dir") {
548            let entry = entry.expect("dir entry");
549            let path = entry.path();
550            filetime::set_file_mtime(&path, time).expect("set entry mtime");
551            if path.is_dir() {
552                set_mtime_recursive(&path, time);
553            }
554        }
555    }
556
557    #[test]
558    fn counts_requirements_from_headings() {
559        let md = r#"
560# Title
561
562## Purpose
563blah
564
565## Requirements
566
567### Requirement: One
568foo
569
570### Requirement: Two
571bar
572"#;
573        assert_eq!(count_requirements_in_spec_markdown(md), 2);
574    }
575
576    #[test]
577    fn iso_millis_matches_expected_shape() {
578        let dt = DateTime::parse_from_rfc3339("2026-01-26T00:00:00.123Z")
579            .unwrap()
580            .with_timezone(&Utc);
581        assert_eq!(to_iso_millis(dt), "2026-01-26T00:00:00.123Z");
582    }
583
584    #[test]
585    fn parse_modular_change_module_id_allows_overflow_change_numbers() {
586        assert_eq!(parse_modular_change_module_id("001-02_foo"), Some("001"));
587        assert_eq!(parse_modular_change_module_id("001-100_foo"), Some("001"));
588        assert_eq!(parse_modular_change_module_id("001-1234_foo"), Some("001"));
589    }
590
591    #[test]
592    fn list_changes_filters_by_progress_status() {
593        let repo = tempfile::tempdir().expect("repo tempdir");
594        let ito_path = repo.path().join(".ito");
595        make_change(
596            repo.path(),
597            "000-01_pending",
598            "## 1. Implementation\n- [ ] 1.1 todo\n",
599        );
600        make_change(
601            repo.path(),
602            "000-02_partial",
603            "## 1. Implementation\n- [x] 1.1 done\n- [ ] 1.2 todo\n",
604        );
605        make_change(
606            repo.path(),
607            "000-03_completed",
608            "## 1. Implementation\n- [x] 1.1 done\n",
609        );
610
611        let change_repo = crate::change_repository::FsChangeRepository::new(&ito_path);
612
613        let ready = list_changes(
614            &change_repo,
615            ListChangesInput {
616                progress_filter: ChangeProgressFilter::Ready,
617                sort: ChangeSortOrder::Name,
618            },
619        )
620        .expect("ready list should succeed");
621        assert_eq!(ready.len(), 2);
622        assert_eq!(ready[0].name, "000-01_pending");
623        assert_eq!(ready[1].name, "000-02_partial");
624
625        let pending = list_changes(
626            &change_repo,
627            ListChangesInput {
628                progress_filter: ChangeProgressFilter::Pending,
629                sort: ChangeSortOrder::Name,
630            },
631        )
632        .expect("pending list should succeed");
633        assert_eq!(pending.len(), 1);
634        assert_eq!(pending[0].name, "000-01_pending");
635
636        let partial = list_changes(
637            &change_repo,
638            ListChangesInput {
639                progress_filter: ChangeProgressFilter::Partial,
640                sort: ChangeSortOrder::Name,
641            },
642        )
643        .expect("partial list should succeed");
644        assert_eq!(partial.len(), 1);
645        assert_eq!(partial[0].name, "000-02_partial");
646
647        let completed = list_changes(
648            &change_repo,
649            ListChangesInput {
650                progress_filter: ChangeProgressFilter::Completed,
651                sort: ChangeSortOrder::Name,
652            },
653        )
654        .expect("completed list should succeed");
655        assert_eq!(completed.len(), 1);
656        assert_eq!(completed[0].name, "000-03_completed");
657        assert!(completed[0].completed);
658    }
659
660    #[test]
661    fn list_changes_sorts_by_name_and_recent() {
662        let repo = tempfile::tempdir().expect("repo tempdir");
663        let ito_path = repo.path().join(".ito");
664        make_change(
665            repo.path(),
666            "000-01_alpha",
667            "## 1. Implementation\n- [ ] 1.1 todo\n",
668        );
669        make_change(
670            repo.path(),
671            "000-02_beta",
672            "## 1. Implementation\n- [ ] 1.1 todo\n",
673        );
674        // Set explicit mtimes recursively so sort-by-recent is deterministic without sleeping.
675        // last_modified_recursive() walks all files, so we must set mtime on every entry.
676        let alpha_dir = repo.path().join(".ito/changes/000-01_alpha");
677        let beta_dir = repo.path().join(".ito/changes/000-02_beta");
678        let earlier = filetime::FileTime::from_unix_time(1_000_000, 0);
679        let later = filetime::FileTime::from_unix_time(2_000_000, 0);
680        set_mtime_recursive(&alpha_dir, earlier);
681        set_mtime_recursive(&beta_dir, later);
682
683        let change_repo = crate::change_repository::FsChangeRepository::new(&ito_path);
684
685        let by_name = list_changes(
686            &change_repo,
687            ListChangesInput {
688                progress_filter: ChangeProgressFilter::All,
689                sort: ChangeSortOrder::Name,
690            },
691        )
692        .expect("name sort should succeed");
693        assert_eq!(by_name[0].name, "000-01_alpha");
694        assert_eq!(by_name[1].name, "000-02_beta");
695
696        let by_recent = list_changes(
697            &change_repo,
698            ListChangesInput {
699                progress_filter: ChangeProgressFilter::All,
700                sort: ChangeSortOrder::Recent,
701            },
702        )
703        .expect("recent sort should succeed");
704        assert_eq!(by_recent[0].name, "000-02_beta");
705        assert_eq!(by_recent[1].name, "000-01_alpha");
706    }
707}