Skip to main content

ito_core/
trace.rs

1//! Core orchestration for the `ito trace` command.
2//!
3//! Loads a change (active or archived), extracts requirement IDs from its delta
4//! specs, and delegates to [`ito_domain::traceability::compute_traceability`] to
5//! produce a structured coverage report.
6
7use crate::error_bridge::IntoCoreResult;
8use crate::errors::{CoreError, CoreResult};
9use crate::show::{parse_change_show_json, read_change_delta_spec_files};
10use ito_domain::changes::ChangeRepository;
11use ito_domain::traceability::{TraceStatus, compute_traceability};
12use serde::Serialize;
13
14/// One covered requirement entry in a [`TraceOutput`].
15#[derive(Debug, Clone, Serialize)]
16pub struct CoveredEntry {
17    /// The stable requirement ID.
18    pub requirement_id: String,
19    /// IDs of tasks that reference this requirement.
20    pub covering_tasks: Vec<String>,
21}
22
23/// One unresolved task reference entry in a [`TraceOutput`].
24#[derive(Debug, Clone, Serialize)]
25pub struct UnresolvedEntry {
26    /// The task that contains the dangling reference.
27    pub task_id: String,
28    /// The requirement ID that could not be resolved.
29    pub requirement_id: String,
30}
31
32/// Structured output for the `ito trace` command.
33#[derive(Debug, Clone, Serialize)]
34pub struct TraceOutput {
35    /// Change identifier.
36    pub change_id: String,
37    /// Lifecycle state: `"active"` or `"archived"`.
38    pub lifecycle: String,
39    /// Traceability status: `"ready"`, `"invalid"`, or `"unavailable"`.
40    pub status: String,
41    /// Human-readable reason (present for `invalid` and `unavailable`).
42    pub reason: Option<String>,
43    /// All requirement IDs declared in delta specs (deduplicated, sorted).
44    pub declared_requirements: Vec<String>,
45    /// Requirements covered by at least one active task.
46    pub covered: Vec<CoveredEntry>,
47    /// Requirement IDs not covered by any active task.
48    pub uncovered: Vec<String>,
49    /// Task references to unknown requirement IDs.
50    pub unresolved: Vec<UnresolvedEntry>,
51    /// Informational diagnostics (e.g. duplicate IDs).
52    pub diagnostics: Vec<String>,
53}
54
55/// Compute requirement traceability for a change.
56///
57/// Loads the change, reads its delta specification files, extracts requirement
58/// identifiers from the parsed deltas, determines the change lifecycle
59/// ("archived" when the change path contains `/archive/` or `/archived/`,
60/// otherwise "active"), and returns a structured `TraceOutput` describing
61/// declared, covered, uncovered, and unresolved requirements along with
62/// diagnostics and a status/reason.
63///
64/// # Examples
65///
66/// ```ignore
67/// // Given a ChangeRepository `repo` and a change id:
68/// let output = compute_trace_output(&repo, "CH-123").unwrap();
69/// println!("{}", output.status);
70/// ```
71pub fn compute_trace_output(
72    change_repo: &(impl ChangeRepository + ?Sized),
73    change_id: &str,
74) -> CoreResult<TraceOutput> {
75    let change = change_repo.get(change_id).into_core()?;
76
77    // Determine lifecycle from the change path.
78    let lifecycle = {
79        let path_str = change.path.to_string_lossy();
80        if path_str.contains("/archive/") {
81            "archived".to_string()
82        } else {
83            "active".to_string()
84        }
85    };
86
87    let delta_files = read_change_delta_spec_files(change_repo, change_id)?;
88    if delta_files.is_empty() {
89        return Err(CoreError::not_found(format!(
90            "No delta spec files found for change '{change_id}'"
91        )));
92    }
93
94    let show = parse_change_show_json(change_id, &delta_files);
95
96    // Collect (title, id) pairs from all delta requirements.
97    let mut delta_requirements: Vec<(String, Option<String>)> = Vec::new();
98    for d in &show.deltas {
99        for req in &d.requirements {
100            delta_requirements.push((req.text.clone(), req.requirement_id.clone()));
101        }
102    }
103
104    let trace_result = compute_traceability(&delta_requirements, &change.tasks);
105
106    let (status, reason) = match &trace_result.status {
107        TraceStatus::Ready => ("ready".to_string(), None),
108        TraceStatus::Invalid { missing_ids } => {
109            let reason = format!("Requirements missing IDs: {}", missing_ids.join(", "));
110            ("invalid".to_string(), Some(reason))
111        }
112        TraceStatus::Unavailable { reason } => ("unavailable".to_string(), Some(reason.clone())),
113    };
114
115    let mut covered = Vec::new();
116    for c in &trace_result.covered_requirements {
117        covered.push(CoveredEntry {
118            requirement_id: c.requirement_id.clone(),
119            covering_tasks: c.covering_tasks.clone(),
120        });
121    }
122
123    let mut unresolved = Vec::new();
124    for u in &trace_result.unresolved_references {
125        unresolved.push(UnresolvedEntry {
126            task_id: u.task_id.clone(),
127            requirement_id: u.requirement_id.clone(),
128        });
129    }
130
131    Ok(TraceOutput {
132        change_id: change_id.to_string(),
133        lifecycle,
134        status,
135        reason,
136        declared_requirements: trace_result.declared_requirements,
137        covered,
138        uncovered: trace_result.uncovered_requirements,
139        unresolved,
140        diagnostics: trace_result.diagnostics,
141    })
142}