use std::path::Path;
use crate::error::SaraError;
use crate::model::{Item, ItemBuilder, ItemId, ItemType, SourceLocation};
use crate::parser::frontmatter::extract_frontmatter;
use crate::parser::yaml::parse_yaml_frontmatter;
pub fn parse_markdown_file(
content: &str,
file_path: &Path,
repository: &Path,
) -> Result<Item, SaraError> {
let extracted = extract_frontmatter(content, file_path)?;
let frontmatter = parse_yaml_frontmatter(&extracted.yaml, file_path)?;
let item_id = ItemId::new(&frontmatter.id).map_err(|e| SaraError::InvalidFrontmatter {
file: file_path.to_path_buf(),
reason: format!("Invalid item ID: {}", e),
})?;
let source = SourceLocation::new(repository, file_path);
let mut builder = ItemBuilder::new()
.id(item_id)
.item_type(frontmatter.item_type)
.name(&frontmatter.name)
.source(source)
.relationships(frontmatter.to_relationships());
if let Some(desc) = &frontmatter.description {
builder = builder.description(desc);
}
match frontmatter.item_type {
ItemType::Solution | ItemType::UseCase | ItemType::Scenario => {}
ItemType::SystemRequirement
| ItemType::SoftwareRequirement
| ItemType::HardwareRequirement => {
if let Some(spec) = &frontmatter.specification {
builder = builder.specification(spec);
}
for id in &frontmatter.depends_on {
builder = builder.depends_on(ItemId::new_unchecked(id));
}
}
ItemType::SystemArchitecture => {
if let Some(platform) = &frontmatter.platform {
builder = builder.platform(platform);
}
}
ItemType::SoftwareDetailedDesign | ItemType::HardwareDetailedDesign => {}
ItemType::ArchitectureDecisionRecord => {
if let Some(status) = frontmatter.status {
builder = builder.status(status);
}
builder = builder.deciders(frontmatter.deciders.clone());
builder = builder.supersedes_all(
frontmatter
.supersedes
.iter()
.map(ItemId::new_unchecked)
.collect(),
);
}
}
builder.build().map_err(|e| SaraError::InvalidFrontmatter {
file: file_path.to_path_buf(),
reason: e.to_string(),
})
}
#[derive(Debug)]
pub struct ParsedDocument {
pub item: Item,
pub body: String,
}
pub fn parse_document(
content: &str,
file_path: &Path,
repository: &Path,
) -> Result<ParsedDocument, SaraError> {
let extracted = extract_frontmatter(content, file_path)?;
let item = parse_markdown_file(content, file_path, repository)?;
Ok(ParsedDocument {
item,
body: extracted.body,
})
}
pub fn extract_name_from_content(content: &str) -> Option<String> {
for line in content.lines() {
let trimmed = line.trim();
if let Some(heading) = trimmed.strip_prefix("# ") {
return Some(heading.trim().to_string());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{AdrStatus, RelationshipType};
use std::path::PathBuf;
const SOLUTION_MD: &str = r#"---
id: "SOL-001"
type: solution
name: "Test Solution"
description: "A test solution"
is_refined_by:
- "UC-001"
---
# Test Solution
This is the body content.
"#;
const REQUIREMENT_MD: &str = r#"---
id: "SYSREQ-001"
type: system_requirement
name: "Performance Requirement"
specification: "The system SHALL respond within 100ms."
derives_from:
- "SCEN-001"
is_satisfied_by:
- "SYSARCH-001"
---
# Requirement
"#;
#[test]
fn test_parse_solution() {
let item = parse_markdown_file(
SOLUTION_MD,
&PathBuf::from("SOL-001.md"),
&PathBuf::from("/repo"),
)
.unwrap();
assert_eq!(item.id.as_str(), "SOL-001");
assert_eq!(item.item_type, ItemType::Solution);
assert_eq!(item.name, "Test Solution");
assert_eq!(item.description, Some("A test solution".to_string()));
let is_refined_by: Vec<_> = item
.relationship_ids(RelationshipType::IsRefinedBy)
.collect();
assert_eq!(is_refined_by.len(), 1);
assert_eq!(is_refined_by[0].as_str(), "UC-001");
}
#[test]
fn test_parse_requirement() {
let item = parse_markdown_file(
REQUIREMENT_MD,
&PathBuf::from("SYSREQ-001.md"),
&PathBuf::from("/repo"),
)
.unwrap();
assert_eq!(item.id.as_str(), "SYSREQ-001");
assert_eq!(item.item_type, ItemType::SystemRequirement);
assert_eq!(
item.attributes.specification().map(String::as_str),
Some("The system SHALL respond within 100ms.")
);
let derives_from: Vec<_> = item
.relationship_ids(RelationshipType::DerivesFrom)
.collect();
let is_satisfied_by: Vec<_> = item
.relationship_ids(RelationshipType::IsSatisfiedBy)
.collect();
assert_eq!(derives_from.len(), 1);
assert_eq!(is_satisfied_by.len(), 1);
}
#[test]
fn test_parse_document() {
let doc = parse_document(
SOLUTION_MD,
&PathBuf::from("SOL-001.md"),
&PathBuf::from("/repo"),
)
.unwrap();
assert_eq!(doc.item.id.as_str(), "SOL-001");
assert!(doc.body.contains("# Test Solution"));
}
#[test]
fn test_parse_invalid_id() {
let content = r#"---
id: "invalid id with spaces"
type: solution
name: "Test"
---
"#;
let result =
parse_markdown_file(content, &PathBuf::from("test.md"), &PathBuf::from("/repo"));
assert!(result.is_err());
}
#[test]
fn test_parse_missing_type() {
let content = r#"---
id: "SOL-001"
name: "Test"
---
"#;
let result =
parse_markdown_file(content, &PathBuf::from("test.md"), &PathBuf::from("/repo"));
assert!(result.is_err());
}
const ADR_MD: &str = r#"---
id: "ADR-001"
type: architecture_decision_record
name: "Use Microservices Architecture"
description: "Decision to adopt microservices"
status: proposed
deciders:
- "Alice Smith"
- "Bob Jones"
justifies:
- "SYSARCH-001"
- "SWDD-001"
supersedes: []
superseded_by: null
---
# Architecture Decision: Use Microservices Architecture
## Context and problem statement
We need to choose an architecture pattern for our system.
## Decision Outcome
Chosen option: Microservices, because it provides better scalability.
"#;
#[test]
fn test_parse_adr() {
let item = parse_markdown_file(
ADR_MD,
&PathBuf::from("ADR-001.md"),
&PathBuf::from("/repo"),
)
.unwrap();
assert_eq!(item.id.as_str(), "ADR-001");
assert_eq!(item.item_type, ItemType::ArchitectureDecisionRecord);
assert_eq!(item.name, "Use Microservices Architecture");
assert_eq!(
item.description,
Some("Decision to adopt microservices".to_string())
);
assert_eq!(item.attributes.status(), Some(AdrStatus::Proposed));
assert_eq!(item.attributes.deciders().len(), 2);
let justifies: Vec<_> = item.relationship_ids(RelationshipType::Justifies).collect();
assert_eq!(justifies.len(), 2);
assert_eq!(justifies[0].as_str(), "SYSARCH-001");
assert_eq!(justifies[1].as_str(), "SWDD-001");
assert!(item.attributes.supersedes().is_empty());
}
#[test]
fn test_parse_adr_missing_deciders() {
let content = r#"---
id: "ADR-002"
type: architecture_decision_record
name: "Test Decision"
status: proposed
---
"#;
let result =
parse_markdown_file(content, &PathBuf::from("test.md"), &PathBuf::from("/repo"));
assert!(result.is_err());
}
#[test]
fn test_parse_adr_missing_status() {
let content = r#"---
id: "ADR-003"
type: architecture_decision_record
name: "Test Decision"
deciders:
- "Alice"
---
"#;
let result =
parse_markdown_file(content, &PathBuf::from("test.md"), &PathBuf::from("/repo"));
assert!(result.is_err());
}
#[test]
fn test_parse_adr_with_supersession() {
let content = r#"---
id: "ADR-005"
type: architecture_decision_record
name: "Updated Architecture Decision"
status: accepted
deciders:
- "Alice Smith"
justifies:
- "SYSARCH-001"
supersedes:
- "ADR-001"
- "ADR-002"
---
"#;
let item = parse_markdown_file(
content,
&PathBuf::from("ADR-005.md"),
&PathBuf::from("/repo"),
)
.unwrap();
let justifies: Vec<_> = item.relationship_ids(RelationshipType::Justifies).collect();
assert_eq!(justifies.len(), 1);
assert_eq!(justifies[0].as_str(), "SYSARCH-001");
assert_eq!(item.attributes.supersedes().len(), 2);
assert_eq!(item.attributes.supersedes()[0].as_str(), "ADR-001");
assert_eq!(item.attributes.supersedes()[1].as_str(), "ADR-002");
}
#[test]
fn test_extract_name_from_content() {
let content = "# My Document\n\nSome content here.";
assert_eq!(
extract_name_from_content(content),
Some("My Document".to_string())
);
let content_no_heading = "No heading here";
assert_eq!(extract_name_from_content(content_no_heading), None);
}
}