///|
/// CLI entry point for moon_api_guard.
fn main {
let args = @sys.get_cli_args()
match parse_command(args) {
Check(old_path, new_path, opts) => run_check(old_path, new_path, opts)
CheckDir(old_dir, new_dir, opts) => run_check_dir(old_dir, new_dir, opts)
BaselineUpdate(current_path, baseline_path) =>
run_baseline_update(current_path, baseline_path)
PolicyDump(strict) => run_policy_dump(strict)
Help => print_usage()
Invalid(message) => {
println("error: \{message}")
print_usage()
@sys.exit(2)
}
}
}
///|
priv struct CheckOpts {
format : String
breaking_only : Bool
strict : Bool
allow : String
ignore : String
policy_path : String
ignore_path : String
github_summary : Bool
output_path : String
}
///|
enum Command {
Check(String, String, CheckOpts)
CheckDir(String, String, CheckOpts)
BaselineUpdate(String, String)
PolicyDump(Bool)
Help
Invalid(String)
}
///|
fn parse_command(args : Array[String]) -> Command {
if args.length() <= 1 {
return Help
}
let cmd = args[1]
if cmd == "help" || cmd == "--help" || cmd == "-h" {
return Help
}
if cmd == "baseline" {
return parse_baseline_command(args)
}
if cmd == "policy" {
return parse_policy_command(args)
}
if cmd == "check" || cmd == "check-dir" {
return parse_check_like_command(args, cmd)
}
Invalid("unknown command: \{cmd}")
}
///|
fn parse_policy_command(args : Array[String]) -> Command {
if args.length() < 3 || args[2] != "dump" {
return Invalid("policy requires subcommand: dump")
}
let mut strict = false
let mut index = 3
while index < args.length() {
if args[index] == "--strict" {
strict = true
index = index + 1
} else {
return Invalid("unexpected argument: \{args[index]}")
}
}
PolicyDump(strict)
}
///|
fn parse_baseline_command(args : Array[String]) -> Command {
if args.length() < 3 || args[2] != "update" {
return Invalid("baseline requires subcommand: update")
}
if args.length() < 5 {
return Invalid("baseline update requires ")
}
if args.length() > 5 {
return Invalid("unexpected argument: \{args[5]}")
}
BaselineUpdate(args[3], args[4])
}
///|
fn parse_check_like_command(args : Array[String], cmd : String) -> Command {
let mut left = ""
let mut right = ""
let mut format = "text"
let mut breaking_only = false
let mut strict = false
let mut allow = ""
let mut ignore = ""
let mut policy_path = ""
let mut ignore_path = ""
let mut github_summary = false
let mut output_path = ""
let mut index = 2
while index < args.length() {
let arg = args[index]
if arg == "--format" {
if index + 1 >= args.length() {
return Invalid("--format requires text, markdown, json, html, or sarif")
}
format = args[index + 1]
if format != "text" &&
format != "markdown" &&
format != "json" &&
format != "html" &&
format != "sarif" {
return Invalid("unsupported format: \{format}")
}
index = index + 2
} else if arg == "--breaking-only" {
breaking_only = true
index = index + 1
} else if arg == "--strict" {
strict = true
index = index + 1
} else if arg == "--allow" {
if index + 1 >= args.length() {
return Invalid("--allow requires a comma-separated detail list")
}
allow = args[index + 1]
index = index + 2
} else if arg == "--ignore" {
if index + 1 >= args.length() {
return Invalid("--ignore requires a comma-separated detail list")
}
ignore = args[index + 1]
index = index + 2
} else if arg == "--policy" {
if index + 1 >= args.length() {
return Invalid("--policy requires a JSON file path")
}
policy_path = args[index + 1]
index = index + 2
} else if arg == "--ignore-path" {
if index + 1 >= args.length() {
return Invalid("--ignore-path requires a comma-separated glob list")
}
ignore_path = args[index + 1]
index = index + 2
} else if arg == "--github-summary" {
github_summary = true
index = index + 1
} else if arg == "--output" {
if index + 1 >= args.length() {
return Invalid("--output requires a file path")
}
output_path = args[index + 1]
index = index + 2
} else if left.length() == 0 {
left = arg
index = index + 1
} else if right.length() == 0 {
right = arg
index = index + 1
} else {
return Invalid("unexpected argument: \{arg}")
}
}
let opts : CheckOpts = {
format,
breaking_only,
strict,
allow,
ignore,
policy_path,
ignore_path,
github_summary,
output_path,
}
if left.length() == 0 || right.length() == 0 {
if cmd == "check-dir" {
Invalid("check-dir requires two directory paths")
} else {
Invalid("check requires two .mbti file paths")
}
} else if cmd == "check-dir" {
CheckDir(left, right, opts)
} else {
Check(left, right, opts)
}
}
///|
fn resolve_policy(opts : CheckOpts) -> @lib.CompatPolicy {
let discovered = if opts.policy_path.length() > 0 {
opts.policy_path
} else {
discover_policy_file()
}
let mut policy = if discovered.length() > 0 {
match @lib.policy_from_json_text(read_mbti_file(discovered)) {
Some(loaded) => loaded
None => {
println("error: invalid policy JSON: \{discovered}")
@sys.exit(2)
@lib.default_compat_policy()
}
}
} else if opts.strict {
@lib.strict_compat_policy()
} else {
@lib.default_compat_policy()
}
for detail in split_csv(opts.allow) {
policy = @lib.policy_allow(policy, detail)
}
for detail in split_csv(opts.ignore) {
policy = @lib.policy_ignore(policy, detail)
}
policy
}
///|
/// Look for project-local policy files when `--policy` is omitted.
fn discover_policy_file() -> String {
let candidates = ["moon_api_guard.json", ".moon_api_guard.json"]
for path in candidates {
if @fs.path_exists(path) {
return path
}
}
""
}
///|
fn split_csv(text : String) -> Array[String] {
let parts : Array[String] = []
if text.length() == 0 {
return parts
}
for part in text.split(",") {
let trimmed = part.trim(char_set=" \t\r").to_owned()
if trimmed.length() > 0 {
parts.push(trimmed)
}
}
parts
}
///|
fn run_check(old_path : String, new_path : String, opts : CheckOpts) -> Unit {
let report = maybe_breaking_only(
@lib.compare_mbti_content_with_policy(
read_mbti_file(old_path),
read_mbti_file(new_path),
resolve_policy(opts),
),
opts.breaking_only,
)
emit_report_with_opts(report, opts)
}
///|
fn run_check_dir(old_dir : String, new_dir : String, opts : CheckOpts) -> Unit {
let report = maybe_breaking_only(
compare_mbti_dirs_with_policy(
old_dir,
new_dir,
resolve_policy(opts),
opts.ignore_path,
),
opts.breaking_only,
)
emit_report_with_opts(report, opts)
}
///|
fn compare_mbti_dirs_with_policy(
old_dir : String,
new_dir : String,
policy : @lib.CompatPolicy,
ignore_path : String,
) -> @lib.ApiReport {
let old_files = filter_ignored_paths(list_mbti_files(old_dir), ignore_path)
let new_files = filter_ignored_paths(list_mbti_files(new_dir), ignore_path)
let reports : Array[@lib.ApiReport] = []
for path in old_files {
if contains_string(new_files, path) {
let report = @lib.compare_mbti_content_with_policy(
read_mbti_file(join_path(old_dir, path)),
read_mbti_file(join_path(new_dir, path)),
policy,
)
reports.push(report.scoped(path))
} else {
reports.push(@lib.file_removed_report(path))
}
}
for path in new_files {
if !contains_string(old_files, path) {
reports.push(@lib.file_added_report(path))
}
}
@lib.merge_api_reports(reports)
}
///|
fn filter_ignored_paths(
paths : Array[String],
ignore_path : String,
) -> Array[String] {
if ignore_path.length() == 0 {
return paths
}
let kept : Array[String] = []
for path in paths {
if !@lib.path_is_ignored(path, ignore_path) {
kept.push(path)
}
}
kept
}
///|
fn run_baseline_update(current_path : String, baseline_path : String) -> Unit {
let content = read_mbti_file(current_path)
@fs.write_string_to_file(baseline_path, content) catch {
IOError(message) => {
println("error: failed to write \{baseline_path}: \{message}")
@sys.exit(2)
}
}
println("baseline updated: \{baseline_path}")
}
///|
fn run_policy_dump(strict : Bool) -> Unit {
let policy = if strict {
@lib.strict_compat_policy()
} else {
@lib.default_compat_policy()
}
println(@lib.policy_to_json_text(policy, strict~))
}
///|
fn emit_report_with_opts(report : @lib.ApiReport, opts : CheckOpts) -> Unit {
let rendered = render_report(report, opts.format)
if opts.output_path.length() > 0 {
@fs.write_string_to_file(opts.output_path, rendered) catch {
IOError(message) => {
println("error: failed to write \{opts.output_path}: \{message}")
@sys.exit(2)
}
}
println("report written: \{opts.output_path}")
} else {
println(rendered)
}
if opts.github_summary {
write_github_summary(report)
}
if report.release_blocked() {
@sys.exit(1)
}
}
///|
fn render_report(report : @lib.ApiReport, format : String) -> String {
if format == "json" {
report.json_summary()
} else if format == "markdown" {
report.markdown_summary()
} else if format == "html" {
report.html_summary()
} else if format == "sarif" {
report.sarif_summary()
} else {
"moon_api_guard report\n" +
"breaking: \{report.breaking_count()}\n" +
"compatible: \{report.compatible_count()}\n" +
"semver: \{report.semver_suggestion()}\n" +
report.semver_advice() +
"\n" +
"release_blocked: \{report.release_blocked()}\n" +
report.markdown_summary() +
"\n" +
report.json_summary()
}
}
///|
fn write_github_summary(report : @lib.ApiReport) -> Unit {
let summary = report.github_summary()
match @sys.get_env_var("GITHUB_STEP_SUMMARY") {
Some(path) =>
@fs.write_string_to_file(path, summary) catch {
IOError(message) => {
println("error: failed to write GITHUB_STEP_SUMMARY: \{message}")
println(summary)
}
}
None => println(summary)
}
}
///|
fn maybe_breaking_only(
report : @lib.ApiReport,
breaking_only : Bool,
) -> @lib.ApiReport {
if breaking_only {
report.breaking_only()
} else {
report
}
}
///|
fn read_mbti_file(path : String) -> String {
@fs.read_file_to_string(path) catch {
IOError(message) => fail_read(path, message)
}
}
///|
fn fail_read(path : String, message : String) -> String {
println("error: failed to read \{path}: \{message}")
@sys.exit(2)
""
}
///|
fn list_mbti_files(root : String) -> Array[String] {
let files : Array[String] = []
collect_mbti_files(root, "", files)
sort_strings(files)
files
}
///|
fn collect_mbti_files(
root : String,
relative : String,
files : Array[String],
) -> Unit {
let path = if relative.length() == 0 {
root
} else {
join_path(root, relative)
}
let entries = @fs.read_dir(path) catch {
IOError(message) => {
println("error: failed to read directory \{path}: \{message}")
@sys.exit(2)
[]
}
}
for entry in entries {
if entry == "." || entry == ".." {
continue
}
let child_rel = if relative.length() == 0 {
entry
} else {
join_path(relative, entry)
}
let child_path = join_path(root, child_rel)
let is_directory = @fs.is_dir(child_path) catch {
IOError(message) => {
println("error: failed to inspect \{child_path}: \{message}")
@sys.exit(2)
false
}
}
if is_directory {
collect_mbti_files(root, child_rel, files)
} else if entry.has_suffix(".mbti") {
files.push(child_rel)
}
}
}
///|
fn join_path(left : String, right : String) -> String {
if left.has_suffix("/") {
left + right
} else {
"\{left}/\{right}"
}
}
///|
fn contains_string(items : Array[String], target : String) -> Bool {
for item in items {
if item == target {
return true
}
}
false
}
///|
fn sort_strings(items : Array[String]) -> Unit {
let mut i = 0
while i < items.length() {
let mut j = i + 1
while j < items.length() {
if items[j] < items[i] {
let tmp = items[i]
items[i] = items[j]
items[j] = tmp
}
j = j + 1
}
i = i + 1
}
}
///|
fn print_usage() -> Unit {
println("moon_api_guard - MoonBit public API compatibility guard")
println("")
println("Usage:")
println(" moon run cmd/main -- check [options]")
println(" moon run cmd/main -- check-dir [options]")
println(
" moon run cmd/main -- baseline update ",
)
println(" moon run cmd/main -- policy dump [--strict]")
println(" moon run cmd/main -- help")
println("")
println("Options:")
println(" --format text|markdown|json|html|sarif")
println(" --output ")
println(" --breaking-only")
println(" --strict")
println(" --allow [,detail...]")
println(" --ignore [,detail...]")
println(" --policy ")
println(" --ignore-path [,glob...]")
println(" --github-summary")
println("")
println("Exit codes:")
println(" 0 - no breaking changes")
println(" 1 - breaking changes detected")
println(" 2 - invalid usage or file read error")
}