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