feat: add workspace skeleton and server configuration validation
This commit is contained in:
commit
6a3a198975
20 changed files with 1891 additions and 0 deletions
23
bin/Cargo.toml
Normal file
23
bin/Cargo.toml
Normal 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
17
bin/src/cli.rs
Normal 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
84
bin/src/lib.rs
Normal 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
16
bin/src/main.rs
Normal 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
10
bin/src/registry.rs
Normal 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
64
bin/tests/check.rs
Normal 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}");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue