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