feat(types): add settings v2 parse tree scaffolding

Stage 1 of the settings TOML redesign. Introduces the namespaced v2
schema module alongside the existing flat Settings shape so the
workspace still builds while the new parser architecture comes online.

- value-language helpers with full unit-test coverage:
  - Duration: single-unit suffixes (ms, s, m, h, d); rejects composed
    values like '1h30m'; canonical renderer picks the largest unit
  - Size: decimal (KB, MB, GB, TB) and binary (KiB, MiB, GiB, TiB)
    units; bare integers default to GB; canonical renderer picks the
    largest decimal unit
  - ModelRef: bare vs qualified forms with a ModelRegistry trait for
    later ambiguity resolution
  - InterpString: ${env.NAME} tokens with whole-value, substring, and
    multi-token support; provenance tagging for outward-facing redaction
  - SpliceArray: '...' marker with append, prepend, and single-marker
    enforcement
- SchemaVersion pre-validation: missing defaults to 1, legacy 'version'
  key hard-fails with a rename hint, unsupported higher versions
  hard-fail with an upgrade hint
- SettingsFile top-level sparse parse tree with strict unknown-key
  rejection and targeted rename hints for every legacy top-level
  section (llm, vars, exec, fabro, setup, sandbox, etc.)
- Skeleton ProjectLayer/WorkflowLayer/RunLayer/CliLayer/ServerLayer/
  FeaturesLayer with deny_unknown_fields; full subtree fleshed out in
  Stage 2

65 new unit tests all passing. fabro-types is clippy-clean under
-D warnings.
This commit is contained in:
Bryan Helmkamp 2026-04-09 09:02:45 -04:00
parent c9f65f97d5
commit 288e733213
No known key found for this signature in database
17 changed files with 2078 additions and 0 deletions

1
Cargo.lock generated
View file

@ -2059,6 +2059,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"toml 0.8.23",
"ulid",
]

View file

@ -27,4 +27,5 @@ hex.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
toml.workspace = true
ulid.workspace = true

View file

@ -10,6 +10,7 @@ pub mod run;
pub mod sandbox;
pub mod server;
pub mod user;
pub mod v2;
pub use hook::{HookDefinition, HookEvent, HookSettings, HookType, TlsMode};
pub use mcp::{

View file

@ -0,0 +1,14 @@
//! CLI domain.
//!
//! `[cli]` is owner-first: the CLI process reads its settings from
//! `~/.fabro/settings.toml` plus process-local overrides. `cli.*` stanzas in
//! `fabro.toml` and `workflow.toml` remain schema-valid but runtime-inert.
//! This file holds only the Stage-1 skeleton; Stage 2 fleshes out the full
//! subtree (target, auth, exec, output, updates, logging).
use serde::{Deserialize, Serialize};
/// A sparse `[cli]` layer as it appears in a single settings file.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CliLayer;

View file

@ -0,0 +1,303 @@
//! Config-facing duration values.
//!
//! Accepts a single-unit suffix per value: `ms`, `s`, `m`, `h`, or `d`.
//! Composed values like `1h30m` are rejected. The canonical renderer emits
//! the same single-unit form.
use std::fmt;
use std::str::FromStr;
use std::time::Duration as StdDuration;
use serde::de::{self, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// A duration parsed from a single-unit human-readable string.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Duration(StdDuration);
impl Duration {
#[must_use]
pub const fn from_std(duration: StdDuration) -> Self {
Self(duration)
}
#[must_use]
pub const fn as_std(&self) -> StdDuration {
self.0
}
#[must_use]
pub const fn from_secs(secs: u64) -> Self {
Self(StdDuration::from_secs(secs))
}
#[must_use]
pub const fn from_millis(millis: u64) -> Self {
Self(StdDuration::from_millis(millis))
}
}
impl From<StdDuration> for Duration {
fn from(value: StdDuration) -> Self {
Self(value)
}
}
impl From<Duration> for StdDuration {
fn from(value: Duration) -> Self {
value.0
}
}
/// An error returned when parsing a duration string fails.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseDurationError {
/// The input was empty or whitespace only.
Empty,
/// The input had a numeric portion that could not be parsed as a non-negative integer.
InvalidNumber { input: String },
/// The input had no unit suffix.
MissingUnit { input: String },
/// The input had an unrecognized unit suffix.
InvalidUnit { input: String, unit: String },
/// The input contained more than one unit (e.g. `1h30m`), which is not supported.
Composed { input: String },
}
impl fmt::Display for ParseDurationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => f.write_str("duration value is empty"),
Self::InvalidNumber { input } => {
write!(
f,
"duration {input:?}: numeric portion is not a non-negative integer"
)
}
Self::MissingUnit { input } => {
write!(
f,
"duration {input:?}: missing unit suffix (expected one of ms, s, m, h, d)"
)
}
Self::InvalidUnit { input, unit } => {
write!(
f,
"duration {input:?}: unknown unit {unit:?} (expected one of ms, s, m, h, d)"
)
}
Self::Composed { input } => {
write!(
f,
"duration {input:?}: composed values like 1h30m are not supported; use the smallest needed unit instead"
)
}
}
}
}
impl std::error::Error for ParseDurationError {}
impl FromStr for Duration {
type Err = ParseDurationError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(ParseDurationError::Empty);
}
// Split numeric prefix from alphabetic suffix. Reject interior alpha runs.
let (num, unit) =
split_number_and_suffix(trimmed).ok_or_else(|| ParseDurationError::MissingUnit {
input: input.to_owned(),
})?;
if num.is_empty() {
return Err(ParseDurationError::InvalidNumber {
input: input.to_owned(),
});
}
// A composed value like "1h30m" leaves digits inside the suffix.
if unit.chars().any(|c| c.is_ascii_digit()) {
return Err(ParseDurationError::Composed {
input: input.to_owned(),
});
}
let n: u64 = num.parse().map_err(|_| ParseDurationError::InvalidNumber {
input: input.to_owned(),
})?;
let duration = match unit {
"ms" => StdDuration::from_millis(n),
"s" => StdDuration::from_secs(n),
"m" => StdDuration::from_secs(n.saturating_mul(60)),
"h" => StdDuration::from_secs(n.saturating_mul(60 * 60)),
"d" => StdDuration::from_secs(n.saturating_mul(24 * 60 * 60)),
other => {
return Err(ParseDurationError::InvalidUnit {
input: input.to_owned(),
unit: other.to_owned(),
});
}
};
Ok(Self(duration))
}
}
/// Split a trimmed string into `(numeric_prefix, alphabetic_suffix)`.
///
/// Returns `None` if there is no alphabetic suffix or the string is all alpha.
fn split_number_and_suffix(input: &str) -> Option<(&str, &str)> {
let first_alpha = input.find(|c: char| !c.is_ascii_digit())?;
if first_alpha == 0 {
return None;
}
Some((&input[..first_alpha], &input[first_alpha..]))
}
impl fmt::Display for Duration {
/// Canonical rendering picks the largest unit that represents the value as an
/// integer multiple, preferring `d`, `h`, `m`, `s`, `ms` in that order.
/// Zero renders as `0s`.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
const MS_PER_S: u128 = 1_000;
const MS_PER_M: u128 = 60 * MS_PER_S;
const MS_PER_H: u128 = 60 * MS_PER_M;
const MS_PER_D: u128 = 24 * MS_PER_H;
let total_ms = self.0.as_millis();
if total_ms == 0 {
return f.write_str("0s");
}
if total_ms.is_multiple_of(MS_PER_D) {
write!(f, "{}d", total_ms / MS_PER_D)
} else if total_ms.is_multiple_of(MS_PER_H) {
write!(f, "{}h", total_ms / MS_PER_H)
} else if total_ms.is_multiple_of(MS_PER_M) {
write!(f, "{}m", total_ms / MS_PER_M)
} else if total_ms.is_multiple_of(MS_PER_S) {
write!(f, "{}s", total_ms / MS_PER_S)
} else {
write!(f, "{total_ms}ms")
}
}
}
impl Serialize for Duration {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for Duration {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct DurationVisitor;
impl Visitor<'_> for DurationVisitor {
type Value = Duration;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(r#"a duration string such as "30s", "1m", or "1h""#)
}
fn visit_str<E: de::Error>(self, value: &str) -> Result<Duration, E> {
value.parse().map_err(de::Error::custom)
}
fn visit_string<E: de::Error>(self, value: String) -> Result<Duration, E> {
self.visit_str(&value)
}
}
deserializer.deserialize_str(DurationVisitor)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_each_unit() {
assert_eq!(
"500ms".parse::<Duration>().unwrap(),
Duration::from_millis(500)
);
assert_eq!("30s".parse::<Duration>().unwrap(), Duration::from_secs(30));
assert_eq!("2m".parse::<Duration>().unwrap(), Duration::from_secs(120));
assert_eq!(
"1h".parse::<Duration>().unwrap(),
Duration::from_secs(3_600)
);
assert_eq!(
"1d".parse::<Duration>().unwrap(),
Duration::from_secs(86_400)
);
}
#[test]
fn rejects_composed_values() {
let err = "1h30m".parse::<Duration>().unwrap_err();
assert!(matches!(err, ParseDurationError::Composed { .. }));
}
#[test]
fn rejects_missing_unit() {
let err = "30".parse::<Duration>().unwrap_err();
assert!(matches!(err, ParseDurationError::MissingUnit { .. }));
}
#[test]
fn rejects_unknown_unit() {
let err = "1w".parse::<Duration>().unwrap_err();
assert!(matches!(err, ParseDurationError::InvalidUnit { unit, .. } if unit == "w"));
}
#[test]
fn rejects_empty() {
let err = "".parse::<Duration>().unwrap_err();
assert!(matches!(err, ParseDurationError::Empty));
}
#[test]
fn canonical_render_picks_largest_unit() {
assert_eq!(Duration::from_secs(86_400).to_string(), "1d");
assert_eq!(Duration::from_secs(3_600).to_string(), "1h");
assert_eq!(Duration::from_secs(120).to_string(), "2m");
assert_eq!(Duration::from_secs(30).to_string(), "30s");
assert_eq!(Duration::from_millis(500).to_string(), "500ms");
assert_eq!(Duration::from_millis(0).to_string(), "0s");
}
#[test]
fn canonical_render_rounds_down_units_that_do_not_divide() {
// 90s cannot render as a whole number of minutes.
assert_eq!(Duration::from_secs(90).to_string(), "90s");
}
#[test]
fn round_trip_through_parse_and_display() {
for input in ["500ms", "30s", "1m", "2h", "3d"] {
let parsed: Duration = input.parse().unwrap();
assert_eq!(parsed.to_string(), input);
}
}
#[test]
fn serde_round_trip_via_json() {
#[derive(Debug, serde::Deserialize, serde::Serialize, PartialEq)]
struct Wrap {
d: Duration,
}
let input = r#"{"d":"30s"}"#;
let parsed: Wrap = serde_json::from_str(input).unwrap();
assert_eq!(parsed.d, Duration::from_secs(30));
let rendered = serde_json::to_string(&parsed).unwrap();
assert_eq!(rendered, r#"{"d":"30s"}"#);
}
}

View file

@ -0,0 +1,17 @@
//! Features domain.
//!
//! `[features]` is a reserved cross-cutting namespace for Fabro capability
//! flags only. It has a high admission bar and must not become a junk drawer.
use serde::{Deserialize, Serialize};
/// A sparse `[features]` layer as it appears in a single settings file.
///
/// Every field is an `Option<bool>` so layers can independently set or
/// override a flag without forcing a default that hides an unset value.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FeaturesLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_sandboxes: Option<bool>,
}

