mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-15 23:32:46 +00:00
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:
parent
c9f65f97d5
commit
288e733213
17 changed files with 2078 additions and 0 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -2059,6 +2059,7 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"toml 0.8.23",
|
||||
"ulid",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -27,4 +27,5 @@ hex.workspace = true
|
|||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
toml.workspace = true
|
||||
ulid.workspace = true
|
||||
|
|
|
|||
|
|
@ -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::{
|
||||
|
|
|
|||
14
lib/crates/fabro-types/src/settings/v2/cli.rs
Normal file
14
lib/crates/fabro-types/src/settings/v2/cli.rs
Normal 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;
|
||||
303
lib/crates/fabro-types/src/settings/v2/duration.rs
Normal file
303
lib/crates/fabro-types/src/settings/v2/duration.rs
Normal 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"}"#);
|
||||
}
|
||||
}
|
||||
17
lib/crates/fabro-types/src/settings/v2/features.rs
Normal file
17
lib/crates/fabro-types/src/settings/v2/features.rs
Normal 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>,
|
||||
}
|
||||
335
lib/crates/fabro-types/src/settings/v2/interp.rs
Normal file
335
lib/crates/fabro-types/src/settings/v2/interp.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
37
lib/crates/fabro-types/src/settings/v2/mod.rs
Normal file
37
lib/crates/fabro-types/src/settings/v2/mod.rs
Normal 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;
|
||||
375
lib/crates/fabro-types/src/settings/v2/model_ref.rs
Normal file
375
lib/crates/fabro-types/src/settings/v2/model_ref.rs
Normal 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(®).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(®).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(®)
|
||||
.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(®)
|
||||
.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);
|
||||
}
|
||||
}
|
||||
24
lib/crates/fabro-types/src/settings/v2/project.rs
Normal file
24
lib/crates/fabro-types/src/settings/v2/project.rs
Normal 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>,
|
||||
}
|
||||
26
lib/crates/fabro-types/src/settings/v2/run.rs
Normal file
26
lib/crates/fabro-types/src/settings/v2/run.rs
Normal 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>>,
|
||||
}
|
||||
13
lib/crates/fabro-types/src/settings/v2/server.rs
Normal file
13
lib/crates/fabro-types/src/settings/v2/server.rs
Normal 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;
|
||||
313
lib/crates/fabro-types/src/settings/v2/size.rs
Normal file
313
lib/crates/fabro-types/src/settings/v2/size.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
261
lib/crates/fabro-types/src/settings/v2/splice_array.rs
Normal file
261
lib/crates/fabro-types/src/settings/v2/splice_array.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
223
lib/crates/fabro-types/src/settings/v2/tree.rs
Normal file
223
lib/crates/fabro-types/src/settings/v2/tree.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
111
lib/crates/fabro-types/src/settings/v2/version.rs
Normal file
111
lib/crates/fabro-types/src/settings/v2/version.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
23
lib/crates/fabro-types/src/settings/v2/workflow.rs
Normal file
23
lib/crates/fabro-types/src/settings/v2/workflow.rs
Normal 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>,
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue