///|
/// Content profile checks turn a parsed metadata block into a readable
/// contract. They intentionally work above Markdown parsing: MoonFolio cares
/// about whether a content folio is publishable, stable, and maintainable.
///|
pub(all) enum ProfileFindingKind {
MissingRequired
ForbiddenField
BlankText
TextTooLong
TagNotAllowed
TagRequired
TooManyTags
DraftBlocked
DraftNeedsApproval
PermalinkMissing
PermalinkPrefixMismatch
DateMissing
DateInvalid
DateOutOfRange
} derive(Eq, Debug)
///|
pub(all) struct ProfileFinding {
kind : ProfileFindingKind
key : String
value : String
message : String
severity : Severity
} derive(Eq, Debug)
///|
pub(all) struct ContentProfile {
required_fields : Array[String]
forbidden_fields : Array[String]
title_key : String
max_title_length : Int
summary_key : String
require_summary : Bool
max_summary_length : Int
draft_key : String
allow_draft : Bool
require_approval_for_draft : Bool
approval_key : String
tag_key : String
allowed_tags : Array[String]
required_tags : Array[String]
max_tags : Int
permalink_key : String
require_permalink : Bool
permalink_prefix : String
date_key : String
require_date : Bool
min_year : Int
max_year : Int
} derive(Eq, Debug)
///|
pub(all) struct ProfileReport {
ok : Bool
score : Int
checked : Array[String]
findings : Array[ProfileFinding]
diagnostics : Array[Diagnostic]
suggestions : Array[String]
} derive(Eq, Debug)
///|
pub fn default_content_profile() -> ContentProfile {
{
required_fields: ["title"],
forbidden_fields: [],
title_key: "title",
max_title_length: 80,
summary_key: "summary",
require_summary: false,
max_summary_length: 180,
draft_key: "draft",
allow_draft: true,
require_approval_for_draft: false,
approval_key: "approved",
tag_key: "tags",
allowed_tags: [],
required_tags: [],
max_tags: 8,
permalink_key: "permalink",
require_permalink: false,
permalink_prefix: "",
date_key: "date",
require_date: false,
min_year: 1970,
max_year: 9999,
}
}
///|
pub fn strict_content_profile() -> ContentProfile {
{
required_fields: ["title", "summary", "date", "tags"],
forbidden_fields: ["todo", "tmp", "placeholder"],
title_key: "title",
max_title_length: 72,
summary_key: "summary",
require_summary: true,
max_summary_length: 160,
draft_key: "draft",
allow_draft: false,
require_approval_for_draft: true,
approval_key: "approved",
tag_key: "tags",
allowed_tags: [],
required_tags: [],
max_tags: 6,
permalink_key: "permalink",
require_permalink: true,
permalink_prefix: "/",
date_key: "date",
require_date: true,
min_year: 2020,
max_year: 2100,
}
}
///|
pub fn profile_with_allowed_tags(
profile : ContentProfile,
tags : Array[String],
) -> ContentProfile {
{
required_fields: copy_strings(profile.required_fields),
forbidden_fields: copy_strings(profile.forbidden_fields),
title_key: profile.title_key,
max_title_length: profile.max_title_length,
summary_key: profile.summary_key,
require_summary: profile.require_summary,
max_summary_length: profile.max_summary_length,
draft_key: profile.draft_key,
allow_draft: profile.allow_draft,
require_approval_for_draft: profile.require_approval_for_draft,
approval_key: profile.approval_key,
tag_key: profile.tag_key,
allowed_tags: normalize_tags(tags),
required_tags: copy_strings(profile.required_tags),
max_tags: profile.max_tags,
permalink_key: profile.permalink_key,
require_permalink: profile.require_permalink,
permalink_prefix: profile.permalink_prefix,
date_key: profile.date_key,
require_date: profile.require_date,
min_year: profile.min_year,
max_year: profile.max_year,
}
}
///|
pub fn profile_with_required_tags(
profile : ContentProfile,
tags : Array[String],
) -> ContentProfile {
{
required_fields: copy_strings(profile.required_fields),
forbidden_fields: copy_strings(profile.forbidden_fields),
title_key: profile.title_key,
max_title_length: profile.max_title_length,
summary_key: profile.summary_key,
require_summary: profile.require_summary,
max_summary_length: profile.max_summary_length,
draft_key: profile.draft_key,
allow_draft: profile.allow_draft,
require_approval_for_draft: profile.require_approval_for_draft,
approval_key: profile.approval_key,
tag_key: profile.tag_key,
allowed_tags: copy_strings(profile.allowed_tags),
required_tags: normalize_tags(tags),
max_tags: profile.max_tags,
permalink_key: profile.permalink_key,
require_permalink: profile.require_permalink,
permalink_prefix: profile.permalink_prefix,
date_key: profile.date_key,
require_date: profile.require_date,
min_year: profile.min_year,
max_year: profile.max_year,
}
}
///|
pub fn profile_with_required_field(
profile : ContentProfile,
key : String,
) -> ContentProfile {
{
required_fields: append_unique_string(profile.required_fields, key),
forbidden_fields: copy_strings(profile.forbidden_fields),
title_key: profile.title_key,
max_title_length: profile.max_title_length,
summary_key: profile.summary_key,
require_summary: profile.require_summary,
max_summary_length: profile.max_summary_length,
draft_key: profile.draft_key,
allow_draft: profile.allow_draft,
require_approval_for_draft: profile.require_approval_for_draft,
approval_key: profile.approval_key,
tag_key: profile.tag_key,
allowed_tags: copy_strings(profile.allowed_tags),
required_tags: copy_strings(profile.required_tags),
max_tags: profile.max_tags,
permalink_key: profile.permalink_key,
require_permalink: profile.require_permalink,
permalink_prefix: profile.permalink_prefix,
date_key: profile.date_key,
require_date: profile.require_date,
min_year: profile.min_year,
max_year: profile.max_year,
}
}
///|
pub fn profile_without_drafts(profile : ContentProfile) -> ContentProfile {
{
required_fields: copy_strings(profile.required_fields),
forbidden_fields: copy_strings(profile.forbidden_fields),
title_key: profile.title_key,
max_title_length: profile.max_title_length,
summary_key: profile.summary_key,
require_summary: profile.require_summary,
max_summary_length: profile.max_summary_length,
draft_key: profile.draft_key,
allow_draft: false,
require_approval_for_draft: profile.require_approval_for_draft,
approval_key: profile.approval_key,
tag_key: profile.tag_key,
allowed_tags: copy_strings(profile.allowed_tags),
required_tags: copy_strings(profile.required_tags),
max_tags: profile.max_tags,
permalink_key: profile.permalink_key,
require_permalink: profile.require_permalink,
permalink_prefix: profile.permalink_prefix,
date_key: profile.date_key,
require_date: profile.require_date,
min_year: profile.min_year,
max_year: profile.max_year,
}
}
///|
pub fn profile_with_permalink_prefix(
profile : ContentProfile,
prefix : String,
) -> ContentProfile {
{
required_fields: copy_strings(profile.required_fields),
forbidden_fields: copy_strings(profile.forbidden_fields),
title_key: profile.title_key,
max_title_length: profile.max_title_length,
summary_key: profile.summary_key,
require_summary: profile.require_summary,
max_summary_length: profile.max_summary_length,
draft_key: profile.draft_key,
allow_draft: profile.allow_draft,
require_approval_for_draft: profile.require_approval_for_draft,
approval_key: profile.approval_key,
tag_key: profile.tag_key,
allowed_tags: copy_strings(profile.allowed_tags),
required_tags: copy_strings(profile.required_tags),
max_tags: profile.max_tags,
permalink_key: profile.permalink_key,
require_permalink: true,
permalink_prefix: prefix,
date_key: profile.date_key,
require_date: profile.require_date,
min_year: profile.min_year,
max_year: profile.max_year,
}
}
///|
pub fn profile_with_date_range(
profile : ContentProfile,
min_year : Int,
max_year : Int,
) -> ContentProfile {
{
required_fields: append_unique_string(
profile.required_fields,
profile.date_key,
),
forbidden_fields: copy_strings(profile.forbidden_fields),
title_key: profile.title_key,
max_title_length: profile.max_title_length,
summary_key: profile.summary_key,
require_summary: profile.require_summary,
max_summary_length: profile.max_summary_length,
draft_key: profile.draft_key,
allow_draft: profile.allow_draft,
require_approval_for_draft: profile.require_approval_for_draft,
approval_key: profile.approval_key,
tag_key: profile.tag_key,
allowed_tags: copy_strings(profile.allowed_tags),
required_tags: copy_strings(profile.required_tags),
max_tags: profile.max_tags,
permalink_key: profile.permalink_key,
require_permalink: profile.require_permalink,
permalink_prefix: profile.permalink_prefix,
date_key: profile.date_key,
require_date: true,
min_year,
max_year,
}
}
///|
pub fn evaluate_profile(
doc : Document,
profile : ContentProfile,
) -> ProfileReport {
let checked : Array[String] = []
let findings : Array[ProfileFinding] = []
check_required_fields(doc, profile, checked, findings)
check_forbidden_fields(doc, profile, checked, findings)
check_title_profile(doc, profile, checked, findings)
check_summary_profile(doc, profile, checked, findings)
check_draft_profile(doc, profile, checked, findings)
check_tag_profile(doc, profile, checked, findings)
check_permalink_profile(doc, profile, checked, findings)
check_date_profile(doc, profile, checked, findings)
let diagnostics = profile_findings_to_diagnostics(findings, doc.start_line)
let suggestions = profile_suggestions(findings)
let score = profile_score(findings, checked.length())
{
ok: count_error_findings(findings) == 0,
score,
checked,
findings,
diagnostics,
suggestions,
}
}
///|
pub fn profile_ok(doc : Document, profile : ContentProfile) -> Bool {
evaluate_profile(doc, profile).ok
}
///|
pub fn profile_findings_to_diagnostics(
findings : Array[ProfileFinding],
line : Int,
) -> Array[Diagnostic] {
let diagnostics : Array[Diagnostic] = []
for finding in findings {
diagnostics.push(Diagnostic::{
severity: finding.severity,
line,
code: profile_kind_code(finding.kind),
message: finding.message,
})
}
diagnostics
}
///|
pub fn profile_kind_code(kind : ProfileFindingKind) -> String {
match kind {
MissingRequired => "profile-missing-required"
ForbiddenField => "profile-forbidden-field"
BlankText => "profile-blank-text"
TextTooLong => "profile-text-too-long"
TagNotAllowed => "profile-tag-not-allowed"
TagRequired => "profile-tag-required"
TooManyTags => "profile-too-many-tags"
DraftBlocked => "profile-draft-blocked"
DraftNeedsApproval => "profile-draft-needs-review"
PermalinkMissing => "profile-permalink-missing"
PermalinkPrefixMismatch => "profile-permalink-prefix"
DateMissing => "profile-date-missing"
DateInvalid => "profile-date-invalid"
DateOutOfRange => "profile-date-out-of-range"
}
}
///|
pub fn profile_suggestions(findings : Array[ProfileFinding]) -> Array[String] {
let suggestions : Array[String] = []
for finding in findings {
match finding.kind {
MissingRequired =>
push_unique_in_place(
suggestions,
"Add metadata field `" + finding.key + "`.",
)
ForbiddenField =>
push_unique_in_place(
suggestions,
"Remove unsupported metadata field `" + finding.key + "`.",
)
BlankText =>
push_unique_in_place(
suggestions,
"Fill a non-empty value for `" + finding.key + "`.",
)
TextTooLong =>
push_unique_in_place(
suggestions,
"Shorten `" + finding.key + "` to fit the configured limit.",
)
TagNotAllowed =>
push_unique_in_place(
suggestions,
"Replace tag `" + finding.value + "` with an allowed taxonomy tag.",
)
TagRequired =>
push_unique_in_place(
suggestions,
"Add required tag `" + finding.value + "`.",
)
TooManyTags =>
push_unique_in_place(suggestions, "Reduce the number of tags.")
DraftBlocked =>
push_unique_in_place(
suggestions, "Publish-ready content must set draft to false.",
)
DraftNeedsApproval =>
push_unique_in_place(
suggestions, "Mark draft content as approved before release.",
)
PermalinkMissing =>
push_unique_in_place(suggestions, "Add a permalink field.")
PermalinkPrefixMismatch =>
push_unique_in_place(
suggestions, "Move permalink under the configured prefix.",
)
DateMissing =>
push_unique_in_place(suggestions, "Add a YYYY-MM-DD date field.")
DateInvalid =>
push_unique_in_place(suggestions, "Use YYYY-MM-DD for the date field.")
DateOutOfRange =>
push_unique_in_place(
suggestions, "Move the date into the configured publication range.",
)
}
}
suggestions
}
///|
pub fn profile_score(
findings : Array[ProfileFinding],
checked_count : Int,
) -> Int {
let mut score = 100
for finding in findings {
match finding.severity {
Error => score = score - 18
Warning => score = score - 6
}
}
if checked_count == 0 {
score = score - 10
}
clamp_int(score, 0, 100)
}
///|
pub fn profile_contract_rules(profile : ContentProfile) -> Array[FieldRule] {
let rules : Array[FieldRule] = []
for key in profile.required_fields {
rules.push({ key, kind: Any, required: true })
}
if profile.max_title_length > 0 {
push_field_rule_unique(rules, {
key: profile.title_key,
kind: TextType,
required: true,
})
}
if profile.require_summary {
push_field_rule_unique(rules, {
key: profile.summary_key,
kind: TextType,
required: true,
})
}
if profile.max_tags > 0 ||
profile.allowed_tags.length() > 0 ||
profile.required_tags.length() > 0 {
push_field_rule_unique(rules, {
key: profile.tag_key,
kind: ListType,
required: false,
})
}
if profile.require_date {
push_field_rule_unique(rules, {
key: profile.date_key,
kind: TextType,
required: true,
})
}
if profile.require_permalink {
push_field_rule_unique(rules, {
key: profile.permalink_key,
kind: TextType,
required: true,
})
}
rules
}
///|
pub fn count_error_findings(findings : Array[ProfileFinding]) -> Int {
let mut count = 0
for finding in findings {
if finding.severity == Error {
count = count + 1
}
}
count
}
///|
pub fn count_warning_findings(findings : Array[ProfileFinding]) -> Int {
let mut count = 0
for finding in findings {
if finding.severity == Warning {
count = count + 1
}
}
count
}
///|
pub fn diagnostics_have_errors(diagnostics : Array[Diagnostic]) -> Bool {
diagnostics.any(diagnostic => diagnostic.severity == Error)
}
///|
pub fn count_error_diagnostics(diagnostics : Array[Diagnostic]) -> Int {
let mut count = 0
for diagnostic in diagnostics {
if diagnostic.severity == Error {
count = count + 1
}
}
count
}
///|
pub fn count_warning_diagnostics(diagnostics : Array[Diagnostic]) -> Int {
let mut count = 0
for diagnostic in diagnostics {
if diagnostic.severity == Warning {
count = count + 1
}
}
count
}
///|
pub fn normalize_tag(tag : String) -> String {
slugify(tag.trim().to_owned())
}
///|
pub fn normalize_tags(tags : Array[String]) -> Array[String] {
let values : Array[String] = []
for tag in tags {
let normalized = normalize_tag(tag)
if !normalized.is_empty() && !contains_string(values, normalized) {
values.push(normalized)
}
}
values
}
///|
pub fn contains_string(values : Array[String], value : String) -> Bool {
values.any(item => item == value)
}
///|
pub fn copy_strings(values : Array[String]) -> Array[String] {
let result : Array[String] = []
for value in values {
result.push(value)
}
result
}
///|
pub fn append_unique_string(
values : Array[String],
value : String,
) -> Array[String] {
let result = copy_strings(values)
if !contains_string(result, value) {
result.push(value)
}
result
}
///|
pub fn difference_strings(
left : Array[String],
right : Array[String],
) -> Array[String] {
let result : Array[String] = []
for value in left {
if !contains_string(right, value) {
result.push(value)
}
}
result
}
///|
pub fn intersect_strings(
left : Array[String],
right : Array[String],
) -> Array[String] {
let result : Array[String] = []
for value in left {
if contains_string(right, value) && !contains_string(result, value) {
result.push(value)
}
}
result
}
///|
pub fn dedupe_strings(values : Array[String]) -> Array[String] {
let result : Array[String] = []
for value in values {
if !contains_string(result, value) {
result.push(value)
}
}
result
}
///|
pub fn sorted_strings(values : Array[String]) -> Array[String] {
let result : Array[String] = []
for value in values {
insert_sorted_string(result, value)
}
result
}
///|
pub fn join_backticked(values : Array[String]) -> String {
let parts : Array[String] = []
for value in values {
parts.push("`" + value + "`")
}
parts.join(", ")
}
///|
fn check_required_fields(
doc : Document,
profile : ContentProfile,
checked : Array[String],
findings : Array[ProfileFinding],
) -> Unit {
for key in profile.required_fields {
checked.push("required:" + key)
if !doc.fields.contains(key) {
findings.push(
make_finding(
MissingRequired,
key,
"",
"required field `" + key + "` is missing",
Error,
),
)
}
}
}
///|
fn check_forbidden_fields(
doc : Document,
profile : ContentProfile,
checked : Array[String],
findings : Array[ProfileFinding],
) -> Unit {
for key in profile.forbidden_fields {
checked.push("forbidden:" + key)
if doc.fields.contains(key) {
findings.push(
make_finding(
ForbiddenField,
key,
"",
"field `" + key + "` is forbidden by profile",
Error,
),
)
}
}
}
///|
fn check_title_profile(
doc : Document,
profile : ContentProfile,
checked : Array[String],
findings : Array[ProfileFinding],
) -> Unit {
checked.push("title:" + profile.title_key)
match get_text(doc, profile.title_key) {
None =>
if contains_string(profile.required_fields, profile.title_key) {
()
} else {
findings.push(
make_finding(
MissingRequired,
profile.title_key,
"",
"title field `" + profile.title_key + "` is missing",
Error,
),
)
}
Some(title) => {
let cleaned = title.trim().to_owned()
if cleaned.is_empty() {
findings.push(
make_finding(
BlankText,
profile.title_key,
"",
"title field `" + profile.title_key + "` is blank",
Error,
),
)
} else if profile.max_title_length > 0 &&
cleaned.length() > profile.max_title_length {
findings.push(
make_finding(
TextTooLong,
profile.title_key,
cleaned,
"title is longer than " + profile.max_title_length.to_string(),
Warning,
),
)
}
}
}
}
///|
fn check_summary_profile(
doc : Document,
profile : ContentProfile,
checked : Array[String],
findings : Array[ProfileFinding],
) -> Unit {
checked.push("summary:" + profile.summary_key)
match get_text(doc, profile.summary_key) {
None =>
if profile.require_summary {
findings.push(
make_finding(
MissingRequired,
profile.summary_key,
"",
"summary field `" + profile.summary_key + "` is required",
Error,
),
)
}
Some(summary) => {
let cleaned = summary.trim().to_owned()
if cleaned.is_empty() {
findings.push(
make_finding(
BlankText,
profile.summary_key,
"",
"summary field `" + profile.summary_key + "` is blank",
Error,
),
)
} else if profile.max_summary_length > 0 &&
cleaned.length() > profile.max_summary_length {
findings.push(
make_finding(
TextTooLong,
profile.summary_key,
cleaned,
"summary is longer than " + profile.max_summary_length.to_string(),
Warning,
),
)
}
}
}
}
///|
fn check_draft_profile(
doc : Document,
profile : ContentProfile,
checked : Array[String],
findings : Array[ProfileFinding],
) -> Unit {
checked.push("draft:" + profile.draft_key)
let draft = get_bool(doc, profile.draft_key).unwrap_or(false)
if draft && !profile.allow_draft {
findings.push(
make_finding(
DraftBlocked,
profile.draft_key,
"true",
"draft content is blocked by profile",
Error,
),
)
}
if draft && profile.require_approval_for_draft {
let approved = get_bool(doc, profile.approval_key).unwrap_or(false)
if !approved {
findings.push(
make_finding(
DraftNeedsApproval,
profile.approval_key,
"false",
"draft content requires `" + profile.approval_key + "`",
Warning,
),
)
}
}
}
///|
fn check_tag_profile(
doc : Document,
profile : ContentProfile,
checked : Array[String],
findings : Array[ProfileFinding],
) -> Unit {
checked.push("tags:" + profile.tag_key)
let tags = normalized_doc_tags(doc, profile.tag_key)
if profile.max_tags > 0 && tags.length() > profile.max_tags {
findings.push(
make_finding(
TooManyTags,
profile.tag_key,
tags.length().to_string(),
"too many tags: " + tags.length().to_string(),
Warning,
),
)
}
if profile.allowed_tags.length() > 0 {
for tag in tags {
if !contains_string(profile.allowed_tags, tag) {
findings.push(
make_finding(
TagNotAllowed,
profile.tag_key,
tag,
"tag `" + tag + "` is not in the allowed taxonomy",
Error,
),
)
}
}
}
for required in profile.required_tags {
if !contains_string(tags, required) {
findings.push(
make_finding(
TagRequired,
profile.tag_key,
required,
"required tag `" + required + "` is missing",
Error,
),
)
}
}
}
///|
fn check_permalink_profile(
doc : Document,
profile : ContentProfile,
checked : Array[String],
findings : Array[ProfileFinding],
) -> Unit {
checked.push("permalink:" + profile.permalink_key)
match get_text(doc, profile.permalink_key) {
None =>
if profile.require_permalink {
findings.push(
make_finding(
PermalinkMissing,
profile.permalink_key,
"",
"permalink field `" + profile.permalink_key + "` is required",
Error,
),
)
}
Some(permalink) => {
let cleaned = permalink.trim().to_owned()
if cleaned.is_empty() {
findings.push(
make_finding(
PermalinkMissing,
profile.permalink_key,
"",
"permalink field `" + profile.permalink_key + "` is blank",
Error,
),
)
} else if !profile.permalink_prefix.is_empty() &&
!cleaned.has_prefix(profile.permalink_prefix) {
findings.push(
make_finding(
PermalinkPrefixMismatch,
profile.permalink_key,
cleaned,
"permalink must start with `" + profile.permalink_prefix + "`",
Error,
),
)
}
}
}
}
///|
fn check_date_profile(
doc : Document,
profile : ContentProfile,
checked : Array[String],
findings : Array[ProfileFinding],
) -> Unit {
checked.push("date:" + profile.date_key)
match get_text(doc, profile.date_key) {
None =>
if profile.require_date {
findings.push(
make_finding(
DateMissing,
profile.date_key,
"",
"date field `" + profile.date_key + "` is required",
Error,
),
)
}
Some(date) =>
if !is_iso_date(date) {
findings.push(
make_finding(
DateInvalid,
profile.date_key,
date,
"date must use YYYY-MM-DD",
Error,
),
)
} else {
let year = parse_year(date)
if year < profile.min_year || year > profile.max_year {
findings.push(
make_finding(
DateOutOfRange,
profile.date_key,
date,
"date year is outside configured range",
Error,
),
)
}
}
}
}
///|
fn normalized_doc_tags(doc : Document, key : String) -> Array[String] {
match get_list(doc, key) {
Some(tags) => normalize_tags(tags)
None => []
}
}
///|
fn make_finding(
kind : ProfileFindingKind,
key : String,
value : String,
message : String,
severity : Severity,
) -> ProfileFinding {
{ kind, key, value, message, severity }
}
///|
fn push_unique_in_place(values : Array[String], value : String) -> Unit {
if !contains_string(values, value) {
values.push(value)
}
}
///|
fn push_field_rule_unique(rules : Array[FieldRule], rule : FieldRule) -> Unit {
let mut found = false
for existing in rules {
if existing.key == rule.key {
found = true
}
}
if !found {
rules.push(rule)
}
}
///|
fn insert_sorted_string(values : Array[String], value : String) -> Unit {
if values.length() == 0 {
values.push(value)
return
}
for i in 0.. Bool {
let limit = if left.length() < right.length() {
left.length()
} else {
right.length()
}
for i in 0.. right_code {
return false
}
}
left.length() <= right.length()
}
///|
fn is_iso_date(value : String) -> Bool {
if value.length() != 10 {
return false
}
if value.get_char(4).unwrap() != '-' || value.get_char(7).unwrap() != '-' {
return false
}
digits_only(value.sub(start=0, end=4).to_owned()) &&
digits_only(value.sub(start=5, end=7).to_owned()) &&
digits_only(value.sub(start=8, end=10).to_owned()) &&
valid_month_day(value)
}
///|
fn valid_month_day(value : String) -> Bool {
let month = parse_two_digits(value, 5)
let day = parse_two_digits(value, 8)
month >= 1 && month <= 12 && day >= 1 && day <= days_in_month(month)
}
///|
fn days_in_month(month : Int) -> Int {
match month {
2 => 29
4 => 30
6 => 30
9 => 30
11 => 30
_ => 31
}
}
///|
fn parse_year(value : String) -> Int {
parse_fixed_digits(value, 0, 4)
}
///|
fn parse_two_digits(value : String, start : Int) -> Int {
parse_fixed_digits(value, start, start + 2)
}
///|
fn parse_fixed_digits(value : String, start : Int, end : Int) -> Int {
let mut result = 0
for i in start.. Bool {
if value.is_empty() {
return false
}
value.all(ch => {
let code = ch.to_int()
code >= 48 && code <= 57
})
}
///|
fn clamp_int(value : Int, low : Int, high : Int) -> Int {
if value < low {
low
} else if value > high {
high
} else {
value
}
}