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 crate::spec_repository::FsSpecRepository;
12use ito_domain::modules::ModuleRepository;
13use ito_domain::specs::SpecRepository;
14use serde::Serialize;
15
16use ito_domain::changes::ChangeRepository;
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
19/// One raw scenario block from a spec or delta.
20pub struct Scenario {
21    #[serde(rename = "rawText")]
22    /// The original scenario text (preserves newlines).
23    pub raw_text: String,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
27/// A single requirement statement and its scenarios.
28pub struct Requirement {
29    /// The normalized requirement statement.
30    pub text: String,
31
32    /// Optional stable identifier for traceability (e.g. `REQ-001`).
33    #[serde(rename = "requirementId", skip_serializing_if = "Option::is_none")]
34    pub requirement_id: Option<String>,
35
36    /// Scenario blocks associated with the requirement.
37    pub scenarios: Vec<Scenario>,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
41/// JSON-serializable view of a spec markdown file.
42pub struct SpecShowJson {
43    /// Spec id (folder name under `.ito/specs/`).
44    pub id: String,
45    /// Human-readable title (currently same as `id`).
46    pub title: String,
47    /// Extracted `## Purpose` section.
48    pub overview: String,
49    #[serde(rename = "requirementCount")]
50    /// Total number of requirements.
51    pub requirement_count: u32,
52
53    /// Requirements parsed from the markdown.
54    pub requirements: Vec<Requirement>,
55
56    /// Metadata describing the output format.
57    pub metadata: SpecMetadata,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
61/// Additional info included in serialized spec output.
62pub struct SpecMetadata {
63    /// Output schema version.
64    pub version: String,
65
66    /// Output format identifier.
67    pub format: String,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
71/// JSON-serializable view of all main specs bundled together.
72pub struct SpecsBundleJson {
73    #[serde(rename = "specCount")]
74    /// Total number of bundled specs.
75    pub spec_count: u32,
76
77    /// Bundled specs, ordered by ascending spec id.
78    pub specs: Vec<BundledSpec>,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
82/// One bundled spec entry (id + source path + raw markdown).
83pub struct BundledSpec {
84    /// Spec id (folder name under `.ito/specs/`).
85    pub id: String,
86
87    /// Absolute path to the source `.ito/specs/<id>/spec.md` file.
88    pub path: String,
89
90    /// Raw markdown contents of the spec.
91    pub markdown: String,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
95/// JSON-serializable view of a change (proposal + deltas).
96pub struct ChangeShowJson {
97    /// Change id (folder name under `.ito/changes/`).
98    pub id: String,
99    /// Human-readable title (currently same as `id`).
100    pub title: String,
101    #[serde(rename = "deltaCount")]
102    /// Total number of deltas.
103    pub delta_count: u32,
104
105    /// Parsed deltas from delta spec files.
106    pub deltas: Vec<ChangeDelta>,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
110/// One delta entry extracted from a change delta spec.
111pub struct ChangeDelta {
112    /// Spec id the delta belongs to.
113    pub spec: String,
114
115    /// Delta operation (e.g. `ADDED`, `MODIFIED`).
116    pub operation: String,
117
118    /// Human-readable description for display.
119    pub description: String,
120
121    /// Primary requirement extracted for the delta (legacy shape).
122    pub requirement: Requirement,
123
124    /// All requirements extracted for the delta.
125    pub requirements: Vec<Requirement>,
126}
127
128/// Read the markdown for a spec id from `.ito/specs/<id>/spec.md`.
129pub fn read_spec_markdown(ito_path: &Path, id: &str) -> CoreResult<String> {
130    let repo = FsSpecRepository::new(ito_path);
131    read_spec_markdown_from_repository(&repo, id)
132}
133
134/// Read the markdown for a spec id from a repository.
135pub fn read_spec_markdown_from_repository(
136    repo: &(impl SpecRepository + ?Sized),
137    id: &str,
138) -> CoreResult<String> {
139    let spec = repo.get(id).into_core()?;
140    Ok(spec.markdown)
141}
142
143/// Read the proposal markdown for a change id.
144pub fn read_change_proposal_markdown(
145    repo: &(impl ChangeRepository + ?Sized),
146    change_id: &str,
147) -> CoreResult<Option<String>> {
148    let change = repo.get(change_id).into_core()?;
149    Ok(change.proposal)
150}
151
152/// Read the raw markdown for a module's `module.md` file.
153pub fn read_module_markdown(
154    module_repo: &(impl ModuleRepository + ?Sized),
155    module_id: &str,
156) -> CoreResult<String> {
157    let module = module_repo.get(module_id).into_core()?;
158    let module_md_path = module.path.join("module.md");
159    if module_md_path.is_file() {
160        let md = ito_common::io::read_to_string_or_default(&module_md_path);
161        return Ok(md);
162    }
163
164    if module.path.as_os_str().is_empty() {
165        return Ok(render_module_markdown_fallback(&module));
166    }
167
168    Ok(String::new())
169}
170
171fn render_module_markdown_fallback(module: &ito_domain::modules::Module) -> String {
172    let mut out = String::new();
173    out.push_str(&format!("# {}\n", module.name));
174    if let Some(description) = module.description.as_deref()
175        && !description.trim().is_empty()
176    {
177        out.push_str("\n## Purpose\n");
178        out.push_str(description.trim());
179        out.push('\n');
180    }
181    out
182}
183
184/// Parse spec markdown into a serializable structure.
185pub fn parse_spec_show_json(id: &str, markdown: &str) -> SpecShowJson {
186    let overview = extract_section_text(markdown, "Purpose");
187    let requirements = parse_spec_requirements(markdown);
188    SpecShowJson {
189        id: id.to_string(),
190        title: id.to_string(),
191        overview,
192        requirement_count: requirements.len() as u32,
193        requirements,
194        metadata: SpecMetadata {
195            version: "1.0.0".to_string(),
196            format: "ito".to_string(),
197        },
198    }
199}
200
201/// Bundle all main specs under `.ito/specs/*/spec.md` into a JSON-friendly structure.
202pub fn bundle_main_specs_show_json(ito_path: &Path) -> CoreResult<SpecsBundleJson> {
203    use ito_common::fs::StdFs;
204
205    let fs = StdFs;
206    let mut ids = ito_domain::discovery::list_spec_dir_names(&fs, ito_path).into_core()?;
207    ids.sort();
208
209    if ids.is_empty() {
210        return Err(CoreError::not_found(
211            "No specs found under .ito/specs (expected .ito/specs/<id>/spec.md)".to_string(),
212        ));
213    }
214
215    let mut specs = Vec::with_capacity(ids.len());
216    for id in ids {
217        let path = ito_common::paths::spec_markdown_path(ito_path, &id);
218        let markdown = ito_common::io::read_to_string(&path)
219            .map_err(|e| CoreError::io(format!("reading spec {}", id), std::io::Error::other(e)))?;
220        specs.push(BundledSpec {
221            id,
222            path: path.to_string_lossy().to_string(),
223            markdown,
224        });
225    }
226
227    Ok(SpecsBundleJson {
228        spec_count: specs.len() as u32,
229        specs,
230    })
231}
232
233/// Bundle all promoted specs from a repository into a JSON-friendly structure.
234pub fn bundle_specs_show_json_from_repository(
235    repo: &(impl SpecRepository + ?Sized),
236) -> CoreResult<SpecsBundleJson> {
237    let mut summaries = repo.list().into_core()?;
238    summaries.sort_by(|left, right| left.id.cmp(&right.id));
239    if summaries.is_empty() {
240        return Err(CoreError::not_found(
241            "No specs found under .ito/specs (expected .ito/specs/<id>/spec.md)".to_string(),
242        ));
243    }
244
245    let mut specs = Vec::with_capacity(summaries.len());
246    for summary in summaries {
247        let spec = repo.get(&summary.id).into_core()?;
248        specs.push(BundledSpec {
249            id: spec.id,
250            path: spec.path.to_string_lossy().to_string(),
251            markdown: spec.markdown,
252        });
253    }
254
255    Ok(SpecsBundleJson {
256        spec_count: specs.len() as u32,
257        specs,
258    })
259}
260
261/// Bundle all main specs under `.ito/specs/*/spec.md` into a single markdown stream.
262///
263/// Each spec is preceded by a metadata comment line:
264/// `<!-- spec-id: <id>; source: <absolute-path-to-spec.md> -->`.
265pub fn bundle_main_specs_markdown(ito_path: &Path) -> CoreResult<String> {
266    let repo = FsSpecRepository::new(ito_path);
267    bundle_specs_markdown_from_repository(&repo)
268}
269
270/// Bundle all promoted specs from a repository into a single markdown stream.
271pub fn bundle_specs_markdown_from_repository(
272    repo: &(impl SpecRepository + ?Sized),
273) -> CoreResult<String> {
274    let bundle = bundle_specs_show_json_from_repository(repo)?;
275    let mut out = String::new();
276    for (i, spec) in bundle.specs.iter().enumerate() {
277        if i != 0 {
278            out.push_str("\n\n");
279        }
280        out.push_str(&format!(
281            "<!-- spec-id: {}; source: {} -->\n",
282            spec.id, spec.path
283        ));
284        out.push_str(&spec.markdown);
285    }
286    Ok(out)
287}
288
289/// Return all delta spec files for a change from the repository.
290pub fn read_change_delta_spec_files(
291    repo: &(impl ChangeRepository + ?Sized),
292    change_id: &str,
293) -> CoreResult<Vec<DeltaSpecFile>> {
294    let change = repo.get(change_id).into_core()?;
295    let mut out: Vec<DeltaSpecFile> = change
296        .specs
297        .into_iter()
298        .map(|spec| DeltaSpecFile {
299            spec: spec.name,
300            markdown: spec.content,
301        })
302        .collect();
303    out.sort_by(|a, b| a.spec.cmp(&b.spec));
304    Ok(out)
305}
306
307/// Parse a change id plus its delta spec files into a JSON-friendly structure.
308pub fn parse_change_show_json(change_id: &str, delta_specs: &[DeltaSpecFile]) -> ChangeShowJson {
309    let mut deltas: Vec<ChangeDelta> = Vec::new();
310    for file in delta_specs {
311        deltas.extend(parse_delta_spec_file(file));
312    }
313
314    ChangeShowJson {
315        id: change_id.to_string(),
316        title: change_id.to_string(),
317        delta_count: deltas.len() as u32,
318        deltas,
319    }
320}
321
322#[derive(Debug, Clone)]
323/// One loaded delta spec file.
324pub struct DeltaSpecFile {
325    /// Spec id this delta spec belongs to.
326    pub spec: String,
327
328    /// Full markdown contents of the delta `spec.md`.
329    pub markdown: String,
330}
331
332/// Load a delta `spec.md` and infer the spec id from its parent directory.
333pub fn load_delta_spec_file(path: &Path) -> CoreResult<DeltaSpecFile> {
334    let markdown = ito_common::io::read_to_string(path).map_err(|e| {
335        CoreError::io(
336            format!("reading delta spec {}", path.display()),
337            std::io::Error::other(e),
338        )
339    })?;
340    let spec = path
341        .parent()
342        .and_then(|p| p.file_name())
343        .map(|s| s.to_string_lossy().to_string())
344        .unwrap_or_else(|| "unknown".to_string());
345    Ok(DeltaSpecFile { spec, markdown })
346}
347
348fn parse_delta_spec_file(file: &DeltaSpecFile) -> Vec<ChangeDelta> {
349    let mut out: Vec<ChangeDelta> = Vec::new();
350
351    let mut current_op: Option<String> = None;
352    let mut i = 0usize;
353    let normalized = file.markdown.replace('\r', "");
354    let lines: Vec<&str> = normalized.split('\n').collect();
355    while i < lines.len() {
356        let line = lines[i].trim_end();
357        if let Some(op) = parse_delta_op_header(line) {
358            current_op = Some(op);
359            i += 1;
360            continue;
361        }
362
363        if let Some(title) = line.strip_prefix("### Requirement:") {
364            let op = current_op.clone().unwrap_or_else(|| "ADDED".to_string());
365            let (_req_title, requirement, next) = parse_requirement_block(&lines, i);
366            i = next;
367
368            let description = match op.as_str() {
369                "ADDED" => format!("Add requirement: {}", requirement.text),
370                "MODIFIED" => format!("Modify requirement: {}", requirement.text),
371                "REMOVED" => format!("Remove requirement: {}", requirement.text),
372                "RENAMED" => format!("Rename requirement: {}", requirement.text),
373                _ => format!("Add requirement: {}", requirement.text),
374            };
375            out.push(ChangeDelta {
376                spec: file.spec.clone(),
377                operation: op,
378                description,
379                requirement: requirement.clone(),
380                requirements: vec![requirement],
381            });
382            // Title is currently unused but parsed for parity with TS structure.
383            let _ = title;
384            continue;
385        }
386
387        i += 1;
388    }
389
390    out
391}
392
393fn parse_delta_op_header(line: &str) -> Option<String> {
394    // Example: "## ADDED Requirements"
395    let t = line.trim();
396    let rest = t.strip_prefix("## ")?;
397    let rest = rest.trim();
398    let op = rest.strip_suffix(" Requirements").unwrap_or(rest).trim();
399    if op == "ADDED" || op == "MODIFIED" || op == "REMOVED" || op == "RENAMED" {
400        return Some(op.to_string());
401    }
402    None
403}
404
405fn parse_spec_requirements(markdown: &str) -> Vec<Requirement> {
406    let req_section = extract_section_lines(markdown, "Requirements");
407    parse_requirements_from_lines(&req_section)
408}
409
410fn parse_requirements_from_lines(lines: &[String]) -> Vec<Requirement> {
411    let mut out: Vec<Requirement> = Vec::new();
412    let mut i = 0usize;
413    let raw: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
414    while i < raw.len() {
415        let line = raw[i].trim_end();
416        if line.starts_with("### Requirement:") {
417            let (_title, req, next) = parse_requirement_block(&raw, i);
418            out.push(req);
419            i = next;
420            continue;
421        }
422        i += 1;
423    }
424    out
425}
426
427/// Parses a requirement block beginning at `lines[start]` (which must be a `### Requirement:` header).
428///
429/// The function extracts:
430/// - the requirement title from the header,
431/// - the normalized requirement statement text (collapsing internal whitespace),
432/// - an optional Requirement ID from a metadata line of the form `- **Requirement ID**: <id>` (if present),
433/// - any `#### Scenario:` blocks as `Scenario { raw_text }` preserving internal newlines and trimmed of trailing blank lines.
434///   Parsing stops when the next `### Requirement:` or a top-level `## ` section is encountered; the returned index is the first unconsumed line.
435///
436/// # Parameters
437/// - `lines`: slice of input lines (typically from the markdown) to parse from.
438/// - `start`: index of the line that contains the `### Requirement:` header to begin parsing.
439///
440/// # Returns
441/// A tuple `(title, requirement, next_index)` where `title` is the requirement header title, `requirement` is a `Requirement` with `text`, optional `requirement_id`, and collected `scenarios`, and `next_index` is the index of the first line not consumed by this requirement.
442///
443/// # Examples
444///
445/// ```ignore
446/// let md = vec![
447///     "### Requirement: Example".as_ref(),
448///     "- **Requirement ID**: RQ-1".as_ref(),
449///     "This is the requirement statement.".as_ref(),
450///     "#### Scenario: happy path".as_ref(),
451///     "Step 1".as_ref(),
452///     "".as_ref(),
453///     "Step 2".as_ref(),
454///     "### Requirement: Next".as_ref(),
455/// ];
456/// let (title, req, next) = parse_requirement_block(&md, 0);
457/// assert_eq!(title, "Example");
458/// assert_eq!(req.requirement_id.as_deref(), Some("RQ-1"));
459/// assert_eq!(req.text, "This is the requirement statement.");
460/// assert_eq!(req.scenarios.len(), 1);
461/// assert!(next >= 6);
462/// ```
463fn parse_requirement_block(lines: &[&str], start: usize) -> (String, Requirement, usize) {
464    let header = lines[start].trim_end();
465    let title = header
466        .strip_prefix("### Requirement:")
467        .unwrap_or("")
468        .trim()
469        .to_string();
470
471    let mut i = start + 1;
472
473    // Requirement statement: consume non-empty lines until we hit a scenario header or next requirement.
474    let mut statement_lines: Vec<String> = Vec::new();
475    let mut requirement_id: Option<String> = None;
476    while i < lines.len() {
477        let t = lines[i].trim_end();
478        if t.starts_with("#### Scenario:")
479            || t.starts_with("### Requirement:")
480            || t.starts_with("## ")
481        {
482            break;
483        }
484        // Detect `- **Requirement ID**: <id>` metadata line; keep the first, ignore duplicates.
485        if let Some(rest) = t
486            .trim()
487            .strip_prefix("- **Requirement ID**:")
488            .map(str::trim)
489        {
490            if !rest.is_empty() && requirement_id.is_none() {
491                requirement_id = Some(rest.to_string());
492            }
493            i += 1;
494            continue;
495        }
496        if !t.trim().is_empty() {
497            statement_lines.push(t.trim().to_string());
498        }
499        i += 1;
500    }
501    let text = collapse_whitespace(&statement_lines.join(" "));
502
503    // Scenarios
504    let mut scenarios: Vec<Scenario> = Vec::new();
505    while i < lines.len() {
506        let t = lines[i].trim_end();
507        if t.starts_with("### Requirement:") || t.starts_with("## ") {
508            break;
509        }
510        if let Some(_name) = t.strip_prefix("#### Scenario:") {
511            i += 1;
512            let mut raw_lines: Vec<String> = Vec::new();
513            while i < lines.len() {
514                let l = lines[i].trim_end();
515                if l.starts_with("#### Scenario:")
516                    || l.starts_with("### Requirement:")
517                    || l.starts_with("## ")
518                {
519                    break;
520                }
521                raw_lines.push(l.to_string());
522                i += 1;
523            }
524            let raw_text = trim_trailing_blank_lines(&raw_lines).join("\n");
525            scenarios.push(Scenario { raw_text });
526            continue;
527        }
528        i += 1;
529    }
530
531    (
532        title,
533        Requirement {
534            text,
535            requirement_id,
536            scenarios,
537        },
538        i,
539    )
540}
541
542/// Extracts the text of the named top-level section and collapses internal whitespace.
543///
544/// The returned string is the content of the first H2 section whose title matches `header` (case-insensitive),
545/// joined into a single line with runs of whitespace replaced by a single space and trimmed.
546///
547/// # Examples
548///
549/// ```ignore
550/// let md = "# Title\n\n## Purpose\nThis  is   a\npurpose.\n\n## Requirements\n...";
551/// let txt = extract_section_text(md, "Purpose");
552/// assert_eq!(txt, "This is a purpose.");
553/// ```
554fn extract_section_text(markdown: &str, header: &str) -> String {
555    let lines = extract_section_lines(markdown, header);
556    let joined = lines.join(" ");
557    collapse_whitespace(joined.trim())
558}
559
560fn extract_section_lines(markdown: &str, header: &str) -> Vec<String> {
561    let mut in_section = false;
562    let mut out: Vec<String> = Vec::new();
563    let normalized = markdown.replace('\r', "");
564    for raw in normalized.split('\n') {
565        let line = raw.trim_end();
566        if let Some(h) = line.strip_prefix("## ") {
567            let title = h.trim();
568            if title.eq_ignore_ascii_case(header) {
569                in_section = true;
570                continue;
571            }
572            if in_section {
573                break;
574            }
575        }
576        if in_section {
577            out.push(line.to_string());
578        }
579    }
580    out
581}
582
583fn collapse_whitespace(input: &str) -> String {
584    let mut out = String::new();
585    let mut last_was_space = false;
586    for ch in input.chars() {
587        if ch.is_whitespace() {
588            if !last_was_space {
589                out.push(' ');
590                last_was_space = true;
591            }
592        } else {
593            out.push(ch);
594            last_was_space = false;
595        }
596    }
597    out.trim().to_string()
598}
599
600fn trim_trailing_blank_lines(lines: &[String]) -> Vec<String> {
601    let mut start = 0usize;
602    while start < lines.len() {
603        if lines[start].trim().is_empty() {
604            start += 1;
605        } else {
606            break;
607        }
608    }
609
610    let mut end = lines.len();
611    while end > start {
612        if lines[end - 1].trim().is_empty() {
613            end -= 1;
614        } else {
615            break;
616        }
617    }
618
619    lines[start..end].to_vec()
620}