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 all main specs bundled together.
66pub struct SpecsBundleJson {
67    #[serde(rename = "specCount")]
68    /// Total number of bundled specs.
69    pub spec_count: u32,
70
71    /// Bundled specs, ordered by ascending spec id.
72    pub specs: Vec<BundledSpec>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
76/// One bundled spec entry (id + source path + raw markdown).
77pub struct BundledSpec {
78    /// Spec id (folder name under `.ito/specs/`).
79    pub id: String,
80
81    /// Absolute path to the source `.ito/specs/<id>/spec.md` file.
82    pub path: String,
83
84    /// Raw markdown contents of the spec.
85    pub markdown: String,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
89/// JSON-serializable view of a change (proposal + deltas).
90pub struct ChangeShowJson {
91    /// Change id (folder name under `.ito/changes/`).
92    pub id: String,
93    /// Human-readable title (currently same as `id`).
94    pub title: String,
95    #[serde(rename = "deltaCount")]
96    /// Total number of deltas.
97    pub delta_count: u32,
98
99    /// Parsed deltas from delta spec files.
100    pub deltas: Vec<ChangeDelta>,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
104/// One delta entry extracted from a change delta spec.
105pub struct ChangeDelta {
106    /// Spec id the delta belongs to.
107    pub spec: String,
108
109    /// Delta operation (e.g. `ADDED`, `MODIFIED`).
110    pub operation: String,
111
112    /// Human-readable description for display.
113    pub description: String,
114
115    /// Primary requirement extracted for the delta (legacy shape).
116    pub requirement: Requirement,
117
118    /// All requirements extracted for the delta.
119    pub requirements: Vec<Requirement>,
120}
121
122/// Read the markdown for a spec id from `.ito/specs/<id>/spec.md`.
123pub fn read_spec_markdown(ito_path: &Path, id: &str) -> CoreResult<String> {
124    let path = paths::spec_markdown_path(ito_path, id);
125    ito_common::io::read_to_string(&path)
126        .map_err(|e| CoreError::io(format!("reading spec {}", id), std::io::Error::other(e)))
127}
128
129/// Read the proposal markdown for a change id.
130pub fn read_change_proposal_markdown(
131    repo: &impl ChangeRepository,
132    change_id: &str,
133) -> CoreResult<Option<String>> {
134    let change = repo.get(change_id).into_core()?;
135    Ok(change.proposal)
136}
137
138/// Read the raw markdown for a module's `module.md` file.
139pub fn read_module_markdown(ito_path: &Path, module_id: &str) -> CoreResult<String> {
140    use crate::error_bridge::IntoCoreResult;
141    use crate::module_repository::FsModuleRepository;
142
143    let module_repo = FsModuleRepository::new(ito_path);
144    let module = module_repo.get(module_id).into_core()?;
145    let module_md_path = module.path.join("module.md");
146    let md = ito_common::io::read_to_string_or_default(&module_md_path);
147    Ok(md)
148}
149
150/// Parse spec markdown into a serializable structure.
151pub fn parse_spec_show_json(id: &str, markdown: &str) -> SpecShowJson {
152    let overview = extract_section_text(markdown, "Purpose");
153    let requirements = parse_spec_requirements(markdown);
154    SpecShowJson {
155        id: id.to_string(),
156        title: id.to_string(),
157        overview,
158        requirement_count: requirements.len() as u32,
159        requirements,
160        metadata: SpecMetadata {
161            version: "1.0.0".to_string(),
162            format: "ito".to_string(),
163        },
164    }
165}
166
167/// Bundle all main specs under `.ito/specs/*/spec.md` into a JSON-friendly structure.
168pub fn bundle_main_specs_show_json(ito_path: &Path) -> CoreResult<SpecsBundleJson> {
169    use crate::error_bridge::IntoCoreResult;
170    use ito_common::fs::StdFs;
171
172    let fs = StdFs;
173    let mut ids = ito_domain::discovery::list_spec_dir_names(&fs, ito_path).into_core()?;
174    ids.sort();
175
176    if ids.is_empty() {
177        return Err(CoreError::not_found(
178            "No specs found under .ito/specs (expected .ito/specs/<id>/spec.md)".to_string(),
179        ));
180    }
181    let mut specs: Vec<BundledSpec> = Vec::new();
182    for id in ids {
183        let path = paths::spec_markdown_path(ito_path, &id);
184        let markdown = ito_common::io::read_to_string(&path)
185            .map_err(|e| CoreError::io(format!("reading spec {}", id), std::io::Error::other(e)))?;
186        specs.push(BundledSpec {
187            id,
188            path: path.to_string_lossy().to_string(),
189            markdown,
190        });
191    }
192
193    Ok(SpecsBundleJson {
194        spec_count: specs.len() as u32,
195        specs,
196    })
197}
198
199/// Bundle all main specs under `.ito/specs/*/spec.md` into a single markdown stream.
200///
201/// Each spec is preceded by a metadata comment line:
202/// `<!-- spec-id: <id>; source: <absolute-path-to-spec.md> -->`.
203pub fn bundle_main_specs_markdown(ito_path: &Path) -> CoreResult<String> {
204    let bundle = bundle_main_specs_show_json(ito_path)?;
205    let mut out = String::new();
206    for (i, spec) in bundle.specs.iter().enumerate() {
207        if i != 0 {
208            out.push_str("\n\n");
209        }
210        out.push_str(&format!(
211            "<!-- spec-id: {}; source: {} -->\n",
212            spec.id, spec.path
213        ));
214        out.push_str(&spec.markdown);
215    }
216    Ok(out)
217}
218
219/// Return all delta spec files for a change from the repository.
220pub fn read_change_delta_spec_files(
221    repo: &impl ChangeRepository,
222    change_id: &str,
223) -> CoreResult<Vec<DeltaSpecFile>> {
224    let change = repo.get(change_id).into_core()?;
225    let mut out: Vec<DeltaSpecFile> = change
226        .specs
227        .into_iter()
228        .map(|spec| DeltaSpecFile {
229            spec: spec.name,
230            markdown: spec.content,
231        })
232        .collect();
233    out.sort_by(|a, b| a.spec.cmp(&b.spec));
234    Ok(out)
235}
236
237/// Parse a change id plus its delta spec files into a JSON-friendly structure.
238pub fn parse_change_show_json(change_id: &str, delta_specs: &[DeltaSpecFile]) -> ChangeShowJson {
239    let mut deltas: Vec<ChangeDelta> = Vec::new();
240    for file in delta_specs {
241        deltas.extend(parse_delta_spec_file(file));
242    }
243
244    ChangeShowJson {
245        id: change_id.to_string(),
246        title: change_id.to_string(),
247        delta_count: deltas.len() as u32,
248        deltas,
249    }
250}
251
252#[derive(Debug, Clone)]
253/// One loaded delta spec file.
254pub struct DeltaSpecFile {
255    /// Spec id this delta spec belongs to.
256    pub spec: String,
257
258    /// Full markdown contents of the delta `spec.md`.
259    pub markdown: String,
260}
261
262/// Load a delta `spec.md` and infer the spec id from its parent directory.
263pub fn load_delta_spec_file(path: &Path) -> CoreResult<DeltaSpecFile> {
264    let markdown = ito_common::io::read_to_string(path).map_err(|e| {
265        CoreError::io(
266            format!("reading delta spec {}", path.display()),
267            std::io::Error::other(e),
268        )
269    })?;
270    let spec = path
271        .parent()
272        .and_then(|p| p.file_name())
273        .map(|s| s.to_string_lossy().to_string())
274        .unwrap_or_else(|| "unknown".to_string());
275    Ok(DeltaSpecFile { spec, markdown })
276}
277
278fn parse_delta_spec_file(file: &DeltaSpecFile) -> Vec<ChangeDelta> {
279    let mut out: Vec<ChangeDelta> = Vec::new();
280
281    let mut current_op: Option<String> = None;
282    let mut i = 0usize;
283    let normalized = file.markdown.replace('\r', "");
284    let lines: Vec<&str> = normalized.split('\n').collect();
285    while i < lines.len() {
286        let line = lines[i].trim_end();
287        if let Some(op) = parse_delta_op_header(line) {
288            current_op = Some(op);
289            i += 1;
290            continue;
291        }
292
293        if let Some(title) = line.strip_prefix("### Requirement:") {
294            let op = current_op.clone().unwrap_or_else(|| "ADDED".to_string());
295            let (_req_title, requirement, next) = parse_requirement_block(&lines, i);
296            i = next;
297
298            let description = match op.as_str() {
299                "ADDED" => format!("Add requirement: {}", requirement.text),
300                "MODIFIED" => format!("Modify requirement: {}", requirement.text),
301                "REMOVED" => format!("Remove requirement: {}", requirement.text),
302                "RENAMED" => format!("Rename requirement: {}", requirement.text),
303                _ => format!("Add requirement: {}", requirement.text),
304            };
305            out.push(ChangeDelta {
306                spec: file.spec.clone(),
307                operation: op,
308                description,
309                requirement: requirement.clone(),
310                requirements: vec![requirement],
311            });
312            // Title is currently unused but parsed for parity with TS structure.
313            let _ = title;
314            continue;
315        }
316
317        i += 1;
318    }
319
320    out
321}
322
323fn parse_delta_op_header(line: &str) -> Option<String> {
324    // Example: "## ADDED Requirements"
325    let t = line.trim();
326    let rest = t.strip_prefix("## ")?;
327    let rest = rest.trim();
328    let op = rest.strip_suffix(" Requirements").unwrap_or(rest).trim();
329    if op == "ADDED" || op == "MODIFIED" || op == "REMOVED" || op == "RENAMED" {
330        return Some(op.to_string());
331    }
332    None
333}
334
335fn parse_spec_requirements(markdown: &str) -> Vec<Requirement> {
336    let req_section = extract_section_lines(markdown, "Requirements");
337    parse_requirements_from_lines(&req_section)
338}
339
340fn parse_requirements_from_lines(lines: &[String]) -> Vec<Requirement> {
341    let mut out: Vec<Requirement> = Vec::new();
342    let mut i = 0usize;
343    let raw: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
344    while i < raw.len() {
345        let line = raw[i].trim_end();
346        if line.starts_with("### Requirement:") {
347            let (_title, req, next) = parse_requirement_block(&raw, i);
348            out.push(req);
349            i = next;
350            continue;
351        }
352        i += 1;
353    }
354    out
355}
356
357fn parse_requirement_block(lines: &[&str], start: usize) -> (String, Requirement, usize) {
358    let header = lines[start].trim_end();
359    let title = header
360        .strip_prefix("### Requirement:")
361        .unwrap_or("")
362        .trim()
363        .to_string();
364
365    let mut i = start + 1;
366
367    // Requirement statement: consume non-empty lines until we hit a scenario header or next requirement.
368    let mut statement_lines: Vec<String> = Vec::new();
369    while i < lines.len() {
370        let t = lines[i].trim_end();
371        if t.starts_with("#### Scenario:")
372            || t.starts_with("### Requirement:")
373            || t.starts_with("## ")
374        {
375            break;
376        }
377        if !t.trim().is_empty() {
378            statement_lines.push(t.trim().to_string());
379        }
380        i += 1;
381    }
382    let text = collapse_whitespace(&statement_lines.join(" "));
383
384    // Scenarios
385    let mut scenarios: Vec<Scenario> = Vec::new();
386    while i < lines.len() {
387        let t = lines[i].trim_end();
388        if t.starts_with("### Requirement:") || t.starts_with("## ") {
389            break;
390        }
391        if let Some(_name) = t.strip_prefix("#### Scenario:") {
392            i += 1;
393            let mut raw_lines: Vec<String> = Vec::new();
394            while i < lines.len() {
395                let l = lines[i].trim_end();
396                if l.starts_with("#### Scenario:")
397                    || l.starts_with("### Requirement:")
398                    || l.starts_with("## ")
399                {
400                    break;
401                }
402                raw_lines.push(l.to_string());
403                i += 1;
404            }
405            let raw_text = trim_trailing_blank_lines(&raw_lines).join("\n");
406            scenarios.push(Scenario { raw_text });
407            continue;
408        }
409        i += 1;
410    }
411
412    (title, Requirement { text, scenarios }, i)
413}
414
415fn extract_section_text(markdown: &str, header: &str) -> String {
416    let lines = extract_section_lines(markdown, header);
417    let joined = lines.join(" ");
418    collapse_whitespace(joined.trim())
419}
420
421fn extract_section_lines(markdown: &str, header: &str) -> Vec<String> {
422    let mut in_section = false;
423    let mut out: Vec<String> = Vec::new();
424    let normalized = markdown.replace('\r', "");
425    for raw in normalized.split('\n') {
426        let line = raw.trim_end();
427        if let Some(h) = line.strip_prefix("## ") {
428            let title = h.trim();
429            if title.eq_ignore_ascii_case(header) {
430                in_section = true;
431                continue;
432            }
433            if in_section {
434                break;
435            }
436        }
437        if in_section {
438            out.push(line.to_string());
439        }
440    }
441    out
442}
443
444fn collapse_whitespace(input: &str) -> String {
445    let mut out = String::new();
446    let mut last_was_space = false;
447    for ch in input.chars() {
448        if ch.is_whitespace() {
449            if !last_was_space {
450                out.push(' ');
451                last_was_space = true;
452            }
453        } else {
454            out.push(ch);
455            last_was_space = false;
456        }
457    }
458    out.trim().to_string()
459}
460
461fn trim_trailing_blank_lines(lines: &[String]) -> Vec<String> {
462    let mut start = 0usize;
463    while start < lines.len() {
464        if lines[start].trim().is_empty() {
465            start += 1;
466        } else {
467            break;
468        }
469    }
470
471    let mut end = lines.len();
472    while end > start {
473        if lines[end - 1].trim().is_empty() {
474            end -= 1;
475        } else {
476            break;
477        }
478    }
479
480    lines[start..end].to_vec()
481}