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 let mut specs: Vec<BundledSpec> = Vec::new();
182 for id in ids {
183 let path = paths::spec_markdown_path(ito_path, &id);
184 let markdown = ito_common::io::read_to_string(&path)
185 .map_err(|e| CoreError::io(format!("reading spec {}", id), std::io::Error::other(e)))?;
186 specs.push(BundledSpec {
187 id,
188 path: path.to_string_lossy().to_string(),
189 markdown,
190 });
191 }
192
193 Ok(SpecsBundleJson {
194 spec_count: specs.len() as u32,
195 specs,
196 })
197}
198
199pub fn bundle_main_specs_markdown(ito_path: &Path) -> CoreResult<String> {
204 let bundle = bundle_main_specs_show_json(ito_path)?;
205 let mut out = String::new();
206 for (i, spec) in bundle.specs.iter().enumerate() {
207 if i != 0 {
208 out.push_str("\n\n");
209 }
210 out.push_str(&format!(
211 "<!-- spec-id: {}; source: {} -->\n",
212 spec.id, spec.path
213 ));
214 out.push_str(&spec.markdown);
215 }
216 Ok(out)
217}
218
219pub fn read_change_delta_spec_files(
221 repo: &impl ChangeRepository,
222 change_id: &str,
223) -> CoreResult<Vec<DeltaSpecFile>> {
224 let change = repo.get(change_id).into_core()?;
225 let mut out: Vec<DeltaSpecFile> = change
226 .specs
227 .into_iter()
228 .map(|spec| DeltaSpecFile {
229 spec: spec.name,
230 markdown: spec.content,
231 })
232 .collect();
233 out.sort_by(|a, b| a.spec.cmp(&b.spec));
234 Ok(out)
235}
236
237pub fn parse_change_show_json(change_id: &str, delta_specs: &[DeltaSpecFile]) -> ChangeShowJson {
239 let mut deltas: Vec<ChangeDelta> = Vec::new();
240 for file in delta_specs {
241 deltas.extend(parse_delta_spec_file(file));
242 }
243
244 ChangeShowJson {
245 id: change_id.to_string(),
246 title: change_id.to_string(),
247 delta_count: deltas.len() as u32,
248 deltas,
249 }
250}
251
252#[derive(Debug, Clone)]
253pub struct DeltaSpecFile {
255 pub spec: String,
257
258 pub markdown: String,
260}
261
262pub fn load_delta_spec_file(path: &Path) -> CoreResult<DeltaSpecFile> {
264 let markdown = ito_common::io::read_to_string(path).map_err(|e| {
265 CoreError::io(
266 format!("reading delta spec {}", path.display()),
267 std::io::Error::other(e),
268 )
269 })?;
270 let spec = path
271 .parent()
272 .and_then(|p| p.file_name())
273 .map(|s| s.to_string_lossy().to_string())
274 .unwrap_or_else(|| "unknown".to_string());
275 Ok(DeltaSpecFile { spec, markdown })
276}
277
278fn parse_delta_spec_file(file: &DeltaSpecFile) -> Vec<ChangeDelta> {
279 let mut out: Vec<ChangeDelta> = Vec::new();
280
281 let mut current_op: Option<String> = None;
282 let mut i = 0usize;
283 let normalized = file.markdown.replace('\r', "");
284 let lines: Vec<&str> = normalized.split('\n').collect();
285 while i < lines.len() {
286 let line = lines[i].trim_end();
287 if let Some(op) = parse_delta_op_header(line) {
288 current_op = Some(op);
289 i += 1;
290 continue;
291 }
292
293 if let Some(title) = line.strip_prefix("### Requirement:") {
294 let op = current_op.clone().unwrap_or_else(|| "ADDED".to_string());
295 let (_req_title, requirement, next) = parse_requirement_block(&lines, i);
296 i = next;
297
298 let description = match op.as_str() {
299 "ADDED" => format!("Add requirement: {}", requirement.text),
300 "MODIFIED" => format!("Modify requirement: {}", requirement.text),
301 "REMOVED" => format!("Remove requirement: {}", requirement.text),
302 "RENAMED" => format!("Rename requirement: {}", requirement.text),
303 _ => format!("Add requirement: {}", requirement.text),
304 };
305 out.push(ChangeDelta {
306 spec: file.spec.clone(),
307 operation: op,
308 description,
309 requirement: requirement.clone(),
310 requirements: vec![requirement],
311 });
312 let _ = title;
314 continue;
315 }
316
317 i += 1;
318 }
319
320 out
321}
322
323fn parse_delta_op_header(line: &str) -> Option<String> {
324 let t = line.trim();
326 let rest = t.strip_prefix("## ")?;
327 let rest = rest.trim();
328 let op = rest.strip_suffix(" Requirements").unwrap_or(rest).trim();
329 if op == "ADDED" || op == "MODIFIED" || op == "REMOVED" || op == "RENAMED" {
330 return Some(op.to_string());
331 }
332 None
333}
334
335fn parse_spec_requirements(markdown: &str) -> Vec<Requirement> {
336 let req_section = extract_section_lines(markdown, "Requirements");
337 parse_requirements_from_lines(&req_section)
338}
339
340fn parse_requirements_from_lines(lines: &[String]) -> Vec<Requirement> {
341 let mut out: Vec<Requirement> = Vec::new();
342 let mut i = 0usize;
343 let raw: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
344 while i < raw.len() {
345 let line = raw[i].trim_end();
346 if line.starts_with("### Requirement:") {
347 let (_title, req, next) = parse_requirement_block(&raw, i);
348 out.push(req);
349 i = next;
350 continue;
351 }
352 i += 1;
353 }
354 out
355}
356
357fn parse_requirement_block(lines: &[&str], start: usize) -> (String, Requirement, usize) {
358 let header = lines[start].trim_end();
359 let title = header
360 .strip_prefix("### Requirement:")
361 .unwrap_or("")
362 .trim()
363 .to_string();
364
365 let mut i = start + 1;
366
367 let mut statement_lines: Vec<String> = Vec::new();
369 while i < lines.len() {
370 let t = lines[i].trim_end();
371 if t.starts_with("#### Scenario:")
372 || t.starts_with("### Requirement:")
373 || t.starts_with("## ")
374 {
375 break;
376 }
377 if !t.trim().is_empty() {
378 statement_lines.push(t.trim().to_string());
379 }
380 i += 1;
381 }
382 let text = collapse_whitespace(&statement_lines.join(" "));
383
384 let mut scenarios: Vec<Scenario> = Vec::new();
386 while i < lines.len() {
387 let t = lines[i].trim_end();
388 if t.starts_with("### Requirement:") || t.starts_with("## ") {
389 break;
390 }
391 if let Some(_name) = t.strip_prefix("#### Scenario:") {
392 i += 1;
393 let mut raw_lines: Vec<String> = Vec::new();
394 while i < lines.len() {
395 let l = lines[i].trim_end();
396 if l.starts_with("#### Scenario:")
397 || l.starts_with("### Requirement:")
398 || l.starts_with("## ")
399 {
400 break;
401 }
402 raw_lines.push(l.to_string());
403 i += 1;
404 }
405 let raw_text = trim_trailing_blank_lines(&raw_lines).join("\n");
406 scenarios.push(Scenario { raw_text });
407 continue;
408 }
409 i += 1;
410 }
411
412 (title, Requirement { text, scenarios }, i)
413}
414
415fn extract_section_text(markdown: &str, header: &str) -> String {
416 let lines = extract_section_lines(markdown, header);
417 let joined = lines.join(" ");
418 collapse_whitespace(joined.trim())
419}
420
421fn extract_section_lines(markdown: &str, header: &str) -> Vec<String> {
422 let mut in_section = false;
423 let mut out: Vec<String> = Vec::new();
424 let normalized = markdown.replace('\r', "");
425 for raw in normalized.split('\n') {
426 let line = raw.trim_end();
427 if let Some(h) = line.strip_prefix("## ") {
428 let title = h.trim();
429 if title.eq_ignore_ascii_case(header) {
430 in_section = true;
431 continue;
432 }
433 if in_section {
434 break;
435 }
436 }
437 if in_section {
438 out.push(line.to_string());
439 }
440 }
441 out
442}
443
444fn collapse_whitespace(input: &str) -> String {
445 let mut out = String::new();
446 let mut last_was_space = false;
447 for ch in input.chars() {
448 if ch.is_whitespace() {
449 if !last_was_space {
450 out.push(' ');
451 last_was_space = true;
452 }
453 } else {
454 out.push(ch);
455 last_was_space = false;
456 }
457 }
458 out.trim().to_string()
459}
460
461fn trim_trailing_blank_lines(lines: &[String]) -> Vec<String> {
462 let mut start = 0usize;
463 while start < lines.len() {
464 if lines[start].trim().is_empty() {
465 start += 1;
466 } else {
467 break;
468 }
469 }
470
471 let mut end = lines.len();
472 while end > start {
473 if lines[end - 1].trim().is_empty() {
474 end -= 1;
475 } else {
476 break;
477 }
478 }
479
480 lines[start..end].to_vec()
481}