View file

@ -0,0 +1,335 @@
//! Env var interpolation for config strings.
//!
//! Any string field may use `${env.NAME}` tokens, either as a whole value or
//! as one or more substrings inside a larger string. Resolution happens only
//! when the field is consumed, and provenance tracking lets outward-facing
//! renderers redact env-sourced values uniformly.
use std::fmt;
use serde::de::{self, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// A config string that may contain `${env.NAME}` tokens.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InterpString {
segments: Vec<Segment>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Segment {
Literal(String),
EnvVar(String),
}
impl InterpString {
/// Parse a raw string into its literal/env-var segments.
///
/// Parsing is infallible: the token grammar is intentionally permissive so
/// that validation happens at consumption time along with env lookup.
#[must_use]
pub fn parse(input: &str) -> Self {
let mut segments: Vec<Segment> = Vec::new();
let mut rest = input;
while let Some(start) = rest.find("${env.") {
if start > 0 {
segments.push(Segment::Literal(rest[..start].to_owned()));
}
// rest[start..] begins with "${env."
let after_prefix = &rest[start + "${env.".len()..];
if let Some(close) = after_prefix.find('}') {
let name = after_prefix[..close].to_owned();
segments.push(Segment::EnvVar(name));
rest = &after_prefix[close + 1..];
} else {
// Unterminated token — treat the remainder as literal text.
segments.push(Segment::Literal(rest[start..].to_owned()));
rest = "";
break;
}
}
if !rest.is_empty() {
segments.push(Segment::Literal(rest.to_owned()));
}
if segments.is_empty() {
segments.push(Segment::Literal(String::new()));
}
Self { segments }
}
/// True when this string contains no env var tokens.
#[must_use]
pub fn is_literal(&self) -> bool {
self.segments
.iter()
.all(|seg| matches!(seg, Segment::Literal(_)))
}
/// True when this string contains at least one env var token.
#[must_use]
pub fn references_env(&self) -> bool {
self.segments
.iter()
.any(|seg| matches!(seg, Segment::EnvVar(_)))
}
/// The env var names referenced by this string, in source order.
#[must_use]
pub fn env_var_names(&self) -> Vec<&str> {
self.segments
.iter()
.filter_map(|seg| match seg {
Segment::EnvVar(name) => Some(name.as_str()),
Segment::Literal(_) => None,
})
.collect()
}
/// The raw source string.
#[must_use]
pub fn as_source(&self) -> String {
let mut out = String::new();
for seg in &self.segments {
match seg {
Segment::Literal(text) => out.push_str(text),
Segment::EnvVar(name) => {
out.push_str("${env.");
out.push_str(name);
out.push('}');
}
}
}
out
}
/// Resolve this string using `lookup`, which should return the current
/// value for a given env var name (or `None` if unset).
///
/// On success the caller gets the final string plus provenance describing
/// whether any env var contributed to the value. On failure the caller
/// learns which env var was unresolved.
pub fn resolve<F>(&self, mut lookup: F) -> Result<Resolved, ResolveEnvError>
where
F: FnMut(&str) -> Option<String>,
{
let mut value = String::new();
let mut used = Vec::new();
for seg in &self.segments {
match seg {
Segment::Literal(text) => value.push_str(text),
Segment::EnvVar(name) => {
let Some(resolved) = lookup(name) else {
return Err(ResolveEnvError { name: name.clone() });
};
value.push_str(&resolved);
used.push(name.clone());
}
}
}
let provenance = if used.is_empty() {
Provenance::Literal
} else {
Provenance::EnvSourced { names: used }
};
Ok(Resolved { value, provenance })
}
}
impl From<String> for InterpString {
fn from(value: String) -> Self {
Self::parse(&value)
}
}
impl From<&str> for InterpString {
fn from(value: &str) -> Self {
Self::parse(value)
}
}
/// The outcome of a successful env interpolation resolution.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Resolved {
pub value: String,
pub provenance: Provenance,
}
/// Provenance metadata for resolved config values.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Provenance {
/// No env var contributed to this value.
Literal,
/// One or more env vars contributed to this value. Used by outward-facing
/// renderers to redact env-sourced values uniformly.
EnvSourced { names: Vec<String> },
}
/// An error returned when an env var referenced in a config string is not set.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolveEnvError {
pub name: String,
}
impl fmt::Display for ResolveEnvError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"environment variable {:?} referenced by ${{env.{}}} is not set",
self.name, self.name
)
}
}
impl std::error::Error for ResolveEnvError {}
impl Serialize for InterpString {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.as_source())
}
}
impl<'de> Deserialize<'de> for InterpString {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct InterpStringVisitor;
impl Visitor<'_> for InterpStringVisitor {
type Value = InterpString;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a string, optionally containing ${env.NAME} interpolation tokens")
}
fn visit_str<E: de::Error>(self, value: &str) -> Result<InterpString, E> {
Ok(InterpString::parse(value))
}
fn visit_string<E: de::Error>(self, value: String) -> Result<InterpString, E> {
Ok(InterpString::parse(&value))
}
}
deserializer.deserialize_str(InterpStringVisitor)
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
fn lookup_from(values: &[(&str, &str)]) -> impl FnMut(&str) -> Option<String> + 'static {
let map: HashMap<String, String> = values
.iter()
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
.collect();
move |name| map.get(name).cloned()
}
#[test]
fn literal_string_has_no_env_refs() {
let s = InterpString::parse("hello world");
assert!(s.is_literal());
assert!(!s.references_env());
assert_eq!(s.env_var_names(), Vec::<&str>::new());
}
#[test]
fn whole_value_env_reference() {
let s = InterpString::parse("${env.API_KEY}");
assert!(!s.is_literal());
assert_eq!(s.env_var_names(), vec!["API_KEY"]);
assert_eq!(s.as_source(), "${env.API_KEY}");
}
#[test]
fn substring_env_reference() {
let s = InterpString::parse("Bearer ${env.TOKEN}");
assert_eq!(s.env_var_names(), vec!["TOKEN"]);
}
#[test]
fn multi_token_env_reference() {
let s = InterpString::parse("${env.USER}@${env.HOST}:${env.PORT}");
assert_eq!(s.env_var_names(), vec!["USER", "HOST", "PORT"]);
}
#[test]
fn resolve_literal_string() {
let s = InterpString::parse("static");
let resolved = s.resolve(lookup_from(&[])).unwrap();
assert_eq!(resolved.value, "static");
assert_eq!(resolved.provenance, Provenance::Literal);
}
#[test]
fn resolve_whole_value() {
let s = InterpString::parse("${env.API_KEY}");
let resolved = s
.resolve(lookup_from(&[("API_KEY", "secret-123")]))
.unwrap();
assert_eq!(resolved.value, "secret-123");
assert_eq!(
resolved.provenance,
Provenance::EnvSourced {
names: vec!["API_KEY".into()]
}
);
}
#[test]
fn resolve_substring() {
let s = InterpString::parse("Bearer ${env.TOKEN}");
let resolved = s.resolve(lookup_from(&[("TOKEN", "abc")])).unwrap();
assert_eq!(resolved.value, "Bearer abc");
}
#[test]
fn resolve_multiple_tokens() {
let s = InterpString::parse("${env.USER}@${env.HOST}");
let resolved = s
.resolve(lookup_from(&[("USER", "root"), ("HOST", "example.com")]))
.unwrap();
assert_eq!(resolved.value, "root@example.com");
assert_eq!(
resolved.provenance,
Provenance::EnvSourced {
names: vec!["USER".into(), "HOST".into()]
}
);
}
#[test]
fn resolve_missing_env_fails_with_name() {
let s = InterpString::parse("${env.MISSING}");
let err = s.resolve(lookup_from(&[])).unwrap_err();
assert_eq!(err.name, "MISSING");
}
#[test]
fn unterminated_token_treated_as_literal() {
let s = InterpString::parse("${env.OPEN");
let resolved = s.resolve(lookup_from(&[])).unwrap();
assert_eq!(resolved.value, "${env.OPEN");
assert_eq!(resolved.provenance, Provenance::Literal);
}
#[test]
fn serde_round_trip_preserves_token_form() {
#[derive(Debug, serde::Deserialize, serde::Serialize, PartialEq)]
struct Wrap {
s: InterpString,
}
let input = r#"{"s":"Bearer ${env.TOKEN}"}"#;
let parsed: Wrap = serde_json::from_str(input).unwrap();
assert_eq!(parsed.s.as_source(), "Bearer ${env.TOKEN}");
let rendered = serde_json::to_string(&parsed).unwrap();
assert_eq!(rendered, input);
}
}

