- AnthropicBackend (Messages API, cached system block, output_config.effort,
server-side fallbacks, refusal surfaced as an error); Llms { bulk, editor }
with editor_or_bulk(); PriceTable-based UsageMeter per provider.
- [anthropic], [editorial], deepseek.max_concurrent_requests and
curation.max_article_count config; startup logs resolved providers.
- Budget day is the UTC date of started_at, preloaded from
runs.provider_costs_json; finish_run writes provider_costs_json and
config_json. Stage A batches run concurrently with per-batch budget checks.
- Editor prompt with one-line "why" per pick; no minimum lineup size;
--max-articles is a ceiling; top-up branch deleted; why stored on picks and
issue_articles.why and rendered in chapters and In this issue.
- Summaries on the editor client (3k-token input, concurrency 4, bulk then
excerpt fallback); "The Brief" replaces From the Editor; section intros gone.
- Colophon carries per-provider costs and models.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1rCLQeKBgnBo3oTgHuTMe
670 lines
23 KiB
Rust
670 lines
23 KiB
Rust
//! Typed configuration (spec §3.14).
|
||
//!
|
||
//! Load order, later wins: built-in defaults ← `config.toml` (path from `--config`,
|
||
//! else `./config.toml` if present) ← `DAILY_EPUB_*` environment variables, where
|
||
//! nesting is expressed with a double underscore (`DAILY_EPUB_MINIFLUX__API_KEY`).
|
||
|
||
use std::path::{Path, PathBuf};
|
||
|
||
use figment::Figment;
|
||
use figment::providers::{Env, Format, Serialized, Toml};
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
/// Environment-variable prefix for every override (§3.14).
|
||
pub const ENV_PREFIX: &str = "DAILY_EPUB_";
|
||
/// Nesting separator inside env var names.
|
||
pub const ENV_SPLIT: &str = "__";
|
||
/// Default config file looked up when `--config` is not given.
|
||
pub const DEFAULT_CONFIG_FILE: &str = "config.toml";
|
||
|
||
#[derive(Debug, thiserror::Error)]
|
||
pub enum ConfigError {
|
||
#[error("failed to load configuration: {0}")]
|
||
Figment(#[from] Box<figment::Error>),
|
||
#[error("config file not found: {0}")]
|
||
Missing(PathBuf),
|
||
#[error("invalid configuration: {0}")]
|
||
Invalid(String),
|
||
}
|
||
|
||
impl From<figment::Error> for ConfigError {
|
||
fn from(e: figment::Error) -> Self {
|
||
ConfigError::Figment(Box::new(e))
|
||
}
|
||
}
|
||
|
||
/// Legacy/alternate env var for the rating-link HMAC key (spec §1).
|
||
pub const ENV_SECRET_ALIAS: &str = "DAILY_EPUB_SECRET";
|
||
|
||
/// Root configuration document (§3.14).
|
||
///
|
||
/// Unknown *top-level* keys are ignored on purpose: the prefix `DAILY_EPUB_` is
|
||
/// shared with plain operator env vars such as [`ENV_SECRET_ALIAS`].
|
||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
#[serde(default)]
|
||
pub struct Config {
|
||
/// IANA tz used for day boundaries and `--date` (§3.14, notes §2).
|
||
pub timezone: String,
|
||
/// Ingest window size in hours (§3.1).
|
||
pub lookback_hours: u32,
|
||
/// How many articles the lineup should contain (§3.6 stage B).
|
||
pub target_article_count: usize,
|
||
/// How many articles survive the heuristic pre-filter (§3.5).
|
||
pub prefilter_keep: usize,
|
||
/// Days of published EPUBs kept in `publish.epub_dir` (§3.11).
|
||
pub retention_days: u32,
|
||
/// How many XTC issues to keep in `publish.xtc_dir` (§3.11).
|
||
///
|
||
/// Counted, not dated, because an XTCH issue is ~80–100 MB of pre-rendered
|
||
/// page bitmaps: the constraint is disk, not age.
|
||
pub xtc_retention_count: u32,
|
||
/// Hard cost ceiling per run (§3.6 guardrail).
|
||
pub max_daily_usd: f64,
|
||
/// Include the Wikipedia Current Events section (§3.8).
|
||
pub world_briefing: bool,
|
||
|
||
/// SQLite file; parent dirs are created on open.
|
||
pub database_path: PathBuf,
|
||
/// Default artifact output directory (overridden by `generate --out`).
|
||
pub out_dir: PathBuf,
|
||
/// Scour interests OPML used to seed the taste profile (§3.6).
|
||
pub interests_opml: PathBuf,
|
||
/// Hand-maintained reader profile loaded for every curation run (§8.2).
|
||
pub profile_path: PathBuf,
|
||
|
||
pub miniflux: MinifluxConfig,
|
||
pub deepseek: DeepseekConfig,
|
||
pub anthropic: AnthropicConfig,
|
||
pub curation: CurationConfig,
|
||
pub editorial: EditorialConfig,
|
||
pub publish: PublishConfig,
|
||
pub xtc: XtcConfig,
|
||
pub server: ServerConfig,
|
||
}
|
||
|
||
impl Default for Config {
|
||
fn default() -> Self {
|
||
Self {
|
||
timezone: "America/New_York".into(),
|
||
lookback_hours: 26,
|
||
target_article_count: 20,
|
||
prefilter_keep: 120,
|
||
retention_days: 21,
|
||
xtc_retention_count: 5,
|
||
max_daily_usd: 2.0,
|
||
world_briefing: true,
|
||
database_path: PathBuf::from("/var/lib/daily-epub/daily-epub.db"),
|
||
out_dir: PathBuf::from("/var/lib/daily-epub/out"),
|
||
interests_opml: PathBuf::from("data/scour-interests.opml"),
|
||
profile_path: PathBuf::from("data/profile.md"),
|
||
miniflux: MinifluxConfig::default(),
|
||
deepseek: DeepseekConfig::default(),
|
||
anthropic: AnthropicConfig::default(),
|
||
curation: CurationConfig::default(),
|
||
editorial: EditorialConfig::default(),
|
||
publish: PublishConfig::default(),
|
||
xtc: XtcConfig::default(),
|
||
server: ServerConfig::default(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `[miniflux]` — API client settings (§3.1).
|
||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
#[serde(deny_unknown_fields, default)]
|
||
pub struct MinifluxConfig {
|
||
pub base_url: String,
|
||
/// `X-Auth-Token`; supply via `DAILY_EPUB_MINIFLUX__API_KEY`.
|
||
pub api_key: Option<String>,
|
||
/// Page size for `GET /v1/entries` (Miniflux caps this at 250).
|
||
pub page_limit: u32,
|
||
}
|
||
|
||
impl Default for MinifluxConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
base_url: "http://127.0.0.1:8082".into(),
|
||
api_key: None,
|
||
page_limit: 250,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `[deepseek]` — LLM endpoint, model and pricing (§3.6, notes "verified facts").
|
||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
#[serde(deny_unknown_fields, default)]
|
||
pub struct DeepseekConfig {
|
||
pub base_url: String,
|
||
pub model: String,
|
||
/// Supply via `DAILY_EPUB_DEEPSEEK__API_KEY`.
|
||
pub api_key: Option<String>,
|
||
/// Articles per stage-A scoring request (§3.6).
|
||
pub score_batch_size: usize,
|
||
pub max_concurrent_requests: usize,
|
||
pub score_temperature: f32,
|
||
pub editorial_temperature: f32,
|
||
/// USD per 1M cache-miss input tokens.
|
||
pub price_input_per_mtok: f64,
|
||
/// USD per 1M prefix-cache-hit input tokens.
|
||
pub price_cached_input_per_mtok: f64,
|
||
/// USD per 1M output tokens.
|
||
pub price_output_per_mtok: f64,
|
||
}
|
||
|
||
impl Default for DeepseekConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
base_url: "https://api.deepseek.com/v1".into(),
|
||
model: "deepseek-v4-flash".into(),
|
||
api_key: None,
|
||
score_batch_size: 12,
|
||
max_concurrent_requests: 4,
|
||
score_temperature: 0.3,
|
||
editorial_temperature: 0.8,
|
||
price_input_per_mtok: 0.14,
|
||
price_cached_input_per_mtok: 0.0028,
|
||
price_output_per_mtok: 0.28,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `[anthropic]` — Claude editor, editorial and profile settings (§4.2).
|
||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
#[serde(deny_unknown_fields, default)]
|
||
pub struct AnthropicConfig {
|
||
pub enabled: bool,
|
||
pub base_url: String,
|
||
pub model: String,
|
||
/// Supply only via `DAILY_EPUB_ANTHROPIC__API_KEY`.
|
||
pub api_key: Option<String>,
|
||
pub effort: String,
|
||
pub price_input_per_mtok: f64,
|
||
pub price_cache_write_per_mtok: f64,
|
||
pub price_cache_read_per_mtok: f64,
|
||
pub price_output_per_mtok: f64,
|
||
pub max_daily_usd: f64,
|
||
pub max_concurrent_requests: usize,
|
||
}
|
||
|
||
impl Default for AnthropicConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
enabled: true,
|
||
base_url: "https://api.anthropic.com".into(),
|
||
model: "claude-opus-5".into(),
|
||
api_key: None,
|
||
effort: "high".into(),
|
||
price_input_per_mtok: 5.0,
|
||
price_cache_write_per_mtok: 6.25,
|
||
price_cache_read_per_mtok: 0.5,
|
||
price_output_per_mtok: 25.0,
|
||
max_daily_usd: 3.0,
|
||
max_concurrent_requests: 4,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Which provider writes per-article summaries (§14.1).
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "lowercase")]
|
||
pub enum SummaryModel {
|
||
Editor,
|
||
Bulk,
|
||
}
|
||
|
||
/// `[editorial]` — summary provider and per-article input budget (§14).
|
||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
#[serde(deny_unknown_fields, default)]
|
||
pub struct EditorialConfig {
|
||
pub summary_model: SummaryModel,
|
||
pub summary_input_tokens: usize,
|
||
}
|
||
|
||
impl Default for EditorialConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
summary_model: SummaryModel::Editor,
|
||
summary_input_tokens: 3_000,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `[curation]` — pre-filter and section palette (§3.5, §3.6).
|
||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
#[serde(deny_unknown_fields, default)]
|
||
pub struct CurationConfig {
|
||
/// Absolute issue-size ceiling; the editor has no minimum (§13).
|
||
pub max_article_count: usize,
|
||
/// Miniflux feed ids or site URLs that can never be dropped (§3.5).
|
||
pub always_include_feeds: Vec<String>,
|
||
/// Hosts excluded outright (§3.5).
|
||
pub blocked_domains: Vec<String>,
|
||
/// Extra paywalled hosts, merged with [`crate::extract::DEFAULT_PAYWALL_DOMAINS`]
|
||
/// by the extraction stage's `excerpt_only` heuristic (§3.3).
|
||
pub paywall_domains: Vec<String>,
|
||
/// The only section names the LLM may use (§3.6 stage B).
|
||
pub sections: Vec<String>,
|
||
pub feedback: FeedbackConfig,
|
||
}
|
||
|
||
impl Default for CurationConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
max_article_count: 28,
|
||
always_include_feeds: Vec::new(),
|
||
blocked_domains: Vec::new(),
|
||
paywall_domains: Vec::new(),
|
||
sections: [
|
||
"Top Stories",
|
||
"Tech & Engineering",
|
||
"Science & Space",
|
||
"AI & Machine Learning",
|
||
"Culture & Essays",
|
||
"Boston & Local",
|
||
"Niche Corner",
|
||
"From the Blogroll",
|
||
]
|
||
.iter()
|
||
.map(|s| s.to_string())
|
||
.collect(),
|
||
feedback: FeedbackConfig::default(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `[curation.feedback]` — explicit verdict weights and prompt history (§6, §8.4).
|
||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
#[serde(deny_unknown_fields, default)]
|
||
pub struct FeedbackConfig {
|
||
pub loved_value: f64,
|
||
pub good_value: f64,
|
||
pub not_for_me_value: f64,
|
||
pub verdicts_in_prompt: usize,
|
||
}
|
||
|
||
impl Default for FeedbackConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
loved_value: 1.0,
|
||
good_value: 0.35,
|
||
not_for_me_value: -1.0,
|
||
verdicts_in_prompt: 60,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `[publish]` — where finished artifacts land (§3.11).
|
||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
#[serde(deny_unknown_fields, default)]
|
||
pub struct PublishConfig {
|
||
/// Where both EPUB editions land: the source of the OPDS feed, served at
|
||
/// `/files/epub/`, and a BookOrbit watched folder if one is configured.
|
||
///
|
||
/// Renamed from `bookorbit_dir` once the built-in feed started serving this
|
||
/// directory directly — BookOrbit is optional, the directory is not.
|
||
pub epub_dir: PathBuf,
|
||
/// Directory served at `/files/xtc/`. Not listed in the OPDS feed (§3.11).
|
||
pub xtc_dir: PathBuf,
|
||
}
|
||
|
||
impl Default for PublishConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
epub_dir: PathBuf::from("/srv/bookorbit/libraries/daily-epub"),
|
||
xtc_dir: PathBuf::from("/var/lib/daily-epub/xtc"),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// XTC output flavour (§3.11).
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "lowercase")]
|
||
pub enum XtcFormat {
|
||
/// 1-bit.
|
||
Xtc,
|
||
/// 2-bit grayscale — the default (better image quality).
|
||
Xtch,
|
||
}
|
||
|
||
impl XtcFormat {
|
||
/// Value passed to the converter's `-f` flag.
|
||
pub fn as_str(self) -> &'static str {
|
||
match self {
|
||
XtcFormat::Xtc => "xtc",
|
||
XtcFormat::Xtch => "xtch",
|
||
}
|
||
}
|
||
|
||
/// File extension of the produced artifact.
|
||
pub fn extension(self) -> &'static str {
|
||
self.as_str()
|
||
}
|
||
}
|
||
|
||
/// `[xtc]` — invocation of `epub-to-xtc-converter` (§3.11, notes "verified facts").
|
||
///
|
||
/// The converter has no global npm bin, so `command` + `args` form the prefix and
|
||
/// the code appends `<input.epub> -o <output> -f <format>` (plus `-c <settings>`).
|
||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
#[serde(deny_unknown_fields, default)]
|
||
pub struct XtcConfig {
|
||
pub enabled: bool,
|
||
pub command: String,
|
||
pub args: Vec<String>,
|
||
pub format: XtcFormat,
|
||
/// Optional settings JSON passed as `-c`.
|
||
pub settings: Option<PathBuf>,
|
||
}
|
||
|
||
impl Default for XtcConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
enabled: true,
|
||
command: "node".into(),
|
||
args: vec![
|
||
"/opt/epub-to-xtc-converter/cli/index.js".into(),
|
||
"convert".into(),
|
||
],
|
||
format: XtcFormat::Xtch,
|
||
settings: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `[server]` — axum listener and rating-link signing (§3.9, §3.12).
|
||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
#[serde(deny_unknown_fields, default)]
|
||
pub struct ServerConfig {
|
||
pub bind: String,
|
||
/// Base URL rating links are built from.
|
||
pub public_url: String,
|
||
/// HMAC key for rating tokens; supply via `DAILY_EPUB_SERVER__HMAC_SECRET`.
|
||
pub hmac_secret: Option<String>,
|
||
/// Optional Basic auth for `/opds/*` and `/files/*`.
|
||
pub basic_auth_user: Option<String>,
|
||
pub basic_auth_pass: Option<String>,
|
||
}
|
||
|
||
impl Default for ServerConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
bind: "127.0.0.1:3499".into(),
|
||
public_url: "https://daily.hallada.net".into(),
|
||
hmac_secret: None,
|
||
basic_auth_user: None,
|
||
basic_auth_pass: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Config {
|
||
/// Build the figment layer stack. `path` is required to exist when explicit.
|
||
fn figment(path: Option<&Path>, require_file: bool) -> Result<Figment, ConfigError> {
|
||
let mut fig = Figment::from(Serialized::defaults(Config::default()));
|
||
if let Some(p) = path {
|
||
if require_file && !p.exists() {
|
||
return Err(ConfigError::Missing(p.to_path_buf()));
|
||
}
|
||
if p.exists() {
|
||
fig = fig.merge(Toml::file(p));
|
||
}
|
||
}
|
||
Ok(fig.merge(Env::prefixed(ENV_PREFIX).split(ENV_SPLIT)))
|
||
}
|
||
|
||
/// Load config for the CLI: explicit `--config` path, else `./config.toml`
|
||
/// when it exists, then `DAILY_EPUB_*` env overrides (§3.14).
|
||
pub fn load(explicit: Option<&Path>) -> Result<Self, ConfigError> {
|
||
let (path, require) = match explicit {
|
||
Some(p) => (Some(p.to_path_buf()), true),
|
||
None => (Some(PathBuf::from(DEFAULT_CONFIG_FILE)), false),
|
||
};
|
||
let mut config: Config = Self::figment(path.as_deref(), require)?.extract()?;
|
||
// §1 tells the operator to set `DAILY_EPUB_SECRET`; §3.14 calls the key
|
||
// `server.hmac_secret`. Accept both, with the explicit key winning.
|
||
if config.server.hmac_secret.is_none() {
|
||
config.server.hmac_secret = std::env::var(ENV_SECRET_ALIAS)
|
||
.ok()
|
||
.filter(|v| !v.is_empty());
|
||
}
|
||
config.validate()?;
|
||
Ok(config)
|
||
}
|
||
|
||
/// Cheap sanity checks so misconfiguration fails at startup, not mid-run.
|
||
pub fn validate(&self) -> Result<(), ConfigError> {
|
||
if self.lookback_hours == 0 {
|
||
return Err(ConfigError::Invalid("lookback_hours must be > 0".into()));
|
||
}
|
||
if self.target_article_count == 0 {
|
||
return Err(ConfigError::Invalid(
|
||
"target_article_count must be > 0".into(),
|
||
));
|
||
}
|
||
if self.prefilter_keep < self.target_article_count {
|
||
return Err(ConfigError::Invalid(
|
||
"prefilter_keep must be >= target_article_count".into(),
|
||
));
|
||
}
|
||
if self.curation.max_article_count < self.target_article_count {
|
||
return Err(ConfigError::Invalid(
|
||
"curation.max_article_count must be >= target_article_count".into(),
|
||
));
|
||
}
|
||
if self.deepseek.score_batch_size == 0 {
|
||
return Err(ConfigError::Invalid(
|
||
"deepseek.score_batch_size must be >= 1".into(),
|
||
));
|
||
}
|
||
if self.deepseek.max_concurrent_requests == 0 {
|
||
return Err(ConfigError::Invalid(
|
||
"deepseek.max_concurrent_requests must be >= 1".into(),
|
||
));
|
||
}
|
||
if self.anthropic.max_concurrent_requests == 0 {
|
||
return Err(ConfigError::Invalid(
|
||
"anthropic.max_concurrent_requests must be >= 1".into(),
|
||
));
|
||
}
|
||
if self.editorial.summary_input_tokens == 0 {
|
||
return Err(ConfigError::Invalid(
|
||
"editorial.summary_input_tokens must be >= 1".into(),
|
||
));
|
||
}
|
||
if !matches!(
|
||
self.anthropic.effort.as_str(),
|
||
"low" | "medium" | "high" | "xhigh" | "max"
|
||
) {
|
||
return Err(ConfigError::Invalid(
|
||
"anthropic.effort must be one of low, medium, high, xhigh, max".into(),
|
||
));
|
||
}
|
||
if self.curation.sections.is_empty() {
|
||
return Err(ConfigError::Invalid(
|
||
"curation.sections must not be empty".into(),
|
||
));
|
||
}
|
||
self.tz()?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Resolve [`Config::timezone`] into a `jiff` time zone (notes §2).
|
||
pub fn tz(&self) -> Result<jiff::tz::TimeZone, ConfigError> {
|
||
jiff::tz::TimeZone::get(&self.timezone)
|
||
.map_err(|e| ConfigError::Invalid(format!("unknown timezone {}: {e}", self.timezone)))
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use figment::Jail;
|
||
|
||
#[test]
|
||
fn defaults_match_the_spec() {
|
||
let c = Config::default();
|
||
assert_eq!(c.timezone, "America/New_York");
|
||
assert_eq!(c.lookback_hours, 26);
|
||
assert_eq!(c.target_article_count, 20);
|
||
assert_eq!(c.prefilter_keep, 120);
|
||
assert_eq!(c.retention_days, 21);
|
||
assert_eq!(c.max_daily_usd, 2.0);
|
||
assert!(c.world_briefing);
|
||
assert_eq!(c.deepseek.model, "deepseek-v4-flash");
|
||
assert_eq!(c.profile_path, PathBuf::from("data/profile.md"));
|
||
assert_eq!(c.curation.feedback.good_value, 0.35);
|
||
assert_eq!(c.curation.feedback.verdicts_in_prompt, 60);
|
||
assert_eq!(c.xtc.format, XtcFormat::Xtch);
|
||
assert_eq!(c.curation.sections.len(), 8);
|
||
c.validate().unwrap();
|
||
}
|
||
|
||
#[test]
|
||
// `Jail::expect_with` dictates the closure's `figment::Error` return type.
|
||
#[allow(clippy::result_large_err)]
|
||
fn toml_then_env_layering() {
|
||
Jail::expect_with(|jail| {
|
||
jail.create_file(
|
||
"config.toml",
|
||
r#"
|
||
lookback_hours = 30
|
||
world_briefing = false
|
||
|
||
[miniflux]
|
||
base_url = "http://127.0.0.1:9999"
|
||
|
||
[curation]
|
||
sections = ["Top Stories", "Niche Corner"]
|
||
|
||
[xtc]
|
||
command = "node"
|
||
args = ["/opt/epub-to-xtc-converter/cli/index.js", "convert"]
|
||
format = "xtc"
|
||
"#,
|
||
)?;
|
||
jail.set_env("DAILY_EPUB_MINIFLUX__API_KEY", "secret-token");
|
||
jail.set_env("DAILY_EPUB_TARGET_ARTICLE_COUNT", "12");
|
||
jail.set_env("DAILY_EPUB_SERVER__HMAC_SECRET", "hunter2");
|
||
|
||
let c = Config::load(None).map_err(|e| figment::Error::from(e.to_string()))?;
|
||
// from file
|
||
assert_eq!(c.lookback_hours, 30);
|
||
assert!(!c.world_briefing);
|
||
assert_eq!(c.miniflux.base_url, "http://127.0.0.1:9999");
|
||
assert_eq!(c.curation.sections, ["Top Stories", "Niche Corner"]);
|
||
assert_eq!(c.xtc.format, XtcFormat::Xtc);
|
||
assert_eq!(c.xtc.args.len(), 2);
|
||
// from env
|
||
assert_eq!(c.miniflux.api_key.as_deref(), Some("secret-token"));
|
||
assert_eq!(c.target_article_count, 12);
|
||
assert_eq!(c.server.hmac_secret.as_deref(), Some("hunter2"));
|
||
// untouched default
|
||
assert_eq!(c.retention_days, 21);
|
||
assert_eq!(c.timezone, "America/New_York");
|
||
Ok(())
|
||
});
|
||
}
|
||
|
||
#[test]
|
||
fn explicit_missing_path_is_an_error() {
|
||
assert!(matches!(
|
||
Config::load(Some(Path::new("/nonexistent/daily-epub.toml"))),
|
||
Err(ConfigError::Missing(_))
|
||
));
|
||
}
|
||
|
||
/// `publish.bookorbit_dir` was renamed to `publish.epub_dir`. A config still
|
||
/// using the old key must fail loudly and name both — silently falling back
|
||
/// to the default would publish the issue into the wrong directory, where
|
||
/// the OPDS feed would then find nothing.
|
||
#[test]
|
||
fn the_renamed_publish_key_fails_loudly() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let path = dir.path().join("config.toml");
|
||
std::fs::write(
|
||
&path,
|
||
"[publish]\nbookorbit_dir = \"/srv/books\"\nxtc_dir = \"/srv/xtc\"\n",
|
||
)
|
||
.unwrap();
|
||
|
||
let err = Config::load(Some(&path)).expect_err("the stale key must be rejected");
|
||
let message = err.to_string();
|
||
assert!(message.contains("bookorbit_dir"), "{message}");
|
||
assert!(message.contains("epub_dir"), "{message}");
|
||
}
|
||
|
||
#[test]
|
||
fn shipped_example_config_parses() {
|
||
let example = Path::new(env!("CARGO_MANIFEST_DIR")).join("config.example.toml");
|
||
let c = Config::load(Some(&example)).expect("config.example.toml must parse");
|
||
assert_eq!(c.xtc.command, "node");
|
||
assert_eq!(c.xtc.format, XtcFormat::Xtch);
|
||
assert_eq!(c.server.bind, "127.0.0.1:3499");
|
||
assert_eq!(c.deepseek.base_url, "https://api.deepseek.com/v1");
|
||
assert_eq!(c.deepseek.max_concurrent_requests, 4);
|
||
assert!(c.anthropic.enabled);
|
||
assert_eq!(c.anthropic.model, "claude-opus-5");
|
||
assert_eq!(c.anthropic.effort, "high");
|
||
assert!(c.anthropic.api_key.is_none(), "keys never live in the file");
|
||
assert_eq!(c.anthropic.max_daily_usd, 3.0);
|
||
assert_eq!(c.curation.max_article_count, 28);
|
||
assert_eq!(c.editorial.summary_model, SummaryModel::Editor);
|
||
assert_eq!(c.editorial.summary_input_tokens, 3000);
|
||
}
|
||
|
||
#[test]
|
||
fn provider_validation_rejects_nonsense() {
|
||
let mut c = Config::default();
|
||
c.curation.max_article_count = c.target_article_count - 1;
|
||
assert!(c.validate().is_err(), "max_article_count below the target");
|
||
let mut c = Config::default();
|
||
c.anthropic.effort = "turbo".into();
|
||
assert!(c.validate().is_err(), "unknown effort");
|
||
for effort in ["low", "medium", "high", "xhigh", "max"] {
|
||
let mut c = Config::default();
|
||
c.anthropic.effort = effort.into();
|
||
assert!(c.validate().is_ok(), "{effort} is a valid effort");
|
||
}
|
||
let mut c = Config::default();
|
||
c.deepseek.max_concurrent_requests = 0;
|
||
assert!(c.validate().is_err());
|
||
let mut c = Config::default();
|
||
c.anthropic.max_concurrent_requests = 0;
|
||
assert!(c.validate().is_err());
|
||
let mut c = Config::default();
|
||
c.deepseek.score_batch_size = 0;
|
||
assert!(c.validate().is_err());
|
||
let mut c = Config::default();
|
||
c.editorial.summary_input_tokens = 0;
|
||
assert!(c.validate().is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn validation_rejects_nonsense() {
|
||
assert!(
|
||
Config {
|
||
prefilter_keep: 5,
|
||
..Config::default()
|
||
}
|
||
.validate()
|
||
.is_err()
|
||
);
|
||
assert!(
|
||
Config {
|
||
timezone: "Mars/Olympus_Mons".into(),
|
||
..Config::default()
|
||
}
|
||
.validate()
|
||
.is_err()
|
||
);
|
||
assert!(
|
||
Config {
|
||
lookback_hours: 0,
|
||
..Config::default()
|
||
}
|
||
.validate()
|
||
.is_err()
|
||
);
|
||
}
|
||
}
|