Skip to main content

ito_core/show/
mod.rs

1//! Convert Ito markdown artifacts into JSON-friendly structures.
2//!
3//! This module is used by "show"-style commands and APIs. It reads spec and
4//! change markdown files from disk and produces lightweight structs that can be
5//! serialized to JSON.
6
7use std::path::Path;
8
9use crate::error_bridge::IntoCoreResult;
10use crate::errors::{CoreError, CoreResult};
11use serde::Serialize;
12
13use ito_common::paths;
14use ito_domain::changes::ChangeRepository;
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
17/// One raw scenario block from a spec or delta.
18pub struct Scenario {
19    #[serde(rename = "rawText")]
20    /// The original scenario text (preserves newlines).
21    pub raw_text: String,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
25/// A single requirement statement and its scenarios.
26pub struct Requirement {
27    /// The normalized requirement statement.
28    pub text: String,
29
30    /// Scenario blocks associated with the requirement.
31    pub scenarios: Vec<Scenario>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
35/// JSON-serializable view of a spec markdown file.
36pub struct SpecShowJson {
37    /// Spec id (folder name under `.ito/specs/`).
38    pub id: String,
39    /// Human-readable title (currently same as `id`).
40    pub title: String,
41    /// Extracted `## Purpose` section.
42    pub overview: String,
43    #[serde(rename = "requirementCount")]
44    /// Total number of requirements.
45    pub requirement_count: u32,
46
47    /// Requirements parsed from the markdown.
48    pub requirements: Vec<Requirement>,
49
50    /// Metadata describing the output format.
51    pub metadata: SpecMetadata,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
55/// Additional info included in serialized spec output.
56pub struct SpecMetadata {
57    /// Output schema version.
58    pub version: String,
59
60    /// Output format identifier.
61    pub format: String,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
65/// JSON-serializable view of a change (proposal + deltas).
66pub struct ChangeShowJson {
67    /// Change id (folder name under `.ito/changes/`).
68    pub id: String,
69    /// Human-readable title (currently same as `id`).
70    pub title: String,
71    #[serde(rename = "deltaCount")]
72    /// Total number of deltas.
73    pub delta_count: u32,
74
75    /// Parsed deltas from delta spec files.
76    pub deltas: Vec<ChangeDelta>,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
80/// One delta entry extracted from a change delta spec.
81pub struct ChangeDelta {
82    /// Spec id the delta belongs to.
83    pub spec: String,
84
85    /// Delta operation (e.g. `ADDED`, `MODIFIED`).
86    pub operation: String,
87
88    /// Human-readable description for display.
89    pub description: String,
90
91    /// Primary requirement extracted for the delta (legacy shape).
92    pub requirement: Requirement,
93
94    /// All requirements extracted for the delta.
95    pub requirements: Vec<Requirement>,
96}
97
98/// Read the markdown for a spec id from `.ito/specs/<id>/spec.md`.
99pub fn read_spec_markdown(ito_path: &Path, id: &str) -> CoreResult<String> {
100    let path = paths::spec_markdown_path(ito_path, id);
101    ito_common::io::read_to_string(&path)
102        .map_err(|e| CoreError::io(format!("reading spec {}", id), std::io::Error::other(e)))
103}
104
105/// Read the proposal markdown for a change id.
106pub fn read_change_proposal_markdown(
107    repo: &impl ChangeRepository,
108    change_id: &str,
109) -> CoreResult<Option<String>> {
110    let change = repo.get(change_id).into_core()?;
111    Ok(change.proposal)
112}
113
114/// Read the raw markdown for a module's `module.md` file.
115pub fn read_module_markdown(ito_path: &Path, module_id: &str) -> CoreResult<String> {
116    use crate::error_bridge::IntoCoreResult;
117    use crate::module_repository::FsModuleRepository;
118
119    let module_repo = FsModuleRepository::new(ito_path);
120    let module = module_repo.get(module_id).into_core()?;
121    let module_md_path = module.path.join("module.md");
122    let md = ito_common::io::read_to_string_or_default(&module_md_path);
123    Ok(md)
124}
125
126/// Parse spec markdown into a serializable structure.
127pub fn parse_spec_show_json(id: &str, markdown: &str) -> SpecShowJson {
128    let overview = extract_section_text(markdown, "Purpose");
129    let requirements = parse_spec_requirements(markdown);
130    SpecShowJson {
131        id: id.to_string(),
132        title: id.to_string(),
133        overview,
134        requirement_count: requirements.len() as u32,
135        requirements,
136        metadata: SpecMetadata {
137            version: "1.0.0".to_string(),
138            format: "ito".to_string(),
139        },
140    }
141}
142
143/// Return all delta spec files for a change from the repository.
144pub fn read_change_delta_spec_files(
145    repo: &impl ChangeRepository,
146    change_id: &str,
147) -> CoreResult<Vec<DeltaSpecFile>> {
148    let change = repo.get(change_id).into_core()?;
149    let mut out: Vec<DeltaSpecFile> = change
150        .specs
151        .into_iter()
152        .map(|spec| DeltaSpecFile {
153            spec: spec.name,
154            markdown: spec.content,
155        })
156        .collect();
157    out.sort_by(|a, b| a.spec.cmp(&b.spec));
158    Ok(out)
159}
160
161/// Parse a change id plus its delta spec files into a JSON-friendly structure.
162pub fn parse_change_show_json(change_id: &str, delta_specs: &[DeltaSpecFile]) -> ChangeShowJson {
163    let mut deltas: Vec<ChangeDelta> = Vec::new();
164    for file in delta_specs {
165        deltas.extend(parse_delta_spec_file(file));
166    }
167
168    ChangeShowJson {
169        id: change_id.to_string(),
170        title: change_id.to_string(),
171        delta_count: deltas.len() as u32,
172        deltas,
173    }
174}
175
176#[derive(Debug, Clone)]
177/// One loaded delta spec file.
178pub struct DeltaSpecFile {
179    /// Spec id this delta spec belongs to.
180    pub spec: String,
181
182    /// Full markdown contents of the delta `spec.md`.
183    pub markdown: String,
184}
185
186/// Load a delta `spec.md` and infer the spec id from its parent directory.
187pub fn load_delta_spec_file(path: &Path) -> CoreResult<DeltaSpecFile> {
188    let markdown = ito_common::io::read_to_string(path).map_err(|e| {
189        CoreError::io(
190            format!("reading delta spec {}", path.display()),
191            std::io::Error::other(e),
192        )
193    })?;
194    let spec = path
195        .parent()
196        .and_then(|p| p.file_name())
197        .map(|s| s.to_string_lossy().to_string())
198        .unwrap_or_else(|| "unknown".to_string());
199    Ok(DeltaSpecFile { spec, markdown })
200}
201
202fn parse_delta_spec_file(file: &DeltaSpecFile) -> Vec<ChangeDelta> {
203    let mut out: Vec<ChangeDelta> = Vec::new();
204
205    let mut current_op: Option<String> = None;
206    let mut i = 0usize;
207    let normalized = file.markdown.replace('\r', "");
208    let lines: Vec<&str> = normalized.split('\n').collect();
209    while i < lines.len() {
210        let line = lines[i].trim_end();
211        if let Some(op) = parse_delta_op_header(line) {
212            current_op = Some(op);
213            i += 1;
214            continue;
215        }
216
217        if let Some(title) = line.strip_prefix("### Requirement:") {
218            let op = current_op.clone().unwrap_or_else(|| "ADDED".to_string());
219            let (_req_title, requirement, next) = parse_requirement_block(&lines, i);
220            i = next;
221
222            let description = match op.as_str() {
223                "ADDED" => format!("Add requirement: {}", requirement.text),
224                "MODIFIED" => format!("Modify requirement: {}", requirement.text),
225                "REMOVED" => format!("Remove requirement: {}", requirement.text),
226                "RENAMED" => format!("Rename requirement: {}", requirement.text),
227                _ => format!("Add requirement: {}", requirement.text),
228            };
229            out.push(ChangeDelta {
230                spec: file.spec.clone(),
231                operation: op,
232                description,
233                requirement: requirement.clone(),
234                requirements: vec![requirement],
235            });
236            // Title is currently unused but parsed for parity with TS structure.
237            let _ = title;
238            continue;
239        }
240
241        i += 1;
242    }
243
244    out
245}
246
247fn parse_delta_op_header(line: &str) -> Option<String> {
248    // Example: "## ADDED Requirements"
249    let t = line.trim();
250    let rest = t.strip_prefix("## ")?;
251    let rest = rest.trim();
252    let op = rest.strip_suffix(" Requirements").unwrap_or(rest).trim();
253    if matches!(op, "ADDED" | "MODIFIED" | "REMOVED" | "RENAMED") {
254        return Some(op.to_string());
255    }
256    None
257}
258
259fn parse_spec_requirements(markdown: &str) -> Vec<Requirement> {
260    let req_section = extract_section_lines(markdown, "Requirements");
261    parse_requirements_from_lines(&req_section)
262}
263
264fn parse_requirements_from_lines(lines: &[String]) -> Vec<Requirement> {
265    let mut out: Vec<Requirement> = Vec::new();
266    let mut i = 0usize;
267    let raw: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
268    while i < raw.len() {
269        let line = raw[i].trim_end();
270        if line.starts_with("### Requirement:") {
271            let (_title, req, next) = parse_requirement_block(&raw, i);
272            out.push(req);
273            i = next;
274            continue;
275        }
276        i += 1;
277    }
278    out
279}
280
281fn parse_requirement_block(lines: &[&str], start: usize) -> (String, Requirement, usize) {
282    let header = lines[start].trim_end();
283    let title = header
284        .strip_prefix("### Requirement:")
285        .unwrap_or("")
286        .trim()
287        .to_string();
288
289    let mut i = start + 1;
290
291    // Requirement statement: consume non-empty lines until we hit a scenario header or next requirement.
292    let mut statement_lines: Vec<String> = Vec::new();
293    while i < lines.len() {
294        let t = lines[i].trim_end();
295        if t.starts_with("#### Scenario:")
296            || t.starts_with("### Requirement:")
297            || t.starts_with("## ")
298        {
299            break;
300        }
301        if !t.trim().is_empty() {
302            statement_lines.push(t.trim().to_string());
303        }
304        i += 1;
305    }
306    let text = collapse_whitespace(&statement_lines.join(" "));
307
308    // Scenarios
309    let mut scenarios: Vec<Scenario> = Vec::new();
310    while i < lines.len() {
311        let t = lines[i].trim_end();
312        if t.starts_with("### Requirement:") || t.starts_with("## ") {
313            break;
314        }
315        if let Some(_name) = t.strip_prefix("#### Scenario:") {
316            i += 1;
317            let mut raw_lines: Vec<String> = Vec::new();
318            while i < lines.len() {
319                let l = lines[i].trim_end();
320                if l.starts_with("#### Scenario:")
321                    || l.starts_with("### Requirement:")
322                    || l.starts_with("## ")
323                {
324                    break;
325                }
326                raw_lines.push(l.to_string());
327                i += 1;
328            }
329            let raw_text = trim_trailing_blank_lines(&raw_lines).join("\n");
330            scenarios.push(Scenario { raw_text });
331            continue;
332        }
333        i += 1;
334    }
335
336    (title, Requirement { text, scenarios }, i)
337}
338
339fn extract_section_text(markdown: &str, header: &str) -> String {
340    let lines = extract_section_lines(markdown, header);
341    let joined = lines.join(" ");
342    collapse_whitespace(joined.trim())
343}
344
345fn extract_section_lines(markdown: &str, header: &str) -> Vec<String> {
346    let mut in_section = false;
347    let mut out: Vec<String> = Vec::new();
348    let normalized = markdown.replace('\r', "");
349    for raw in normalized.split('\n') {
350        let line = raw.trim_end();
351        if let Some(h) = line.strip_prefix("## ") {
352            let title = h.trim();
353            if title.eq_ignore_ascii_case(header) {
354                in_section = true;
355                continue;
356            }
357            if in_section {
358                break;
359            }
360        }
361        if in_section {
362            out.push(line.to_string());
363        }
364    }
365    out
366}
367
368fn collapse_whitespace(input: &str) -> String {
369    let mut out = String::new();
370    let mut last_was_space = false;
371    for ch in input.chars() {
372        if ch.is_whitespace() {
373            if !last_was_space {
374                out.push(' ');
375                last_was_space = true;
376            }
377        } else {
378            out.push(ch);
379            last_was_space = false;
380        }
381    }
382    out.trim().to_string()
383}
384
385fn trim_trailing_blank_lines(lines: &[String]) -> Vec<String> {
386    let mut start = 0usize;
387    while start < lines.len() {
388        if lines[start].trim().is_empty() {
389            start += 1;
390        } else {
391            break;
392        }
393    }
394
395    let mut end = lines.len();
396    while end > start {
397        if lines[end - 1].trim().is_empty() {
398            end -= 1;
399        } else {
400            break;
401        }
402    }
403
404    lines[start..end].to_vec()
405}