#![allow(dead_code, clippy::all)]
#[allow(unused_imports)]
use ought_spec::parser::Parser;
#[allow(unused_imports)]
use ought_spec::types::*;
#[allow(unused_imports)]
use ought_spec::graph::SpecGraph;
#[allow(unused_imports)]
use std::path::{Path, PathBuf};
#[allow(unused_imports)]
use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[allow(unused_imports)]
use std::fs;
#[test]
fn test_parser_clause_ir_must_generate_a_content_hash_for_each_clause_based_on_keyword_tex() {
let md = "# Svc\n\n## Rules\n\n- **MUST** do something specific\n";
let spec1 = Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed");
let spec2 = Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed");
assert_eq!(
spec1.sections[0].clauses[0].content_hash,
spec2.sections[0].clauses[0].content_hash
);
let md_should = "# Svc\n\n## Rules\n\n- **SHOULD** do something specific\n";
let spec_should =
Parser::parse_string(md_should, Path::new("test.ought.md")).expect("parse failed");
assert_ne!(
spec1.sections[0].clauses[0].content_hash,
spec_should.sections[0].clauses[0].content_hash
);
let md_diff = "# Svc\n\n## Rules\n\n- **MUST** do something entirely different\n";
let spec_diff =
Parser::parse_string(md_diff, Path::new("test.ought.md")).expect("parse failed");
assert_ne!(
spec1.sections[0].clauses[0].content_hash,
spec_diff.sections[0].clauses[0].content_hash
);
let md_cond =
"# Svc\n\n## Rules\n\n- **GIVEN** logged in:\n - **MUST** do something specific\n";
let spec_cond =
Parser::parse_string(md_cond, Path::new("test.ought.md")).expect("parse failed");
assert_ne!(
spec1.sections[0].clauses[0].content_hash,
spec_cond.sections[0].clauses[0].content_hash
);
let hash = &spec1.sections[0].clauses[0].content_hash;
assert!(!hash.is_empty());
assert!(
hash.chars().all(|c| c.is_ascii_hexdigit()),
"expected hex hash, got: {hash}"
);
}
#[test]
fn test_parser_clause_ir_must_generate_stable_clause_identifiers_from_the_section_path_and() {
let md = "# Auth\n\n## Login\n\n- **MUST** return a JWT token\n";
let spec1 = Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed");
let spec2 = Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed");
assert_eq!(
spec1.sections[0].clauses[0].id.0,
spec2.sections[0].clauses[0].id.0
);
assert_eq!(
spec1.sections[0].clauses[0].id.0,
"auth::login::must_return_a_jwt_token"
);
let nested_md =
"# Auth\n\n## Login\n\n### OAuth\n\n- **MUST** validate token signature\n";
let nested_spec =
Parser::parse_string(nested_md, Path::new("test.ought.md")).expect("parse failed");
let nested_id = &nested_spec.sections[0].subsections[0].clauses[0].id.0;
assert!(
nested_id.starts_with("auth::login::oauth::"),
"expected nested id to start with auth::login::oauth::, got: {nested_id}"
);
let md2 = "# Auth\n\n## Login\n\n- **MUST** reject invalid tokens\n";
let spec3 = Parser::parse_string(md2, Path::new("test.ought.md")).expect("parse failed");
assert_ne!(
spec1.sections[0].clauses[0].id.0,
spec3.sections[0].clauses[0].id.0
);
}
#[test]
fn test_parser_clause_ir_must_include_a_condition_field_populated_from_the_parent_given_bl() {
let md = "# Svc\n\n## Rules\n\n- **MUST** always do this\n";
let spec = Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed");
assert!(spec.sections[0].clauses[0].condition.is_none());
let md_given = concat!(
"# Svc\n\n## Rules\n\n",
"- **GIVEN** the user is authenticated:\n",
" - **MUST** return profile data\n",
" - **MUST NOT** expose other users' data\n"
);
let spec_given =
Parser::parse_string(md_given, Path::new("test.ought.md")).expect("parse failed");
let clauses = &spec_given.sections[0].clauses;
assert_eq!(clauses.len(), 2);
assert_eq!(clauses[0].keyword, Keyword::Must);
assert_eq!(clauses[1].keyword, Keyword::MustNot);
assert_eq!(
clauses[0].condition.as_deref(),
Some("the user is authenticated:")
);
assert_eq!(clauses[0].condition, clauses[1].condition);
}
#[test]
fn test_parser_clause_ir_must_include_a_temporal_field_for_must_always_qualifier_invariant() {
let md = "# Svc\n\n## Rules\n\n- **MUST** validate input\n";
let spec = Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed");
assert!(spec.sections[0].clauses[0].temporal.is_none());
let md_always =
"# Svc\n\n## Invariants\n\n- **MUST ALWAYS** keep connections below pool maximum\n";
let spec_always =
Parser::parse_string(md_always, Path::new("test.ought.md")).expect("parse failed");
let clause_always = &spec_always.sections[0].clauses[0];
assert_eq!(clause_always.keyword, Keyword::MustAlways);
assert_eq!(clause_always.severity, Severity::Required);
assert!(
matches!(clause_always.temporal, Some(Temporal::Invariant)),
"MUST ALWAYS should produce Temporal::Invariant"
);
let md_ms = "# Svc\n\n## Perf\n\n- **MUST BY 200ms** return a response\n";
let spec_ms = Parser::parse_string(md_ms, Path::new("test.ought.md")).expect("parse failed");
let clause_ms = &spec_ms.sections[0].clauses[0];
assert_eq!(clause_ms.keyword, Keyword::MustBy);
assert_eq!(clause_ms.severity, Severity::Required);
assert!(
matches!(clause_ms.temporal, Some(Temporal::Deadline(d)) if d == Duration::from_millis(200)),
"MUST BY 200ms should produce Deadline(200ms)"
);
let md_s = "# Svc\n\n## Perf\n\n- **MUST BY 5s** complete handshake\n";
let spec_s = Parser::parse_string(md_s, Path::new("test.ought.md")).expect("parse failed");
assert!(
matches!(spec_s.sections[0].clauses[0].temporal, Some(Temporal::Deadline(d)) if d == Duration::from_secs(5)),
"MUST BY 5s should produce Deadline(5s)"
);
let md_m = "# Svc\n\n## Perf\n\n- **MUST BY 30m** finish batch job\n";
let spec_m = Parser::parse_string(md_m, Path::new("test.ought.md")).expect("parse failed");
assert!(
matches!(spec_m.sections[0].clauses[0].temporal, Some(Temporal::Deadline(d)) if d == Duration::from_secs(30 * 60)),
"MUST BY 30m should produce Deadline(30min)"
);
}
#[test]
fn test_parser_clause_ir_must_include_an_otherwise_field_containing_the_ordered_list_of_fa() {
let md = "# Svc\n\n## Perf\n\n- **MUST** respond within 200ms\n";
let spec = Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed");
assert!(spec.sections[0].clauses[0].otherwise.is_empty());
let md_chain = concat!(
"# Svc\n\n## Perf\n\n",
"- **MUST** respond within 200ms\n",
" - **OTHERWISE** return a cached response\n",
" - **OTHERWISE** return 504 Gateway Timeout\n"
);
let spec_chain =
Parser::parse_string(md_chain, Path::new("test.ought.md")).expect("parse failed");
let clause = &spec_chain.sections[0].clauses[0];
assert_eq!(clause.keyword, Keyword::Must);
assert_eq!(clause.otherwise.len(), 2);
assert_eq!(clause.otherwise[0].keyword, Keyword::Otherwise);
assert!(
clause.otherwise[0].text.contains("cached response"),
"first fallback should be cached response"
);
assert_eq!(clause.otherwise[1].keyword, Keyword::Otherwise);
assert!(
clause.otherwise[1].text.contains("504"),
"second fallback should be 504"
);
assert_eq!(spec_chain.sections[0].clauses.len(), 1);
assert_eq!(clause.otherwise[0].severity, Severity::Required);
}
#[test]
fn test_parser_clause_ir_must_produce_a_clause_ir_struct_containing_keyword_severity_claus() {
let md = "# Auth\n\n## Login\n\n- **MUST** return a JWT token\n";
let spec = Parser::parse_string(md, Path::new("auth.ought.md")).expect("parse failed");
let clause = &spec.sections[0].clauses[0];
assert_eq!(clause.keyword, Keyword::Must);
assert_eq!(clause.severity, Severity::Required);
assert!(clause.text.contains("return a JWT token"));
assert_eq!(clause.source_location.file.to_str().unwrap(), "auth.ought.md");
assert!(clause.source_location.line > 0);
assert_eq!(clause.id.0, "auth::login::must_return_a_jwt_token");
assert!(!clause.content_hash.is_empty());
}
#[test]
fn test_parser_clause_ir_should_include_any_code_blocks_immediately_following_a_clause_as_hi() {
let md = concat!(
"# Svc\n\n## API\n\n",
"- **MUST** return valid JSON\n\n",
"```json\n{\"status\": \"ok\"}\n```\n"
);
let spec = Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed");
let clause = &spec.sections[0].clauses[0];
assert_eq!(clause.hints.len(), 1);
assert!(
clause.hints[0].contains("status"),
"hint should contain code block content"
);
let md_no_hint = "# Svc\n\n## API\n\n- **MUST** return valid JSON\n";
let spec_no_hint =
Parser::parse_string(md_no_hint, Path::new("test.ought.md")).expect("parse failed");
assert!(spec_no_hint.sections[0].clauses[0].hints.is_empty());
let md_prose_code = concat!(
"# Svc\n\n## API\n\n",
"Some introductory text.\n\n",
"```json\n{\"example\": true}\n```\n\n",
"- **MUST** return valid JSON\n"
);
let spec_prose =
Parser::parse_string(md_prose_code, Path::new("test.ought.md")).expect("parse failed");
assert!(
spec_prose.sections[0].clauses[0].hints.is_empty(),
"code block before clause should not become a hint"
);
assert!(
spec_prose.sections[0].prose.contains("example"),
"code block before clause should appear in section prose"
);
}
#[test]
fn test_parser_clause_ir_should_include_surrounding_prose_markdown_in_the_clause_s_context_f() {
let md = concat!(
"# Svc\n\n## Auth\n\n",
"This section describes the authentication flow.\n",
"Tokens are signed with RS256.\n\n",
"- **MUST** validate token signature\n\n",
"Additional notes about expiry edge cases.\n"
);
let spec = Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed");
let section = &spec.sections[0];
assert!(!section.prose.is_empty());
assert!(
section.prose.contains("authentication flow"),
"prose should include text before the clause"
);
assert!(
section.prose.contains("RS256"),
"prose should include all surrounding markdown content"
);
assert_eq!(section.clauses.len(), 1);
assert!(section.clauses[0].text.contains("validate token signature"));
let md_no_prose = "# Svc\n\n## Rules\n\n- **MUST** do something\n";
let spec_no_prose =
Parser::parse_string(md_no_prose, Path::new("test.ought.md")).expect("parse failed");
assert!(spec_no_prose.sections[0].prose.is_empty());
}
#[test]
fn test_parser_conditional_blocks_given_must_attach_the_given_condition_text_to_all_clauses_nested_within() {
fn parse(md: &str) -> Spec {
Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed")
}
let md = r#"# Svc
## Access
- **GIVEN** the request carries a valid token:
- **MUST** allow the request through
- **MUST NOT** log the token value
- **SHOULD** refresh the token if near expiry
"#;
let spec = parse(md);
let clauses = &spec.sections[0].clauses;
assert_eq!(clauses.len(), 3, "all three nested clauses should be emitted");
for clause in clauses {
assert_eq!(
clause.condition.as_deref(),
Some("the request carries a valid token:"),
"every nested clause must carry the GIVEN condition text; clause '{}' did not",
clause.text
);
}
}
#[test]
fn test_parser_conditional_blocks_given_must_not_treat_given_itself_as_a_testable_clause_it_is_a_grouping_con() {
fn parse(md: &str) -> Spec {
Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed")
}
let md = r#"# Svc
## Access
- **GIVEN** the user is authenticated:
- **MUST** return their profile data
"#;
let spec = parse(md);
let clauses = &spec.sections[0].clauses;
let given_clauses: Vec<_> = clauses.iter()
.filter(|c| c.keyword == Keyword::Given)
.collect();
assert!(
given_clauses.is_empty(),
"GIVEN must not appear as a testable clause in the IR; found {} Given clause(s)",
given_clauses.len()
);
assert_eq!(clauses.len(), 1);
assert_eq!(clauses[0].keyword, Keyword::Must);
}
#[test]
fn test_parser_conditional_blocks_given_must_parse_given_as_a_block_level_keyword_that_contains_nested_cl() {
fn parse(md: &str) -> Spec {
Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed")
}
let md = r#"# Svc
## Access
- **GIVEN** the user is authenticated:
- **MUST** return their profile data
- **SHOULD** include last-login timestamp
"#;
let spec = parse(md);
let clauses = &spec.sections[0].clauses;
assert_eq!(clauses.len(), 2);
assert_eq!(clauses[0].keyword, Keyword::Must);
assert!(clauses[0].text.contains("return their profile data"));
assert_eq!(clauses[1].keyword, Keyword::Should);
assert!(clauses[1].text.contains("include last-login timestamp"));
}
#[test]
fn test_parser_conditional_blocks_given_must_require_nested_clauses_to_be_indented_under_the_given_bullet() {
fn parse(md: &str) -> Spec {
Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed")
}
let md = r#"# Svc
## Rules
- **GIVEN** user is admin:
- **MUST** do something important
"#;
let spec = parse(md);
let clauses = &spec.sections[0].clauses;
let must_clause = clauses.iter().find(|c| c.keyword == Keyword::Must)
.expect("expected a MUST clause");
assert!(
must_clause.condition.is_none(),
"un-indented MUST after GIVEN must not inherit the GIVEN condition"
);
}
#[test]
fn test_parser_conditional_blocks_given_must_support_given_blocks_containing_any_keyword_must_should_may() {
fn parse(md: &str) -> Spec {
Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed")
}
let md = r#"# Svc
## Behaviour
- **GIVEN** the feature flag is enabled:
- **MUST** activate the new code path
- **MUST NOT** fall back to the legacy path
- **SHOULD** emit a telemetry event
- **SHOULD NOT** cache the result
- **MAY** log additional debug info
- **WONT** support IE11 in this mode
"#;
let spec = parse(md);
let clauses = &spec.sections[0].clauses;
assert_eq!(clauses.len(), 6);
let keywords: Vec<Keyword> = clauses.iter().map(|c| c.keyword).collect();
assert!(keywords.contains(&Keyword::Must), "MUST inside GIVEN");
assert!(keywords.contains(&Keyword::MustNot), "MUST NOT inside GIVEN");
assert!(keywords.contains(&Keyword::Should), "SHOULD inside GIVEN");
assert!(keywords.contains(&Keyword::ShouldNot), "SHOULD NOT inside GIVEN");
assert!(keywords.contains(&Keyword::May), "MAY inside GIVEN");
assert!(keywords.contains(&Keyword::Wont), "WONT inside GIVEN");
for clause in clauses {
assert_eq!(
clause.condition.as_deref(),
Some("the feature flag is enabled:"),
"clause '{}' missing condition", clause.text
);
}
}
#[test]
fn test_parser_conditional_blocks_given_must_support_multiple_given_blocks_within_a_section() {
fn parse(md: &str) -> Spec {
Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed")
}
let md = r#"# Svc
## Auth
- **GIVEN** the user is an admin:
- **MUST** allow access to the admin panel
- **MAY** impersonate other users
- **GIVEN** the user is a guest:
- **MUST NOT** access private resources
- **SHOULD** be shown a login prompt
"#;
let spec = parse(md);
let clauses = &spec.sections[0].clauses;
assert_eq!(clauses.len(), 4, "two GIVEN blocks with two children each = four clauses");
let admin_clauses: Vec<_> = clauses.iter()
.filter(|c| c.condition.as_deref() == Some("the user is an admin:"))
.collect();
assert_eq!(admin_clauses.len(), 2);
assert!(admin_clauses.iter().any(|c| c.keyword == Keyword::Must));
assert!(admin_clauses.iter().any(|c| c.keyword == Keyword::May));
let guest_clauses: Vec<_> = clauses.iter()
.filter(|c| c.condition.as_deref() == Some("the user is a guest:"))
.collect();
assert_eq!(guest_clauses.len(), 2);
assert!(guest_clauses.iter().any(|c| c.keyword == Keyword::MustNot));
assert!(guest_clauses.iter().any(|c| c.keyword == Keyword::Should));
}
#[test]
fn test_parser_conditional_blocks_given_should_support_nested_given_blocks_conditions_that_narrow_further() {
fn parse(md: &str) -> Spec {
Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed")
}
let md = r#"# Svc
## Permissions
- **GIVEN** the user is an admin:
- **GIVEN** the user account is active:
- **MUST** allow full access
"#;
let spec = parse(md);
let clauses = &spec.sections[0].clauses;
assert_eq!(clauses.len(), 1);
assert_eq!(clauses[0].keyword, Keyword::Must);
assert!(clauses[0].text.contains("allow full access"));
assert!(
clauses[0].condition.is_some(),
"clause nested inside two GIVENs must carry a condition"
);
}
#[test]
fn test_parser_context_metadata_may_support_glob_patterns_in_source_and_schema_paths() {
fn parse(md: &str) -> Spec {
Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed")
}
let md = r#"# MySpec
source: src/**/*.rs, tests/**/*.rs
schema: migrations/*.sql, config/*.json
## Rules
- **MUST** do something
"#;
let spec = parse(md);
assert!(
spec.metadata.sources.iter().any(|s| s == "src/**/*.rs"),
"recursive glob in source not preserved"
);
assert!(
spec.metadata.sources.iter().any(|s| s == "tests/**/*.rs"),
"recursive glob in tests source not preserved"
);
assert!(
spec.metadata.schemas.iter().any(|s| s == "migrations/*.sql"),
"wildcard glob in schema not preserved"
);
assert!(
spec.metadata.schemas.iter().any(|s| s == "config/*.json"),
"wildcard glob in config schema not preserved"
);
}
#[test]
fn test_parser_context_metadata_must_parse_context_as_free_text_context_for_the_llm() {
fn parse(md: &str) -> Spec {
Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed")
}
let md = r#"# MySpec
context: Handles user authentication and session management for the web API
## Rules
- **MUST** do something
"#;
let spec = parse(md);
let ctx = spec
.metadata
.context
.expect("`context:` field should be Some");
assert_eq!(
ctx,
"Handles user authentication and session management for the web API"
);
}
#[test]
fn test_parser_context_metadata_must_parse_schema_as_a_list_of_file_paths_schemas_configs_migrati(
) {
fn parse(md: &str) -> Spec {
Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed")
}
let md = r#"# MySpec
schema: schema/auth.graphql, config/settings.json, migrations/001_init.sql
## Rules
- **MUST** do something
"#;
let spec = parse(md);
assert_eq!(spec.metadata.schemas.len(), 3);
assert!(
spec.metadata.schemas.iter().any(|s| s == "schema/auth.graphql"),
"graphql schema not found"
);
assert!(
spec.metadata.schemas.iter().any(|s| s == "config/settings.json"),
"json config not found"
);
assert!(
spec.metadata.schemas.iter().any(|s| s == "migrations/001_init.sql"),
"sql migration not found"
);
}
#[test]
fn test_parser_context_metadata_must_parse_source_as_a_list_of_file_paths_or_directories_source_c(
) {
fn parse(md: &str) -> Spec {
Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed")
}
let md = r#"# MySpec
source: src/handlers/, src/models/user.rs
## Rules
- **MUST** do something
"#;
let spec = parse(md);
assert_eq!(spec.metadata.sources.len(), 2);
assert!(
spec.metadata.sources.iter().any(|s| s == "src/handlers/"),
"directory path not found in sources"
);
assert!(
spec.metadata.sources.iter().any(|s| s == "src/models/user.rs"),
"file path not found in sources"
);
}
#[test]
fn test_parser_context_metadata_must_support_multiple_values_per_metadata_key_one_per_line_or_com(
) {
fn parse(md: &str) -> Spec {
Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed")
}
let md_comma = r#"# MySpec
source: src/a/, src/b/, src/c/
## Rules
- **MUST** do something
"#;
let spec = parse(md_comma);
assert_eq!(
spec.metadata.sources.len(),
3,
"comma-separated: expected 3 sources"
);
assert!(spec.metadata.sources.iter().any(|s| s == "src/a/"));
assert!(spec.metadata.sources.iter().any(|s| s == "src/b/"));
assert!(spec.metadata.sources.iter().any(|s| s == "src/c/"));
let md_lines = r#"# MySpec
source: src/a/
source: src/b/
source: src/c/
## Rules
- **MUST** do something
"#;
let spec2 = parse(md_lines);
assert_eq!(
spec2.metadata.sources.len(),
3,
"one-per-line: expected 3 sources"
);
assert!(spec2.metadata.sources.iter().any(|s| s == "src/a/"));
assert!(spec2.metadata.sources.iter().any(|s| s == "src/b/"));
assert!(spec2.metadata.sources.iter().any(|s| s == "src/c/"));
}
#[test]
fn test_parser_contrary_to_duty_chains_otherwise_must_link_each_otherwise_clause_to_its_parent_obligation_in_the_c() {
let md = r#"# Svc
## Api
- **MUST** validate the request payload
- **OTHERWISE** reject with 400 Bad Request
- **MUST** authenticate the caller
- **OTHERWISE** reject with 401 Unauthorized
"#;
let spec = Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed");
let clauses = &spec.sections[0].clauses;
assert_eq!(clauses.len(), 2);
assert_eq!(clauses[0].keyword, Keyword::Must);
assert_eq!(clauses[0].otherwise.len(), 1);
assert!(clauses[0].otherwise[0].text.contains("400"));
assert_eq!(clauses[1].keyword, Keyword::Must);
assert_eq!(clauses[1].otherwise.len(), 1);
assert!(clauses[1].otherwise[0].text.contains("401"));
assert!(
clauses.iter().all(|c| c.keyword != Keyword::Otherwise),
"OTHERWISE clauses must not appear as top-level section clauses"
);
}
#[test]
fn test_parser_contrary_to_duty_chains_otherwise_must_not_allow_otherwise_at_the_top_level_it_must_have_a_parent_oblig() {
let md = r#"# Svc
## Section
- **OTHERWISE** this has no parent obligation
"#;
let result = Parser::parse_string(md, Path::new("test.ought.md"));
assert!(
result.is_err(),
"OTHERWISE at the top level without a parent obligation must be a parse error"
);
}
#[test]
fn test_parser_contrary_to_duty_chains_otherwise_must_not_allow_otherwise_under_may_wont_or_given_only_under_obligatio() {
let md_may = r#"# Svc
## Section
- **MAY** use optional feature
- **OTHERWISE** do nothing
"#;
assert!(
Parser::parse_string(md_may, Path::new("test.ought.md")).is_err(),
"OTHERWISE under MAY must be a parse error"
);
let md_wont = r#"# Svc
## Section
- **WONT** implement feature X
- **OTHERWISE** implement feature Y instead
"#;
assert!(
Parser::parse_string(md_wont, Path::new("test.ought.md")).is_err(),
"OTHERWISE under WONT must be a parse error"
);
let md_given = r#"# Svc
## Section
- **GIVEN** some condition:
- **OTHERWISE** fallback without an obligation
"#;
assert!(
Parser::parse_string(md_given, Path::new("test.ought.md")).is_err(),
"OTHERWISE under GIVEN must be a parse error"
);
}
#[test]
fn test_parser_contrary_to_duty_chains_otherwise_must_parse_otherwise_as_a_clause_nested_under_a_parent_obligation() {
let md = r#"# Svc
## Resilience
- **MUST** respond within 200ms
- **OTHERWISE** return a cached response
"#;
let spec = Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed");
let clauses = &spec.sections[0].clauses;
assert_eq!(clauses.len(), 1, "only the parent obligation should appear as a top-level clause");
assert_eq!(clauses[0].keyword, Keyword::Must);
let otherwise = &clauses[0].otherwise;
assert_eq!(otherwise.len(), 1);
assert_eq!(otherwise[0].keyword, Keyword::Otherwise);
assert!(otherwise[0].text.contains("cached response"));
}
#[test]
fn test_parser_contrary_to_duty_chains_otherwise_must_preserve_the_ordering_of_otherwise_clauses_they_form_a_degra() {
let md = r#"# Svc
## Resilience
- **MUST** respond with fresh data
- **OTHERWISE** return stale cache
- **OTHERWISE** return degraded placeholder
- **OTHERWISE** return HTTP 503
"#;
let spec = Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed");
let otherwise = &spec.sections[0].clauses[0].otherwise;
assert_eq!(otherwise.len(), 3);
assert!(otherwise[0].text.contains("stale cache"), "first fallback must be stale cache");
assert!(otherwise[1].text.contains("degraded placeholder"), "second fallback must be degraded placeholder");
assert!(otherwise[2].text.contains("503"), "third fallback must be 503");
}
#[test]
fn test_parser_contrary_to_duty_chains_otherwise_must_support_multiple_otherwise_clauses_under_a_single_parent_ord() {
let md = r#"# Svc
## Payments
- **MUST** charge the primary card
- **OTHERWISE** charge the backup card
- **OTHERWISE** add to pending queue
- **OTHERWISE** reject with insufficient funds error
"#;
let spec = Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed");
let clauses = &spec.sections[0].clauses;
assert_eq!(clauses.len(), 1, "only one top-level clause should exist");
let otherwise = &clauses[0].otherwise;
assert_eq!(otherwise.len(), 3, "all three fallbacks must be collected under the single parent");
assert!(otherwise.iter().all(|c| c.keyword == Keyword::Otherwise));
assert!(otherwise[0].text.contains("backup card"));
assert!(otherwise[1].text.contains("pending queue"));
assert!(otherwise[2].text.contains("insufficient funds"));
}
#[test]
fn test_parser_contrary_to_duty_chains_otherwise_must_support_otherwise_under_any_obligation_keyword_must_should_m() {
let md = r#"# Svc
## Obligations
- **MUST** respond within 200ms
- **OTHERWISE** return HTTP 503
- **SHOULD** include debug headers
- **OTHERWISE** omit debug headers silently
- **MUST ALWAYS** maintain a live connection
- **OTHERWISE** reconnect with exponential backoff
- **MUST BY 5s** complete the handshake
- **OTHERWISE** abort and log timeout
"#;
let spec = Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed");
let clauses = &spec.sections[0].clauses;
assert_eq!(clauses.len(), 4);
assert_eq!(clauses[0].keyword, Keyword::Must);
assert_eq!(clauses[0].otherwise.len(), 1, "MUST must support OTHERWISE");
assert_eq!(clauses[1].keyword, Keyword::Should);
assert_eq!(clauses[1].otherwise.len(), 1, "SHOULD must support OTHERWISE");
assert_eq!(clauses[2].keyword, Keyword::MustAlways);
assert_eq!(clauses[2].otherwise.len(), 1, "MUST ALWAYS must support OTHERWISE");
assert_eq!(clauses[3].keyword, Keyword::MustBy);
assert_eq!(clauses[3].otherwise.len(), 1, "MUST BY must support OTHERWISE");
}
#[test]
fn test_parser_contrary_to_duty_chains_otherwise_should_inherit_the_parent_s_severity_unless_the_otherwise_clause_sp() {
let md = r#"# Svc
## Graceful
- **MUST** return primary data
- **OTHERWISE** return cached copy
- **SHOULD** include metadata
- **OTHERWISE** omit metadata field
"#;
let spec = Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed");
let clauses = &spec.sections[0].clauses;
assert_eq!(clauses.len(), 2);
assert_eq!(clauses[0].severity, Severity::Required);
assert_eq!(
clauses[0].otherwise[0].severity,
Severity::Required,
"OTHERWISE under MUST must inherit Required severity"
);
assert_eq!(clauses[1].severity, Severity::Recommended);
assert_eq!(
clauses[1].otherwise[0].severity,
Severity::Recommended,
"OTHERWISE under SHOULD must inherit Recommended severity"
);
}
#[test]
fn test_parser_cross_file_references_must_build_a_dependency_graph_from_cross_file_references() {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let tmp = std::env::temp_dir().join(format!("ought_xref_graph_{nanos}"));
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(
tmp.join("spec_b.ought.md"),
"# SpecB\n\n## Rules\n\n- **MUST** provide base data\n",
)
.unwrap();
std::fs::write(
tmp.join("spec_a.ought.md"),
"# SpecA\n\nrequires: [SpecB](spec_b.ought.md)\n\n## Rules\n\n- **MUST** use SpecB data\n",
)
.unwrap();
let graph =
SpecGraph::from_roots(&[tmp.clone()]).expect("graph must build successfully with no cycles");
assert_eq!(
graph.specs().len(),
2,
"graph must contain all discovered spec files"
);
let order = graph.topological_order();
assert_eq!(
order.len(),
2,
"topological order must include every spec in the graph"
);
let pos_a = order
.iter()
.position(|s| s.name == "SpecA")
.expect("SpecA must appear in topological order");
let pos_b = order
.iter()
.position(|s| s.name == "SpecB")
.expect("SpecB must appear in topological order");
assert!(
pos_b < pos_a,
"dependency SpecB must appear before dependent SpecA in topological order \
(got pos_b={pos_b}, pos_a={pos_a})"
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_parser_cross_file_references_must_detect_circular_dependencies_and_report_them_as_errors() {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let tmp = std::env::temp_dir().join(format!("ought_xref_cycle_{nanos}"));
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(
tmp.join("a.ought.md"),
"# SpecA\n\nrequires: [SpecB](b.ought.md)\n\n## Rules\n\n- **MUST** do something\n",
)
.unwrap();
std::fs::write(
tmp.join("b.ought.md"),
"# SpecB\n\nrequires: [SpecA](a.ought.md)\n\n## Rules\n\n- **MUST** do something\n",
)
.unwrap();
let result = SpecGraph::from_roots(&[tmp.clone()]);
assert!(
result.is_err(),
"a circular dependency must be reported as an error rather than silently accepted"
);
let errors = result.unwrap_err();
let has_cycle_error = errors
.iter()
.any(|e| e.message.contains("circular dependency"));
assert!(
has_cycle_error,
"error message must identify the circular dependency; got: {:?}",
errors.iter().map(|e| &e.message).collect::<Vec<_>>()
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_parser_cross_file_references_must_parse_anchor_links_e_g_pricing_ought_md_discount_rules_as_re() {
let md = r#"# Checkout
requires: [Pricing](pricing.ought.md#discount-rules), [Auth](auth.ought.md#session-tokens)
## Payment
- **MUST** apply discount rules from the pricing spec
"#;
let spec = Parser::parse_string(md, Path::new("checkout.ought.md"))
.expect("parse failed");
assert_eq!(
spec.metadata.requires.len(),
2,
"anchor links must be parsed as cross-references"
);
let pricing = &spec.metadata.requires[0];
assert_eq!(
pricing.label, "Pricing",
"link label must be captured from an anchor link"
);
assert_eq!(
pricing.path.to_str().unwrap(),
"pricing.ought.md",
"file path must be extracted without the fragment"
);
assert_eq!(
pricing.anchor.as_deref(),
Some("discount-rules"),
"URL fragment must be stored as the anchor field"
);
let auth = &spec.metadata.requires[1];
assert_eq!(
auth.path.to_str().unwrap(),
"auth.ought.md",
"second anchor link file path must be extracted correctly"
);
assert_eq!(
auth.anchor.as_deref(),
Some("session-tokens"),
"second anchor must be extracted from the URL fragment"
);
}
#[test]
fn test_parser_cross_file_references_must_parse_inline_markdown_links_to_other_ought_md_files_as_cross() {
let md = r#"# Checkout
requires: [Pricing](pricing.ought.md), [Users](users.ought.md)
## Payment
- **MUST** apply pricing rules
"#;
let spec = Parser::parse_string(md, Path::new("checkout.ought.md"))
.expect("parse failed");
assert_eq!(
spec.metadata.requires.len(),
2,
"each markdown link in requires: must become a separate cross-reference"
);
let pricing = &spec.metadata.requires[0];
assert_eq!(
pricing.label, "Pricing",
"markdown link label must be captured as the SpecRef label"
);
assert_eq!(
pricing.path.to_str().unwrap(),
"pricing.ought.md",
"markdown link URL must become the SpecRef path"
);
assert!(
pricing.anchor.is_none(),
"link without a URL fragment must have no anchor"
);
let users = &spec.metadata.requires[1];
assert_eq!(
users.label, "Users",
"second link label must be captured"
);
assert_eq!(
users.path.to_str().unwrap(),
"users.ought.md",
"second link URL must become the SpecRef path"
);
assert!(users.anchor.is_none());
}
#[test]
fn test_parser_cross_file_references_must_parse_requires_metadata_as_a_list_of_relative_paths_to_other() {
let md = r#"# Billing
requires: pricing.ought.md
requires: users.ought.md
## Invoices
- **MUST** calculate totals correctly
"#;
let spec = Parser::parse_string(md, Path::new("billing.ought.md"))
.expect("parse failed");
assert_eq!(
spec.metadata.requires.len(),
2,
"requires: metadata must list all referenced spec files"
);
let first = &spec.metadata.requires[0];
assert_eq!(
first.path.to_str().unwrap(),
"pricing.ought.md",
"first requires: entry must carry the correct relative path"
);
assert!(
first.anchor.is_none(),
"plain path without a fragment must have no anchor"
);
let second = &spec.metadata.requires[1];
assert_eq!(
second.path.to_str().unwrap(),
"users.ought.md",
"second requires: entry must carry the correct relative path"
);
assert!(
second.anchor.is_none(),
"plain path without a fragment must have no anchor"
);
}
#[test]
fn test_parser_cross_file_references_should_validate_that_all_cross_references_resolve_to_existing_files() {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let tmp = std::env::temp_dir().join(format!("ought_xref_missing_{nanos}"));
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(
tmp.join("spec_a.ought.md"),
"# SpecA\n\nrequires: [Missing](nonexistent.ought.md)\n\n## Rules\n\n- **MUST** reference real specs\n",
)
.unwrap();
let result = SpecGraph::from_roots(&[tmp.clone()]);
assert!(
result.is_err(),
"a requires: reference to a non-existent file must be reported as a validation error"
);
let errors = result.unwrap_err();
let has_unresolved_error = errors.iter().any(|e| {
e.message.contains("nonexistent.ought.md")
|| e.message.contains("unresolved")
|| e.message.contains("not found")
});
assert!(
has_unresolved_error,
"error must identify the unresolved cross-reference; got: {:?}",
errors.iter().map(|e| &e.message).collect::<Vec<_>>()
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_parser_error_handling_must_continue_parsing_after_non_fatal_errors_collect_all_errors_d() {
let md = "\
# Svc
## Rules
- **MUTS** first typo — not a recognised keyword
- **MUST** first valid clause after typo
- **SHOLD** second typo
- **SHOULD** second valid clause after typo
- **MUST NOT** third valid clause at end of section
";
let result = Parser::parse_string(md, Path::new("test.ought.md"));
assert!(
result.is_ok(),
"unrecognised keyword typos must not cause a hard parse failure"
);
let spec = result.unwrap();
let clauses = &spec.sections[0].clauses;
assert_eq!(
clauses.len(),
3,
"parser must collect all valid clauses across the entire document, \
not stop at the first unrecognised item"
);
assert_eq!(
clauses[0].keyword,
Keyword::Must,
"valid MUST after first typo must be parsed"
);
assert_eq!(
clauses[1].keyword,
Keyword::Should,
"valid SHOULD after second typo must be parsed"
);
assert_eq!(
clauses[2].keyword,
Keyword::MustNot,
"valid MUST NOT at end of section must be parsed"
);
let file_result = Parser::parse_file(Path::new("/no/such/file.ought.md"));
let errors = file_result.unwrap_err();
assert!(
!errors.is_empty(),
"errors must be returned in a Vec so callers see all diagnostics"
);
}
#[test]
fn test_parser_error_handling_must_not_crash_on_malformed_markdown_degrade_gracefully() {
let result = Parser::parse_string("", Path::new("empty.ought.md"));
assert!(result.is_ok(), "empty input must parse without error");
let spec = result.unwrap();
assert_eq!(spec.name, "Untitled", "empty doc must default to 'Untitled'");
assert!(spec.sections.is_empty(), "empty doc must have no sections");
let result = Parser::parse_string(" \n\n \n", Path::new("ws.ought.md"));
assert!(result.is_ok(), "whitespace-only input must not fail");
let result = Parser::parse_string("# Just a Title\n", Path::new("title_only.ought.md"));
assert!(result.is_ok(), "H1-only doc must not fail");
assert_eq!(result.unwrap().name, "Just a Title");
let result = Parser::parse_string(
"## Section\n\n- **MUST** do the thing\n",
Path::new("no_h1.ought.md"),
);
assert!(result.is_ok(), "missing H1 must not crash the parser");
let spec = result.unwrap();
assert!(!spec.sections.is_empty(), "section must be parsed even without H1");
assert_eq!(
spec.sections[0].clauses.len(),
1,
"clause must be parsed when H1 is absent"
);
let result = Parser::parse_string(
"# Svc\n\n## S\n\n- **MUST unclosed bold marker\n- **MUST** valid after unclosed\n",
Path::new("unclosed_bold.ought.md"),
);
if let Ok(spec) = result {
assert!(
!spec.sections[0].clauses.is_empty(),
"valid clause after unclosed bold must be parsed"
);
}
let _ = Parser::parse_string(
"# Svc\n\n## S\n\n- **MUST** clause\n\n```\nno closing fence\n",
Path::new("unclosed_fence.ought.md"),
);
let _ = Parser::parse_string(
"# Svc\n\n## S\n\n- **MUST** handle \\* \\` \\[ edge chars\n",
Path::new("escapes.ought.md"),
);
let deep = format!(
"# Svc\n\n## S\n\n- **MUST** top\n{}- nested\n{}- deeper\n{}- deepest\n",
" ".repeat(1),
" ".repeat(2),
" ".repeat(3),
);
let _ = Parser::parse_string(&deep, Path::new("deep_nesting.ought.md"));
let result = Parser::parse_string("# Svc\n\n## Empty Section\n\n## Next\n\n- **MUST** clause\n", Path::new("empty_sec.ought.md"));
assert!(result.is_ok(), "empty section followed by valid section must not fail");
let spec = result.unwrap();
assert_eq!(
spec.sections.iter().map(|s| s.clauses.len()).sum::<usize>(),
1,
"clause in section after an empty section must still be parsed"
);
}
#[test]
fn test_parser_error_handling_must_report_parse_errors_with_the_file_path_line_number_and_a_cle() {
let path = PathBuf::from("spec/auth.ought.md");
let err = ParseError {
file: path.clone(),
line: 23,
message: "unexpected token after MUST".to_string(),
};
assert_eq!(err.file, path, "error must carry the source file path");
assert_eq!(err.line, 23, "error must carry the 1-based line number");
assert!(!err.message.is_empty(), "error must include a non-empty human-readable message");
let display = format!("{}", err);
assert!(
display.contains("spec/auth.ought.md"),
"display must include the file path; got: {display}"
);
assert!(
display.contains("23"),
"display must include the line number; got: {display}"
);
assert!(
display.contains("unexpected token"),
"display must include the message text; got: {display}"
);
let missing = Path::new("/nonexistent/spec.ought.md");
let result = Parser::parse_file(missing);
assert!(result.is_err(), "parse_file must return Err for a missing file");
let errors = result.unwrap_err();
assert!(!errors.is_empty(), "errors Vec must be non-empty");
assert_eq!(
errors[0].file, missing,
"parse_file error must record the path that was attempted"
);
assert!(
!errors[0].message.is_empty(),
"parse_file error must include a clear message describing the failure"
);
}
#[test]
fn test_parser_error_handling_should_warn_on_likely_typos_e_g_muts_close_to_a_known_keyword() {
let md = "\
# Svc
## Rules
- **MUTS** typo for MUST — must not become a clause
- **MUST** the real keyword
- **SHOLD** typo for SHOULD — must not become a clause
- **SHOUD** another SHOULD variant — must not become a clause
";
let spec = Parser::parse_string(md, Path::new("typos.ought.md"))
.expect("keyword typos must not crash the parser");
let clauses = &spec.sections[0].clauses;
assert!(
!clauses.iter().any(|c| c.text.contains("typo for MUST")),
"**MUTS** must not produce a clause — it is not a recognised keyword"
);
assert!(
!clauses.iter().any(|c| c.text.contains("typo for SHOULD")),
"**SHOLD** must not produce a clause — it is not a recognised keyword"
);
assert!(
!clauses.iter().any(|c| c.text.contains("another SHOULD variant")),
"**SHOUD** must not produce a clause — it is not a recognised keyword"
);
assert_eq!(
clauses.len(),
1,
"only the single valid **MUST** item must become a clause; typos become prose"
);
assert_eq!(
clauses[0].keyword,
Keyword::Must,
"valid **MUST** must be recognised even when surrounded by typo items"
);
}
#[test]
fn test_parser_keywords_must_assign_severity_levels_must_must_not_must_always_must_by_req() {
let md = r#"# Svc
## All Keywords
- **MUST** do something required
- **MUST NOT** skip something required
- **MUST ALWAYS** hold an invariant
- **MUST BY 1s** finish within deadline
- **SHOULD** follow recommendation
- **SHOULD NOT** violate recommendation
- **MAY** use optional feature
- **WONT** implement out-of-scope thing
"#;
let spec = Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed");
let clauses = &spec.sections[0].clauses;
assert_eq!(clauses.len(), 8);
assert_eq!(clauses[0].severity, Severity::Required, "MUST → Required");
assert_eq!(clauses[1].severity, Severity::Required, "MUST NOT → Required");
assert_eq!(clauses[2].severity, Severity::Required, "MUST ALWAYS → Required");
assert_eq!(clauses[3].severity, Severity::Required, "MUST BY → Required");
assert_eq!(clauses[4].severity, Severity::Recommended, "SHOULD → Recommended");
assert_eq!(clauses[5].severity, Severity::Recommended, "SHOULD NOT → Recommended");
assert_eq!(clauses[6].severity, Severity::Optional, "MAY → Optional");
assert_eq!(clauses[7].severity, Severity::NegativeConfirmation, "WONT → NegativeConfirmation");
}
#[test]
fn test_parser_keywords_must_not_treat_bare_non_bold_keyword_occurrences_as_clauses_e_g_you_m() {
let md = r#"# Svc
## Overview
This service must handle all requests. Operators should monitor memory usage.
You must not store credentials in logs. The system may cache responses.
Deployments wont need downtime. Given the above, otherwise consider alternatives.
- A plain list item that says you must restart after upgrade
- Another item: should not be mistaken for a clause
"#;
let spec = Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed");
assert!(
spec.sections[0].clauses.is_empty(),
"bare keywords in paragraphs and unbolded list items must not produce any clauses"
);
let md_mixed = r#"# Svc
## Mixed
This service must handle all requests as described above.
- **MUST** actually validate the token
"#;
let spec2 = Parser::parse_string(md_mixed, Path::new("test.ought.md")).expect("parse failed");
let clauses = &spec2.sections[0].clauses;
assert_eq!(clauses.len(), 1, "only the bold keyword item must become a clause");
assert_eq!(clauses[0].keyword, Keyword::Must);
assert!(clauses[0].text.contains("validate the token"));
}
#[test]
fn test_parser_keywords_must_parse_keywords_case_insensitively_but_require_them_to_appear() {
let md_bold = r#"# Svc
## Rules
- **must** do something lowercase
- **Must** do something title-case
- **MUST** do something uppercase
- **must not** reject lowercase compound
- **Should** recommend title-case
- **may** allow lowercase optional
- **wont** refuse lowercase
"#;
let spec = Parser::parse_string(md_bold, Path::new("test.ought.md")).expect("parse failed");
let clauses = &spec.sections[0].clauses;
assert_eq!(clauses.len(), 7, "all bold keyword variants must be parsed regardless of case");
assert_eq!(clauses[0].keyword, Keyword::Must);
assert_eq!(clauses[1].keyword, Keyword::Must);
assert_eq!(clauses[2].keyword, Keyword::Must);
assert_eq!(clauses[3].keyword, Keyword::MustNot);
assert_eq!(clauses[4].keyword, Keyword::Should);
assert_eq!(clauses[5].keyword, Keyword::May);
assert_eq!(clauses[6].keyword, Keyword::Wont);
let md_bare = r#"# Svc
## Prose
You must restart the service after upgrading.
The system should validate inputs.
"#;
let spec2 = Parser::parse_string(md_bare, Path::new("test.ought.md")).expect("parse failed");
assert!(
spec2.sections[0].clauses.is_empty(),
"bare (non-bold) keywords in prose must not produce clauses"
);
}
#[test]
fn test_parser_keywords_must_recognize_rfc_2119_keywords_must_must_not_should_should_not() {
let md = r#"# Svc
## Rules
- **MUST** perform authentication
- **MUST NOT** expose internal errors
- **SHOULD** log failed attempts
- **SHOULD NOT** cache sensitive tokens
- **MAY** support remember-me sessions
"#;
let spec = Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed");
let clauses = &spec.sections[0].clauses;
assert_eq!(clauses.len(), 5);
assert_eq!(clauses[0].keyword, Keyword::Must);
assert!(clauses[0].text.contains("authentication"));
assert_eq!(clauses[1].keyword, Keyword::MustNot);
assert!(clauses[1].text.contains("internal errors"));
assert_eq!(clauses[2].keyword, Keyword::Should);
assert!(clauses[2].text.contains("failed attempts"));
assert_eq!(clauses[3].keyword, Keyword::ShouldNot);
assert!(clauses[3].text.contains("sensitive tokens"));
assert_eq!(clauses[4].keyword, Keyword::May);
assert!(clauses[4].text.contains("remember-me"));
}
#[test]
fn test_parser_keywords_must_recognize_temporal_compound_keywords_must_always_must_by() {
let md = r#"# Svc
## Temporal
- **MUST ALWAYS** keep connection pool below maximum capacity
- **MUST BY 500ms** return a search result
- **MUST BY 10s** complete the checkout flow
- **MUST BY 2m** finish a background import job
"#;
let spec = Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed");
let clauses = &spec.sections[0].clauses;
assert_eq!(clauses.len(), 4);
assert_eq!(clauses[0].keyword, Keyword::MustAlways);
assert!(
matches!(clauses[0].temporal, Some(Temporal::Invariant)),
"MUST ALWAYS must carry Temporal::Invariant"
);
assert_eq!(clauses[1].keyword, Keyword::MustBy);
assert!(
matches!(clauses[1].temporal, Some(Temporal::Deadline(d)) if d == Duration::from_millis(500)),
"MUST BY 500ms must produce a 500ms deadline"
);
assert_eq!(clauses[2].keyword, Keyword::MustBy);
assert!(
matches!(clauses[2].temporal, Some(Temporal::Deadline(d)) if d == Duration::from_secs(10)),
"MUST BY 10s must produce a 10s deadline"
);
assert_eq!(clauses[3].keyword, Keyword::MustBy);
assert!(
matches!(clauses[3].temporal, Some(Temporal::Deadline(d)) if d == Duration::from_secs(120)),
"MUST BY 2m must produce a 120s deadline"
);
}
#[test]
fn test_parser_keywords_must_recognize_the_given_keyword_as_a_conditional_block_opener_fr() {
let md = r#"# Svc
## Access
- **GIVEN** the user holds an admin role:
- **MUST** allow deletion of any record
- **MUST NOT** expose other tenants' data
"#;
let spec = Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed");
let clauses = &spec.sections[0].clauses;
assert_eq!(clauses.len(), 2);
assert_eq!(clauses[0].keyword, Keyword::Must);
let condition = clauses[0].condition.as_deref().expect("condition must be present");
assert!(condition.contains("admin role"));
assert_eq!(clauses[1].keyword, Keyword::MustNot);
assert_eq!(clauses[1].condition, clauses[0].condition,
"all children of the same GIVEN block share the same condition");
}
#[test]
fn test_parser_keywords_must_recognize_the_otherwise_keyword_as_a_contrary_to_duty_fallba() {
let md = r#"# Svc
## Resilience
- **MUST** respond within 100ms
- **OTHERWISE** return a stale cached response
- **OTHERWISE** return HTTP 503 with Retry-After header
"#;
let spec = Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed");
let clauses = &spec.sections[0].clauses;
assert_eq!(clauses.len(), 1);
assert_eq!(clauses[0].keyword, Keyword::Must);
let otherwise = &clauses[0].otherwise;
assert_eq!(otherwise.len(), 2);
assert_eq!(otherwise[0].keyword, Keyword::Otherwise);
assert!(otherwise[0].text.contains("stale cached"));
assert_eq!(otherwise[1].keyword, Keyword::Otherwise);
assert!(otherwise[1].text.contains("503"));
}
#[test]
fn test_parser_keywords_must_recognize_the_wont_keyword_as_an_ought_extension_not_in_rfc() {
let md = r#"# Svc
## Scope
- **WONT** support OAuth 1.0
- **WONT** implement SOAP endpoints
"#;
let spec = Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed");
let clauses = &spec.sections[0].clauses;
assert_eq!(clauses.len(), 2);
assert_eq!(clauses[0].keyword, Keyword::Wont);
assert!(clauses[0].text.contains("OAuth 1.0"));
assert_eq!(clauses[1].keyword, Keyword::Wont);
assert!(clauses[1].text.contains("SOAP"));
}
#[test]
fn test_parser_spec_file_structure_must_not_fail_on_standard_markdown_that_doesn_t_contain_ought_keyword() {
let md = r#"# Plain Spec
## Overview
This is a standard markdown document with no ought keywords whatsoever.
It has paragraphs, *italic text*, and `code spans`.
- A plain list item
- Another plain list item
## Details
More prose here. No deontic keywords appear in bold in any list items.
```python
def example():
return True
```
"#;
let result = Parser::parse_string(md, Path::new("test.ought.md"));
assert!(
result.is_ok(),
"Parser must not fail on keyword-free markdown: {:?}",
result.err()
);
let spec = result.unwrap();
assert_eq!(spec.name, "Plain Spec");
assert!(
!spec.sections.is_empty(),
"sections must still be parsed from headings"
);
let total_clauses: usize = spec.sections.iter().map(|s| s.clauses.len()).sum();
assert_eq!(
total_clauses, 0,
"keyword-free markdown must produce zero clauses"
);
}
#[test]
fn test_parser_spec_file_structure_must_parse_frontmatter_style_metadata_at_the_top_of_the_file_cont() {
let md = r#"# Payments
context: Handles payment processing and invoicing
source: src/payments/, src/billing/
schema: schema/payments.graphql, schema/billing.graphql
requires: [Auth](auth.ought.md), [Users](users.ought.md#accounts)
## Checkout
- **MUST** process payments
"#;
let spec = Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed");
assert_eq!(
spec.metadata.context.as_deref(),
Some("Handles payment processing and invoicing"),
"context: field must be parsed into metadata.context"
);
assert_eq!(
spec.metadata.sources,
vec!["src/payments/", "src/billing/"],
"source: field must be parsed as a comma-separated list"
);
assert_eq!(
spec.metadata.schemas,
vec!["schema/payments.graphql", "schema/billing.graphql"],
"schema: field must be parsed as a comma-separated list"
);
assert_eq!(
spec.metadata.requires.len(),
2,
"requires: field must list all spec dependencies"
);
assert_eq!(spec.metadata.requires[0].label, "Auth");
assert_eq!(
spec.metadata.requires[0].path.to_str().unwrap(),
"auth.ought.md"
);
assert_eq!(
spec.metadata.requires[0].anchor,
None,
"link without fragment must have no anchor"
);
assert_eq!(spec.metadata.requires[1].label, "Users");
assert_eq!(
spec.metadata.requires[1].path.to_str().unwrap(),
"users.ought.md"
);
assert_eq!(
spec.metadata.requires[1].anchor.as_deref(),
Some("accounts"),
"link fragment must be captured as anchor"
);
}
#[test]
fn test_parser_spec_file_structure_must_parse_standard_markdown_commonmark_as_the_base_format() {
let md = r#"# My Spec
## Intro
A paragraph with *italic*, ***bold italic***, and `inline code` text.
> A blockquote providing background context.
See also [the reference docs](http://example.com) for more detail.
- A plain list item
- Another informational bullet
```json
{"example": true}
```
## Rules
- **MUST** handle all CommonMark elements in surrounding prose
"#;
let result = Parser::parse_string(md, Path::new("commonmark_test.ought.md"));
assert!(
result.is_ok(),
"Parser must not fail on standard CommonMark: {:?}",
result.err()
);
let spec = result.unwrap();
assert_eq!(spec.name, "My Spec");
let rules = spec.sections.iter().find(|s| s.title == "Rules").unwrap();
assert_eq!(rules.clauses.len(), 1);
assert_eq!(rules.clauses[0].keyword, Keyword::Must);
}
#[test]
fn test_parser_spec_file_structure_must_preserve_all_non_clause_markdown_as_documentation_context_fo() {
let md = r#"# Svc
## Security
This section describes the security requirements for the service.
Access control is enforced at the API boundary.
- Background: authentication uses bearer tokens
- Note: tokens expire after 24 hours
- **MUST** reject unauthenticated requests
"#;
let spec = Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed");
let section = &spec.sections[0];
assert_eq!(section.clauses.len(), 1);
assert_eq!(section.clauses[0].keyword, Keyword::Must);
assert!(
!section.prose.is_empty(),
"non-clause markdown must be preserved in section.prose"
);
assert!(
section.prose.contains("security requirements") || section.prose.contains("API boundary"),
"paragraph text must appear in section.prose"
);
}
#[test]
fn test_parser_spec_file_structure_must_recognize_files_with_the_ought_md_extension() {
let content = "# Ext Test\n\n## Section\n\n- **MUST** work\n";
let path = std::env::temp_dir().join("ought_recognize_ext_test.ought.md");
fs::write(&path, content).expect("failed to write temp .ought.md file");
let result = Parser::parse_file(&path);
fs::remove_file(&path).ok();
assert!(
result.is_ok(),
"Parser must recognize and parse .ought.md files: {:?}",
result.err()
);
let spec = result.unwrap();
assert_eq!(spec.name, "Ext Test");
assert_eq!(spec.sections[0].clauses.len(), 1);
assert!(
spec.source_path
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.ends_with(".ought.md"))
.unwrap_or(false),
"source_path must preserve the .ought.md extension"
);
}
#[test]
fn test_parser_spec_file_structure_must_treat_bullet_points_keyword_as_individual_clauses() {
let md = r#"# Svc
## API
- **MUST** return a response body
- **MUST NOT** leak internal error details
- **SHOULD** include a request-id header
"#;
let spec = Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed");
let clauses = &spec.sections[0].clauses;
assert_eq!(clauses.len(), 3, "each bold-keyword bullet must become exactly one clause");
assert_eq!(clauses[0].keyword, Keyword::Must);
assert_eq!(clauses[0].severity, Severity::Required);
assert!(clauses[0].text.contains("return a response body"));
assert_eq!(clauses[1].keyword, Keyword::MustNot);
assert_eq!(clauses[1].severity, Severity::Required);
assert!(clauses[1].text.contains("leak internal error details"));
assert_eq!(clauses[2].keyword, Keyword::Should);
assert_eq!(clauses[2].severity, Severity::Recommended);
assert!(clauses[2].text.contains("request-id header"));
assert!(!clauses[0].id.0.is_empty());
assert_ne!(clauses[0].id.0, clauses[1].id.0);
assert_ne!(clauses[1].id.0, clauses[2].id.0);
}
#[test]
fn test_parser_spec_file_structure_must_treat_h1_as_the_spec_name() {
let md = "# Payment Gateway\n\n## Rules\n\n- **MUST** work\n";
let spec = Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed");
assert_eq!(
spec.name, "Payment Gateway",
"H1 heading text must become spec.name"
);
assert!(
spec.sections.iter().all(|s| s.title != "Payment Gateway"),
"H1 must not be duplicated as a top-level section"
);
}
#[test]
fn test_parser_spec_file_structure_must_treat_h2_etc_as_nested_test_groups_sections() {
let md = r#"# Svc
## Auth
### Login
#### Credentials
- **MUST** validate credentials
### Logout
- **SHOULD** clear session
## Billing
- **MUST** charge the correct amount
"#;
let spec = Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed");
assert_eq!(spec.sections.len(), 2);
assert_eq!(spec.sections[0].title, "Auth");
assert_eq!(spec.sections[0].depth, 2);
assert_eq!(spec.sections[1].title, "Billing");
assert_eq!(spec.sections[1].depth, 2);
let auth = &spec.sections[0];
assert_eq!(auth.subsections.len(), 2);
assert_eq!(auth.subsections[0].title, "Login");
assert_eq!(auth.subsections[0].depth, 3);
assert_eq!(auth.subsections[1].title, "Logout");
assert_eq!(auth.subsections[1].depth, 3);
let login = &auth.subsections[0];
assert_eq!(login.subsections.len(), 1);
assert_eq!(login.subsections[0].title, "Credentials");
assert_eq!(login.subsections[0].depth, 4);
assert_eq!(login.subsections[0].clauses.len(), 1);
}
#[test]
fn test_parser_temporal_obligations_must_always_invariant_must_assign_the_invariant_temporal_qualifier_to_the_clause() {
fn parse(md: &str) -> Spec {
Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed")
}
let md = r#"# Svc
## Invariants
- **MUST ALWAYS** hold the invariant that no account balance drops below zero
"#;
let spec = parse(md);
let clause = &spec.sections[0].clauses[0];
assert!(clause.temporal.is_some(), "temporal should be Some, not None");
assert!(
matches!(clause.temporal, Some(Temporal::Invariant)),
"temporal should be Invariant, got {:?}",
clause.temporal
);
assert!(
!matches!(clause.temporal, Some(Temporal::Deadline(_))),
"MUST ALWAYS must not produce a Deadline temporal qualifier"
);
}
#[test]
fn test_parser_temporal_obligations_must_always_invariant_must_parse_must_always_as_a_single_compound_keyword() {
fn parse(md: &str) -> Spec {
Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed")
}
let md = r#"# Svc
## Invariants
- **MUST ALWAYS** keep connection count below the pool maximum
"#;
let spec = parse(md);
let clauses = &spec.sections[0].clauses;
assert_eq!(clauses.len(), 1);
assert_eq!(clauses[0].keyword, Keyword::MustAlways);
assert_ne!(clauses[0].keyword, Keyword::Must);
assert!(!clauses[0].text.to_uppercase().starts_with("ALWAYS"));
assert!(clauses[0].text.contains("keep connection count below the pool maximum"));
}
#[test]
fn test_parser_temporal_obligations_must_always_invariant_must_represent_invariants_distinctly_in_the_clause_ir_they_genera() {
fn parse(md: &str) -> Spec {
Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed")
}
let md = r#"# Svc
## Rules
- **MUST** validate the request before processing
- **MUST ALWAYS** reject requests that exceed the rate limit
"#;
let spec = parse(md);
let clauses = &spec.sections[0].clauses;
assert_eq!(clauses.len(), 2);
let plain_must = &clauses[0];
let invariant = &clauses[1];
assert_eq!(plain_must.keyword, Keyword::Must);
assert_eq!(invariant.keyword, Keyword::MustAlways);
assert_ne!(plain_must.keyword, invariant.keyword);
assert!(
plain_must.temporal.is_none(),
"plain MUST should have no temporal qualifier"
);
assert!(
matches!(invariant.temporal, Some(Temporal::Invariant)),
"MUST ALWAYS should carry Temporal::Invariant"
);
assert_ne!(
plain_must.id, invariant.id,
"plain MUST and MUST ALWAYS clauses must have different IDs"
);
assert_eq!(plain_must.severity, Severity::Required);
assert_eq!(invariant.severity, Severity::Required);
}
#[test]
fn test_parser_temporal_obligations_must_by_deadline_must_not_accept_must_by_without_a_duration_it_is_a_parse_error() {
let md_no_duration = r#"# Svc
## Rules
- **MUST BY** respond to every request
"#;
let result = Parser::parse_string(md_no_duration, Path::new("test.ought.md"));
assert!(
result.is_err(),
"parsing MUST BY with no duration should return Err, got Ok"
);
let md_early_close = r#"# Svc
## Rules
- **MUST BY** 30s respond to every request
"#;
let result2 = Parser::parse_string(md_early_close, Path::new("test.ought.md"));
assert!(
result2.is_err(),
"MUST BY with duration outside the bold span should return Err, got Ok"
);
}
#[test]
fn test_parser_temporal_obligations_must_by_deadline_must_parse_duration_suffixes_ms_milliseconds_s_seconds_m_minutes() {
fn parse(md: &str) -> Spec {
Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed")
}
let md_ms = r#"# Svc
## Deadlines
- **MUST BY 200ms** return a cached response
"#;
let spec_ms = parse(md_ms);
let clause_ms = &spec_ms.sections[0].clauses[0];
assert_eq!(clause_ms.keyword, Keyword::MustBy);
let temporal_ms = clause_ms.temporal.as_ref().expect("temporal must be Some for MUST BY");
assert!(
matches!(temporal_ms, Temporal::Deadline(d) if *d == Duration::from_millis(200)),
"expected Temporal::Deadline(200ms), got {:?}", temporal_ms
);
let md_s = r#"# Svc
## Deadlines
- **MUST BY 5s** complete the database write
"#;
let spec_s = parse(md_s);
let clause_s = &spec_s.sections[0].clauses[0];
assert_eq!(clause_s.keyword, Keyword::MustBy);
let temporal_s = clause_s.temporal.as_ref().expect("temporal must be Some for MUST BY");
assert!(
matches!(temporal_s, Temporal::Deadline(d) if *d == Duration::from_secs(5)),
"expected Temporal::Deadline(5s), got {:?}", temporal_s
);
let md_m = r#"# Svc
## Deadlines
- **MUST BY 10m** finish the batch export job
"#;
let spec_m = parse(md_m);
let clause_m = &spec_m.sections[0].clauses[0];
assert_eq!(clause_m.keyword, Keyword::MustBy);
let temporal_m = clause_m.temporal.as_ref().expect("temporal must be Some for MUST BY");
assert!(
matches!(temporal_m, Temporal::Deadline(d) if *d == Duration::from_secs(10 * 60)),
"expected Temporal::Deadline(10m), got {:?}", temporal_m
);
}
#[test]
fn test_parser_temporal_obligations_must_by_deadline_must_parse_must_by_duration_as_a_compound_keyword_with_a_duration() {
fn parse(md: &str) -> Spec {
Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed")
}
let md = r#"# Svc
## Deadlines
- **MUST BY 30s** respond to every health-check request
"#;
let spec = parse(md);
let clauses = &spec.sections[0].clauses;
assert_eq!(clauses.len(), 1, "expected exactly one clause");
assert_eq!(clauses[0].keyword, Keyword::MustBy);
assert_ne!(clauses[0].keyword, Keyword::Must);
assert!(!clauses[0].text.trim_start().to_uppercase().starts_with("BY "));
assert!(clauses[0].text.contains("respond to every health-check request"));
}
#[test]
fn test_parser_temporal_obligations_must_by_deadline_must_store_the_duration_value_and_unit_in_the_clause_ir() {
fn parse(md: &str) -> Spec {
Parser::parse_string(md, Path::new("test.ought.md")).expect("parse failed")
}
let md = r#"# Svc
## SLAs
- **MUST BY 250ms** acknowledge every incoming message
"#;
let spec = parse(md);
let clause = &spec.sections[0].clauses[0];
assert_eq!(clause.keyword, Keyword::MustBy);
let temporal = clause.temporal.as_ref().expect("temporal should be Some for MUST BY clause");
assert!(
matches!(temporal, Temporal::Deadline(d) if *d == Duration::from_millis(250)),
"expected Temporal::Deadline(250ms), got {:?}", temporal
);
assert!(
!matches!(clause.temporal, Some(Temporal::Invariant)),
"MUST BY must not produce an Invariant temporal qualifier"
);
assert_eq!(clause.severity, Severity::Required);
assert!(clause.text.contains("acknowledge every incoming message"));
assert!(!clause.text.contains("250ms"));
}