View file

@ -0,0 +1,37 @@
//! Namespaced settings schema (v2).
//!
//! This module is the hard-cut replacement for the flat [`super::Settings`]
//! shape. The top-level schema is strictly namespaced with `_version`,
//! `[project]`, `[workflow]`, `[run]`, `[cli]`, `[server]`, and `[features]`.
//! Value-language helpers live alongside the tree: durations, byte sizes,
//! model references, env interpolation, and splice-capable arrays.
pub mod cli;
pub mod duration;
pub mod features;
pub mod interp;
pub mod model_ref;
pub mod project;
pub mod run;
pub mod server;
pub mod size;
pub mod splice_array;
pub mod tree;
pub mod version;
pub mod workflow;
pub use cli::CliLayer;
pub use duration::{Duration, ParseDurationError};
pub use features::FeaturesLayer;
pub use interp::{InterpString, Provenance, ResolveEnvError, Resolved};
pub use model_ref::{
AmbiguousModelRef, ModelRef, ModelRegistry, ParseModelRefError, ResolvedModelRef,
};
pub use project::ProjectLayer;
pub use run::RunLayer;
pub use server::ServerLayer;
pub use size::{ParseSizeError, Size};
pub use splice_array::{SPLICE_MARKER, SpliceArray, SpliceArrayError};
pub use tree::{ParseError, SettingsFile, parse_settings_file};
pub use version::{CURRENT_VERSION, SchemaVersion, VersionError, validate_version};
pub use workflow::WorkflowLayer;

View file

