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 SpecsBundleJson {
67 #[serde(rename = "specCount")]
68 pub spec_count: u32,
70
71 pub specs: Vec<BundledSpec>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
76pub struct BundledSpec {
78 pub id: String,
80
81 pub path: String,
83
84 pub markdown: String,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
89pub struct ChangeShowJson {
91 pub id: String,
93 pub title: String,
95 #[serde(rename = "deltaCount")]
96 pub delta_count: u32,
98
99 pub deltas: Vec<ChangeDelta>,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
104pub struct ChangeDelta {
106 pub spec: String,
108
109 pub operation: String,
111
112 pub description: String,
114
115 pub requirement: Requirement,
117
118 pub requirements: Vec<Requirement>,
120}
121
122pub 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
129pub 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
138pub 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
150pub 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
167pub 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
200pub 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
220pub 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
238pub 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)]
254pub struct DeltaSpecFile {
256 pub spec: String,
258
259 pub markdown: String,
261}
262
263pub 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 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 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 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 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}