A question may carry event: {starts, duration, place}. While the
window is open the page is announced in a strip at the top of every
header (name, when, 'in 3 days'), soonest first, and kept out of the
footer nav; when it closes the page becomes a followup - only a
visitor carrying an answer chain still sees it.
announce.rs keeps one record per event page in the runtime-owned
portal_events bucket (built-in state graph: announced ->
awaiting_summary -> summarized, content may override) and, on a
one-minute idempotent sweep, moves ended windows to awaiting_summary,
publishing the transition on portal.answers.submitted as 'Summary
due' - the post-what-happened task a review desk picks up. Lint
warns when event pages exist but nothing reads portal_events.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
159 lines
6.4 KiB
Rust
159 lines
6.4 KiB
Rust
//! 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<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);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 <gitea-url> [--branch main] [--subdir questions] | --path <local-dir>"
|
|
);
|
|
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<content::SiteConfig> {
|
|
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<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)
|
|
}
|
|
|
|
/// 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<std::collections::HashMap<String, content::Question>> {
|
|
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(())
|
|
}
|