@ -0,0 +1,375 @@
//! Model references for `run.model.fallbacks`.
//!
//! Each entry is one of:
//!
//! - a bare token such as `openai` or `gpt-5.4` — the parser cannot tell alone
//! whether the token is a provider name or a model alias
//! - a qualified reference such as `gemini/gemini-flash`, which names both a
//! provider and a model
//!
//! The parser produces [`ModelRef`]; ambiguity resolution against a known
//! registry of providers and models happens at consumption time via
//! [`ModelRef::resolve`].
use std::fmt;
use std::str::FromStr;
use serde::de::{self, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// A parsed model reference. Bare tokens remain ambiguous until resolved
/// against a registry.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ModelRef {
/// A bare token. May be a provider name, a model alias, or a model id.
Bare(String),
/// A provider-qualified model reference.
Qualified { provider: String, model: String },
}
/// An error returned when parsing a model reference fails.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseModelRefError {
/// The input was empty or whitespace only.
Empty,
/// The input contained more than one `/`, which is not a valid qualified ref.
TooManySlashes { input: String },
/// The provider or model side of a qualified reference was empty.
EmptySide { input: String },
}
impl fmt::Display for ParseModelRefError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => f.write_str("model reference is empty"),
Self::TooManySlashes { input } => {
write!(
f,
"model reference {input:?}: expected at most one \"/\" separator between provider and model"
)
}
Self::EmptySide { input } => {
write!(
f,
"model reference {input:?}: provider and model sides must both be non-empty"
)
}
}
}
}
impl std::error::Error for ParseModelRefError {}
impl FromStr for ModelRef {
type Err = ParseModelRefError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(ParseModelRefError::Empty);
}
let parts: Vec<&str> = trimmed.split('/').collect();
match parts.as_slice() {
[bare] => Ok(Self::Bare((*bare).to_owned())),
[provider, model] => {
if provider.is_empty() || model.is_empty() {
Err(ParseModelRefError::EmptySide {
input: input.to_owned(),
})
} else {
Ok(Self::Qualified {
provider: (*provider).to_owned(),
model: (*model).to_owned(),
})
}
}
_ => Err(ParseModelRefError::TooManySlashes {
input: input.to_owned(),
}),
}
}
}
impl fmt::Display for ModelRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Bare(token) => f.write_str(token),
Self::Qualified { provider, model } => write!(f, "{provider}/{model}"),
}
}
}
/// An error returned when resolving an ambiguous bare model reference.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AmbiguousModelRef {
pub input: String,
pub providers: Vec<String>,
pub models: Vec<String>,
}
impl fmt::Display for AmbiguousModelRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"model reference {:?} is ambiguous: matches provider names {:?} and model names {:?}; qualify it as \"provider/model\"",
self.input, self.providers, self.models
)
}
}
impl std::error::Error for AmbiguousModelRef {}
/// The resolved form of a model reference after registry lookup.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolvedModelRef {
/// The reference named a provider; the runtime should pick the best model from that provider.
Provider(String),
/// The reference named a model (qualified or unambiguously bare).
Model {
provider: Option<String>,
model: String,
},
}
/// A minimal registry view used by [`ModelRef::resolve`].
///
/// Each method reports whether a bare token is a known provider, model, or
/// both. The registry is abstract so unit tests and runtime resolution can
/// share the same logic.
pub trait ModelRegistry {
fn is_provider(&self, token: &str) -> bool;
fn is_model(&self, token: &str) -> bool;
/// Returns the canonical provider for a bare model token, when the registry
/// knows of a unique provider for that model.
fn provider_of(&self, token: &str) -> Option<String>;
}
impl ModelRef {
/// Resolve this reference against a registry.
///
/// - [`ModelRef::Qualified`] always resolves to a model.
/// - [`ModelRef::Bare`] resolves to a provider if the token is only a provider,
/// to a model if the token is only a model, and returns [`AmbiguousModelRef`]
/// if the token matches both a provider and a model name.
pub fn resolve(
&self,
registry: &dyn ModelRegistry,
) -> Result<ResolvedModelRef, AmbiguousModelRef> {
match self {
Self::Qualified { provider, model } => Ok(ResolvedModelRef::Model {
provider: Some(provider.clone()),
model: model.clone(),
}),
Self::Bare(token) => {
let is_provider = registry.is_provider(token);
let is_model = registry.is_model(token);
match (is_provider, is_model) {
(true, false) => Ok(ResolvedModelRef::Provider(token.clone())),
(false, true) => Ok(ResolvedModelRef::Model {
provider: registry.provider_of(token),
model: token.clone(),
}),
(true, true) => Err(AmbiguousModelRef {
input: token.clone(),
providers: vec![token.clone()],
models: vec![token.clone()],
}),
// Unknown tokens flow through as bare models — provider TBD at runtime.
(false, false) => Ok(ResolvedModelRef::Model {
provider: None,
model: token.clone(),
}),
}
}
}
}
}
impl Serialize for ModelRef {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for ModelRef {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct ModelRefVisitor;
impl Visitor<'_> for ModelRefVisitor {
type Value = ModelRef;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(
r#"a model reference such as "openai", "gpt-5.4", or "gemini/gemini-flash""#,
)
}
fn visit_str<E: de::Error>(self, value: &str) -> Result<ModelRef, E> {
value.parse().map_err(de::Error::custom)
}
fn visit_string<E: de::Error>(self, value: String) -> Result<ModelRef, E> {
self.visit_str(&value)
}
}
deserializer.deserialize_str(ModelRefVisitor)
}
}
#[cfg(test)]
mod tests {
use super::*;
struct TestRegistry {
providers: &'static [&'static str],
models: &'static [&'static str],
}
impl ModelRegistry for TestRegistry {
fn is_provider(&self, token: &str) -> bool {
self.providers.contains(&token)
}
fn is_model(&self, token: &str) -> bool {
self.models.contains(&token)
}
fn provider_of(&self, token: &str) -> Option<String> {
if self.models.contains(&token) {
Some("test".to_owned())
} else {
None
}
}
}
#[test]
fn parses_bare_token() {
assert_eq!(
"openai".parse::<ModelRef>().unwrap(),
ModelRef::Bare("openai".into())
);
}
#[test]
fn parses_qualified() {
assert_eq!(
"gemini/gemini-flash".parse::<ModelRef>().unwrap(),
ModelRef::Qualified {
provider: "gemini".into(),
model: "gemini-flash".into()
}
);
}
#[test]
fn rejects_too_many_slashes() {
let err = "a/b/c".parse::<ModelRef>().unwrap_err();
assert!(matches!(err, ParseModelRefError::TooManySlashes { .. }));
}
#[test]
fn rejects_empty_side() {
assert!(matches!(
"/foo".parse::<ModelRef>().unwrap_err(),
ParseModelRefError::EmptySide { .. }
));
assert!(matches!(
"foo/".parse::<ModelRef>().unwrap_err(),
ParseModelRefError::EmptySide { .. }
));
}
#[test]
fn rejects_empty_input() {
assert!(matches!(
"".parse::<ModelRef>().unwrap_err(),
ParseModelRefError::Empty
));
}
#[test]
fn resolves_unique_provider_token() {
let reg = TestRegistry {
providers: &["openai"],
models: &[],
};
let resolved = ModelRef::Bare("openai".into()).resolve(&reg).unwrap();
assert_eq!(resolved, ResolvedModelRef::Provider("openai".into()));
}
#[test]
fn resolves_unique_model_token() {
let reg = TestRegistry {
providers: &[],
models: &["gpt-5.4"],
};
let resolved = ModelRef::Bare("gpt-5.4".into()).resolve(&reg).unwrap();
assert_eq!(
resolved,
ResolvedModelRef::Model {
provider: Some("test".into()),
model: "gpt-5.4".into()
}
);
}
#[test]
fn ambiguous_bare_token_errors() {
let reg = TestRegistry {
providers: &["ambiguous"],
models: &["ambiguous"],
};
let err = ModelRef::Bare("ambiguous".into())
.resolve(&reg)
.unwrap_err();
assert_eq!(err.input, "ambiguous");
}
#[test]
fn qualified_never_ambiguous() {
let reg = TestRegistry {
providers: &["ambiguous"],
models: &["ambiguous"],
};
let resolved = ModelRef::Qualified {
provider: "a".into(),
model: "b".into(),
}
.resolve(&reg)
.unwrap();
assert_eq!(
resolved,
ResolvedModelRef::Model {
provider: Some("a".into()),
model: "b".into(),
}
);
}
#[test]
fn display_round_trip() {
for input in ["openai", "gpt-5.4", "gemini/gemini-flash"] {
let parsed: ModelRef = input.parse().unwrap();
assert_eq!(parsed.to_string(), input);
}
}
#[test]
fn serde_round_trip_via_json() {
#[derive(Debug, serde::Deserialize, serde::Serialize, PartialEq)]
struct Wrap {
m: ModelRef,
}
let input = r#"{"m":"gemini/gemini-flash"}"#;
let parsed: Wrap = serde_json::from_str(input).unwrap();
assert!(matches!(
parsed.m,
ModelRef::Qualified { ref provider, ref model }
if provider == "gemini" && model == "gemini-flash"
));
let rendered = serde_json::to_string(&parsed).unwrap();
assert_eq!(rendered, input);
}
}

