2026-08-11 21:43:32 +02:00
|
|
|
//! Headless schema-check binary - loads content and `aggregates.yaml`
|
|
|
|
|
//! (from a Gitea repo URL or a local directory) and validates them
|
|
|
|
|
//! exactly the way `content::watch_for_reload`/`main.rs`'s boot path
|
|
|
|
|
//! do, with no NATS, OIDC, web server, or JetStream connection
|
|
|
|
|
//! involved. Built once by portal's own deploy workflow and run
|
|
|
|
|
//! directly by `questions`' own CI (same bare-metal runner/host,
|
|
|
|
|
//! published to a stable path - no artifact download needed), rather
|
|
|
|
|
//! than compiled there - keeps that repo's CI coupling to "run a
|
|
|
|
|
//! static binary," not "build a Rust workspace."
|
2026-08-06 08:52:29 +02:00
|
|
|
#![cfg(feature = "ssr")]
|
|
|
|
|
|
2026-08-11 21:43:32 +02:00
|
|
|
use portal::{aggregates, content};
|
2026-08-06 08:52:29 +02:00
|
|
|
|
|
|
|
|
#[tokio::main]
|
|
|
|
|
async fn main() -> anyhow::Result<()> {
|
|
|
|
|
let mut args = std::env::args().skip(1);
|
|
|
|
|
let mut repo: Option<String> = None;
|
|
|
|
|
let mut branch = "main".to_string();
|
|
|
|
|
let mut subdir = "questions".to_string();
|
|
|
|
|
let mut path: Option<String> = None;
|
|
|
|
|
|
|
|
|
|
while let Some(arg) = args.next() {
|
|
|
|
|
match arg.as_str() {
|
|
|
|
|
"--repo" => repo = args.next(),
|
|
|
|
|
"--branch" => branch = args.next().unwrap_or(branch),
|
|
|
|
|
"--subdir" => subdir = args.next().unwrap_or(subdir),
|
|
|
|
|
"--path" => path = args.next(),
|
|
|
|
|
other => {
|
|
|
|
|
eprintln!("unknown argument: {other}");
|
|
|
|
|
std::process::exit(2);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-11 21:43:32 +02:00
|
|
|
let (questions, aggregates_map) = if let Some(dir) = path {
|
|
|
|
|
(load_from_dir(&dir)?, load_aggregates_from_dir(&dir)?)
|
2026-08-06 08:52:29 +02:00
|
|
|
} else if let Some(repo_url) = repo {
|
2026-08-11 21:43:32 +02:00
|
|
|
let questions = content::load_questions_from_gitea(&repo_url, &branch, &subdir).await?;
|
|
|
|
|
let aggregates_map = content::load_aggregates_from_gitea(&repo_url, &branch).await?;
|
|
|
|
|
(questions, aggregates_map)
|
2026-08-06 08:52:29 +02:00
|
|
|
} else {
|
|
|
|
|
eprintln!(
|
|
|
|
|
"usage: question-lint --repo <gitea-url> [--branch main] [--subdir questions] | --path <local-dir>"
|
|
|
|
|
);
|
|
|
|
|
std::process::exit(2);
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-11 21:43:32 +02:00
|
|
|
match content::validate_questions(&questions, &aggregates_map) {
|
2026-08-06 08:52:29 +02:00
|
|
|
Ok(()) => {
|
2026-08-11 21:43:32 +02:00
|
|
|
println!(
|
|
|
|
|
"OK: {} question(s), {} aggregate(s) valid",
|
|
|
|
|
questions.len(),
|
|
|
|
|
aggregates_map.len()
|
|
|
|
|
);
|
2026-08-06 08:52:29 +02:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
eprintln!("FAIL: {e}");
|
|
|
|
|
std::process::exit(1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-11 21:43:32 +02:00
|
|
|
/// The offline counterpart to `content::load_aggregates_from_gitea` -
|
|
|
|
|
/// `aggregates.yaml` lives at the repo root, one level up from the
|
|
|
|
|
/// pages directory `--path` names, so `dir`'s parent is where it's
|
|
|
|
|
/// looked for. Missing entirely is not an error here (unlike
|
|
|
|
|
/// `--repo` mode, where `aggregates.yaml` is required) - a local
|
|
|
|
|
/// checkout being linted may not have one, and every declared
|
|
|
|
|
/// transition still gets checked against whatever *is* found; an
|
|
|
|
|
/// empty map just means nothing is checked.
|
|
|
|
|
fn load_aggregates_from_dir(
|
|
|
|
|
dir: &str,
|
|
|
|
|
) -> anyhow::Result<std::collections::HashMap<String, aggregates::AggregateSchema>> {
|
|
|
|
|
let aggregates_path = std::path::Path::new(dir)
|
|
|
|
|
.parent()
|
|
|
|
|
.unwrap_or_else(|| std::path::Path::new("."))
|
|
|
|
|
.join("aggregates.yaml");
|
|
|
|
|
if !aggregates_path.exists() {
|
|
|
|
|
return Ok(std::collections::HashMap::new());
|
|
|
|
|
}
|
|
|
|
|
let raw = std::fs::read_to_string(&aggregates_path)
|
|
|
|
|
.map_err(|e| anyhow::anyhow!("reading {}: {e}", aggregates_path.display()))?;
|
|
|
|
|
aggregates::parse_aggregates_yaml(&raw)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 08:52:29 +02:00
|
|
|
/// The offline counterpart to `content::load_questions_from_gitea` -
|
|
|
|
|
/// same "every `*.yaml` file becomes a `Question` keyed by its own
|
|
|
|
|
/// `id`" shape, just reading a local checkout instead of Gitea's API,
|
|
|
|
|
/// for linting a branch that hasn't been pushed yet.
|
|
|
|
|
fn load_from_dir(
|
|
|
|
|
dir: &str,
|
|
|
|
|
) -> anyhow::Result<std::collections::HashMap<String, content::Question>> {
|
|
|
|
|
let mut out = std::collections::HashMap::new();
|
|
|
|
|
for entry in std::fs::read_dir(dir)? {
|
|
|
|
|
let entry = entry?;
|
|
|
|
|
let path = entry.path();
|
|
|
|
|
if path.extension().and_then(|e| e.to_str()) != Some("yaml") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let raw = std::fs::read_to_string(&path)?;
|
|
|
|
|
let question: content::Question = serde_yaml::from_str(&raw)
|
|
|
|
|
.map_err(|e| anyhow::anyhow!("parsing {}: {e}", path.display()))?;
|
|
|
|
|
out.insert(question.id.clone(), question);
|
|
|
|
|
}
|
|
|
|
|
Ok(out)
|
|
|
|
|
}
|