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
182    let mut specs: Vec<BundledSpec> = Vec::new();
183    for id in ids {
184        let path = paths::spec_markdown_path(ito_path, &id);
185        let markdown = ito_common::io::read_to_string(&path)
186            .map_err(|e| CoreError::io(format!("reading spec {}", id), std::io::Error::other(e)))?;
187        specs.push(BundledSpec {
188            id,
189            path: path.to_string_lossy().to_string(),
190            markdown,
191        });
192    }
193
194    Ok(SpecsBundleJson {
195        spec_count: specs.len() as u32,
196        specs,
197    })
198}
199
200/// Bundle all main specs under `.ito/specs/*/spec.md` into a single markdown stream.
201///
202/// Each spec is preceded by a metadata comment line:
203/// `<!-- spec-id: <id>; source: <absolute-path-to-spec.md> -->`.
204pub fn bundle_main_specs_markdown(ito_path: &Path) -> CoreResult<String> {
205    let bundle = bundle_main_specs_show_json(ito_path)?;
206    let mut out = String::new();
207    for (i, spec) in bundle.specs.iter().enumerate() {
208        if i != 0 {
209            out.push_str("\n\n");
210        }
211        out.push_str(&format!(
212            "<!-- spec-id: {}; source: {} -->\n",
213            spec.id, spec.path
214        ));
215        out.push_str(&spec.markdown);
216    }
217    Ok(out)
218}
219
220/// Return all delta spec files for a change from the repository.
221pub fn read_change_delta_spec_files(
222    repo: &impl ChangeRepository,
223    change_id: &str,
224) -> CoreResult<Vec<DeltaSpecFile>> {
225    let change = repo.get(change_id).into_core()?;
226    let mut out: Vec<DeltaSpecFile> = change
227        .specs
228        .into_iter()
229        .map(|spec| DeltaSpecFile {
230            spec: spec.name,
231            markdown: spec.content,
232        })
233        .collect();
234    out.sort_by(|a, b| a.spec.cmp(&b.spec));
235    Ok(out)
236}
237
238/// Parse a change id plus its delta spec files into a JSON-friendly structure.
239pub fn parse_change_show_json(change_id: &str, delta_specs: &[DeltaSpecFile]) -> ChangeShowJson {
240    let mut deltas: Vec<ChangeDelta> = Vec::new();
241    for file in delta_specs {
242        deltas.extend(parse_delta_spec_file(file));
243    }
244
245    ChangeShowJson {
246        id: change_id.to_string(),
247        title: change_id.to_string(),
248        delta_count: deltas.len() as u32,
249        deltas,
250    }
251}
252
253#[derive(Debug, Clone)]
254/// One loaded delta spec file.
255pub struct DeltaSpecFile {
256    /// Spec id this delta spec belongs to.
257    pub spec: String,
258
259    /// Full markdown contents of the delta `spec.md`.
260    pub markdown: String,
261}
262
263/// Load a delta `spec.md` and infer the spec id from its parent directory.
264pub fn load_delta_spec_file(path: &Path) -> CoreResult<DeltaSpecFile> {
265    let markdown = ito_common::io::read_to_string(path).map_err(|e| {
266        CoreError::io(
267            format!("reading delta spec {}", path.display()),
268            std::io::Error::other(e),
269        )
270    })?;
271    let spec = path
272        .parent()
273        .and_then(|p| p.file_name())
274        .map(|s| s.to_string_lossy().to_string())
275        .unwrap_or_else(|| "unknown".to_string());
276    Ok(DeltaSpecFile { spec, markdown })
277}
278
279fn parse_delta_spec_file(file: &DeltaSpecFile) -> Vec<ChangeDelta> {
280    let mut out: Vec<ChangeDelta> = Vec::new();
281
282    let mut current_op: Option<String> = None;
283    let mut i = 0usize;
284    let normalized = file.markdown.replace('\r', "");
285    let lines: Vec<&str> = normalized.split('\n').collect();
286    while i < lines.len() {
287        let line = lines[i].trim_end();
288        if let Some(op) = parse_delta_op_header(line) {
289            current_op = Some(op);
290            i += 1;
291            continue;
292        }
293
294        if let Some(title) = line.strip_prefix("### Requirement:") {
295            let op = current_op.clone().unwrap_or_else(|| "ADDED".to_string());
296            let (_req_title, requirement, next) = parse_requirement_block(&lines, i);
297            i = next;
298
299            let description = match op.as_str() {
300                "ADDED" => format!("Add requirement: {}", requirement.text),
301                "MODIFIED" => format!("Modify requirement: {}", requirement.text),
302                "REMOVED" => format!("Remove requirement: {}", requirement.text),
303                "RENAMED" => format!("Rename requirement: {}", requirement.text),
304                _ => format!("Add requirement: {}", requirement.text),
305            };
306            out.push(ChangeDelta {
307                spec: file.spec.clone(),
308                operation: op,
309                description,
310                requirement: requirement.clone(),
311                requirements: vec![requirement],
312            });
313            // Title is currently unused but parsed for parity with TS structure.
314            let _ = title;
315            continue;
316        }
317
318        i += 1;
319    }
320
321    out
322}
323
324fn parse_delta_op_header(line: &str) -> Option<String> {
325    // Example: "## ADDED Requirements"
326    let t = line.trim();
327    let rest = t.strip_prefix("## ")?;
328    let rest = rest.trim();
329    let op = rest.strip_suffix(" Requirements").unwrap_or(rest).trim();
330    if op == "ADDED" || op == "MODIFIED" || op == "REMOVED" || op == "RENAMED" {
331        return Some(op.to_string());
332    }
333    None
334}
335
336fn parse_spec_requirements(markdown: &str) -> Vec<Requirement> {
337    let req_section = extract_section_lines(markdown, "Requirements");
338    parse_requirements_from_lines(&req_section)
339}
340
341fn parse_requirements_from_lines(lines: &[String]) -> Vec<Requirement> {
342    let mut out: Vec<Requirement> = Vec::new();
343    let mut i = 0usize;
344    let raw: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
345    while i < raw.len() {
346        let line = raw[i].trim_end();
347        if line.starts_with("### Requirement:") {
348            let (_title, req, next) = parse_requirement_block(&raw, i);
349            out.push(req);
350            i = next;
351            continue;
352        }
353        i += 1;
354    }
355    out
356}
357
358fn parse_requirement_block(lines: &[&str], start: usize) -> (String, Requirement, usize) {
359    let header = lines[start].trim_end();
360    let title = header
361        .strip_prefix("### Requirement:")
362        .unwrap_or("")
363        .trim()
364        .to_string();
365
366    let mut i = start + 1;
367
368    // Requirement statement: consume non-empty lines until we hit a scenario header or next requirement.
369    let mut statement_lines: Vec<String> = Vec::new();
370    while i < lines.len() {
371        let t = lines[i].trim_end();
372        if t.starts_with("#### Scenario:")
373            || t.starts_with("### Requirement:")
374            || t.starts_with("## ")
375        {
376            break;
377        }
378        if !t.trim().is_empty() {
379            statement_lines.push(t.trim().to_string());
380        }
381        i += 1;
382    }
383    let text = collapse_whitespace(&statement_lines.join(" "));
384
385    // Scenarios
386    let mut scenarios: Vec<Scenario> = Vec::new();
387    while i < lines.len() {
388        let t = lines[i].trim_end();
389        if t.starts_with("### Requirement:") || t.starts_with("## ") {
390            break;
391        }
392        if let Some(_name) = t.strip_prefix("#### Scenario:") {
393            i += 1;
394            let mut raw_lines: Vec<String> = Vec::new();
395            while i < lines.len() {
396                let l = lines[i].trim_end();
397                if l.starts_with("#### Scenario:")
398                    || l.starts_with("### Requirement:")
399                    || l.starts_with("## ")
400                {
401                    break;
402                }
403                raw_lines.push(l.to_string());
404                i += 1;
405            }
406            let raw_text = trim_trailing_blank_lines(&raw_lines).join("\n");
407            scenarios.push(Scenario { raw_text });
408            continue;
409        }
410        i += 1;
411    }
412
413    (title, Requirement { text, scenarios }, i)
414}
415
416fn extract_section_text(markdown: &str, header: &str) -> String {
417    let lines = extract_section_lines(markdown, header);
418    let joined = lines.join(" ");
419    collapse_whitespace(joined.trim())
420}
421
422fn extract_section_lines(markdown: &str, header: &str) -> Vec<String> {
423    let mut in_section = false;
424    let mut out: Vec<String> = Vec::new();
425    let normalized = markdown.replace('\r', "");
426    for raw in normalized.split('\n') {
427        let line = raw.trim_end();
428        if let Some(h) = line.strip_prefix("## ") {
429            let title = h.trim();
430            if title.eq_ignore_ascii_case(header) {
431                in_section = true;
432                continue;
433            }
434            if in_section {
435                break;
436            }
437        }
438        if in_section {
439            out.push(line.to_string());
440        }
441    }
442    out
443}
444
445fn collapse_whitespace(input: &str) -> String {
446    let mut out = String::new();
447    let mut last_was_space = false;
448    for ch in input.chars() {
449        if ch.is_whitespace() {
450            if !last_was_space {
451                out.push(' ');
452                last_was_space = true;
453            }
454        } else {
455            out.push(ch);
456            last_was_space = false;
457        }
458    }
459    out.trim().to_string()
460}
461
462fn trim_trailing_blank_lines(lines: &[String]) -> Vec<String> {
463    let mut start = 0usize;
464    while start < lines.len() {
465        if lines[start].trim().is_empty() {
466            start += 1;
467        } else {
468            break;
469        }
470    }
471
472    let mut end = lines.len();
473    while end > start {
474        if lines[end - 1].trim().is_empty() {
475            end -= 1;
476        } else {
477            break;
478        }
479    }
480
481    lines[start..end].to_vec()
482}