1use 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)]
19pub struct Scenario {
21 #[serde(rename = "rawText")]
22 pub raw_text: String,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
27pub struct ContractRef {
29 pub raw: String,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub scheme: Option<String>,
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub identifier: Option<String>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
40pub struct Requirement {
42 pub text: String,
44
45 #[serde(rename = "requirementId", skip_serializing_if = "Option::is_none")]
47 pub requirement_id: Option<String>,
48
49 #[serde(skip_serializing_if = "Vec::is_empty")]
51 pub tags: Vec<String>,
52
53 #[serde(rename = "contractRefs", skip_serializing_if = "Vec::is_empty")]
55 pub contract_refs: Vec<ContractRef>,
56
57 pub scenarios: Vec<Scenario>,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
62pub struct SpecShowJson {
64 pub id: String,
66 pub title: String,
68 pub overview: String,
70 #[serde(rename = "requirementCount")]
71 pub requirement_count: u32,
73
74 pub requirements: Vec<Requirement>,
76
77 pub metadata: SpecMetadata,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
82pub struct SpecMetadata {
84 pub version: String,
86
87 pub format: String,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
92pub struct SpecsBundleJson {
94 #[serde(rename = "specCount")]
95 pub spec_count: u32,
97
98 pub specs: Vec<BundledSpec>,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
103pub struct BundledSpec {
105 pub id: String,
107
108 pub path: String,
110
111 pub markdown: String,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
116pub struct ChangeShowJson {
118 pub id: String,
120 pub title: String,
122 #[serde(rename = "deltaCount")]
123 pub delta_count: u32,
125
126 pub deltas: Vec<ChangeDelta>,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
131pub struct ChangeDelta {
133 pub spec: String,
135
136 pub operation: String,
138
139 pub description: String,
141
142 pub requirement: Requirement,
144
145 pub requirements: Vec<Requirement>,
147}
148
149pub 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
155pub 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
164pub 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
173pub 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
205pub 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
222pub 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
254pub 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
282pub 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
291pub 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
310pub 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
327pub 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)]
343pub struct DeltaSpecFile {
345 pub spec: String,
347
348 pub markdown: String,
350}
351
352pub 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 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 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
453fn 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 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 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 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
605fn 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 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}