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 pub scenarios: Vec<Scenario>,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
37pub struct SpecShowJson {
39 pub id: String,
41 pub title: String,
43 pub overview: String,
45 #[serde(rename = "requirementCount")]
46 pub requirement_count: u32,
48
49 pub requirements: Vec<Requirement>,
51
52 pub metadata: SpecMetadata,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
57pub struct SpecMetadata {
59 pub version: String,
61
62 pub format: String,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
67pub struct SpecsBundleJson {
69 #[serde(rename = "specCount")]
70 pub spec_count: u32,
72
73 pub specs: Vec<BundledSpec>,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
78pub struct BundledSpec {
80 pub id: String,
82
83 pub path: String,
85
86 pub markdown: String,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
91pub struct ChangeShowJson {
93 pub id: String,
95 pub title: String,
97 #[serde(rename = "deltaCount")]
98 pub delta_count: u32,
100
101 pub deltas: Vec<ChangeDelta>,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
106pub struct ChangeDelta {
108 pub spec: String,
110
111 pub operation: String,
113
114 pub description: String,
116
117 pub requirement: Requirement,
119
120 pub requirements: Vec<Requirement>,
122}
123
124pub 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
130pub 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
139pub 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
148pub 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
180pub 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
197pub 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
229pub 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
257pub 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
266pub 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
285pub 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
303pub 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)]
319pub struct DeltaSpecFile {
321 pub spec: String,
323
324 pub markdown: String,
326}
327
328pub 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 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 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 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 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}