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    fn make_change(root: &Path, id: &str, tasks: &str) {
479        write(
480            root.join(".ito/changes").join(id).join("proposal.md"),
481            "## Why\nfixture\n\n## What Changes\n- fixture\n\n## Impact\n- fixture\n",
482        );
483        write(root.join(".ito/changes").join(id).join("tasks.md"), tasks);
484        write(
485            root.join(".ito/changes")
486                .join(id)
487                .join("specs")
488                .join("alpha")
489                .join("spec.md"),
490            "## ADDED Requirements\n\n### Requirement: Fixture\nFixture requirement.\n\n#### Scenario: Works\n- **WHEN** fixture runs\n- **THEN** it is ready\n",
491        );
492    }
493
494    #[test]
495    fn counts_requirements_from_headings() {
496        let md = r#"
497# Title
498
499## Purpose
500blah
501
502## Requirements
503
504### Requirement: One
505foo
506
507### Requirement: Two
508bar
509"#;
510        assert_eq!(count_requirements_in_spec_markdown(md), 2);
511    }
512
513    #[test]
514    fn iso_millis_matches_expected_shape() {
515        let dt = DateTime::parse_from_rfc3339("2026-01-26T00:00:00.123Z")
516            .unwrap()
517            .with_timezone(&Utc);
518        assert_eq!(to_iso_millis(dt), "2026-01-26T00:00:00.123Z");
519    }
520
521    #[test]
522    fn parse_modular_change_module_id_allows_overflow_change_numbers() {
523        assert_eq!(parse_modular_change_module_id("001-02_foo"), Some("001"));
524        assert_eq!(parse_modular_change_module_id("001-100_foo"), Some("001"));
525        assert_eq!(parse_modular_change_module_id("001-1234_foo"), Some("001"));
526    }
527
528    #[test]
529    fn list_changes_filters_by_progress_status() {
530        let repo = tempfile::tempdir().expect("repo tempdir");
531        let ito_path = repo.path().join(".ito");
532        make_change(
533            repo.path(),
534            "000-01_pending",
535            "## 1. Implementation\n- [ ] 1.1 todo\n",
536        );
537        make_change(
538            repo.path(),
539            "000-02_partial",
540            "## 1. Implementation\n- [x] 1.1 done\n- [ ] 1.2 todo\n",
541        );
542        make_change(
543            repo.path(),
544            "000-03_completed",
545            "## 1. Implementation\n- [x] 1.1 done\n",
546        );
547
548        let change_repo = crate::change_repository::FsChangeRepository::new(&ito_path);
549
550        let ready = list_changes(
551            &change_repo,
552            ListChangesInput {
553                progress_filter: ChangeProgressFilter::Ready,
554                sort: ChangeSortOrder::Name,
555            },
556        )
557        .expect("ready list should succeed");
558        assert_eq!(ready.len(), 2);
559        assert_eq!(ready[0].name, "000-01_pending");
560        assert_eq!(ready[1].name, "000-02_partial");
561
562        let pending = list_changes(
563            &change_repo,
564            ListChangesInput {
565                progress_filter: ChangeProgressFilter::Pending,
566                sort: ChangeSortOrder::Name,
567            },
568        )
569        .expect("pending list should succeed");
570        assert_eq!(pending.len(), 1);
571        assert_eq!(pending[0].name, "000-01_pending");
572
573        let partial = list_changes(
574            &change_repo,
575            ListChangesInput {
576                progress_filter: ChangeProgressFilter::Partial,
577                sort: ChangeSortOrder::Name,
578            },
579        )
580        .expect("partial list should succeed");
581        assert_eq!(partial.len(), 1);
582        assert_eq!(partial[0].name, "000-02_partial");
583
584        let completed = list_changes(
585            &change_repo,
586            ListChangesInput {
587                progress_filter: ChangeProgressFilter::Completed,
588                sort: ChangeSortOrder::Name,
589            },
590        )
591        .expect("completed list should succeed");
592        assert_eq!(completed.len(), 1);
593        assert_eq!(completed[0].name, "000-03_completed");
594        assert!(completed[0].completed);
595    }
596
597    #[test]
598    fn list_changes_sorts_by_name_and_recent() {
599        let repo = tempfile::tempdir().expect("repo tempdir");
600        let ito_path = repo.path().join(".ito");
601        make_change(
602            repo.path(),
603            "000-01_alpha",
604            "## 1. Implementation\n- [ ] 1.1 todo\n",
605        );
606        std::thread::sleep(std::time::Duration::from_millis(15));
607        make_change(
608            repo.path(),
609            "000-02_beta",
610            "## 1. Implementation\n- [ ] 1.1 todo\n",
611        );
612
613        let change_repo = crate::change_repository::FsChangeRepository::new(&ito_path);
614
615        let by_name = list_changes(
616            &change_repo,
617            ListChangesInput {
618                progress_filter: ChangeProgressFilter::All,
619                sort: ChangeSortOrder::Name,
620            },
621        )
622        .expect("name sort should succeed");
623        assert_eq!(by_name[0].name, "000-01_alpha");
624        assert_eq!(by_name[1].name, "000-02_beta");
625
626        let by_recent = list_changes(
627            &change_repo,
628            ListChangesInput {
629                progress_filter: ChangeProgressFilter::All,
630                sort: ChangeSortOrder::Recent,
631            },
632        )
633        .expect("recent sort should succeed");
634        assert_eq!(by_recent[0].name, "000-02_beta");
635        assert_eq!(by_recent[1].name, "000-01_alpha");
636    }
637}