use crate::types::Frontmatter;
use regex::Regex;
use std::collections::HashSet;
use std::sync::LazyLock;
pub struct ParsedSpec {
pub frontmatter: Frontmatter,
pub body: String,
}
static FRONTMATTER_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?s)^---\n(.*?)\n---\n(.*)$").unwrap());
pub fn parse_frontmatter(content: &str) -> Option<ParsedSpec> {
let caps = FRONTMATTER_RE.captures(content)?;
let yaml_block = caps.get(1)?.as_str();
let body = caps.get(2)?.as_str().to_string();
let mut fm = Frontmatter::default();
let mut current_key: Option<String> = None;
let mut current_list: Vec<String> = Vec::new();
for line in yaml_block.lines() {
if let Some(stripped) = line.trim_start().strip_prefix("- ")
&& current_key.is_some()
{
current_list.push(stripped.trim().to_string());
continue;
}
if let Some(colon_pos) = line.find(':') {
let key = line[..colon_pos].trim();
if key.is_empty() || key.contains(' ') {
continue;
}
if let Some(prev_key) = current_key.take() {
set_field(&mut fm, &prev_key, ¤t_list);
current_list.clear();
}
let value = line[colon_pos + 1..].trim();
if value.is_empty() || value == "[]" {
current_key = Some(key.to_string());
current_list.clear();
} else {
set_scalar(&mut fm, key, value);
}
continue;
}
let trimmed = line.trim();
if (trimmed.is_empty() || trimmed.starts_with('#'))
&& let Some(prev_key) = current_key.take()
{
set_field(&mut fm, &prev_key, ¤t_list);
current_list.clear();
}
}
if let Some(prev_key) = current_key.take() {
set_field(&mut fm, &prev_key, ¤t_list);
}
Some(ParsedSpec {
frontmatter: fm,
body,
})
}
fn set_scalar(fm: &mut Frontmatter, key: &str, value: &str) {
match key {
"module" => fm.module = Some(value.to_string()),
"version" => fm.version = Some(value.to_string()),
"status" => fm.status = Some(value.to_string()),
"agent_policy" => fm.agent_policy = Some(value.to_string()),
"implements" => fm.implements = parse_inline_issue_numbers(value),
"tracks" => fm.tracks = parse_inline_issue_numbers(value),
_ => {}
}
}
fn parse_inline_issue_numbers(value: &str) -> Vec<u64> {
let s = value.trim();
let inner = if s.starts_with('[') && s.ends_with(']') {
&s[1..s.len() - 1]
} else {
s
};
inner
.split(',')
.filter_map(|v| v.trim().parse::<u64>().ok())
.collect()
}
fn parse_issue_numbers(values: &[String]) -> Vec<u64> {
values
.iter()
.filter_map(|v| v.trim().parse::<u64>().ok())
.collect()
}
fn set_field(fm: &mut Frontmatter, key: &str, values: &[String]) {
match key {
"files" => fm.files = values.to_vec(),
"db_tables" => fm.db_tables = values.to_vec(),
"depends_on" => fm.depends_on = values.to_vec(),
"implements" => fm.implements = parse_issue_numbers(values),
"tracks" => fm.tracks = parse_issue_numbers(values),
_ => {}
}
}
static TABLE_ROW_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\|\s*`(\w+)`").unwrap());
static METHOD_HEADER_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^####\s+.*(?:Methods|Constructor|Properties)").unwrap());
pub fn get_spec_symbols(body: &str) -> Vec<String> {
let mut symbols = Vec::new();
let api_start = match body.find("## Public API") {
Some(pos) => pos,
None => return symbols,
};
let after_header = match body[api_start..].find('\n') {
Some(pos) => api_start + pos + 1,
None => return symbols,
};
let api_section = {
let rest = &body[after_header..];
let heading_re = Regex::new(r"(?m)^## [^#]").unwrap();
match heading_re.find(rest) {
Some(m) => &rest[..m.start()],
None => rest,
}
};
let sub_re = Regex::new(r"(?m)(?:^|\n)(### )").unwrap();
let sub_sections: Vec<&str> = {
let mut sections = Vec::new();
let mut last = 0;
for m in sub_re.find_iter(api_section) {
if m.start() > last {
sections.push(&api_section[last..m.start()]);
}
last = m.start();
}
if last < api_section.len() {
sections.push(&api_section[last..]);
}
sections
};
for sub in sub_sections {
let header = sub
.lines()
.map(|l| l.trim())
.find(|l| !l.is_empty())
.unwrap_or("");
if header.starts_with("### ") && !header.contains("Exported") {
continue;
}
let mut in_method_subsection = false;
for line in sub.lines() {
if METHOD_HEADER_RE.is_match(line) {
in_method_subsection = true;
continue;
}
if line.starts_with("### ") {
in_method_subsection = false;
}
if in_method_subsection {
continue;
}
if let Some(caps) = TABLE_ROW_RE.captures(line)
&& let Some(sym) = caps.get(1)
{
symbols.push(sym.as_str().to_string());
}
}
}
let mut seen = HashSet::new();
symbols.retain(|s| seen.insert(s.clone()));
symbols
}
pub fn get_missing_sections(body: &str, required_sections: &[String]) -> Vec<String> {
let mut missing = Vec::new();
for section in required_sections {
let escaped = regex::escape(section);
let pattern = format!(r"(?m)^## {escaped}");
let re = Regex::new(&pattern).unwrap();
if !re.is_match(body) {
missing.push(section.clone());
}
}
missing
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_frontmatter_basic() {
let content = "---\nmodule: auth\nversion: 1\nstatus: active\nfiles:\n - src/auth.ts\ndb_tables: []\ndepends_on: []\n---\n\n# Auth\n\n## Purpose\n";
let parsed = parse_frontmatter(content).unwrap();
assert_eq!(parsed.frontmatter.module.as_deref(), Some("auth"));
assert_eq!(parsed.frontmatter.version.as_deref(), Some("1"));
assert_eq!(parsed.frontmatter.status.as_deref(), Some("active"));
assert_eq!(parsed.frontmatter.files, vec!["src/auth.ts"]);
assert!(parsed.frontmatter.db_tables.is_empty());
}
#[test]
fn test_parse_frontmatter_missing() {
let content = "# No frontmatter here\n\nJust markdown.";
assert!(parse_frontmatter(content).is_none());
}
#[test]
fn test_get_missing_sections() {
let body = "## Purpose\nSomething\n\n## Public API\nStuff\n";
let required = vec![
"Purpose".to_string(),
"Public API".to_string(),
"Invariants".to_string(),
];
let missing = get_missing_sections(body, &required);
assert_eq!(missing, vec!["Invariants"]);
}
#[test]
fn test_get_spec_symbols() {
let body = r#"## Purpose
Something
## Public API
### Exported Functions
| Function | Parameters | Returns | Description |
|----------|-----------|---------|-------------|
| `createAuth` | config: Config | Auth | Creates auth |
| `validateToken` | token: string | bool | Validates |
### Exported Types
| Type | Description |
|------|-------------|
| `AuthConfig` | Config type |
## Invariants
"#;
let symbols = get_spec_symbols(body);
assert_eq!(symbols, vec!["createAuth", "validateToken", "AuthConfig"]);
}
#[test]
fn test_get_spec_symbols_skips_non_exported_subsections() {
let body = r#"## Public API
### Exported Functions
| Function | Parameters | Returns | Description |
|----------|-----------|---------|-------------|
| `authenticate` | token: string | User | Validates token |
### API Endpoints
| Endpoint | Method | Handler | Description |
|----------|--------|---------|-------------|
| `/login` | POST | `login` | Login route |
| `/logout` | POST | `logout` | Logout route |
### Component API
| Signal | Type | Description |
|--------|------|-------------|
| `activeTab` | string | Current tab |
### Route Handlers
| Handler | Description |
|---------|-------------|
| `registration_status` | Check registration |
### Exported Types
| Type | Description |
|------|-------------|
| `AuthConfig` | Config type |
### Configuration
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `timeout` | number | 30 | Request timeout |
### Internal Functions
| Function | Description |
|----------|-------------|
| `hashPassword` | Internal hashing |
## Invariants
"#;
let symbols = get_spec_symbols(body);
assert_eq!(symbols, vec!["authenticate", "AuthConfig"]);
}
#[test]
fn test_parse_frontmatter_implements_list() {
let content = "---\nmodule: auth\nversion: 1\nstatus: active\nfiles:\n - src/auth.ts\nimplements:\n - 42\n - 57\ntracks:\n - 10\n---\n\n# Auth\n";
let parsed = parse_frontmatter(content).unwrap();
assert_eq!(parsed.frontmatter.implements, vec![42, 57]);
assert_eq!(parsed.frontmatter.tracks, vec![10]);
}
#[test]
fn test_parse_frontmatter_implements_inline() {
let content = "---\nmodule: auth\nversion: 1\nstatus: active\nfiles:\n - src/auth.ts\nimplements: [42, 57]\ntracks: [10]\n---\n\n# Auth\n";
let parsed = parse_frontmatter(content).unwrap();
assert_eq!(parsed.frontmatter.implements, vec![42, 57]);
assert_eq!(parsed.frontmatter.tracks, vec![10]);
}
#[test]
fn test_parse_frontmatter_empty_implements() {
let content = "---\nmodule: auth\nversion: 1\nstatus: active\nfiles:\n - src/auth.ts\nimplements: []\n---\n\n# Auth\n";
let parsed = parse_frontmatter(content).unwrap();
assert!(parsed.frontmatter.implements.is_empty());
assert!(parsed.frontmatter.tracks.is_empty());
}
#[test]
fn test_get_spec_symbols_top_level_table() {
let body = r#"## Public API
| Function | Parameters | Returns | Description |
|----------|-----------|---------|-------------|
| `helper` | input: string | string | Helps |
## Invariants
"#;
let symbols = get_spec_symbols(body);
assert_eq!(symbols, vec!["helper"]);
}
}