From 288e73321343a44e2126ffb8303718662df7ec66 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 09:02:45 -0400 Subject: [PATCH] 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. --- Cargo.lock | 1 + lib/crates/fabro-types/Cargo.toml | 1 + lib/crates/fabro-types/src/settings/mod.rs | 1 + lib/crates/fabro-types/src/settings/v2/cli.rs | 14 + .../fabro-types/src/settings/v2/duration.rs | 303 ++++++++++++++ .../fabro-types/src/settings/v2/features.rs | 17 + .../fabro-types/src/settings/v2/interp.rs | 335 ++++++++++++++++ lib/crates/fabro-types/src/settings/v2/mod.rs | 37 ++ .../fabro-types/src/settings/v2/model_ref.rs | 375 ++++++++++++++++++ .../fabro-types/src/settings/v2/project.rs | 24 ++ lib/crates/fabro-types/src/settings/v2/run.rs | 26 ++ .../fabro-types/src/settings/v2/server.rs | 13 + .../fabro-types/src/settings/v2/size.rs | 313 +++++++++++++++ .../src/settings/v2/splice_array.rs | 261 ++++++++++++ .../fabro-types/src/settings/v2/tree.rs | 223 +++++++++++ .../fabro-types/src/settings/v2/version.rs | 111 ++++++ .../fabro-types/src/settings/v2/workflow.rs | 23 ++ 17 files changed, 2078 insertions(+) create mode 100644 lib/crates/fabro-types/src/settings/v2/cli.rs create mode 100644 lib/crates/fabro-types/src/settings/v2/duration.rs create mode 100644 lib/crates/fabro-types/src/settings/v2/features.rs create mode 100644 lib/crates/fabro-types/src/settings/v2/interp.rs create mode 100644 lib/crates/fabro-types/src/settings/v2/mod.rs create mode 100644 lib/crates/fabro-types/src/settings/v2/model_ref.rs create mode 100644 lib/crates/fabro-types/src/settings/v2/project.rs create mode 100644 lib/crates/fabro-types/src/settings/v2/run.rs create mode 100644 lib/crates/fabro-types/src/settings/v2/server.rs create mode 100644 lib/crates/fabro-types/src/settings/v2/size.rs create mode 100644 lib/crates/fabro-types/src/settings/v2/splice_array.rs create mode 100644 lib/crates/fabro-types/src/settings/v2/tree.rs create mode 100644 lib/crates/fabro-types/src/settings/v2/version.rs create mode 100644 lib/crates/fabro-types/src/settings/v2/workflow.rs diff --git a/Cargo.lock b/Cargo.lock index 7986ddaa5..d538cf560 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2059,6 +2059,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "toml 0.8.23", "ulid", ] diff --git a/lib/crates/fabro-types/Cargo.toml b/lib/crates/fabro-types/Cargo.toml index 9ca57b410..aed2dee7f 100644 --- a/lib/crates/fabro-types/Cargo.toml +++ b/lib/crates/fabro-types/Cargo.toml @@ -27,4 +27,5 @@ hex.workspace = true serde.workspace = true serde_json.workspace = true sha2.workspace = true +toml.workspace = true ulid.workspace = true diff --git a/lib/crates/fabro-types/src/settings/mod.rs b/lib/crates/fabro-types/src/settings/mod.rs index 229b65b36..1393e6845 100644 --- a/lib/crates/fabro-types/src/settings/mod.rs +++ b/lib/crates/fabro-types/src/settings/mod.rs @@ -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::{ diff --git a/lib/crates/fabro-types/src/settings/v2/cli.rs b/lib/crates/fabro-types/src/settings/v2/cli.rs new file mode 100644 index 000000000..1946a50d1 --- /dev/null +++ b/lib/crates/fabro-types/src/settings/v2/cli.rs @@ -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; diff --git a/lib/crates/fabro-types/src/settings/v2/duration.rs b/lib/crates/fabro-types/src/settings/v2/duration.rs new file mode 100644 index 000000000..f3698fd9e --- /dev/null +++ b/lib/crates/fabro-types/src/settings/v2/duration.rs @@ -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 for Duration { + fn from(value: StdDuration) -> Self { + Self(value) + } +} + +impl From 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 { + 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(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for Duration { + fn deserialize>(deserializer: D) -> Result { + 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(self, value: &str) -> Result { + value.parse().map_err(de::Error::custom) + } + + fn visit_string(self, value: String) -> Result { + self.visit_str(&value) + } + } + + deserializer.deserialize_str(DurationVisitor) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_each_unit() { + assert_eq!( + "500ms".parse::().unwrap(), + Duration::from_millis(500) + ); + assert_eq!("30s".parse::().unwrap(), Duration::from_secs(30)); + assert_eq!("2m".parse::().unwrap(), Duration::from_secs(120)); + assert_eq!( + "1h".parse::().unwrap(), + Duration::from_secs(3_600) + ); + assert_eq!( + "1d".parse::().unwrap(), + Duration::from_secs(86_400) + ); + } + + #[test] + fn rejects_composed_values() { + let err = "1h30m".parse::().unwrap_err(); + assert!(matches!(err, ParseDurationError::Composed { .. })); + } + + #[test] + fn rejects_missing_unit() { + let err = "30".parse::().unwrap_err(); + assert!(matches!(err, ParseDurationError::MissingUnit { .. })); + } + + #[test] + fn rejects_unknown_unit() { + let err = "1w".parse::().unwrap_err(); + assert!(matches!(err, ParseDurationError::InvalidUnit { unit, .. } if unit == "w")); + } + + #[test] + fn rejects_empty() { + let err = "".parse::().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"}"#); + } +} diff --git a/lib/crates/fabro-types/src/settings/v2/features.rs b/lib/crates/fabro-types/src/settings/v2/features.rs new file mode 100644 index 000000000..a590076ff --- /dev/null +++ b/lib/crates/fabro-types/src/settings/v2/features.rs @@ -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` 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, +} diff --git a/lib/crates/fabro-types/src/settings/v2/interp.rs b/lib/crates/fabro-types/src/settings/v2/interp.rs new file mode 100644 index 000000000..ef8e43b3a --- /dev/null +++ b/lib/crates/fabro-types/src/settings/v2/interp.rs @@ -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, +} + +#[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 = 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(&self, mut lookup: F) -> Result + where + F: FnMut(&str) -> Option, + { + 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 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 }, +} + +/// 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(&self, serializer: S) -> Result { + serializer.serialize_str(&self.as_source()) + } +} + +impl<'de> Deserialize<'de> for InterpString { + fn deserialize>(deserializer: D) -> Result { + 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(self, value: &str) -> Result { + Ok(InterpString::parse(value)) + } + + fn visit_string(self, value: String) -> Result { + 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 + 'static { + let map: HashMap = 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); + } +} diff --git a/lib/crates/fabro-types/src/settings/v2/mod.rs b/lib/crates/fabro-types/src/settings/v2/mod.rs new file mode 100644 index 000000000..c88494cb3 --- /dev/null +++ b/lib/crates/fabro-types/src/settings/v2/mod.rs @@ -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; diff --git a/lib/crates/fabro-types/src/settings/v2/model_ref.rs b/lib/crates/fabro-types/src/settings/v2/model_ref.rs new file mode 100644 index 000000000..6b2514559 --- /dev/null +++ b/lib/crates/fabro-types/src/settings/v2/model_ref.rs @@ -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 { + 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, + pub models: Vec, +} + +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, + 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; +} + +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 { + 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(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for ModelRef { + fn deserialize>(deserializer: D) -> Result { + 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(self, value: &str) -> Result { + value.parse().map_err(de::Error::custom) + } + + fn visit_string(self, value: String) -> Result { + 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 { + if self.models.contains(&token) { + Some("test".to_owned()) + } else { + None + } + } + } + + #[test] + fn parses_bare_token() { + assert_eq!( + "openai".parse::().unwrap(), + ModelRef::Bare("openai".into()) + ); + } + + #[test] + fn parses_qualified() { + assert_eq!( + "gemini/gemini-flash".parse::().unwrap(), + ModelRef::Qualified { + provider: "gemini".into(), + model: "gemini-flash".into() + } + ); + } + + #[test] + fn rejects_too_many_slashes() { + let err = "a/b/c".parse::().unwrap_err(); + assert!(matches!(err, ParseModelRefError::TooManySlashes { .. })); + } + + #[test] + fn rejects_empty_side() { + assert!(matches!( + "/foo".parse::().unwrap_err(), + ParseModelRefError::EmptySide { .. } + )); + assert!(matches!( + "foo/".parse::().unwrap_err(), + ParseModelRefError::EmptySide { .. } + )); + } + + #[test] + fn rejects_empty_input() { + assert!(matches!( + "".parse::().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); + } +} diff --git a/lib/crates/fabro-types/src/settings/v2/project.rs b/lib/crates/fabro-types/src/settings/v2/project.rs new file mode 100644 index 000000000..3355559d2 --- /dev/null +++ b/lib/crates/fabro-types/src/settings/v2/project.rs @@ -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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// 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, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub metadata: HashMap, +} diff --git a/lib/crates/fabro-types/src/settings/v2/run.rs b/lib/crates/fabro-types/src/settings/v2/run.rs new file mode 100644 index 000000000..e03170e6c --- /dev/null +++ b/lib/crates/fabro-types/src/settings/v2/run.rs @@ -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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub working_dir: Option, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub metadata: HashMap, + /// Run-time inputs. Stage 2 will widen the value type beyond strings. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inputs: Option>, +} diff --git a/lib/crates/fabro-types/src/settings/v2/server.rs b/lib/crates/fabro-types/src/settings/v2/server.rs new file mode 100644 index 000000000..9e0ff87db --- /dev/null +++ b/lib/crates/fabro-types/src/settings/v2/server.rs @@ -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; diff --git a/lib/crates/fabro-types/src/settings/v2/size.rs b/lib/crates/fabro-types/src/settings/v2/size.rs new file mode 100644 index 000000000..2ac4685e2 --- /dev/null +++ b/lib/crates/fabro-types/src/settings/v2/size.rs @@ -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 { + 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(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for Size { + fn deserialize>(deserializer: D) -> Result { + 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(self, value: &str) -> Result { + value.parse().map_err(de::Error::custom) + } + + fn visit_string(self, value: String) -> Result { + self.visit_str(&value) + } + + // Bare integers (non-negative) should parse as GB. + fn visit_u64(self, value: u64) -> Result { + Ok(Size::from_gigabytes(value)) + } + + fn visit_i64(self, value: i64) -> Result { + 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::().unwrap(), Size::from_gigabytes(8)); + } + + #[test] + fn parses_decimal_units() { + assert_eq!("1B".parse::().unwrap().as_bytes(), 1); + assert_eq!("1KB".parse::().unwrap().as_bytes(), 1_000); + assert_eq!("1MB".parse::().unwrap().as_bytes(), 1_000_000); + assert_eq!("1GB".parse::().unwrap().as_bytes(), 1_000_000_000); + assert_eq!("1TB".parse::().unwrap().as_bytes(), 1_000_000_000_000); + } + + #[test] + fn parses_binary_units() { + assert_eq!("1KiB".parse::().unwrap().as_bytes(), 1_024); + assert_eq!("1MiB".parse::().unwrap().as_bytes(), 1_024 * 1_024); + assert_eq!( + "1GiB".parse::().unwrap().as_bytes(), + 1_024 * 1_024 * 1_024 + ); + assert_eq!( + "1TiB".parse::().unwrap().as_bytes(), + 1_024u64 * 1_024 * 1_024 * 1_024 + ); + } + + #[test] + fn rejects_fractional_values() { + let err = "1.5GB".parse::().unwrap_err(); + assert!(matches!(err, ParseSizeError::Fractional { .. })); + } + + #[test] + fn rejects_unknown_units() { + let err = "5XB".parse::().unwrap_err(); + assert!(matches!(err, ParseSizeError::InvalidUnit { unit, .. } if unit == "XB")); + } + + #[test] + fn rejects_empty_input() { + let err = "".parse::().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::().unwrap(); + assert_eq!(size.to_string(), "1024B"); + } + + #[test] + fn overflow_detected() { + let err = format!("{}TB", u64::MAX).parse::().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)); + } +} diff --git a/lib/crates/fabro-types/src/settings/v2/splice_array.rs b/lib/crates/fabro-types/src/settings/v2/splice_array.rs new file mode 100644 index 000000000..435570d67 --- /dev/null +++ b/lib/crates/fabro-types/src/settings/v2/splice_array.rs @@ -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` 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, +} + +#[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`. + pub fn from_raw(raw: Vec) -> Result { + 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) -> 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 { + 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) -> Vec { + 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(&self, serializer: S) -> Result { + 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>(deserializer: D) -> Result { + 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>(self, mut seq: A) -> Result { + let mut raw: Vec = Vec::new(); + while let Some(item) = seq.next_element::()? { + 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::(input).unwrap_err(); + assert!(err.to_string().contains("at most one")); + } +} diff --git a/lib/crates/fabro-types/src/settings/v2/tree.rs b/lib/crates/fabro-types/src/settings/v2/tree.rs new file mode 100644 index 000000000..2d404cff3 --- /dev/null +++ b/lib/crates/fabro-types/src/settings/v2/tree.rs @@ -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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workflow: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub run: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cli: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub features: Option, +} + +/// 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 }, +} + +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 { + 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::() + .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 { + 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.]` or `[cli.exec.agent.mcps.]`", + "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")); + } +} diff --git a/lib/crates/fabro-types/src/settings/v2/version.rs b/lib/crates/fabro-types/src/settings/v2/version.rs new file mode 100644 index 000000000..bb3dc8e15 --- /dev/null +++ b/lib/crates/fabro-types/src/settings/v2/version.rs @@ -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 { + 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")); + } +} diff --git a/lib/crates/fabro-types/src/settings/v2/workflow.rs b/lib/crates/fabro-types/src/settings/v2/workflow.rs new file mode 100644 index 000000000..8b0e986fd --- /dev/null +++ b/lib/crates/fabro-types/src/settings/v2/workflow.rs @@ -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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Optional override for the default `workflow.fabro` graph path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub graph: Option, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub metadata: HashMap, +}