///|
pub suberror CliError {
MissingArgument(String)
InvalidNumber(String, String)
UnknownCommand(String)
InvalidLanguage(String)
} derive(Eq, Debug)
///|
pub struct CliResult {
exit_code : Int
stdout : String
stderr : String
} derive(Eq, Debug)
///|
fn cli_success(output : String) -> CliResult {
{ exit_code: 0, stdout: output, stderr: "" }
}
///|
fn cli_failure(code : Int, message : String) -> CliResult {
{ exit_code: code, stdout: "", stderr: "error: " + message }
}
///|
pub fn cli_help() -> String {
[
"RRuleLab - explainable RFC 5545 recurrence laboratory", "", "USAGE", " rrule_lab [arguments]",
"", "COMMANDS", " validate ", " normalize ", " explain [en|zh]",
" expand [LIMIT]", " audit [LIMIT]",
" diff ", " duration [DTSTART]",
" demo", " version", "", "DATE-TIME", " Basic 20260801T090000 or extended 2026-08-01T09:00:00.",
"", "EXAMPLES", " rrule_lab explain 'FREQ=WEEKLY;BYDAY=MO,WE' zh", " rrule_lab expand 20260801T090000 'FREQ=DAILY;COUNT=3' 20260801T000000 20260810T000000",
].join("\n")
}
///|
fn require_cli_arg(
args : Array[String],
index : Int,
label : String,
) -> String raise CliError {
if index >= args.length() {
raise MissingArgument(label)
}
args[index]
}
///|
fn parse_cli_int(text : String, label : String) -> Int raise CliError {
@string.parse_int(text, base=10) catch {
_ => raise InvalidNumber(label, text)
}
}
///|
fn diagnostics_text(diagnostics : Array[Diagnostic]) -> String {
if diagnostics.length() == 0 {
return "valid"
}
let lines : Array[String] = []
for item in diagnostics {
let severity = match item.severity {
Error => "error"
Warning => "warning"
Information => "info"
}
lines.push(
"[" + severity + "] " + item.code + " " + item.field + ": " + item.message,
)
}
lines.join("\n")
}
///|
fn datetime_lines(values : Array[DateTime]) -> String {
if values.length() == 0 {
return "(no occurrences)"
}
values.map(value => value.to_iso_string()).join("\n")
}
///|
fn expansion_options_from_cli(
start_text : String,
end_text : String,
limit_text : String?,
) -> ExpansionOptions raise {
let limit = match limit_text {
Some(value) => parse_cli_int(value, "LIMIT")
None => 100
}
ExpansionOptions::new(
parse_datetime(start_text),
parse_datetime(end_text),
limit~,
)
}
///|
fn run_validate(args : Array[String]) -> CliResult raise {
let rule = parse_rrule(require_cli_arg(args, 1, "RRULE"))
let diagnostics = rule.validate()
let has_error = diagnostics.any(item => item.severity == Error)
{
exit_code: if has_error {
2
} else {
0
},
stdout: diagnostics_text(diagnostics),
stderr: "",
}
}
///|
fn run_normalize(args : Array[String]) -> CliResult raise {
let rule = parse_rrule(require_cli_arg(args, 1, "RRULE"))
cli_success(rule.to_string())
}
///|
fn run_explain(args : Array[String]) -> CliResult raise {
let rule = parse_rrule(require_cli_arg(args, 1, "RRULE"))
let language = if args.length() > 2 { args[2].to_lower() } else { "en" }
match language {
"en" => cli_success(rule.explain_en())
"zh" | "zh-cn" => cli_success(rule.explain_zh())
_ => raise InvalidLanguage(language)
}
}
///|
fn run_expand(args : Array[String]) -> CliResult raise {
let start = parse_datetime(require_cli_arg(args, 1, "DTSTART"))
let rule = parse_rrule(require_cli_arg(args, 2, "RRULE"))
let window_start = require_cli_arg(args, 3, "WINDOW_START")
let window_end = require_cli_arg(args, 4, "WINDOW_END")
let limit = if args.length() > 5 { Some(args[5]) } else { None }
cli_success(
datetime_lines(
expand_rrule(
start,
rule,
expansion_options_from_cli(window_start, window_end, limit),
),
),
)
}
///|
fn run_audit(args : Array[String]) -> CliResult raise {
let start = parse_datetime(require_cli_arg(args, 1, "DTSTART"))
let rule = parse_rrule(require_cli_arg(args, 2, "RRULE"))
let window_start = require_cli_arg(args, 3, "WINDOW_START")
let window_end = require_cli_arg(args, 4, "WINDOW_END")
let limit = if args.length() > 5 { Some(args[5]) } else { None }
let audit = audit_rule(
start,
rule,
expansion_options_from_cli(window_start, window_end, limit),
)
cli_success(audit.to_text())
}
///|
fn impact_text(impact : ChangeImpact) -> String {
let lines : Array[String] = [
"baseline-count: " + impact.baseline_count.to_string(),
"candidate-count: " + impact.candidate_count.to_string(),
"similarity-permille: " + impact.similarity_permille.to_string(),
"added:",
]
if impact.added.length() == 0 {
lines.push(" (none)")
} else {
for value in impact.added {
lines.push(" + " + value.to_iso_string())
}
}
lines.push("removed:")
if impact.removed.length() == 0 {
lines.push(" (none)")
} else {
for value in impact.removed {
lines.push(" - " + value.to_iso_string())
}
}
lines.join("\n")
}
///|
fn run_diff(args : Array[String]) -> CliResult raise {
let start = parse_datetime(require_cli_arg(args, 1, "DTSTART"))
let baseline = parse_rrule(require_cli_arg(args, 2, "OLD_RRULE"))
let candidate = parse_rrule(require_cli_arg(args, 3, "NEW_RRULE"))
let options = expansion_options_from_cli(
require_cli_arg(args, 4, "WINDOW_START"),
require_cli_arg(args, 5, "WINDOW_END"),
None,
)
cli_success(
impact_text(analyze_rule_change(start, baseline, candidate, options)),
)
}
///|
fn run_duration(args : Array[String]) -> CliResult raise {
let value = parse_duration(require_cli_arg(args, 1, "ISO_DURATION"))
let lines : Array[String] = [
"normalized: " + value.to_iso_string(),
"english: " + value.explain_en(),
"chinese: " + value.explain_zh(),
]
if args.length() > 2 {
let start = parse_datetime(args[2])
lines.push("result: " + start.add_duration(value).to_iso_string())
}
cli_success(lines.join("\n"))
}
///|
fn run_demo() -> CliResult raise {
let start = parse_datetime("20260803T093000")
let rule = parse_rrule("FREQ=WEEKLY;COUNT=8;BYDAY=MO,WE,FR")
let options = ExpansionOptions::new(
parse_datetime("20260801T000000"),
parse_datetime("20260901T000000"),
limit=20,
)
let values = expand_rrule(start, rule, options)
let audit = audit_occurrences(values)
cli_success(
[
"rule: " + rule.to_string(),
"explanation: " + rule.explain_en(),
"",
datetime_lines(values),
"",
audit.to_text(),
].join("\n"),
)
}
///|
/// Execute one CLI command without performing process I/O. This design makes
/// the command interface fully testable and portable across Wasm and JS.
pub fn execute_cli(args : Array[String]) -> CliResult {
if args.length() == 0 {
return cli_success(cli_help())
}
try {
match args[0].to_lower() {
"help" | "--help" | "-h" => cli_success(cli_help())
"version" | "--version" | "-v" => cli_success(version())
"validate" => run_validate(args)
"normalize" => run_normalize(args)
"explain" => run_explain(args)
"expand" => run_expand(args)
"audit" => run_audit(args)
"diff" => run_diff(args)
"duration" => run_duration(args)
"demo" => run_demo()
command => raise UnknownCommand(command)
}
} catch {
MissingArgument(label) => cli_failure(64, "missing argument " + label)
InvalidNumber(label, value) =>
cli_failure(64, label + " is not an integer: " + value)
UnknownCommand(command) => cli_failure(64, "unknown command: " + command)
InvalidLanguage(language) =>
cli_failure(64, "unsupported language: " + language)
error => cli_failure(2, error.to_string())
}
}