1use 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)]
17pub struct Scenario {
19 #[serde(rename = "rawText")]
20 pub raw_text: String,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
25pub struct Requirement {
27 pub text: String,
29
30 pub scenarios: Vec<Scenario>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
35pub struct SpecShowJson {
37 pub id: String,
39 pub title: String,
41 pub overview: String,
43 #[serde(rename = "requirementCount")]
44 pub requirement_count: u32,
46
47 pub requirements: Vec<Requirement>,
49
50 pub metadata: SpecMetadata,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
55pub struct SpecMetadata {
57 pub version: String,
59
60 pub format: String,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
65pub struct ChangeShowJson {
67 pub id: String,
69 pub title: String,
71 #[serde(rename = "deltaCount")]
72 pub delta_count: u32,
74
75 pub deltas: Vec<ChangeDelta>,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
80pub struct ChangeDelta {
82 pub spec: String,
84
85 pub operation: String,
87
88 pub description: String,
90
91 pub requirement: Requirement,
93
94 pub requirements: Vec<Requirement>,
96}
97
98pub fn read_spec_markdown(ito_path: &Path, id: &str) -> CoreResult<String> {
100 let path = paths::spec_markdown_path(ito_path, id);
101 ito_common::io::read_to_string(&path)
102 .map_err(|e| CoreError::io(format!("reading spec {}", id), std::io::Error::other(e)))
103}
104
105pub fn read_change_proposal_markdown(
107 repo: &impl ChangeRepository,
108 change_id: &str,
109) -> CoreResult<Option<String>> {
110 let change = repo.get(change_id).into_core()?;
111 Ok(change.proposal)
112}
113
114pub fn read_module_markdown(ito_path: &Path, module_id: &str) -> CoreResult<String> {
116 use crate::error_bridge::IntoCoreResult;
117 use crate::module_repository::FsModuleRepository;
118
119 let module_repo = FsModuleRepository::new(ito_path);
120 let module = module_repo.get(module_id).into_core()?;
121 let module_md_path = module.path.join("module.md");
122 let md = ito_common::io::read_to_string_or_default(&module_md_path);
123 Ok(md)
124}
125
126pub fn parse_spec_show_json(id: &str, markdown: &str) -> SpecShowJson {
128 let overview = extract_section_text(markdown, "Purpose");
129 let requirements = parse_spec_requirements(markdown);
130 SpecShowJson {
131 id: id.to_string(),
132 title: id.to_string(),
133 overview,
134 requirement_count: requirements.len() as u32,
135 requirements,
136 metadata: SpecMetadata {
137 version: "1.0.0".to_string(),
138 format: "ito".to_string(),
139 },
140 }
141}
142
143pub fn read_change_delta_spec_files(
145 repo: &impl ChangeRepository,
146 change_id: &str,
147) -> CoreResult<Vec<DeltaSpecFile>> {
148 let change = repo.get(change_id).into_core()?;
149 let mut out: Vec<DeltaSpecFile> = change
150 .specs
151 .into_iter()
152 .map(|spec| DeltaSpecFile {
153 spec: spec.name,
154 markdown: spec.content,
155 })
156 .collect();
157 out.sort_by(|a, b| a.spec.cmp(&b.spec));
158 Ok(out)
159}
160
161pub fn parse_change_show_json(change_id: &str, delta_specs: &[DeltaSpecFile]) -> ChangeShowJson {
163 let mut deltas: Vec<ChangeDelta> = Vec::new();
164 for file in delta_specs {
165 deltas.extend(parse_delta_spec_file(file));
166 }
167
168 ChangeShowJson {
169 id: change_id.to_string(),
170 title: change_id.to_string(),
171 delta_count: deltas.len() as u32,
172 deltas,
173 }
174}
175
176#[derive(Debug, Clone)]
177pub struct DeltaSpecFile {
179 pub spec: String,
181
182 pub markdown: String,
184}
185
186pub fn load_delta_spec_file(path: &Path) -> CoreResult<DeltaSpecFile> {
188 let markdown = ito_common::io::read_to_string(path).map_err(|e| {
189 CoreError::io(
190 format!("reading delta spec {}", path.display()),
191 std::io::Error::other(e),
192 )
193 })?;
194 let spec = path
195 .parent()
196 .and_then(|p| p.file_name())
197 .map(|s| s.to_string_lossy().to_string())
198 .unwrap_or_else(|| "unknown".to_string());
199 Ok(DeltaSpecFile { spec, markdown })
200}
201
202fn parse_delta_spec_file(file: &DeltaSpecFile) -> Vec<ChangeDelta> {
203 let mut out: Vec<ChangeDelta> = Vec::new();
204
205 let mut current_op: Option<String> = None;
206 let mut i = 0usize;
207 let normalized = file.markdown.replace('\r', "");
208 let lines: Vec<&str> = normalized.split('\n').collect();
209 while i < lines.len() {
210 let line = lines[i].trim_end();
211 if let Some(op) = parse_delta_op_header(line) {
212 current_op = Some(op);
213 i += 1;
214 continue;
215 }
216
217 if let Some(title) = line.strip_prefix("### Requirement:") {
218 let op = current_op.clone().unwrap_or_else(|| "ADDED".to_string());
219 let (_req_title, requirement, next) = parse_requirement_block(&lines, i);
220 i = next;
221
222 let description = match op.as_str() {
223 "ADDED" => format!("Add requirement: {}", requirement.text),
224 "MODIFIED" => format!("Modify requirement: {}", requirement.text),
225 "REMOVED" => format!("Remove requirement: {}", requirement.text),
226 "RENAMED" => format!("Rename requirement: {}", requirement.text),
227 _ => format!("Add requirement: {}", requirement.text),
228 };
229 out.push(ChangeDelta {
230 spec: file.spec.clone(),
231 operation: op,
232 description,
233 requirement: requirement.clone(),
234 requirements: vec![requirement],
235 });
236 let _ = title;
238 continue;
239 }
240
241 i += 1;
242 }
243
244 out
245}
246
247fn parse_delta_op_header(line: &str) -> Option<String> {
248 let t = line.trim();
250 let rest = t.strip_prefix("## ")?;
251 let rest = rest.trim();
252 let op = rest.strip_suffix(" Requirements").unwrap_or(rest).trim();
253 if op == "ADDED" || op == "MODIFIED" || op == "REMOVED" || op == "RENAMED" {
254 return Some(op.to_string());
255 }
256 None
257}
258
259fn parse_spec_requirements(markdown: &str) -> Vec<Requirement> {
260 let req_section = extract_section_lines(markdown, "Requirements");
261 parse_requirements_from_lines(&req_section)
262}
263
264fn parse_requirements_from_lines(lines: &[String]) -> Vec<Requirement> {
265 let mut out: Vec<Requirement> = Vec::new();
266 let mut i = 0usize;
267 let raw: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
268 while i < raw.len() {
269 let line = raw[i].trim_end();
270 if line.starts_with("### Requirement:") {
271 let (_title, req, next) = parse_requirement_block(&raw, i);
272 out.push(req);
273 i = next;
274 continue;
275 }
276 i += 1;
277 }
278 out
279}
280
281fn parse_requirement_block(lines: &[&str], start: usize) -> (String, Requirement, usize) {
282 let header = lines[start].trim_end();
283 let title = header
284 .strip_prefix("### Requirement:")
285 .unwrap_or("")
286 .trim()
287 .to_string();
288
289 let mut i = start + 1;
290
291 let mut statement_lines: Vec<String> = Vec::new();
293 while i < lines.len() {
294 let t = lines[i].trim_end();
295 if t.starts_with("#### Scenario:")
296 || t.starts_with("### Requirement:")
297 || t.starts_with("## ")
298 {
299 break;
300 }
301 if !t.trim().is_empty() {
302 statement_lines.push(t.trim().to_string());
303 }
304 i += 1;
305 }
306 let text = collapse_whitespace(&statement_lines.join(" "));
307
308 let mut scenarios: Vec<Scenario> = Vec::new();
310 while i < lines.len() {
311 let t = lines[i].trim_end();
312 if t.starts_with("### Requirement:") || t.starts_with("## ") {
313 break;
314 }
315 if let Some(_name) = t.strip_prefix("#### Scenario:") {
316 i += 1;
317 let mut raw_lines: Vec<String> = Vec::new();
318 while i < lines.len() {
319 let l = lines[i].trim_end();
320 if l.starts_with("#### Scenario:")
321 || l.starts_with("### Requirement:")
322 || l.starts_with("## ")
323 {
324 break;
325 }
326 raw_lines.push(l.to_string());
327 i += 1;
328 }
329 let raw_text = trim_trailing_blank_lines(&raw_lines).join("\n");
330 scenarios.push(Scenario { raw_text });
331 continue;
332 }
333 i += 1;
334 }
335
336 (title, Requirement { text, scenarios }, i)
337}
338
339fn extract_section_text(markdown: &str, header: &str) -> String {
340 let lines = extract_section_lines(markdown, header);
341 let joined = lines.join(" ");
342 collapse_whitespace(joined.trim())
343}
344
345fn extract_section_lines(markdown: &str, header: &str) -> Vec<String> {
346 let mut in_section = false;
347 let mut out: Vec<String> = Vec::new();
348 let normalized = markdown.replace('\r', "");
349 for raw in normalized.split('\n') {
350 let line = raw.trim_end();
351 if let Some(h) = line.strip_prefix("## ") {
352 let title = h.trim();
353 if title.eq_ignore_ascii_case(header) {
354 in_section = true;
355 continue;
356 }
357 if in_section {
358 break;
359 }
360 }
361 if in_section {
362 out.push(line.to_string());
363 }
364 }
365 out
366}
367
368fn collapse_whitespace(input: &str) -> String {
369 let mut out = String::new();
370 let mut last_was_space = false;
371 for ch in input.chars() {
372 if ch.is_whitespace() {
373 if !last_was_space {
374 out.push(' ');
375 last_was_space = true;
376 }
377 } else {
378 out.push(ch);
379 last_was_space = false;
380 }
381 }
382 out.trim().to_string()
383}
384
385fn trim_trailing_blank_lines(lines: &[String]) -> Vec<String> {
386 let mut start = 0usize;
387 while start < lines.len() {
388 if lines[start].trim().is_empty() {
389 start += 1;
390 } else {
391 break;
392 }
393 }
394
395 let mut end = lines.len();
396 while end > start {
397 if lines[end - 1].trim().is_empty() {
398 end -= 1;
399 } else {
400 break;
401 }
402 }
403
404 lines[start..end].to_vec()
405}