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