feat: add workspace skeleton and server configuration validation

This commit is contained in:
randogoth 2026-10-04 18:55:33 +03:00
commit 6a3a198975
20 changed files with 1891 additions and 0 deletions

23
bin/Cargo.toml Normal file
View file

@ -0,0 +1,23 @@
[package]
name = "itsybitsy"
version = "0.1.0"
edition = "2024"
rust-version = "1.97"
description = "Serve folders of Markdown to several domains over HTTP, Spartan and Nex"
license = "Apache-2.0"
publish = false
[[bin]]
name = "itsybitsy"
path = "src/main.rs"
[dependencies]
anyhow = "1"
clap = { version = "4.6", features = ["derive"] }
itsybitsy-core = { path = "../core" }
[dev-dependencies]
tempfile = "3"
[lints.rust]
unsafe_code = "forbid"

17
bin/src/cli.rs Normal file
View file

@ -0,0 +1,17 @@
//! Command line surface.
use std::path::PathBuf;
use clap::Parser;
#[derive(Debug, Parser)]
#[command(version, about = "Serve folders of Markdown to several domains")]
pub struct Cli {
/// Server configuration file declaring the sites and listeners.
#[arg(long, value_name = "FILE")]
pub config: PathBuf,
/// Validate the configuration, report what it resolved to, and exit.
#[arg(long)]
pub check: bool,
}

84
bin/src/lib.rs Normal file
View file

@ -0,0 +1,84 @@
//! Wiring: the command line, the renderer registry, and the listeners.
//!
//! Everything testable without a socket lives in `itsybitsy-core` instead. This
//! crate is a library as well as a binary so integration tests can bind a
//! listener on port 0 in-process.
pub mod cli;
pub mod registry;
use std::path::Path;
use anyhow::{Context, Result};
use itsybitsy_core::config::{Checked, ServerConfig};
use crate::cli::Cli;
pub fn run(cli: &Cli) -> Result<()> {
let formats = registry::format_ids();
if cli.check {
return check(&cli.config, &formats);
}
let _ = load(&cli.config, &formats)?;
anyhow::bail!("serving is not implemented yet; run with --check to validate the configuration")
}
/// Validate a configuration and report what it resolved to.
///
/// Takes the format set rather than reading [`registry`] directly, so a test can
/// supply the ids a later milestone's registry will provide.
pub fn check(config_path: &Path, formats: &[&str]) -> Result<()> {
let (config, checked) = load(config_path, formats)?;
report(&config, &checked);
Ok(())
}
fn load(config_path: &Path, formats: &[&str]) -> Result<(ServerConfig, Checked)> {
// No context on load: its errors already name the file.
let config = ServerConfig::load(config_path)?;
let checked = config
.validate(config_path, formats)
.with_context(|| format!("validating {}", config_path.display()))?;
Ok((config, checked))
}
/// What `--check` prints: the resolved routing, so an operator can see which
/// name reaches which folder without sending a request.
fn report(config: &ServerConfig, checked: &Checked) {
println!("sites:");
for (name, root) in &checked.roots {
println!(" {name}: {}", root.display());
}
println!("hosts:");
if checked.hosts.is_empty() {
println!(" (none; every site is reachable only by name)");
}
for (host, site) in &checked.hosts {
println!(" {host} -> {site}");
}
for group in &checked.shared_roots {
println!("note: sites {} share one root, and so one render cache", group.join(", "));
}
println!("listeners:");
for (name, spec) in &config.listener {
let target = match (&spec.site, &spec.default_site) {
(Some(site), _) => format!("site {site}"),
(_, Some(site)) => format!("by host, default {site}"),
_ => "by host, no default".to_string(),
};
println!(
" {name}: {} on {} [{}] {target}",
spec.protocol.as_str(),
spec.bind,
spec.formats.join(", ")
);
}
println!(
"formats rendered per page: {}",
checked.formats.iter().cloned().collect::<Vec<_>>().join(", ")
);
}

16
bin/src/main.rs Normal file
View file

@ -0,0 +1,16 @@
use std::process::ExitCode;
use clap::Parser;
use itsybitsy::cli::Cli;
fn main() -> ExitCode {
let cli = Cli::parse();
match itsybitsy::run(&cli) {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("itsybitsy: {err:#}");
ExitCode::FAILURE
}
}
}

10
bin/src/registry.rs Normal file
View file

@ -0,0 +1,10 @@
//! The only file that names a format crate.
//!
//! Cargo features select which arms compile, so a build carries exactly the
//! formats it was asked for. The registry is built once at startup and shared
//! immutably across every connection.
/// Ids of the formats this build provides.
pub fn format_ids() -> Vec<&'static str> {
Vec::new()
}

64
bin/tests/check.rs Normal file
View file

@ -0,0 +1,64 @@
//! `--check` end to end: a configuration on disk through to the resolved
//! routing, without a renderer registry or a socket.
use std::fs;
const FORMATS: &[&str] = &["gemtext", "html"];
/// Writes a two-site configuration beside a pair of content roots and returns
/// the config path. The config deliberately sits outside both roots.
fn fixture(dir: &std::path::Path) -> std::path::PathBuf {
let one = dir.join("one");
let two = dir.join("two");
fs::create_dir_all(&one).unwrap();
fs::create_dir_all(&two).unwrap();
let text = format!(
r#"
version = 1
[site.one]
root = "{one}"
hosts = ["one.test", "www.one.test"]
[site.two]
root = "{two}"
hosts = ["two.test"]
[listener.web]
protocol = "http"
bind = "0.0.0.0:8080"
formats = ["html"]
default_site = "one"
[listener.feed]
protocol = "nex"
bind = "0.0.0.0:1900"
formats = ["gemtext"]
site = "two"
"#,
one = one.to_str().unwrap(),
two = two.to_str().unwrap(),
);
let path = dir.join("itsybitsy.toml");
fs::write(&path, text).unwrap();
path
}
#[test]
fn checks_a_two_site_configuration() {
let dir = tempfile::tempdir().unwrap();
let path = fixture(dir.path());
itsybitsy::check(&path, FORMATS).unwrap();
}
#[test]
fn an_unprovided_format_names_itself_and_the_file() {
let dir = tempfile::tempdir().unwrap();
let path = fixture(dir.path());
// The registry is empty until a format crate lands, which is exactly the
// "feature switched off" case an operator hits.
let err = itsybitsy::check(&path, &[]).unwrap_err();
let rendered = format!("{err:#}");
assert!(rendered.contains("itsybitsy.toml"), "{rendered}");
assert!(rendered.contains("does not provide"), "{rendered}");
}