View file

@ -0,0 +1,24 @@
//! Project domain: first-class project object.
//!
//! `[project]` replaces the old flat `[fabro]` shape. `directory` means the
//! Fabro-managed project directory inside the repo, defaulting to `fabro/`.
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
/// A sparse `[project]` layer as it appears in a single settings file.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProjectLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// The Fabro-managed project directory inside the repo. Defaults to
/// `fabro/` after layering when unspecified.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub directory: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub metadata: HashMap<String, String>,
}

View file

@ -0,0 +1,26 @@
//! Run domain.
//!
//! `[run]` is the shared execution domain. It may appear in all three config
//! files and layer normally. This file holds only the Stage-1 skeleton; the
//! rich subtree (model, git, prepare, execution, checkpoint, sandbox,
//! notifications, interviews, agent, hooks, scm, pull_request, artifacts) is
//! filled in during Stage 2.
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
/// A sparse `[run]` layer as it appears in a single settings file.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RunLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub goal: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_dir: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub metadata: HashMap<String, String>,
/// Run-time inputs. Stage 2 will widen the value type beyond strings.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inputs: Option<HashMap<String, toml::Value>>,
}

View file

@ -0,0 +1,13 @@
//! Server domain.
//!
//! `[server]` is a namespace container; actual settings live in named
//! subdomains. This file holds only the Stage-1 skeleton; Stage 2 fleshes out
//! the full subtree (listen, api, web, auth, storage, artifacts, slatedb,
//! scheduler, logging, integrations).
use serde::{Deserialize, Serialize};
/// A sparse `[server]` layer as it appears in a single settings file.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ServerLayer;

View file

