//! 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." #![cfg(feature = "ssr")] use portal::{aggregates, content}; #[tokio::main] async fn main() -> anyhow::Result<()> { let mut args = std::env::args().skip(1); let mut repo: Option = None; let mut branch = "main".to_string(); let mut subdir = "questions".to_string(); let mut path: Option = 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); } } } let (questions, aggregates_map, site) = if let Some(dir) = path { (load_from_dir(&dir)?, load_aggregates_from_dir(&dir)?, load_site_from_dir(&dir)?) } else if let Some(repo_url) = repo { let questions = content::load_questions_from_gitea(&repo_url, &branch, &subdir).await?; let aggregates_map = content::load_aggregates_from_gitea(&repo_url, &branch).await?; // load_site_from_gitea already validates; a missing file is // the default config, same as at portal boot. let site = content::load_site_from_gitea(&repo_url, &branch).await?; (questions, aggregates_map, site) } else { eprintln!( "usage: question-lint --repo [--branch main] [--subdir questions] | --path " ); std::process::exit(2); }; if let Err(e) = site.validate() { eprintln!("FAIL: {e}"); std::process::exit(1); } let aggregates_map = content::with_builtin_aggregates(aggregates_map); match content::validate_questions(&questions, &aggregates_map) { Ok(()) => { // The runtime's own event desk: a repo announcing pages // (Question.event) should read portal_events somewhere, or // "post what happened" tasks pile up unseen. Warn, don't // fail - the runtime creates the records either way. if questions.values().any(|q| q.event.is_some()) && !content::bucket_is_read(&questions, content::EVENTS_BUCKET) { eprintln!( "WARN: pages carry `event:` but no kv resource reads bucket {:?} - add a desk so summaries get posted", content::EVENTS_BUCKET ); } println!( "OK: {} question(s), {} aggregate(s) valid", questions.len(), aggregates_map.len() ); Ok(()) } Err(e) => { eprintln!("FAIL: {e}"); std::process::exit(1); } } } /// The offline counterpart to `content::load_site_from_gitea` - /// `site.yaml` lives at the repo root like `aggregates.yaml`, and is /// just as optional locally: missing means default branding. fn load_site_from_dir(dir: &str) -> anyhow::Result { let site_path = std::path::Path::new(dir) .parent() .unwrap_or_else(|| std::path::Path::new(".")) .join("site.yaml"); if !site_path.exists() { return Ok(content::SiteConfig::default()); } let raw = std::fs::read_to_string(&site_path) .map_err(|e| anyhow::anyhow!("reading {}: {e}", site_path.display()))?; serde_yaml::from_str(&raw).map_err(|e| anyhow::anyhow!("parsing {}: {e}", site_path.display())) } /// 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> { 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) } /// The offline counterpart to `content::load_questions_from_gitea` - /// the same recursive tree walk and `content::build_questions` /// pipeline (derived ids, relative refs, sections, followup /// inference), 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> { let base = std::path::Path::new(dir); let mut files = Vec::new(); collect_yaml(base, base, &mut files)?; content::build_questions(&files) } fn collect_yaml( base: &std::path::Path, dir: &std::path::Path, out: &mut Vec<(String, String)>, ) -> anyhow::Result<()> { for entry in std::fs::read_dir(dir)? { let path = entry?.path(); if path.is_dir() { collect_yaml(base, &path, out)?; } else if path.extension().and_then(|e| e.to_str()) == Some("yaml") { let rel = path .strip_prefix(base) .expect("walked paths sit under their base") .to_string_lossy() .replace('\\', "/"); let raw = std::fs::read_to_string(&path) .map_err(|e| anyhow::anyhow!("reading {}: {e}", path.display()))?; out.push((rel, raw)); } } Ok(()) }