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_config::types::ItoConfig;
15use ito_domain::changes::{
16    ChangeLifecycleFilter, ChangeRepository as DomainChangeRepository, ChangeStatus, ChangeSummary,
17};
18use ito_domain::modules::ModuleRepository as DomainModuleRepository;
19
20use crate::implementation_readiness::{ReadinessPhase, ReadinessRequest, evaluate_readiness};
21
22#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
23/// Sub-module entry nested inside a [`ModuleListItem`].
24pub struct SubModuleListItem {
25    /// Canonical sub-module id (e.g., `"024.01"`).
26    pub id: String,
27    /// Sub-module name (slug).
28    pub name: String,
29    #[serde(rename = "changeCount")]
30    /// Number of changes in this sub-module.
31    pub change_count: usize,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
35/// Module entry returned by `ito list modules`.
36pub struct ModuleListItem {
37    /// 3-digit module id.
38    pub id: String,
39    /// Module name (slug).
40    pub name: String,
41    #[serde(rename = "fullName")]
42    /// Folder name (`NNN_name`).
43    pub full_name: String,
44    #[serde(rename = "changeCount")]
45    /// Number of changes currently associated with the module.
46    pub change_count: usize,
47    /// Sub-modules belonging to this module.
48    #[serde(rename = "subModules")]
49    pub sub_modules: Vec<SubModuleListItem>,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
53/// Change entry returned by `ito list changes`.
54pub struct ChangeListItem {
55    /// Change folder name.
56    pub name: String,
57    #[serde(rename = "completedTasks")]
58    /// Number of completed tasks.
59    pub completed_tasks: u32,
60    #[serde(rename = "shelvedTasks")]
61    /// Number of shelved tasks.
62    pub shelved_tasks: u32,
63    #[serde(rename = "inProgressTasks")]
64    /// Number of in-progress tasks.
65    pub in_progress_tasks: u32,
66    #[serde(rename = "pendingTasks")]
67    /// Number of pending tasks.
68    pub pending_tasks: u32,
69    #[serde(rename = "totalTasks")]
70    /// Total number of tasks.
71    pub total_tasks: u32,
72    #[serde(rename = "lastModified")]
73    /// Last modified time for the change directory.
74    pub last_modified: String,
75    /// Legacy status field for backward compatibility
76    pub status: String,
77    /// Work status: draft, ready, in-progress, paused, complete
78    #[serde(rename = "workStatus")]
79    pub work_status: String,
80    /// True when no remaining work (complete or paused)
81    pub completed: bool,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
85/// Archived change entry returned by `ito list-archive`.
86pub struct ArchivedChangeListItem {
87    /// Canonical change id, without the archive date prefix.
88    pub name: String,
89    #[serde(rename = "lastModified")]
90    /// Last modified time for the archived change directory.
91    pub last_modified: String,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95/// Progress filter for the `ito list` default changes path.
96pub enum ChangeProgressFilter {
97    /// Return all changes.
98    All,
99    /// Return only ready changes.
100    Ready,
101    /// Return only completed (including paused) changes.
102    Completed,
103    /// Return only partially complete changes.
104    Partial,
105    /// Return only pending changes.
106    Pending,
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110/// Sort order for the `ito list` default changes path.
111pub enum ChangeSortOrder {
112    /// Sort by most-recent first.
113    Recent,
114    /// Sort by change name.
115    Name,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119/// Input arguments for the default `ito list` changes use-case.
120pub struct ListChangesInput {
121    /// Progress filter to apply before sorting.
122    pub progress_filter: ChangeProgressFilter,
123    /// Sort order applied to filtered changes.
124    pub sort: ChangeSortOrder,
125}
126
127impl Default for ListChangesInput {
128    fn default() -> Self {
129        Self {
130            progress_filter: ChangeProgressFilter::All,
131            sort: ChangeSortOrder::Recent,
132        }
133    }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
137/// Stable typed summary returned to adapters for `ito list` changes.
138pub struct ChangeListSummary {
139    /// Change folder name.
140    pub name: String,
141    /// Number of completed tasks.
142    pub completed_tasks: u32,
143    /// Number of shelved tasks.
144    pub shelved_tasks: u32,
145    /// Number of in-progress tasks.
146    pub in_progress_tasks: u32,
147    /// Number of pending tasks.
148    pub pending_tasks: u32,
149    /// Total number of tasks.
150    pub total_tasks: u32,
151    /// Last modified time for the change directory.
152    pub last_modified: DateTime<Utc>,
153    /// Legacy status field for backward compatibility.
154    pub status: String,
155    /// Work status: draft, ready, in-progress, paused, complete.
156    pub work_status: String,
157    /// True when no remaining work (complete or paused).
158    pub completed: bool,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
162/// Spec entry returned by `ito list specs`.
163pub struct SpecListItem {
164    /// Spec id.
165    pub id: String,
166    #[serde(rename = "requirementCount")]
167    /// Count of requirements in `spec.md`.
168    pub requirement_count: u32,
169}
170
171/// List modules under `{ito_path}/modules`.
172pub fn list_modules(module_repo: &dyn DomainModuleRepository) -> CoreResult<Vec<ModuleListItem>> {
173    let mut modules: Vec<ModuleListItem> = Vec::new();
174
175    for module in module_repo.list().into_core()? {
176        let full_name = format!("{}_{}", module.id, module.name);
177        let mut sub_modules = Vec::with_capacity(module.sub_modules.len());
178        for sm in &module.sub_modules {
179            sub_modules.push(SubModuleListItem {
180                id: sm.id.clone(),
181                name: sm.name.clone(),
182                change_count: sm.change_count as usize,
183            });
184        }
185        sub_modules.sort_by(|a, b| a.id.cmp(&b.id));
186        modules.push(ModuleListItem {
187            id: module.id,
188            name: module.name,
189            full_name,
190            change_count: module.change_count as usize,
191            sub_modules,
192        });
193    }
194
195    modules.sort_by(|a, b| a.full_name.cmp(&b.full_name));
196    Ok(modules)
197}
198
199/// List change directories under `{ito_path}/changes`.
200pub fn list_change_dirs(ito_path: &Path) -> CoreResult<Vec<PathBuf>> {
201    let fs = StdFs;
202    Ok(ito_domain::discovery::list_change_dir_names(&fs, ito_path)
203        .into_core()?
204        .into_iter()
205        .map(|name| paths::change_dir(ito_path, &name))
206        .collect())
207}
208
209/// List active changes using typed summaries for adapter rendering.
210pub fn list_changes(
211    change_repo: &dyn DomainChangeRepository,
212    input: ListChangesInput,
213) -> CoreResult<Vec<ChangeListSummary>> {
214    let mut summaries: Vec<ChangeSummary> = change_repo.list().into_core()?;
215
216    match input.progress_filter {
217        ChangeProgressFilter::All => {}
218        ChangeProgressFilter::Ready => summaries.retain(|s| s.is_ready()),
219        ChangeProgressFilter::Completed => summaries.retain(is_completed),
220        ChangeProgressFilter::Partial => summaries.retain(is_partial),
221        ChangeProgressFilter::Pending => summaries.retain(is_pending),
222    }
223
224    match input.sort {
225        ChangeSortOrder::Name => summaries.sort_by(|a, b| a.id.cmp(&b.id)),
226        ChangeSortOrder::Recent => {
227            summaries.sort_by(|a, b| b.last_modified.cmp(&a.last_modified).then(a.id.cmp(&b.id)))
228        }
229    }
230
231    Ok(summaries
232        .into_iter()
233        .map(|s| {
234            let status = match s.status() {
235                ChangeStatus::NoTasks => "no-tasks",
236                ChangeStatus::InProgress => "in-progress",
237                ChangeStatus::Complete => "complete",
238            };
239            ChangeListSummary {
240                name: s.id.clone(),
241                completed_tasks: s.completed_tasks,
242                shelved_tasks: s.shelved_tasks,
243                in_progress_tasks: s.in_progress_tasks,
244                pending_tasks: s.pending_tasks,
245                total_tasks: s.total_tasks,
246                last_modified: s.last_modified,
247                status: status.to_string(),
248                work_status: s.work_status().to_string(),
249                completed: is_completed(&s),
250            }
251        })
252        .collect())
253}
254
255/// List only changes that pass centralized authoritative `prepare` readiness.
256///
257/// Candidate summaries still come from the configured change repository so
258/// existing display fields remain stable. Inclusion is decided exclusively by
259/// the immutable Git-authority readiness service, never by checkout-local
260/// artifact completeness.
261pub fn list_prepare_ready_changes(
262    change_repo: &dyn DomainChangeRepository,
263    repository_root: &Path,
264    config: &ItoConfig,
265    sort: ChangeSortOrder,
266) -> CoreResult<Vec<ChangeListSummary>> {
267    let summaries = list_changes(
268        change_repo,
269        ListChangesInput {
270            progress_filter: ChangeProgressFilter::All,
271            sort,
272        },
273    )?;
274    Ok(summaries
275        .into_iter()
276        .filter(|summary| {
277            let request =
278                ReadinessRequest::new(&summary.name, ReadinessPhase::Prepare, repository_root);
279            evaluate_readiness(&request, config).ready
280        })
281        .collect())
282}
283
284/// List archived changes as serializable items, sorted by canonical change id.
285///
286/// Each item carries the change id (without any archive date prefix) and the
287/// recursive last-modified timestamp formatted via [`to_iso_millis`]. Adapters
288/// should not reformat the timestamp.
289pub fn list_archived_changes(
290    change_repo: &dyn DomainChangeRepository,
291) -> CoreResult<Vec<ArchivedChangeListItem>> {
292    let mut summaries = change_repo
293        .list_with_filter(ChangeLifecycleFilter::Archived)
294        .into_core()?;
295    summaries.sort_by(|a, b| a.id.cmp(&b.id));
296
297    let mut items = Vec::with_capacity(summaries.len());
298    for s in summaries {
299        items.push(ArchivedChangeListItem {
300            name: s.id,
301            last_modified: to_iso_millis(s.last_modified),
302        });
303    }
304    Ok(items)
305}
306
307/// Compute the most-recent modification time under `path`.
308pub fn last_modified_recursive(path: &Path) -> CoreResult<DateTime<Utc>> {
309    use std::collections::VecDeque;
310
311    let mut max = std::fs::metadata(path)
312        .map_err(|e| CoreError::io("reading metadata", e))?
313        .modified()
314        .map_err(|e| CoreError::io("getting modification time", std::io::Error::other(e)))?;
315
316    let mut queue: VecDeque<PathBuf> = VecDeque::new();
317    queue.push_back(path.to_path_buf());
318
319    while let Some(p) = queue.pop_front() {
320        let meta = match std::fs::symlink_metadata(&p) {
321            Ok(m) => m,
322            Err(_) => continue,
323        };
324        if let Ok(m) = meta.modified()
325            && m > max
326        {
327            max = m;
328        }
329        if meta.is_dir() {
330            let iter = match std::fs::read_dir(&p) {
331                Ok(i) => i,
332                Err(_) => continue,
333            };
334            for entry in iter {
335                let entry = match entry {
336                    Ok(e) => e,
337                    Err(_) => continue,
338                };
339                queue.push_back(entry.path());
340            }
341        }
342    }
343
344    let dt: DateTime<Utc> = max.into();
345    Ok(dt)
346}
347
348/// Render a UTC timestamp as ISO-8601 with millisecond precision.
349pub fn to_iso_millis(dt: DateTime<Utc>) -> String {
350    // JS Date.toISOString() is millisecond-precision. Truncate to millis to avoid
351    // platform-specific sub-ms differences.
352    let nanos = dt.timestamp_subsec_nanos();
353    let truncated = dt
354        .with_nanosecond((nanos / 1_000_000) * 1_000_000)
355        .unwrap_or(dt);
356    truncated.to_rfc3339_opts(SecondsFormat::Millis, true)
357}
358
359/// List specs under `{ito_path}/specs`.
360pub fn list_specs(ito_path: &Path) -> CoreResult<Vec<SpecListItem>> {
361    let mut specs: Vec<SpecListItem> = Vec::new();
362    let specs_dir = paths::specs_dir(ito_path);
363    let fs = StdFs;
364    for id in ito_domain::discovery::list_spec_dir_names(&fs, ito_path).into_core()? {
365        let spec_md = specs_dir.join(&id).join("spec.md");
366        let content = ito_common::io::read_to_string_or_default(&spec_md);
367        let requirement_count = if content.is_empty() {
368            0
369        } else {
370            count_requirements_in_spec_markdown(&content)
371        };
372        specs.push(SpecListItem {
373            id,
374            requirement_count,
375        });
376    }
377
378    specs.sort_by(|a, b| a.id.cmp(&b.id));
379    Ok(specs)
380}
381
382#[cfg(test)]
383fn parse_modular_change_module_id(folder: &str) -> Option<&str> {
384    // Accept canonical folder names like:
385    // - "NNN-NN_name" (2+ digit change number)
386    // - "NNN-100_name" (overflow change number)
387    // NOTE: This is a fast path for listing; full canonicalization lives in `parse_change_id`.
388    let bytes = folder.as_bytes();
389    if bytes.len() < 8 {
390        return None;
391    }
392    if !bytes.first()?.is_ascii_digit()
393        || !bytes.get(1)?.is_ascii_digit()
394        || !bytes.get(2)?.is_ascii_digit()
395    {
396        return None;
397    }
398    if *bytes.get(3)? != b'-' {
399        return None;
400    }
401
402    // Scan digits until '_'
403    let mut i = 4usize;
404    let mut digit_count = 0usize;
405    while i < bytes.len() {
406        let b = bytes[i];
407        if b == b'_' {
408            break;
409        }
410        if !b.is_ascii_digit() {
411            return None;
412        }
413        digit_count += 1;
414        i += 1;
415    }
416    if i >= bytes.len() || bytes[i] != b'_' {
417        return None;
418    }
419    // Canonical change numbers are at least 2 digits ("01"), but be permissive.
420    if digit_count == 0 {
421        return None;
422    }
423
424    let name = &folder[(i + 1)..];
425    let mut chars = name.chars();
426    let first = chars.next()?;
427    if !first.is_ascii_lowercase() {
428        return None;
429    }
430    for c in chars {
431        if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
432            return None;
433        }
434    }
435
436    Some(&folder[0..3])
437}
438
439#[derive(Debug, Clone)]
440struct Section {
441    level: usize,
442    title: String,
443    children: Vec<Section>,
444}
445
446fn count_requirements_in_spec_markdown(content: &str) -> u32 {
447    let sections = parse_sections(content);
448    // Match TS MarkdownParser.parseSpec: requires Purpose and Requirements.
449    let purpose = find_section(&sections, "Purpose");
450    let req = find_section(&sections, "Requirements");
451    if purpose.is_none() || req.is_none() {
452        return 0;
453    }
454    req.map(|s| s.children.len() as u32).unwrap_or(0)
455}
456
457fn is_completed(s: &ChangeSummary) -> bool {
458    use ito_domain::changes::ChangeWorkStatus;
459    let status = s.work_status();
460    match status {
461        ChangeWorkStatus::Complete => true,
462        ChangeWorkStatus::Paused => true,
463        ChangeWorkStatus::Draft => false,
464        ChangeWorkStatus::Ready => false,
465        ChangeWorkStatus::InProgress => false,
466    }
467}
468
469fn is_partial(s: &ChangeSummary) -> bool {
470    use ito_domain::changes::ChangeWorkStatus;
471    let in_active_progress_bucket = match s.work_status() {
472        ChangeWorkStatus::Ready => true,
473        ChangeWorkStatus::InProgress => true,
474        ChangeWorkStatus::Draft => false,
475        ChangeWorkStatus::Paused => false,
476        ChangeWorkStatus::Complete => false,
477    };
478
479    in_active_progress_bucket
480        && s.total_tasks > 0
481        && s.completed_tasks > 0
482        && s.completed_tasks < s.total_tasks
483}
484
485fn is_pending(s: &ChangeSummary) -> bool {
486    use ito_domain::changes::ChangeWorkStatus;
487    let in_active_progress_bucket = match s.work_status() {
488        ChangeWorkStatus::Ready => true,
489        ChangeWorkStatus::InProgress => true,
490        ChangeWorkStatus::Draft => false,
491        ChangeWorkStatus::Paused => false,
492        ChangeWorkStatus::Complete => false,
493    };
494
495    in_active_progress_bucket && s.total_tasks > 0 && s.completed_tasks == 0
496}
497
498fn parse_sections(content: &str) -> Vec<Section> {
499    let normalized = content.replace(['\r'], "");
500    let lines: Vec<&str> = normalized.split('\n').collect();
501    let mut sections: Vec<Section> = Vec::new();
502    let mut stack: Vec<Section> = Vec::new();
503
504    for line in lines {
505        let trimmed = line.trim_end();
506        if let Some((level, title)) = parse_header(trimmed) {
507            let section = Section {
508                level,
509                title: title.to_string(),
510                children: Vec::new(),
511            };
512
513            while stack.last().is_some_and(|s| s.level >= level) {
514                let completed = stack.pop().expect("checked");
515                attach_section(&mut sections, &mut stack, completed);
516            }
517
518            stack.push(section);
519        }
520    }
521
522    while let Some(completed) = stack.pop() {
523        attach_section(&mut sections, &mut stack, completed);
524    }
525
526    sections
527}
528
529fn attach_section(sections: &mut Vec<Section>, stack: &mut [Section], section: Section) {
530    if let Some(parent) = stack.last_mut() {
531        parent.children.push(section);
532    } else {
533        sections.push(section);
534    }
535}
536
537fn parse_header(line: &str) -> Option<(usize, &str)> {
538    let bytes = line.as_bytes();
539    if bytes.is_empty() {
540        return None;
541    }
542    let mut i = 0usize;
543    while i < bytes.len() && bytes[i] == b'#' {
544        i += 1;
545    }
546    if i == 0 || i > 6 {
547        return None;
548    }
549    if i >= bytes.len() || !bytes[i].is_ascii_whitespace() {
550        return None;
551    }
552    let title = line[i..].trim();
553    if title.is_empty() {
554        return None;
555    }
556    Some((i, title))
557}
558
559fn find_section<'a>(sections: &'a [Section], title: &str) -> Option<&'a Section> {
560    for s in sections {
561        if s.title.eq_ignore_ascii_case(title) {
562            return Some(s);
563        }
564        if let Some(child) = find_section(&s.children, title) {
565            return Some(child);
566        }
567    }
568    None
569}
570
571#[cfg(test)]
572#[path = "list_tests.rs"]
573mod list_tests;