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 Requirement {
29 pub text: String,
31
32 #[serde(rename = "requirementId", skip_serializing_if = "Option::is_none")]
34 pub requirement_id: Option<String>,
35
36 pub scenarios: Vec<Scenario>,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
41pub struct SpecShowJson {
43 pub id: String,
45 pub title: String,
47 pub overview: String,
49 #[serde(rename = "requirementCount")]
50 pub requirement_count: u32,
52
53 pub requirements: Vec<Requirement>,
55
56 pub metadata: SpecMetadata,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
61pub struct SpecMetadata {
63 pub version: String,
65
66 pub format: String,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
71pub struct SpecsBundleJson {
73 #[serde(rename = "specCount")]
74 pub spec_count: u32,
76
77 pub specs: Vec<BundledSpec>,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
82pub struct BundledSpec {
84 pub id: String,
86
87 pub path: String,
89
90 pub markdown: String,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
95pub struct ChangeShowJson {
97 pub id: String,
99 pub title: String,
101 #[serde(rename = "deltaCount")]
102 pub delta_count: u32,
104
105 pub deltas: Vec<ChangeDelta>,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
110pub struct ChangeDelta {
112 pub spec: String,
114
115 pub operation: String,
117
118 pub description: String,
120
121 pub requirement: Requirement,
123
124 pub requirements: Vec<Requirement>,
126}
127
128pub fn read_spec_markdown(ito_path: &Path, id: &str) -> CoreResult<String> {
130 let repo = FsSpecRepository::new(ito_path);
131 read_spec_markdown_from_repository(&repo, id)
132}
133
134pub fn read_spec_markdown_from_repository(
136 repo: &(impl SpecRepository + ?Sized),
137 id: &str,
138) -> CoreResult<String> {
139 let spec = repo.get(id).into_core()?;
140 Ok(spec.markdown)
141}
142
143pub fn read_change_proposal_markdown(
145 repo: &(impl ChangeRepository + ?Sized),
146 change_id: &str,
147) -> CoreResult<Option<String>> {
148 let change = repo.get(change_id).into_core()?;
149 Ok(change.proposal)
150}
151
152pub fn read_module_markdown(
154 module_repo: &(impl ModuleRepository + ?Sized),
155 module_id: &str,
156) -> CoreResult<String> {
157 let module = module_repo.get(module_id).into_core()?;
158 let module_md_path = module.path.join("module.md");
159 if module_md_path.is_file() {
160 let md = ito_common::io::read_to_string_or_default(&module_md_path);
161 return Ok(md);
162 }
163
164 if module.path.as_os_str().is_empty() {
165 return Ok(render_module_markdown_fallback(&module));
166 }
167
168 Ok(String::new())
169}
170
171fn render_module_markdown_fallback(module: &ito_domain::modules::Module) -> String {
172 let mut out = String::new();
173 out.push_str(&format!("# {}\n", module.name));
174 if let Some(description) = module.description.as_deref()
175 && !description.trim().is_empty()
176 {
177 out.push_str("\n## Purpose\n");
178 out.push_str(description.trim());
179 out.push('\n');
180 }
181 out
182}
183
184pub fn parse_spec_show_json(id: &str, markdown: &str) -> SpecShowJson {
186 let overview = extract_section_text(markdown, "Purpose");
187 let requirements = parse_spec_requirements(markdown);
188 SpecShowJson {
189 id: id.to_string(),
190 title: id.to_string(),
191 overview,
192 requirement_count: requirements.len() as u32,
193 requirements,
194 metadata: SpecMetadata {
195 version: "1.0.0".to_string(),
196 format: "ito".to_string(),
197 },
198 }
199}
200
201pub fn bundle_main_specs_show_json(ito_path: &Path) -> CoreResult<SpecsBundleJson> {
203 use ito_common::fs::StdFs;
204
205 let fs = StdFs;
206 let mut ids = ito_domain::discovery::list_spec_dir_names(&fs, ito_path).into_core()?;
207 ids.sort();
208
209 if ids.is_empty() {
210 return Err(CoreError::not_found(
211 "No specs found under .ito/specs (expected .ito/specs/<id>/spec.md)".to_string(),
212 ));
213 }
214
215 let mut specs = Vec::with_capacity(ids.len());
216 for id in ids {
217 let path = ito_common::paths::spec_markdown_path(ito_path, &id);
218 let markdown = ito_common::io::read_to_string(&path)
219 .map_err(|e| CoreError::io(format!("reading spec {}", id), std::io::Error::other(e)))?;
220 specs.push(BundledSpec {
221 id,
222 path: path.to_string_lossy().to_string(),
223 markdown,
224 });
225 }
226
227 Ok(SpecsBundleJson {
228 spec_count: specs.len() as u32,
229 specs,
230 })
231}
232
233pub fn bundle_specs_show_json_from_repository(
235 repo: &(impl SpecRepository + ?Sized),
236) -> CoreResult<SpecsBundleJson> {
237 let mut summaries = repo.list().into_core()?;
238 summaries.sort_by(|left, right| left.id.cmp(&right.id));
239 if summaries.is_empty() {
240 return Err(CoreError::not_found(
241 "No specs found under .ito/specs (expected .ito/specs/<id>/spec.md)".to_string(),
242 ));
243 }
244
245 let mut specs = Vec::with_capacity(summaries.len());
246 for summary in summaries {
247 let spec = repo.get(&summary.id).into_core()?;
248 specs.push(BundledSpec {
249 id: spec.id,
250 path: spec.path.to_string_lossy().to_string(),
251 markdown: spec.markdown,
252 });
253 }
254
255 Ok(SpecsBundleJson {
256 spec_count: specs.len() as u32,
257 specs,
258 })
259}
260
261pub fn bundle_main_specs_markdown(ito_path: &Path) -> CoreResult<String> {
266 let repo = FsSpecRepository::new(ito_path);
267 bundle_specs_markdown_from_repository(&repo)
268}
269
270pub fn bundle_specs_markdown_from_repository(
272 repo: &(impl SpecRepository + ?Sized),
273) -> CoreResult<String> {
274 let bundle = bundle_specs_show_json_from_repository(repo)?;
275 let mut out = String::new();
276 for (i, spec) in bundle.specs.iter().enumerate() {
277 if i != 0 {
278 out.push_str("\n\n");
279 }
280 out.push_str(&format!(
281 "<!-- spec-id: {}; source: {} -->\n",
282 spec.id, spec.path
283 ));
284 out.push_str(&spec.markdown);
285 }
286 Ok(out)
287}
288
289pub fn read_change_delta_spec_files(
291 repo: &(impl ChangeRepository + ?Sized),
292 change_id: &str,
293) -> CoreResult<Vec<DeltaSpecFile>> {
294 let change = repo.get(change_id).into_core()?;
295 let mut out: Vec<DeltaSpecFile> = change
296 .specs
297 .into_iter()
298 .map(|spec| DeltaSpecFile {
299 spec: spec.name,
300 markdown: spec.content,
301 })
302 .collect();
303 out.sort_by(|a, b| a.spec.cmp(&b.spec));
304 Ok(out)
305}
306
307pub fn parse_change_show_json(change_id: &str, delta_specs: &[DeltaSpecFile]) -> ChangeShowJson {
309 let mut deltas: Vec<ChangeDelta> = Vec::new();
310 for file in delta_specs {
311 deltas.extend(parse_delta_spec_file(file));
312 }
313
314 ChangeShowJson {
315 id: change_id.to_string(),
316 title: change_id.to_string(),
317 delta_count: deltas.len() as u32,
318 deltas,
319 }
320}
321
322#[derive(Debug, Clone)]
323pub struct DeltaSpecFile {
325 pub spec: String,
327
328 pub markdown: String,
330}
331
332pub fn load_delta_spec_file(path: &Path) -> CoreResult<DeltaSpecFile> {
334 let markdown = ito_common::io::read_to_string(path).map_err(|e| {
335 CoreError::io(
336 format!("reading delta spec {}", path.display()),
337 std::io::Error::other(e),
338 )
339 })?;
340 let spec = path
341 .parent()
342 .and_then(|p| p.file_name())
343 .map(|s| s.to_string_lossy().to_string())
344 .unwrap_or_else(|| "unknown".to_string());
345 Ok(DeltaSpecFile { spec, markdown })
346}
347
348fn parse_delta_spec_file(file: &DeltaSpecFile) -> Vec<ChangeDelta> {
349 let mut out: Vec<ChangeDelta> = Vec::new();
350
351 let mut current_op: Option<String> = None;
352 let mut i = 0usize;
353 let normalized = file.markdown.replace('\r', "");
354 let lines: Vec<&str> = normalized.split('\n').collect();
355 while i < lines.len() {
356 let line = lines[i].trim_end();
357 if let Some(op) = parse_delta_op_header(line) {
358 current_op = Some(op);
359 i += 1;
360 continue;
361 }
362
363 if let Some(title) = line.strip_prefix("### Requirement:") {
364 let op = current_op.clone().unwrap_or_else(|| "ADDED".to_string());
365 let (_req_title, requirement, next) = parse_requirement_block(&lines, i);
366 i = next;
367
368 let description = match op.as_str() {
369 "ADDED" => format!("Add requirement: {}", requirement.text),
370 "MODIFIED" => format!("Modify requirement: {}", requirement.text),
371 "REMOVED" => format!("Remove requirement: {}", requirement.text),
372 "RENAMED" => format!("Rename requirement: {}", requirement.text),
373 _ => format!("Add requirement: {}", requirement.text),
374 };
375 out.push(ChangeDelta {
376 spec: file.spec.clone(),
377 operation: op,
378 description,
379 requirement: requirement.clone(),
380 requirements: vec![requirement],
381 });
382 let _ = title;
384 continue;
385 }
386
387 i += 1;
388 }
389
390 out
391}
392
393fn parse_delta_op_header(line: &str) -> Option<String> {
394 let t = line.trim();
396 let rest = t.strip_prefix("## ")?;
397 let rest = rest.trim();
398 let op = rest.strip_suffix(" Requirements").unwrap_or(rest).trim();
399 if op == "ADDED" || op == "MODIFIED" || op == "REMOVED" || op == "RENAMED" {
400 return Some(op.to_string());
401 }
402 None
403}
404
405fn parse_spec_requirements(markdown: &str) -> Vec<Requirement> {
406 let req_section = extract_section_lines(markdown, "Requirements");
407 parse_requirements_from_lines(&req_section)
408}
409
410fn parse_requirements_from_lines(lines: &[String]) -> Vec<Requirement> {
411 let mut out: Vec<Requirement> = Vec::new();
412 let mut i = 0usize;
413 let raw: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
414 while i < raw.len() {
415 let line = raw[i].trim_end();
416 if line.starts_with("### Requirement:") {
417 let (_title, req, next) = parse_requirement_block(&raw, i);
418 out.push(req);
419 i = next;
420 continue;
421 }
422 i += 1;
423 }
424 out
425}
426
427fn parse_requirement_block(lines: &[&str], start: usize) -> (String, Requirement, usize) {
464 let header = lines[start].trim_end();
465 let title = header
466 .strip_prefix("### Requirement:")
467 .unwrap_or("")
468 .trim()
469 .to_string();
470
471 let mut i = start + 1;
472
473 let mut statement_lines: Vec<String> = Vec::new();
475 let mut requirement_id: Option<String> = None;
476 while i < lines.len() {
477 let t = lines[i].trim_end();
478 if t.starts_with("#### Scenario:")
479 || t.starts_with("### Requirement:")
480 || t.starts_with("## ")
481 {
482 break;
483 }
484 if let Some(rest) = t
486 .trim()
487 .strip_prefix("- **Requirement ID**:")
488 .map(str::trim)
489 {
490 if !rest.is_empty() && requirement_id.is_none() {
491 requirement_id = Some(rest.to_string());
492 }
493 i += 1;
494 continue;
495 }
496 if !t.trim().is_empty() {
497 statement_lines.push(t.trim().to_string());
498 }
499 i += 1;
500 }
501 let text = collapse_whitespace(&statement_lines.join(" "));
502
503 let mut scenarios: Vec<Scenario> = Vec::new();
505 while i < lines.len() {
506 let t = lines[i].trim_end();
507 if t.starts_with("### Requirement:") || t.starts_with("## ") {
508 break;
509 }
510 if let Some(_name) = t.strip_prefix("#### Scenario:") {
511 i += 1;
512 let mut raw_lines: Vec<String> = Vec::new();
513 while i < lines.len() {
514 let l = lines[i].trim_end();
515 if l.starts_with("#### Scenario:")
516 || l.starts_with("### Requirement:")
517 || l.starts_with("## ")
518 {
519 break;
520 }
521 raw_lines.push(l.to_string());
522 i += 1;
523 }
524 let raw_text = trim_trailing_blank_lines(&raw_lines).join("\n");
525 scenarios.push(Scenario { raw_text });
526 continue;
527 }
528 i += 1;
529 }
530
531 (
532 title,
533 Requirement {
534 text,
535 requirement_id,
536 scenarios,
537 },
538 i,
539 )
540}
541
542fn extract_section_text(markdown: &str, header: &str) -> String {
555 let lines = extract_section_lines(markdown, header);
556 let joined = lines.join(" ");
557 collapse_whitespace(joined.trim())
558}
559
560fn extract_section_lines(markdown: &str, header: &str) -> Vec<String> {
561 let mut in_section = false;
562 let mut out: Vec<String> = Vec::new();
563 let normalized = markdown.replace('\r', "");
564 for raw in normalized.split('\n') {
565 let line = raw.trim_end();
566 if let Some(h) = line.strip_prefix("## ") {
567 let title = h.trim();
568 if title.eq_ignore_ascii_case(header) {
569 in_section = true;
570 continue;
571 }
572 if in_section {
573 break;
574 }
575 }
576 if in_section {
577 out.push(line.to_string());
578 }
579 }
580 out
581}
582
583fn collapse_whitespace(input: &str) -> String {
584 let mut out = String::new();
585 let mut last_was_space = false;
586 for ch in input.chars() {
587 if ch.is_whitespace() {
588 if !last_was_space {
589 out.push(' ');
590 last_was_space = true;
591 }
592 } else {
593 out.push(ch);
594 last_was_space = false;
595 }
596 }
597 out.trim().to_string()
598}
599
600fn trim_trailing_blank_lines(lines: &[String]) -> Vec<String> {
601 let mut start = 0usize;
602 while start < lines.len() {
603 if lines[start].trim().is_empty() {
604 start += 1;
605 } else {
606 break;
607 }
608 }
609
610 let mut end = lines.len();
611 while end > start {
612 if lines[end - 1].trim().is_empty() {
613 end -= 1;
614 } else {
615 break;
616 }
617 }
618
619 lines[start..end].to_vec()
620}