@ -0,0 +1,313 @@
//! Config-facing byte sizes.
//!
//! Accepts bare integers plus `B`, `KB`, `MB`, `GB`, `TB`, and `KiB`, `MiB`,
//! `GiB`, `TiB`. Decimal units (`KB`, …) are powers of 1000; binary units
//! (`KiB`, …) are powers of 1024. Bare values default to `GB`. Fractional
//! values are not supported in the first pass. The canonical renderer emits
//! the largest decimal unit that represents the value as an integer multiple.
use std::fmt;
use std::str::FromStr;
use serde::de::{self, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// A byte size parsed from a human-readable string.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
pub struct Size(u64);
impl Size {
#[must_use]
pub const fn from_bytes(bytes: u64) -> Self {
Self(bytes)
}
#[must_use]
pub const fn as_bytes(&self) -> u64 {
self.0
}
#[must_use]
pub const fn from_gigabytes(gb: u64) -> Self {
Self(gb.saturating_mul(1_000_000_000))
}
}
/// An error returned when parsing a size string fails.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseSizeError {
/// The input was empty or whitespace only.
Empty,
/// The input had a numeric portion that could not be parsed as a non-negative integer.
InvalidNumber { input: String },
/// The input contained a fractional value, which is not supported in the first pass.
Fractional { input: String },
/// The input had an unrecognized unit suffix.
InvalidUnit { input: String, unit: String },
/// The resulting value overflowed a `u64`.
Overflow { input: String },
}
impl fmt::Display for ParseSizeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => f.write_str("size value is empty"),
Self::InvalidNumber { input } => {
write!(
f,
"size {input:?}: numeric portion is not a non-negative integer"
)
}
Self::Fractional { input } => {
write!(
f,
"size {input:?}: fractional values are not supported in the first pass"
)
}
Self::InvalidUnit { input, unit } => {
write!(
f,
"size {input:?}: unknown unit {unit:?} (expected one of B, KB, MB, GB, TB, KiB, MiB, GiB, TiB)"
)
}
Self::Overflow { input } => {
write!(f, "size {input:?}: value overflows u64 bytes")
}
}
}
}
impl std::error::Error for ParseSizeError {}
impl FromStr for Size {
type Err = ParseSizeError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(ParseSizeError::Empty);
}
if trimmed.contains('.') {
return Err(ParseSizeError::Fractional {
input: input.to_owned(),
});
}
let first_alpha = trimmed.find(|c: char| !c.is_ascii_digit());
let (num, unit) = match first_alpha {
Some(0) => {
return Err(ParseSizeError::InvalidNumber {
input: input.to_owned(),
});
}
Some(idx) => (&trimmed[..idx], trimmed[idx..].trim()),
None => (trimmed, ""),
};
let n: u64 = num.parse().map_err(|_| ParseSizeError::InvalidNumber {
input: input.to_owned(),
})?;
// Bare integers default to GB per R84.
let multiplier: u64 = match unit {
"" | "GB" => 1_000_000_000,
"B" => 1,
"KB" => 1_000,
"MB" => 1_000_000,
"TB" => 1_000_000_000_000,
"KiB" => 1_024,
"MiB" => 1_024 * 1_024,
"GiB" => 1_024 * 1_024 * 1_024,
"TiB" => 1_024 * 1_024 * 1_024 * 1_024,
other => {
return Err(ParseSizeError::InvalidUnit {
input: input.to_owned(),
unit: other.to_owned(),
});
}
};
let bytes = n
.checked_mul(multiplier)
.ok_or_else(|| ParseSizeError::Overflow {
input: input.to_owned(),
})?;
Ok(Self(bytes))
}
}
impl fmt::Display for Size {
/// Canonical rendering picks the largest decimal unit that represents the
/// value as an integer multiple, preferring `TB`, `GB`, `MB`, `KB`, `B`.
/// Zero renders as `0B`.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
const KB: u64 = 1_000;
const MB: u64 = 1_000_000;
const GB: u64 = 1_000_000_000;
const TB: u64 = 1_000_000_000_000;
let b = self.0;
if b == 0 {
return f.write_str("0B");
}
if b.is_multiple_of(TB) {
write!(f, "{}TB", b / TB)
} else if b.is_multiple_of(GB) {
write!(f, "{}GB", b / GB)
} else if b.is_multiple_of(MB) {
write!(f, "{}MB", b / MB)
} else if b.is_multiple_of(KB) {
write!(f, "{}KB", b / KB)
} else {
write!(f, "{b}B")
}
}
}
impl Serialize for Size {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for Size {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct SizeVisitor;
impl Visitor<'_> for SizeVisitor {
type Value = Size;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(r#"a size string such as "8GB" or "512MiB", or a bare integer interpreted as GB"#)
}
fn visit_str<E: de::Error>(self, value: &str) -> Result<Size, E> {
value.parse().map_err(de::Error::custom)
}
fn visit_string<E: de::Error>(self, value: String) -> Result<Size, E> {
self.visit_str(&value)
}
// Bare integers (non-negative) should parse as GB.
fn visit_u64<E: de::Error>(self, value: u64) -> Result<Size, E> {
Ok(Size::from_gigabytes(value))
}
fn visit_i64<E: de::Error>(self, value: i64) -> Result<Size, E> {
u64::try_from(value)
.map(Size::from_gigabytes)
.map_err(|_| de::Error::custom("size must be non-negative"))
}
}
deserializer.deserialize_any(SizeVisitor)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bare_integer_defaults_to_gb() {
assert_eq!("8".parse::<Size>().unwrap(), Size::from_gigabytes(8));
}
#[test]
fn parses_decimal_units() {
assert_eq!("1B".parse::<Size>().unwrap().as_bytes(), 1);
assert_eq!("1KB".parse::<Size>().unwrap().as_bytes(), 1_000);
assert_eq!("1MB".parse::<Size>().unwrap().as_bytes(), 1_000_000);
assert_eq!("1GB".parse::<Size>().unwrap().as_bytes(), 1_000_000_000);
assert_eq!("1TB".parse::<Size>().unwrap().as_bytes(), 1_000_000_000_000);
}
#[test]
fn parses_binary_units() {
assert_eq!("1KiB".parse::<Size>().unwrap().as_bytes(), 1_024);
assert_eq!("1MiB".parse::<Size>().unwrap().as_bytes(), 1_024 * 1_024);
assert_eq!(
"1GiB".parse::<Size>().unwrap().as_bytes(),
1_024 * 1_024 * 1_024
);
assert_eq!(
"1TiB".parse::<Size>().unwrap().as_bytes(),
1_024u64 * 1_024 * 1_024 * 1_024
);
}
#[test]
fn rejects_fractional_values() {
let err = "1.5GB".parse::<Size>().unwrap_err();
assert!(matches!(err, ParseSizeError::Fractional { .. }));
}
#[test]
fn rejects_unknown_units() {
let err = "5XB".parse::<Size>().unwrap_err();
assert!(matches!(err, ParseSizeError::InvalidUnit { unit, .. } if unit == "XB"));
}
#[test]
fn rejects_empty_input() {
let err = "".parse::<Size>().unwrap_err();
assert!(matches!(err, ParseSizeError::Empty));
}
#[test]
fn canonical_render_uses_largest_decimal_unit() {
assert_eq!(Size::from_bytes(1_000_000_000_000).to_string(), "1TB");
assert_eq!(Size::from_bytes(2_000_000_000).to_string(), "2GB");
assert_eq!(Size::from_bytes(5_000_000).to_string(), "5MB");
assert_eq!(Size::from_bytes(3_000).to_string(), "3KB");
assert_eq!(Size::from_bytes(0).to_string(), "0B");
}
#[test]
fn canonical_render_falls_back_to_bytes_for_odd_values() {
// 1_500 bytes is not a whole MB/KB. 1_500 / 1_000 = 1.5 → falls back to B.
assert_eq!(Size::from_bytes(1_500).to_string(), "1500B");
}
#[test]
fn binary_unit_values_render_as_bytes_when_not_a_whole_decimal_unit() {
// 1 KiB = 1024 bytes — not divisible by 1000, so canonical render is bytes.
let size = "1KiB".parse::<Size>().unwrap();
assert_eq!(size.to_string(), "1024B");
}
#[test]
fn overflow_detected() {
let err = format!("{}TB", u64::MAX).parse::<Size>().unwrap_err();
assert!(matches!(err, ParseSizeError::Overflow { .. }));
}
#[test]
fn serde_round_trip_via_json_string() {
#[derive(Debug, serde::Deserialize, serde::Serialize, PartialEq)]
struct Wrap {
s: Size,
}
let input = r#"{"s":"8GB"}"#;
let parsed: Wrap = serde_json::from_str(input).unwrap();
assert_eq!(parsed.s, Size::from_gigabytes(8));
let rendered = serde_json::to_string(&parsed).unwrap();
assert_eq!(rendered, r#"{"s":"8GB"}"#);
}
#[test]
fn serde_accepts_bare_integer_as_gb() {
#[derive(Debug, serde::Deserialize, PartialEq)]
struct Wrap {
s: Size,
}
let input = r#"{"s":8}"#;
let parsed: Wrap = serde_json::from_str(input).unwrap();
assert_eq!(parsed.s, Size::from_gigabytes(8));
}
}

View file

@ -0,0 +1,261 @@
//! Splice-capable string arrays.
//!
//! In declared splice-capable array paths, the literal string value `"..."`
//! is reserved: it represents "splice in inherited values from lower-precedence
//! layers here." At most one `"..."` marker may appear per array. In the base
//! layer with no inherited parent, the marker resolves to an empty inherited
//! segment. In non-splice paths the same literal is a hard error — enforced
//! by using the plain `Vec<String>` type elsewhere and this type only where
//! splice semantics are explicitly allowed.
use std::fmt;
use serde::de::{self, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// The reserved literal that marks the splice insertion point.
pub const SPLICE_MARKER: &str = "...";
/// A string array that may contain at most one splice marker.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SpliceArray {
entries: Vec<Entry>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Entry {
Value(String),
Splice,
}
/// An error returned when a splice array fails validation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SpliceArrayError {
/// The array contained more than one splice marker.
MultipleMarkers,
}
impl fmt::Display for SpliceArrayError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MultipleMarkers => {
f.write_str(r#"splice array must contain at most one "..." marker"#)
}
}
}
}
impl std::error::Error for SpliceArrayError {}
impl SpliceArray {
/// Build a splice array from a raw `Vec<String>`.
pub fn from_raw(raw: Vec<String>) -> Result<Self, SpliceArrayError> {
let mut entries = Vec::with_capacity(raw.len());
let mut marker_count = 0;
for item in raw {
if item == SPLICE_MARKER {
marker_count += 1;
entries.push(Entry::Splice);
} else {
entries.push(Entry::Value(item));
}
}
if marker_count > 1 {
return Err(SpliceArrayError::MultipleMarkers);
}
Ok(Self { entries })
}
/// Build a splice array with no inherited splice marker.
#[must_use]
pub fn from_values(values: impl IntoIterator<Item = String>) -> Self {
Self {
entries: values.into_iter().map(Entry::Value).collect(),
}
}
/// True when the array contains a splice marker.
#[must_use]
pub fn has_splice(&self) -> bool {
self.entries.iter().any(|e| matches!(e, Entry::Splice))
}
/// The index of the splice marker, if present.
#[must_use]
pub fn splice_position(&self) -> Option<usize> {
self.entries.iter().position(|e| matches!(e, Entry::Splice))
}
/// The non-splice values, in source order.
#[must_use]
pub fn values(&self) -> Vec<&str> {
self.entries
.iter()
.filter_map(|e| match e {
Entry::Value(v) => Some(v.as_str()),
Entry::Splice => None,
})
.collect()
}
/// Resolve this array against an inherited lower-precedence value list.
///
/// - If the array has a splice marker, the inherited list is spliced in
/// at the marker position.
/// - If the array has no splice marker, it replaces the inherited list
/// wholesale.
#[must_use]
pub fn resolve(self, inherited: Vec<String>) -> Vec<String> {
let Some(pos) = self.splice_position() else {
return self
.entries
.into_iter()
.filter_map(|e| match e {
Entry::Value(v) => Some(v),
Entry::Splice => None,
})
.collect();
};
let mut prefix = Vec::new();
let mut suffix = Vec::new();
for (i, entry) in self.entries.into_iter().enumerate() {
match entry {
Entry::Value(v) => {
if i < pos {
prefix.push(v);
} else {
suffix.push(v);
}
}
Entry::Splice => {}
}
}
let mut out = prefix;
out.extend(inherited);
out.extend(suffix);
out
}
}
impl Serialize for SpliceArray {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeSeq;
let mut seq = serializer.serialize_seq(Some(self.entries.len()))?;
for entry in &self.entries {
match entry {
Entry::Value(v) => seq.serialize_element(v)?,
Entry::Splice => seq.serialize_element(SPLICE_MARKER)?,
}
}
seq.end()
}
}
impl<'de> Deserialize<'de> for SpliceArray {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct SpliceArrayVisitor;
impl<'de> Visitor<'de> for SpliceArrayVisitor {
type Value = SpliceArray;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(
r#"an array of strings, optionally containing a single "..." splice marker"#,
)
}
fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<SpliceArray, A::Error> {
let mut raw: Vec<String> = Vec::new();
while let Some(item) = seq.next_element::<String>()? {
raw.push(item);
}
SpliceArray::from_raw(raw).map_err(de::Error::custom)
}
}
deserializer.deserialize_seq(SpliceArrayVisitor)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_raw_with_no_marker() {
let arr = SpliceArray::from_raw(vec!["a".into(), "b".into()]).unwrap();
assert!(!arr.has_splice());
assert_eq!(arr.values(), vec!["a", "b"]);
}
#[test]
fn append_marker_at_front() {
let arr = SpliceArray::from_raw(vec!["...".into(), "c".into()]).unwrap();
assert_eq!(arr.splice_position(), Some(0));
let resolved = arr.resolve(vec!["a".into(), "b".into()]);
assert_eq!(resolved, vec!["a", "b", "c"]);
}
#[test]
fn prepend_marker_at_back() {
let arr = SpliceArray::from_raw(vec!["a".into(), "...".into()]).unwrap();
assert_eq!(arr.splice_position(), Some(1));
let resolved = arr.resolve(vec!["b".into(), "c".into()]);
assert_eq!(resolved, vec!["a", "b", "c"]);
}
#[test]
fn marker_in_middle() {
let arr = SpliceArray::from_raw(vec!["pre".into(), "...".into(), "post".into()]).unwrap();
let resolved = arr.resolve(vec!["mid".into()]);
assert_eq!(resolved, vec!["pre", "mid", "post"]);
}
#[test]
fn replace_semantics_without_marker() {
let arr = SpliceArray::from_raw(vec!["only".into()]).unwrap();
let resolved = arr.resolve(vec!["inherited".into()]);
assert_eq!(resolved, vec!["only"]);
}
#[test]
fn multiple_markers_rejected() {
let err = SpliceArray::from_raw(vec!["...".into(), "...".into()]).unwrap_err();
assert_eq!(err, SpliceArrayError::MultipleMarkers);
}
#[test]
fn base_layer_with_splice_resolves_to_empty_inherited() {
let arr = SpliceArray::from_raw(vec!["...".into(), "b".into()]).unwrap();
let resolved = arr.resolve(vec![]);
assert_eq!(resolved, vec!["b"]);
}
#[test]
fn serde_round_trip_via_json() {
#[derive(Debug, serde::Deserialize, serde::Serialize, PartialEq)]
struct Wrap {
a: SpliceArray,
}
let input = r#"{"a":["...","b"]}"#;
let parsed: Wrap = serde_json::from_str(input).unwrap();
assert!(parsed.a.has_splice());
let rendered = serde_json::to_string(&parsed).unwrap();
assert_eq!(rendered, input);
}
#[test]
fn serde_rejects_multiple_markers() {
#[derive(Debug, serde::Deserialize)]
struct Wrap {
_a: SpliceArray,
}
let input = r#"{"_a":["...","..."]}"#;
let err = serde_json::from_str::<Wrap>(input).unwrap_err();
assert!(err.to_string().contains("at most one"));
}
}

