use std::path::Path;
use crate::error_bridge::IntoCoreResult;
use crate::errors::{CoreError, CoreResult};
use crate::spec_repository::FsSpecRepository;
use ito_domain::modules::ModuleRepository;
use ito_domain::specs::SpecRepository;
use serde::Serialize;
use ito_domain::changes::ChangeRepository;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Scenario {
#[serde(rename = "rawText")]
pub raw_text: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ContractRef {
pub raw: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub scheme: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub identifier: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Requirement {
pub text: String,
#[serde(rename = "requirementId", skip_serializing_if = "Option::is_none")]
pub requirement_id: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(rename = "contractRefs", skip_serializing_if = "Vec::is_empty")]
pub contract_refs: Vec<ContractRef>,
pub scenarios: Vec<Scenario>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SpecShowJson {
pub id: String,
pub title: String,
pub overview: String,
#[serde(rename = "requirementCount")]
pub requirement_count: u32,
pub requirements: Vec<Requirement>,
pub metadata: SpecMetadata,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SpecMetadata {
pub version: String,
pub format: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SpecsBundleJson {
#[serde(rename = "specCount")]
pub spec_count: u32,
pub specs: Vec<BundledSpec>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct BundledSpec {
pub id: String,
pub path: String,
pub markdown: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ChangeShowJson {
pub id: String,
pub title: String,
#[serde(rename = "deltaCount")]
pub delta_count: u32,
pub deltas: Vec<ChangeDelta>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ChangeDelta {
pub spec: String,
pub operation: String,
pub description: String,
pub requirement: Requirement,
pub requirements: Vec<Requirement>,
}
pub fn read_spec_markdown(ito_path: &Path, id: &str) -> CoreResult<String> {
let repo = FsSpecRepository::new(ito_path);
read_spec_markdown_from_repository(&repo, id)
}
pub fn read_spec_markdown_from_repository(
repo: &(impl SpecRepository + ?Sized),
id: &str,
) -> CoreResult<String> {
let spec = repo.get(id).into_core()?;
Ok(spec.markdown)
}
pub fn read_change_proposal_markdown(
repo: &(impl ChangeRepository + ?Sized),
change_id: &str,
) -> CoreResult<Option<String>> {
let change = repo.get(change_id).into_core()?;
Ok(change.proposal)
}
pub fn read_module_markdown(
module_repo: &(impl ModuleRepository + ?Sized),
module_id: &str,
) -> CoreResult<String> {
let module = module_repo.get(module_id).into_core()?;
let module_md_path = module.path.join("module.md");
if module_md_path.is_file() {
let md = ito_common::io::read_to_string_or_default(&module_md_path);
return Ok(md);
}
if module.path.as_os_str().is_empty() {
return Ok(render_module_markdown_fallback(&module));
}
Ok(String::new())
}
fn render_module_markdown_fallback(module: &ito_domain::modules::Module) -> String {
let mut out = String::new();
out.push_str(&format!("# {}\n", module.name));
if let Some(description) = module.description.as_deref()
&& !description.trim().is_empty()
{
out.push_str("\n## Purpose\n");
out.push_str(description.trim());
out.push('\n');
}
out
}
pub fn parse_spec_show_json(id: &str, markdown: &str) -> SpecShowJson {
let overview = extract_section_text(markdown, "Purpose");
let requirements = parse_spec_requirements(markdown);
SpecShowJson {
id: id.to_string(),
title: id.to_string(),
overview,
requirement_count: requirements.len() as u32,
requirements,
metadata: SpecMetadata {
version: "1.0.0".to_string(),
format: "ito".to_string(),
},
}
}
pub fn bundle_main_specs_show_json(ito_path: &Path) -> CoreResult<SpecsBundleJson> {
use ito_common::fs::StdFs;
let fs = StdFs;
let mut ids = ito_domain::discovery::list_spec_dir_names(&fs, ito_path).into_core()?;
ids.sort();
if ids.is_empty() {
return Err(CoreError::not_found(
"No specs found under .ito/specs (expected .ito/specs/<id>/spec.md)".to_string(),
));
}
let mut specs = Vec::with_capacity(ids.len());
for id in ids {
let path = ito_common::paths::spec_markdown_path(ito_path, &id);
let markdown = ito_common::io::read_to_string(&path)
.map_err(|e| CoreError::io(format!("reading spec {}", id), std::io::Error::other(e)))?;
specs.push(BundledSpec {
id,
path: path.to_string_lossy().to_string(),
markdown,
});
}
Ok(SpecsBundleJson {
spec_count: specs.len() as u32,
specs,
})
}
pub fn bundle_specs_show_json_from_repository(
repo: &(impl SpecRepository + ?Sized),
) -> CoreResult<SpecsBundleJson> {
let mut summaries = repo.list().into_core()?;
summaries.sort_by(|left, right| left.id.cmp(&right.id));
if summaries.is_empty() {
return Err(CoreError::not_found(
"No specs found under .ito/specs (expected .ito/specs/<id>/spec.md)".to_string(),
));
}
let mut specs = Vec::with_capacity(summaries.len());
for summary in summaries {
let spec = repo.get(&summary.id).into_core()?;
specs.push(BundledSpec {
id: spec.id,
path: spec.path.to_string_lossy().to_string(),
markdown: spec.markdown,
});
}
Ok(SpecsBundleJson {
spec_count: specs.len() as u32,
specs,
})
}
pub fn bundle_main_specs_markdown(ito_path: &Path) -> CoreResult<String> {
let repo = FsSpecRepository::new(ito_path);
bundle_specs_markdown_from_repository(&repo)
}
pub fn bundle_specs_markdown_from_repository(
repo: &(impl SpecRepository + ?Sized),
) -> CoreResult<String> {
let bundle = bundle_specs_show_json_from_repository(repo)?;
let mut out = String::new();
for (i, spec) in bundle.specs.iter().enumerate() {
if i != 0 {
out.push_str("\n\n");
}
out.push_str(&format!(
"<!-- spec-id: {}; source: {} -->\n",
spec.id, spec.path
));
out.push_str(&spec.markdown);
}
Ok(out)
}
pub fn read_change_delta_spec_files(
repo: &(impl ChangeRepository + ?Sized),
change_id: &str,
) -> CoreResult<Vec<DeltaSpecFile>> {
let change = repo.get(change_id).into_core()?;
let mut out: Vec<DeltaSpecFile> = Vec::new();
for spec in change.specs {
out.push(DeltaSpecFile {
spec: spec.name,
markdown: spec.content,
});
}
out.sort_by(|a, b| a.spec.cmp(&b.spec));
Ok(out)
}
pub fn parse_change_show_json(change_id: &str, delta_specs: &[DeltaSpecFile]) -> ChangeShowJson {
let mut deltas: Vec<ChangeDelta> = Vec::new();
for file in delta_specs {
deltas.extend(parse_delta_spec_file(file));
}
ChangeShowJson {
id: change_id.to_string(),
title: change_id.to_string(),
delta_count: deltas.len() as u32,
deltas,
}
}
#[derive(Debug, Clone)]
pub struct DeltaSpecFile {
pub spec: String,
pub markdown: String,
}
pub fn load_delta_spec_file(path: &Path) -> CoreResult<DeltaSpecFile> {
let markdown = ito_common::io::read_to_string(path).map_err(|e| {
CoreError::io(
format!("reading delta spec {}", path.display()),
std::io::Error::other(e),
)
})?;
let spec = path
.parent()
.and_then(|p| p.file_name())
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown".to_string());
Ok(DeltaSpecFile { spec, markdown })
}
fn parse_delta_spec_file(file: &DeltaSpecFile) -> Vec<ChangeDelta> {
let mut out: Vec<ChangeDelta> = Vec::new();
let mut current_op: Option<String> = None;
let mut i = 0usize;
let normalized = file.markdown.replace('\r', "");
let mut lines: Vec<&str> = Vec::new();
for line in normalized.split('\n') {
lines.push(line);
}
while i < lines.len() {
let line = lines[i].trim_end();
if let Some(op) = parse_delta_op_header(line) {
current_op = Some(op);
i += 1;
continue;
}
if let Some(title) = line.strip_prefix("### Requirement:") {
let op = current_op.clone().unwrap_or_else(|| "ADDED".to_string());
let (_req_title, requirement, next) = parse_requirement_block(&lines, i);
i = next;
let description = match op.as_str() {
"ADDED" => format!("Add requirement: {}", requirement.text),
"MODIFIED" => format!("Modify requirement: {}", requirement.text),
"REMOVED" => format!("Remove requirement: {}", requirement.text),
"RENAMED" => format!("Rename requirement: {}", requirement.text),
_ => format!("Add requirement: {}", requirement.text),
};
out.push(ChangeDelta {
spec: file.spec.clone(),
operation: op,
description,
requirement: requirement.clone(),
requirements: vec![requirement],
});
let _ = title;
continue;
}
i += 1;
}
out
}
fn parse_delta_op_header(line: &str) -> Option<String> {
let t = line.trim();
let rest = t.strip_prefix("## ")?;
let rest = rest.trim();
let op = rest.strip_suffix(" Requirements").unwrap_or(rest).trim();
if op == "ADDED" || op == "MODIFIED" || op == "REMOVED" || op == "RENAMED" {
return Some(op.to_string());
}
None
}
fn parse_spec_requirements(markdown: &str) -> Vec<Requirement> {
let req_section = extract_section_lines(markdown, "Requirements");
parse_requirements_from_lines(&req_section)
}
fn parse_requirements_from_lines(lines: &[String]) -> Vec<Requirement> {
let mut out: Vec<Requirement> = Vec::new();
let mut i = 0usize;
let mut raw: Vec<&str> = Vec::new();
for line in lines {
raw.push(line.as_str());
}
while i < raw.len() {
let line = raw[i].trim_end();
if line.starts_with("### Requirement:") {
let (_title, req, next) = parse_requirement_block(&raw, i);
out.push(req);
i = next;
continue;
}
i += 1;
}
out
}
fn parse_requirement_block(lines: &[&str], start: usize) -> (String, Requirement, usize) {
let header = lines[start].trim_end();
let title = header
.strip_prefix("### Requirement:")
.unwrap_or("")
.trim()
.to_string();
let mut i = start + 1;
let mut statement_lines: Vec<String> = Vec::new();
let mut requirement_id: Option<String> = None;
let mut tags: Vec<String> = Vec::new();
let mut contract_refs: Vec<ContractRef> = Vec::new();
while i < lines.len() {
let t = lines[i].trim_end();
if t.starts_with("#### Scenario:")
|| t.starts_with("### Requirement:")
|| t.starts_with("## ")
{
break;
}
if let Some(rest) = t
.trim()
.strip_prefix("- **Requirement ID**:")
.or_else(|| t.trim().strip_prefix("* **Requirement ID**:"))
.map(str::trim)
{
if !rest.is_empty() && requirement_id.is_none() {
requirement_id = Some(rest.to_string());
}
i += 1;
continue;
}
if let Some(rest) = t
.trim()
.strip_prefix("- **Tags**:")
.or_else(|| t.trim().strip_prefix("* **Tags**:"))
.map(str::trim)
{
if !rest.is_empty() && tags.is_empty() {
let mut parsed_tags = Vec::new();
for tag in rest.split(',') {
let tag = tag.trim().to_ascii_lowercase();
if tag.is_empty() {
continue;
}
parsed_tags.push(tag);
}
tags = parsed_tags;
}
i += 1;
continue;
}
if let Some(rest) = t
.trim()
.strip_prefix("- **Contract Refs**:")
.or_else(|| t.trim().strip_prefix("* **Contract Refs**:"))
.map(str::trim)
{
if !rest.is_empty() && contract_refs.is_empty() {
contract_refs = parse_contract_refs(rest);
}
i += 1;
continue;
}
if !t.trim().is_empty() {
statement_lines.push(t.trim().to_string());
}
i += 1;
}
let text = collapse_whitespace(&statement_lines.join(" "));
let mut scenarios: Vec<Scenario> = Vec::new();
while i < lines.len() {
let t = lines[i].trim_end();
if t.starts_with("### Requirement:") || t.starts_with("## ") {
break;
}
if let Some(_name) = t.strip_prefix("#### Scenario:") {
i += 1;
let mut raw_lines: Vec<String> = Vec::new();
while i < lines.len() {
let l = lines[i].trim_end();
if l.starts_with("#### Scenario:")
|| l.starts_with("### Requirement:")
|| l.starts_with("## ")
{
break;
}
raw_lines.push(l.to_string());
i += 1;
}
let raw_text = trim_trailing_blank_lines(&raw_lines).join("\n");
scenarios.push(Scenario { raw_text });
continue;
}
i += 1;
}
(
title,
Requirement {
text,
requirement_id,
tags,
contract_refs,
scenarios,
},
i,
)
}
fn extract_section_text(markdown: &str, header: &str) -> String {
let lines = extract_section_lines(markdown, header);
let joined = lines.join(" ");
collapse_whitespace(joined.trim())
}
fn extract_section_lines(markdown: &str, header: &str) -> Vec<String> {
let mut in_section = false;
let mut out: Vec<String> = Vec::new();
let normalized = markdown.replace('\r', "");
for raw in normalized.split('\n') {
let line = raw.trim_end();
if let Some(h) = line.strip_prefix("## ") {
let title = h.trim();
if title.eq_ignore_ascii_case(header) {
in_section = true;
continue;
}
if in_section {
break;
}
}
if in_section {
out.push(line.to_string());
}
}
out
}
fn collapse_whitespace(input: &str) -> String {
let mut out = String::new();
let mut last_was_space = false;
for ch in input.chars() {
if ch.is_whitespace() {
if !last_was_space {
out.push(' ');
last_was_space = true;
}
} else {
out.push(ch);
last_was_space = false;
}
}
out.trim().to_string()
}
fn parse_contract_refs(input: &str) -> Vec<ContractRef> {
let mut refs = Vec::new();
for entry in input.split(", ") {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
let Some((scheme, identifier)) = entry.split_once(':') else {
refs.push(ContractRef {
raw: entry.to_string(),
scheme: None,
identifier: None,
});
continue;
};
let scheme = scheme.trim();
let identifier = identifier.trim();
refs.push(ContractRef {
raw: entry.to_string(),
scheme: (!scheme.is_empty()).then(|| scheme.to_ascii_lowercase()),
identifier: (!identifier.is_empty()).then(|| identifier.to_string()),
});
}
refs
}
fn trim_trailing_blank_lines(lines: &[String]) -> Vec<String> {
let mut start = 0usize;
while start < lines.len() {
if lines[start].trim().is_empty() {
start += 1;
} else {
break;
}
}
let mut end = lines.len();
while end > start {
if lines[end - 1].trim().is_empty() {
end -= 1;
} else {
break;
}
}
lines[start..end].to_vec()
}