pub mod classifier;
pub mod id_generator;
pub mod mapper;
pub mod relation;
pub mod section;
use crate::model::file::{AgmFile, Header};
use crate::model::node::Node;
use self::classifier::NodeTypeClassifier;
use self::id_generator::IdGenerator;
use self::mapper::FieldMapper;
use self::relation::RelationInferrer;
use self::section::extract_sections;
#[derive(Debug, Clone)]
pub struct CompileOptions {
pub package: String,
pub version: String,
pub min_confidence: f32,
pub merge_same_type: bool,
pub id_prefix: Option<String>,
}
impl Default for CompileOptions {
fn default() -> Self {
Self {
package: "unnamed.package".to_owned(),
version: "0.1.0".to_owned(),
min_confidence: 0.5,
merge_same_type: false,
id_prefix: None,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct CompileWarning {
pub kind: CompileWarningKind,
pub message: String,
pub source_line: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CompileWarningKind {
LowConfidence,
AmbiguousType,
SkippedSection,
EmptySection,
IdCollision,
}
#[derive(Debug, Clone)]
pub struct CompileResult {
pub file: AgmFile,
pub warnings: Vec<CompileWarning>,
}
#[must_use]
pub fn compile(markdown: &str, options: &CompileOptions) -> CompileResult {
let mut warnings = Vec::new();
let sections = extract_sections(markdown);
if sections.is_empty() {
warnings.push(CompileWarning {
kind: CompileWarningKind::SkippedSection,
message: "No headings found in input; entire body treated as a single section"
.to_owned(),
source_line: Some(1),
});
}
let classifier = NodeTypeClassifier::new();
let mut id_gen = IdGenerator::new(options.id_prefix.as_deref());
let field_mapper = FieldMapper::new();
let mut nodes: Vec<Node> = Vec::new();
for section in §ions {
if section.body_text.trim().is_empty()
&& section.list_items.is_empty()
&& section.code_blocks.is_empty()
{
warnings.push(CompileWarning {
kind: CompileWarningKind::EmptySection,
message: format!("Empty section: '{}'", section.heading),
source_line: Some(section.source_line_start),
});
continue;
}
let (node_type, confidence, alt_type) = classifier.classify(section);
if confidence < options.min_confidence {
warnings.push(CompileWarning {
kind: CompileWarningKind::LowConfidence,
message: format!(
"Section '{}' classified as {} with confidence {:.2} (below threshold {:.2})",
section.heading, node_type, confidence, options.min_confidence
),
source_line: Some(section.source_line_start),
});
continue;
}
if let Some(ref alt) = alt_type {
warnings.push(CompileWarning {
kind: CompileWarningKind::AmbiguousType,
message: format!(
"Section '{}' could be {} or {}; chose {} (confidence {:.2})",
section.heading, node_type, alt, node_type, confidence
),
source_line: Some(section.source_line_start),
});
}
let (id, collision) = id_gen.generate(§ion.heading);
if collision {
warnings.push(CompileWarning {
kind: CompileWarningKind::IdCollision,
message: format!(
"ID collision for heading '{}'; using '{}'",
section.heading, id
),
source_line: Some(section.source_line_start),
});
}
let node = field_mapper.map_to_node(section, node_type, &id);
nodes.push(node);
}
if options.merge_same_type {
nodes = merge_consecutive_same_type(nodes);
}
RelationInferrer::infer(&mut nodes);
let header = Header {
agm: "1.0".to_owned(),
package: options.package.clone(),
version: options.version.clone(),
title: None,
owner: None,
imports: None,
default_load: None,
description: None,
tags: None,
status: None,
load_profiles: None,
target_runtime: None,
};
let file = AgmFile { header, nodes };
CompileResult { file, warnings }
}
fn merge_consecutive_same_type(nodes: Vec<Node>) -> Vec<Node> {
if nodes.is_empty() {
return nodes;
}
let mut merged: Vec<Node> = Vec::new();
let mut iter = nodes.into_iter();
let mut current = iter.next().unwrap();
for next in iter {
if next.node_type == current.node_type {
if let Some(next_items) = next.items {
current
.items
.get_or_insert_with(Vec::new)
.extend(next_items);
}
if let Some(next_steps) = next.steps {
current
.steps
.get_or_insert_with(Vec::new)
.extend(next_steps);
}
if let Some(next_detail) = next.detail {
let existing = current.detail.get_or_insert_with(String::new);
if !existing.is_empty() {
existing.push_str("\n\n");
}
existing.push_str(&next_detail);
}
} else {
merged.push(current);
current = next;
}
}
merged.push(current);
merged
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_compile_empty_input_returns_warning() {
let result = compile("", &CompileOptions::default());
assert!(!result.warnings.is_empty());
}
#[test]
fn test_compile_single_heading_with_body_returns_one_node() {
let md = "## Login Flow\n\n1. Resolve tenant.\n2. Redirect.\n";
let opts = CompileOptions {
package: "auth.platform".to_owned(),
version: "0.1.0".to_owned(),
min_confidence: 0.0, ..Default::default()
};
let result = compile(md, &opts);
assert_eq!(result.file.nodes.len(), 1);
assert_eq!(result.file.header.package, "auth.platform");
assert_eq!(result.file.header.agm, "1.0");
}
#[test]
fn test_compile_spec_s35_4_example_produces_two_nodes() {
let md = "\
## Login Constraints
- Access tokens must never be exposed to the browser.
- Sensitive calls must originate from the server.
## Login Flow
1. Resolve tenant by host.
2. Redirect to the provider.
3. Validate callback.
4. Create a server-side session.
";
let opts = CompileOptions {
package: "auth.platform".to_owned(),
version: "0.1.0".to_owned(),
min_confidence: 0.0,
..Default::default()
};
let result = compile(md, &opts);
assert_eq!(result.file.nodes.len(), 2);
}
#[test]
fn test_compile_low_confidence_section_excluded_with_warning() {
let md = "## Some Random Heading\n\nJust some text.\n";
let opts = CompileOptions {
min_confidence: 0.99, ..Default::default()
};
let result = compile(md, &opts);
assert!(result.file.nodes.is_empty());
assert!(
result
.warnings
.iter()
.any(|w| w.kind == CompileWarningKind::LowConfidence)
);
}
#[test]
fn test_compile_header_uses_options() {
let md = "## Test\n\nSome content.\n";
let opts = CompileOptions {
package: "my.pkg".to_owned(),
version: "2.0.0".to_owned(),
min_confidence: 0.0,
..Default::default()
};
let result = compile(md, &opts);
assert_eq!(result.file.header.package, "my.pkg");
assert_eq!(result.file.header.version, "2.0.0");
}
}