View file

@ -0,0 +1,223 @@
//! The top-level v2 sparse parse tree.
//!
//! This struct models a single settings file (`~/.fabro/settings.toml`,
//! `fabro.toml`, or `workflow.toml`) after parsing. Fields unset in the source
//! stay `None`/empty. Strict unknown-key handling catches any top-level key
//! that is not one of the reserved domains, with targeted rename hints for
//! legacy flat shapes.
use std::fmt;
use serde::{Deserialize, Serialize};
use super::cli::CliLayer;
use super::features::FeaturesLayer;
use super::project::ProjectLayer;
use super::run::RunLayer;
use super::server::ServerLayer;
use super::workflow::WorkflowLayer;
/// A parsed settings file before layering.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct SettingsFile {
#[serde(default, rename = "_version", skip_serializing_if = "Option::is_none")]
pub version: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<ProjectLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workflow: Option<WorkflowLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run: Option<RunLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cli: Option<CliLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server: Option<ServerLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub features: Option<FeaturesLayer>,
}
/// A top-level key in a v2 settings file. Anything not in this list is rejected
/// at parse time with a targeted rename hint when possible.
const ALLOWED_TOP_LEVEL_KEYS: &[&str] = &[
"_version", "project", "workflow", "run", "cli", "server", "features",
];
/// An error returned when a settings file fails parse-level validation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseError {
/// A low-level TOML parse error.
Toml(String),
/// Schema version pre-validation failed.
Version(super::version::VersionError),
/// A top-level key is not part of the v2 schema. Rename hints are
/// populated for known-legacy keys.
UnknownTopLevelKey { key: String, hint: Option<String> },
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Toml(msg) => write!(f, "settings file is not valid TOML: {msg}"),
Self::Version(err) => fmt::Display::fmt(err, f),
Self::UnknownTopLevelKey { key, hint } => {
if let Some(hint) = hint {
write!(f, "unknown top-level settings key `{key}`: {hint}")
} else {
write!(
f,
"unknown top-level settings key `{key}`: expected one of `_version`, `project`, `workflow`, `run`, `cli`, `server`, `features`"
)
}
}
}
}
}
impl std::error::Error for ParseError {}
/// Parse a v2 settings file from TOML text.
///
/// This runs `_version` pre-validation, top-level unknown-key validation
/// with rename hints, and then decodes the sparse namespaced tree. Deeper
/// unknown-key validation for nested tables is enforced by the individual
/// layer types via `#[serde(deny_unknown_fields)]`.
pub fn parse_settings_file(input: &str) -> Result<SettingsFile, ParseError> {
let raw: toml::Value = toml::from_str(input).map_err(|e| ParseError::Toml(e.to_string()))?;
super::version::validate_version(&raw).map_err(ParseError::Version)?;
if let Some(table) = raw.as_table() {
for key in table.keys() {
if !ALLOWED_TOP_LEVEL_KEYS.contains(&key.as_str()) {
return Err(ParseError::UnknownTopLevelKey {
key: key.clone(),
hint: rename_hint(key),
});
}
}
}
let file: SettingsFile = raw
.try_into::<SettingsFile>()
.map_err(|e| ParseError::Toml(e.to_string()))?;
Ok(file)
}
/// Targeted rename hint for known legacy top-level keys.
fn rename_hint(key: &str) -> Option<String> {
let target = match key {
"version" => "rename to `_version`",
"goal" | "goal_file" | "work_dir" | "directory" => "move to `[run]`",
"graph" => "move to `[workflow]`",
"labels" => "move to `[run.metadata]`",
"llm" => "rename to `[run.model]`",
"vars" => "rename to `[run.inputs]`",
"setup" => "rename to `[run.prepare]`",
"sandbox" => "move under `[run.sandbox]`",
"checkpoint" => "move under `[run.checkpoint]`",
"pull_request" => "move under `[run.pull_request]`",
"artifacts" => "move under `[run.artifacts]`",
"hooks" => "move under `[[run.hooks]]`",
"mcp_servers" => "move under `[run.agent.mcps.<name>]` or `[cli.exec.agent.mcps.<name>]`",
"exec" => "rename to `[cli.exec]`",
"api" => "rename to `[server.api]`",
"web" => "rename to `[server.web]`",
"artifact_storage" => "rename to `[server.artifacts]`",
"storage_dir" | "data_dir" => "rename to `[server.storage] root`",
"max_concurrent_runs" => "rename to `[server.scheduler]` field",
"fabro" => "rename to `[project]`; `fabro.root` becomes `project.directory`",
"git" => "split into `[run.git]` (local git behavior) and `[server.integrations.github]`",
"github" => "rename to `[server.integrations.github]`",
"slack" => "move under `[server.integrations.slack]`",
"log" => "rename to `[server.logging]` or `[cli.logging]` depending on owner",
"prevent_idle_sleep" => "rename to `[cli.exec] prevent_idle_sleep`",
"verbose" => "rename to `[cli.output] verbosity`",
"upgrade_check" => "rename to `[cli.updates] check`",
"dry_run" => "rename to `[run.execution] mode = \"dry_run\"`",
"auto_approve" => "rename to `[run.execution] approval = \"auto\"`",
"no_retro" => "rename to `[run.execution] retros = false`",
_ => return None,
};
Some(target.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_empty_file() {
let file = parse_settings_file("").unwrap();
assert_eq!(file, SettingsFile::default());
}
#[test]
fn parses_minimal_valid_file() {
let input = r#"
_version = 1
[project]
name = "Fabro"
"#;
let file = parse_settings_file(input).unwrap();
assert_eq!(file.version, Some(1));
assert!(file.project.is_some());
}
#[test]
fn rejects_legacy_version_key_with_rename_hint() {
let err = parse_settings_file("version = 1").unwrap_err();
assert!(matches!(err, ParseError::Version(_)));
assert!(err.to_string().contains("_version"));
}
#[test]
fn rejects_unknown_top_level_key() {
let err = parse_settings_file("unknown_key = 1").unwrap_err();
let ParseError::UnknownTopLevelKey { key, .. } = err else {
panic!("expected UnknownTopLevelKey, got: {err:?}");
};
assert_eq!(key, "unknown_key");
}
#[test]
fn legacy_llm_section_gets_run_model_rename_hint() {
let err = parse_settings_file("[llm]\nprovider = \"openai\"").unwrap_err();
assert!(
err.to_string().contains("run.model"),
"expected rename hint for [llm]: {err}"
);
}
#[test]
fn legacy_vars_section_gets_run_inputs_rename_hint() {
let err = parse_settings_file("[vars]\nk = \"v\"").unwrap_err();
assert!(
err.to_string().contains("run.inputs"),
"expected rename hint for [vars]: {err}"
);
}
#[test]
fn legacy_exec_section_gets_cli_exec_rename_hint() {
let err = parse_settings_file("[exec]\nmodel = \"claude-opus\"").unwrap_err();
assert!(
err.to_string().contains("cli.exec"),
"expected rename hint for [exec]: {err}"
);
}
#[test]
fn legacy_fabro_section_gets_project_rename_hint() {
let err = parse_settings_file("[fabro]\nroot = \"fabro/\"").unwrap_err();
assert!(
err.to_string().contains("project"),
"expected rename hint for [fabro]: {err}"
);
}
#[test]
fn higher_version_rejected_with_upgrade_hint() {
let err = parse_settings_file("_version = 99").unwrap_err();
assert!(err.to_string().contains("Upgrade"));
}
}

View file

@ -0,0 +1,111 @@
//! Schema version handling.
//!
//! The settings schema version lives under the reserved top-level key
//! `_version`. Missing defaults to `1`. The legacy top-level `version` key is
//! a targeted rename hint. Unsupported higher versions hard-fail with an
//! upgrade hint before deeper validation continues.
use std::fmt;
/// The highest schema version this parser can consume.
pub const CURRENT_VERSION: u32 = 1;
/// An error returned when `_version` pre-validation fails.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VersionError {
/// The file contains a legacy top-level `version` key. Offer a rename hint.
LegacyVersionKey,
/// The file declares `_version` higher than [`CURRENT_VERSION`].
UnsupportedHigherVersion { found: u32 },
}
impl fmt::Display for VersionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::LegacyVersionKey => f.write_str(
"settings files must use `_version` instead of `version`. Rename the key and try again.",
),
Self::UnsupportedHigherVersion { found } => write!(
f,
"settings schema version {found} is newer than this build supports (current: {CURRENT_VERSION}). Upgrade Fabro to read this file."
),
}
}
}
impl std::error::Error for VersionError {}
/// The parsed schema version for a settings file.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SchemaVersion(pub u32);
impl Default for SchemaVersion {
fn default() -> Self {
Self(CURRENT_VERSION)
}
}
/// Validate and extract the schema version from a parsed TOML value before
/// deeper validation continues.
///
/// This function peeks at the top-level table and enforces three rules:
///
/// 1. `version = ...` (no underscore) is an explicit rename hint error.
/// 2. `_version` higher than [`CURRENT_VERSION`] is an upgrade hint error.
/// 3. Missing `_version` defaults to [`CURRENT_VERSION`].
pub fn validate_version(raw: &toml::Value) -> Result<SchemaVersion, VersionError> {
if let Some(table) = raw.as_table() {
if table.contains_key("version") {
return Err(VersionError::LegacyVersionKey);
}
if let Some(value) = table.get("_version") {
if let Some(n) = value.as_integer() {
let found = u32::try_from(n).unwrap_or(u32::MAX);
if found > CURRENT_VERSION {
return Err(VersionError::UnsupportedHigherVersion { found });
}
return Ok(SchemaVersion(found));
}
}
}
Ok(SchemaVersion::default())
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(input: &str) -> toml::Value {
toml::from_str(input).expect("toml parse")
}
#[test]
fn missing_version_defaults_to_current() {
let raw = parse("");
let v = validate_version(&raw).unwrap();
assert_eq!(v, SchemaVersion(CURRENT_VERSION));
}
#[test]
fn explicit_version_one_is_accepted() {
let raw = parse("_version = 1");
let v = validate_version(&raw).unwrap();
assert_eq!(v, SchemaVersion(1));
}
#[test]
fn legacy_version_key_errors_with_rename_hint() {
let raw = parse("version = 1");
let err = validate_version(&raw).unwrap_err();
assert_eq!(err, VersionError::LegacyVersionKey);
assert!(err.to_string().contains("_version"));
}
#[test]
fn unsupported_higher_version_errors_with_upgrade_hint() {
let raw = parse("_version = 99");
let err = validate_version(&raw).unwrap_err();
assert_eq!(err, VersionError::UnsupportedHigherVersion { found: 99 });
assert!(err.to_string().contains("Upgrade"));
}
}

View file

@ -0,0 +1,23 @@
//! Workflow domain.
//!
//! `[workflow]` is descriptive: `name`, `description`, optional `graph` (a
//! path override for the default `workflow.fabro` file), and `metadata`.
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
/// A sparse `[workflow]` layer as it appears in a single settings file.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkflowLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Optional override for the default `workflow.fabro` graph path.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graph: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub metadata: HashMap<String, String>,
}