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