diff --git a/CHANGELOG.md b/CHANGELOG.md index 48f2f2f..c9d1e5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/SemVer ### Added - Initial release preparation +- Regression suite `tests/spec_conformance_regressions.rs` covering the conformance + gaps fixed below, including a check that `run --format json` keeps stdout parseable. + +### Fixed +- **Parser**: a comment line with an empty body (a bare `#`) no longer fails with + `F003`. Appendix B defines `COMMENT = "#" *(%x20-10FFFF) NL`, i.e. zero or more + characters after the marker. +- **Runtime inputs**: `@input` now validates against the full type system. Composite + FTS types — `list`, `map`, `struct { ... }`, unions and multimodal + assets — previously always failed with `F453`, even for conforming values, because + only primitives were matched. `int` is still widened to `float` per §8.4. +- **Token Box Model**: a `min` larger than the section's own content no longer makes + that section undroppable and no longer lets the layout exceed the budget. `min` is + clamped to the section size before truncation and drop decisions (§11.3). +- **`json()` lens**: renders the value as plain JSON instead of the compiler's internal + tagged `ValueNode` representation, and honours `indent` both as a positional and as a + named argument (Appendix A). Unevaluated nodes are now an explicit error. +- **CLI**: diagnostics go to stderr, so stdout carries only machine-readable output and + `facet-fct run --format json | jq` works. ANSI colouring is disabled when stderr is + not a terminal. This makes the log-stripping in `scripts/spec_matrix_examples.sh` + redundant. +- **CLI**: the startup log reported the host default budget and the gas limit as if + they were the layout budget; it now reports the effective layout budget after + `@context budget` is applied. ## [0.1.2] - 2026-04-02 @@ -206,6 +230,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/SemVer ### Added - Initial release preparation +- Regression suite `tests/spec_conformance_regressions.rs` covering the conformance + gaps fixed below, including a check that `run --format json` keeps stdout parseable. + +### Fixed +- **Parser**: a comment line with an empty body (a bare `#`) no longer fails with + `F003`. Appendix B defines `COMMENT = "#" *(%x20-10FFFF) NL`, i.e. zero or more + characters after the marker. +- **Runtime inputs**: `@input` now validates against the full type system. Composite + FTS types — `list`, `map`, `struct { ... }`, unions and multimodal + assets — previously always failed with `F453`, even for conforming values, because + only primitives were matched. `int` is still widened to `float` per §8.4. +- **Token Box Model**: a `min` larger than the section's own content no longer makes + that section undroppable and no longer lets the layout exceed the budget. `min` is + clamped to the section size before truncation and drop decisions (§11.3). +- **`json()` lens**: renders the value as plain JSON instead of the compiler's internal + tagged `ValueNode` representation, and honours `indent` both as a positional and as a + named argument (Appendix A). Unevaluated nodes are now an explicit error. +- **CLI**: diagnostics go to stderr, so stdout carries only machine-readable output and + `facet-fct run --format json | jq` works. ANSI colouring is disabled when stderr is + not a terminal. This makes the log-stripping in `scripts/spec_matrix_examples.sh` + redundant. +- **CLI**: the startup log reported the host default budget and the gas limit as if + they were the layout budget; it now reports the effective layout budget after + `@context budget` is applied. ## [0.1.0] - 2025-12-09 diff --git a/FACET-v2.1.3-Production-Language-Specification.md b/FACET-v2.1.3-Production-Language-Specification.md index 0d32ffe..fafd47f 100644 --- a/FACET-v2.1.3-Production-Language-Specification.md +++ b/FACET-v2.1.3-Production-Language-Specification.md @@ -590,13 +590,23 @@ Let `B` be budget in FACET Units and `size[i] = facet_units(content[i])`. 2. `shrink` descending 3. original section order ascending +For each flexible section, define the **effective minimum**: + +``` +effective_min[i] = min(min[i], size[i]) +``` + +`min` is a floor on *retained* content. A declared `min` greater than the section's own +size MUST NOT make the section unshrinkable or undroppable, and MUST NOT permit the +packed layout to exceed `B`. + Iterate `Flex` in that order while total size > B: - If `strategy` is set: apply strategy to `content[i]` (Pure Mode: Level‑0 only; else `F801`) - Recompute `size[i]` and total -- If still over budget: truncate deterministically from the end down to satisfy budget but not below `min` +- If still over budget: truncate deterministically from the end down to satisfy budget but not below `effective_min[i]` - truncation MUST NOT split UTF‑8 sequences -- If still over budget and `size[i] == min`: drop the entire section (unless Critical) +- If still over budget and `size[i] <= effective_min[i]`: drop the entire section (unless Critical) Result MUST be deterministic across implementations. @@ -1333,6 +1343,10 @@ If an Execution Artifact is produced during `run` or `test`, it SHOULD be emitte ## 21. Change History +### v2.1.3 (rev. 2026-08-21 — targeted normative clarification) + +- **§11.3** — Defined `effective_min[i] = min(min[i], size[i])` for flexible sections. Previously a `min` larger than the section's own content satisfied neither the truncation condition (`size[i] > min`) nor the drop condition (`size[i] == min`), so a conforming implementation could terminate with a packed layout exceeding `B` — contradicting the resource bound the model exists to provide. + ### v2.1.3 (rev. 2026-02-19 — targeted normative clarifications) Normative additions within v2.1.3 to close formal gaps identified post-publication: diff --git a/crates/fct-engine/Cargo.toml b/crates/fct-engine/Cargo.toml index 753c414..d7266b5 100644 --- a/crates/fct-engine/Cargo.toml +++ b/crates/fct-engine/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] fct-ast = { path = "../fct-ast" } fct-std = { path = "../fct-std" } +fct-validator = { path = "../fct-validator" } thiserror = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/crates/fct-engine/src/box_model.rs b/crates/fct-engine/src/box_model.rs index 0083a6a..c4aaa93 100644 --- a/crates/fct-engine/src/box_model.rs +++ b/crates/fct-engine/src/box_model.rs @@ -369,15 +369,20 @@ impl TokenBoxModel { } } + // §11.3: `min` is a floor on *retained* content. A `min` larger than the + // section's own size must not make the section unshrinkable and must not + // let the layout exceed the budget, so clamp it to the size we actually have. + let effective_min = std::cmp::min(section.min, section.current_size); + // If still over budget, truncate deterministically from the end down to `min`. - if running_total > self.budget && section.current_size > section.min { + if running_total > self.budget && section.current_size > effective_min { let need = running_total - self.budget; - let reducible = section.current_size - section.min; + let reducible = section.current_size - effective_min; let requested_reduction = std::cmp::min(need, reducible); if requested_reduction > 0 { let target_size = section.current_size - requested_reduction; let (truncated_content, truncated_size) = - self.truncate_content(§ion.content, target_size, section.min); + self.truncate_content(§ion.content, target_size, effective_min); if truncated_size < section.current_size { section.content = truncated_content; running_total = running_total @@ -388,8 +393,8 @@ impl TokenBoxModel { } } - // If still over budget and this section is at min, drop it. - if running_total > self.budget && section.current_size == section.min { + // If still over budget and this section is at its (clamped) min, drop it. + if running_total > self.budget && section.current_size <= effective_min { running_total = running_total.saturating_sub(section.current_size); allocated_sections.push(AllocatedSection { final_size: 0, @@ -567,6 +572,6 @@ impl TokenBoxModel { } fn sort_allocated_by_source(mut sections: Vec) -> Vec { - sections.sort_by(|a, b| a.section.source_index.cmp(&b.section.source_index)); + sections.sort_by_key(|a| a.section.source_index); sections } diff --git a/crates/fct-engine/src/r_dag.rs b/crates/fct-engine/src/r_dag.rs index 275e82f..edcab6e 100644 --- a/crates/fct-engine/src/r_dag.rs +++ b/crates/fct-engine/src/r_dag.rs @@ -502,7 +502,7 @@ impl RDagEngine { }); }; - if !Self::value_matches_runtime_type(&value, declared_type) { + if !Self::value_matches_runtime_type(&value, declared_type)? { return Err(EngineError::InputValidationFailed { message: format!( "Input '{}' does not satisfy declared type '{}'", @@ -514,19 +514,24 @@ impl RDagEngine { Ok(value) } - fn value_matches_runtime_type(value: &ValueNode, declared_type: &str) -> bool { - match declared_type { - "any" => true, - "string" => matches!(value, ValueNode::String(_)), - "int" => matches!(value, ValueNode::Scalar(ScalarValue::Int(_))), - "float" => matches!( - value, - ValueNode::Scalar(ScalarValue::Float(_)) | ValueNode::Scalar(ScalarValue::Int(_)) - ), - "bool" => matches!(value, ValueNode::Scalar(ScalarValue::Bool(_))), - "null" => matches!(value, ValueNode::Scalar(ScalarValue::Null)), - _ => false, + /// Validate a host-supplied runtime value against its declared FTS type. + /// + /// The full type system is used here (§8): composite types — `list`, + /// `map`, `struct { ... }`, unions and multimodal assets — are as + /// valid in `@input` as primitives are. + fn value_matches_runtime_type(value: &ValueNode, declared_type: &str) -> EngineResult { + // `int` is accepted where `float` is declared (widening), matching §8.4. + if declared_type.trim() == "float" { + if let ValueNode::Scalar(ScalarValue::Int(_)) = value { + return Ok(true); + } } + + fct_validator::runtime_value_matches_type(value, declared_type).map_err(|e| { + EngineError::InputValidationFailed { + message: format!("invalid type expression '{}': {}", declared_type, e), + } + }) } fn resolve_variable_ref( diff --git a/crates/fct-parser/src/parser.rs b/crates/fct-parser/src/parser.rs index 6fe0473..1c9aeef 100644 --- a/crates/fct-parser/src/parser.rs +++ b/crates/fct-parser/src/parser.rs @@ -27,7 +27,9 @@ fn to_span(input: SpanInput) -> Span { } fn comment(input: SpanInput) -> ParseResult { - recognize(pair(char('#'), is_not("\n\r")))(input) + // Spec Appendix B: COMMENT = "#" *(%x20-10FFFF) NL + // The comment body is zero-or-more characters, so a bare `#` line is valid. + recognize(pair(char('#'), opt(is_not("\n\r"))))(input) } fn eol(input: SpanInput) -> ParseResult { @@ -2037,14 +2039,10 @@ fn parse_assertion_from_string( target: "output".to_string(), text: parts[2..].join(" ").trim_matches('"').to_string(), }, - "not" => { - if parts.len() >= 4 && parts[2] == "contains" { - fct_ast::AssertionKind::NotContains { - target: "output".to_string(), - text: parts[3..].join(" ").trim_matches('"').to_string(), - } - } else { - return None; + "not" if parts.len() >= 4 && parts[2] == "contains" => { + fct_ast::AssertionKind::NotContains { + target: "output".to_string(), + text: parts[3..].join(" ").trim_matches('"').to_string(), } } _ => return None, @@ -2081,16 +2079,10 @@ fn parse_assertion_from_string( return None; } } - "sentiment" => { - if parts.len() >= 2 { - fct_ast::AssertionKind::Sentiment { - target: "output".to_string(), - expected: parts[1].trim_matches('"').to_string(), - } - } else { - return None; - } - } + "sentiment" if parts.len() >= 2 => fct_ast::AssertionKind::Sentiment { + target: "output".to_string(), + expected: parts[1].trim_matches('"').to_string(), + }, _ => return None, } }; diff --git a/crates/fct-std/src/lenses/utility.rs b/crates/fct-std/src/lenses/utility.rs index a1e106a..dc293a0 100644 --- a/crates/fct-std/src/lenses/utility.rs +++ b/crates/fct-std/src/lenses/utility.rs @@ -45,36 +45,89 @@ impl Lens for DefaultLens { /// json(indent) - Format value as JSON pub struct JsonLens; +/// Convert a FACET value into plain JSON. +/// +/// Appendix A defines `json(indent: int = 0) -> string`, i.e. the JSON form of the +/// *value*. Serializing `ValueNode` directly would leak the compiler's internal +/// tagged representation (`{"kind":"Map","value":...}`) into rendered content. +fn value_node_to_json(value: &ValueNode) -> LensResult { + Ok(match value { + ValueNode::String(s) => serde_json::Value::String(s.clone()), + ValueNode::Scalar(ScalarValue::Null) => serde_json::Value::Null, + ValueNode::Scalar(ScalarValue::Bool(b)) => serde_json::Value::Bool(*b), + ValueNode::Scalar(ScalarValue::Int(i)) => serde_json::Value::Number((*i).into()), + ValueNode::Scalar(ScalarValue::Float(f)) => serde_json::Number::from_f64(*f) + .map(serde_json::Value::Number) + .ok_or_else(|| LensError::ExecutionError { + message: format!("json(): non-finite float cannot be serialized: {}", f), + })?, + ValueNode::List(items) => serde_json::Value::Array( + items + .iter() + .map(value_node_to_json) + .collect::>>()?, + ), + ValueNode::Map(map) => { + let mut obj = serde_json::Map::new(); + for (key, val) in map { + obj.insert(key.clone(), value_node_to_json(val)?); + } + serde_json::Value::Object(obj) + } + other => { + return Err(LensError::ExecutionError { + message: format!( + "json(): value is not fully evaluated and cannot be serialized: {:?}", + other + ), + }) + } + }) +} + +fn to_json_string(value: &serde_json::Value, indent: usize) -> LensResult { + if indent == 0 { + return serde_json::to_string(value).map_err(|e| LensError::ExecutionError { + message: format!("JSON serialization failed: {}", e), + }); + } + + let spaces = " ".repeat(indent); + let formatter = serde_json::ser::PrettyFormatter::with_indent(spaces.as_bytes()); + let mut buf = Vec::new(); + let mut ser = serde_json::Serializer::with_formatter(&mut buf, formatter); + serde::Serialize::serialize(value, &mut ser).map_err(|e| LensError::ExecutionError { + message: format!("JSON serialization failed: {}", e), + })?; + String::from_utf8(buf).map_err(|e| LensError::ExecutionError { + message: format!("JSON serialization produced invalid UTF-8: {}", e), + }) +} + impl Lens for JsonLens { fn execute( &self, input: ValueNode, args: Vec, - _kwargs: HashMap, + kwargs: HashMap, _ctx: &LensContext, ) -> LensResult { - // Get indent size (default None for compact) - let indent = if let Some(ValueNode::Scalar(ScalarValue::Int(n))) = args.first() { - Some(*n as usize) - } else { - None - }; - - let json_str = if let Some(indent_size) = indent { - serde_json::to_string_pretty(&input) - .map_err(|e| LensError::ExecutionError { - message: format!("JSON serialization failed: {}", e), - })? - .lines() - .map(|line| " ".repeat(indent_size) + line) - .collect::>() - .join("\n") - } else { - serde_json::to_string(&input).map_err(|e| LensError::ExecutionError { - message: format!("JSON serialization failed: {}", e), - })? + // `indent` may be positional or named; default 0 means compact output. + let indent_arg = kwargs.get("indent").or_else(|| args.first()); + let indent = match indent_arg { + None => 0, + Some(ValueNode::Scalar(ScalarValue::Int(n))) if *n >= 0 => *n as usize, + Some(other) => { + return Err(LensError::ArgumentError { + message: format!( + "json(indent=...) expects a non-negative int, got {:?}", + other + ), + }) + } }; + let json_str = to_json_string(&value_node_to_json(&input)?, indent)?; Ok(ValueNode::String(json_str)) } diff --git a/crates/fct-std/src/lib.rs b/crates/fct-std/src/lib.rs index 69799df..71262ab 100644 --- a/crates/fct-std/src/lib.rs +++ b/crates/fct-std/src/lib.rs @@ -474,14 +474,65 @@ mod tests { .execute(ValueNode::Map(map), vec![], HashMap::new(), &ctx) .unwrap(); - match result { - ValueNode::String(s) => { - assert!(s.contains("\"key\"")); - assert!(s.contains("\"value\"")); - assert!(s.contains("\"num\"")); - } - _ => panic!("Expected string"), - } + // Appendix A: json() renders the value itself, never the compiler's + // internal tagged ValueNode representation. + assert_eq!( + result, + ValueNode::String("{\"key\":\"value\",\"num\":42}".to_string()) + ); + } + + #[test] + fn test_json_lens_nested_and_indent() { + let lens = JsonLens; + let ctx = LensContext { + variables: HashMap::new(), + }; + + let mut map = fct_ast::OrderedMap::new(); + map.insert( + "items".to_string(), + ValueNode::List(vec![ + ValueNode::String("a".to_string()), + ValueNode::Scalar(ScalarValue::Bool(true)), + ValueNode::Scalar(ScalarValue::Null), + ]), + ); + + // `indent` is documented as a named parameter and must be honoured as one. + let mut kwargs = HashMap::new(); + kwargs.insert("indent".to_string(), ValueNode::Scalar(ScalarValue::Int(2))); + + let result = lens + .execute(ValueNode::Map(map), vec![], kwargs, &ctx) + .unwrap(); + + assert_eq!( + result, + ValueNode::String( + "{\n \"items\": [\n \"a\",\n true,\n null\n ]\n}".to_string() + ) + ); + } + + #[test] + fn test_json_lens_rejects_unevaluated_value() { + let lens = JsonLens; + let ctx = LensContext { + variables: HashMap::new(), + }; + + let result = lens.execute( + ValueNode::Variable("unresolved".to_string()), + vec![], + HashMap::new(), + &ctx, + ); + + assert!( + result.is_err(), + "json() must not serialize unresolved nodes" + ); } #[test] diff --git a/crates/fct-validator/src/checker.rs b/crates/fct-validator/src/checker.rs index 2f20833..45df8de 100644 --- a/crates/fct-validator/src/checker.rs +++ b/crates/fct-validator/src/checker.rs @@ -2245,6 +2245,17 @@ fn struct_field_type(struct_fields: &[crate::types::StructField], name: &str) -> .map(|field| field.field_type.clone()) } +/// Validate a fully evaluated value against an FTS type expression. +/// +/// This is the entry point used by the execution engine for runtime `@input` values +/// (F453). The value must already be evaluated — literals, lists and maps — which is +/// always the case for host-supplied inputs parsed from JSON. +pub fn runtime_value_matches_type(value: &ValueNode, type_str: &str) -> ValidationResult { + let expected = parse_type_expr(type_str)?; + let checker = TypeChecker::new(); + value_matches_expected_type(value, &expected, &checker) +} + fn parse_type_expr(type_str: &str) -> ValidationResult { let mut parser = TypeExprParser::new(type_str); let parsed = parser.parse_type_expr()?; diff --git a/crates/fct-validator/src/lib.rs b/crates/fct-validator/src/lib.rs index 52e45ab..f9404d8 100644 --- a/crates/fct-validator/src/lib.rs +++ b/crates/fct-validator/src/lib.rs @@ -68,7 +68,7 @@ pub mod errors; pub mod types; // Re-export public API -pub use checker::{TypeChecker, ValidationProfile}; +pub use checker::{runtime_value_matches_type, TypeChecker, ValidationProfile}; pub use constraints::TypeConstraints; pub use errors::{ValidationError, ValidationResult}; pub use types::{ diff --git a/src/commands/run.rs b/src/commands/run.rs index 49c867d..6aca75b 100644 --- a/src/commands/run.rs +++ b/src/commands/run.rs @@ -48,7 +48,6 @@ pub fn execute_run( } info!("Starting full pipeline for file: {:?}", input); - info!("Budget: {}, Context budget: {}", budget, context_budget); let (execution_mode, mode) = resolve_execution_mode(pure, exec)?; @@ -87,7 +86,12 @@ pub fn execute_run( } engine.execute(&mut exec_ctx)?; + // Report the budget actually used: `@context budget` wins over the host default. let effective_budget = effective_layout_budget(&resolved, budget); + info!( + "Layout budget: {} facet units (host default {}), gas limit: {}", + effective_budget, budget, context_budget + ); let lens_registry = LensRegistry::new(); let sections = doc_to_sections(&resolved, &exec_ctx.variables, &lens_registry)?; let box_model = TokenBoxModel::new(effective_budget); diff --git a/src/main.rs b/src/main.rs index b07c69e..1b855aa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,7 +9,8 @@ use clap::Parser; use commands::{Cli, Commands, DefaultRateLimiter}; use governor::{Quota, RateLimiter}; use nonzero_ext::nonzero; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use std::io::IsTerminal; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, Layer}; fn main() -> anyhow::Result<()> { // Parse command line arguments @@ -94,25 +95,29 @@ fn main() -> anyhow::Result<()> { /// Setup logging configuration based on CLI flags fn setup_logging(cli: &Cli) { - let subscriber = tracing_subscriber::fmt() - .with_max_level(tracing::Level::INFO) - .finish(); + // Diagnostics go to stderr: stdout carries machine-readable output + // (Canonical JSON from `run`, reports from `test`) and must stay pipeable. + let level = if cli.verbose { + tracing::Level::DEBUG + } else { + tracing::Level::INFO + }; if cli.json_logs { tracing_subscriber::registry() - .with(tracing_subscriber::fmt::layer().json()) + .with( + tracing_subscriber::fmt::layer() + .json() + .with_writer(std::io::stderr) + .with_filter(tracing_subscriber::filter::LevelFilter::from_level(level)), + ) .init(); } else { - subscriber.init(); - } - - if cli.verbose { - tracing::subscriber::set_global_default( - tracing_subscriber::fmt() - .with_max_level(tracing::Level::DEBUG) - .finish(), - ) - .unwrap(); + tracing_subscriber::fmt() + .with_max_level(level) + .with_writer(std::io::stderr) + .with_ansi(std::io::stderr().is_terminal()) + .init(); } } diff --git a/tests/spec_conformance_regressions.rs b/tests/spec_conformance_regressions.rs new file mode 100644 index 0000000..602a11f --- /dev/null +++ b/tests/spec_conformance_regressions.rs @@ -0,0 +1,210 @@ +//! Regressions for spec conformance gaps found while writing real contracts. +//! +//! Each test pins behaviour that the specification requires and that the +//! implementation previously got wrong. + +use fct_ast::{OrderedMap, ScalarValue, ValueNode}; +use fct_engine::{ExecutionContext, RDagEngine, Section, TokenBoxModel}; +use fct_parser::parse_document; +use fct_std::LensRegistry; +use fct_validator::TypeChecker; + +fn validate_source(source: &str) -> Result { + let doc = parse_document(source)?; + let mut checker = TypeChecker::new(); + checker.validate(&doc).map_err(|e| e.to_string())?; + Ok(doc) +} + +fn execute_with_input(source: &str, name: &str, value: ValueNode) -> Result<(), String> { + let doc = validate_source(source)?; + let mut engine = RDagEngine::new(); + engine.build(&doc).map_err(|e| e.to_string())?; + engine.validate().map_err(|e| e.to_string())?; + let mut ctx = ExecutionContext::new(10_000); + ctx.set_input(name.to_string(), value); + engine.execute(&mut ctx).map_err(|e| e.to_string()) +} + +fn input_doc(type_expr: &str) -> String { + format!("@vars\n v: @input(type=\"{type_expr}\")\n") +} + +// --------------------------------------------------------------------------- +// Appendix B: COMMENT = "#" *(%x20-10FFFF) NL — the body is zero-or-more chars +// --------------------------------------------------------------------------- + +#[test] +fn bare_hash_comment_line_parses() { + let source = "#\n@user\n content: \"hi\"\n"; + assert!( + parse_document(source).is_ok(), + "a comment line with an empty body must parse" + ); +} + +#[test] +fn bare_hash_comment_inside_body_parses() { + let source = "@vars\n a: \"x\"\n #\n b: \"y\"\n"; + assert!(parse_document(source).is_ok()); +} + +// --------------------------------------------------------------------------- +// §8 + §14.3: `@input` accepts every FTS type, not just primitives +// --------------------------------------------------------------------------- + +#[test] +fn input_accepts_list_type() { + let value = ValueNode::List(vec![ + ValueNode::String("a".to_string()), + ValueNode::String("b".to_string()), + ]); + assert!(execute_with_input(&input_doc("list"), "v", value).is_ok()); +} + +#[test] +fn input_accepts_map_type() { + let mut map = OrderedMap::new(); + map.insert("a".to_string(), ValueNode::Scalar(ScalarValue::Int(1))); + assert!(execute_with_input(&input_doc("map"), "v", ValueNode::Map(map)).is_ok()); +} + +#[test] +fn input_accepts_struct_type() { + let mut map = OrderedMap::new(); + map.insert("a".to_string(), ValueNode::Scalar(ScalarValue::Int(1))); + assert!(execute_with_input(&input_doc("struct { a: int }"), "v", ValueNode::Map(map)).is_ok()); +} + +#[test] +fn input_accepts_union_with_null() { + assert!(execute_with_input( + &input_doc("string | null"), + "v", + ValueNode::Scalar(ScalarValue::Null) + ) + .is_ok()); +} + +#[test] +fn input_still_rejects_wrong_element_type() { + let value = ValueNode::List(vec![ValueNode::Scalar(ScalarValue::Int(1))]); + let err = execute_with_input(&input_doc("list"), "v", value) + .expect_err("list must reject a list of ints"); + assert!(err.contains("F453"), "expected F453, got: {err}"); +} + +#[test] +fn input_still_rejects_missing_struct_field() { + let mut map = OrderedMap::new(); + map.insert("b".to_string(), ValueNode::Scalar(ScalarValue::Int(1))); + let err = execute_with_input(&input_doc("struct { a: int }"), "v", ValueNode::Map(map)) + .expect_err("missing required struct field must be rejected"); + assert!(err.contains("F453"), "expected F453, got: {err}"); +} + +#[test] +fn input_widens_int_to_float() { + assert!(execute_with_input( + &input_doc("float"), + "v", + ValueNode::Scalar(ScalarValue::Int(3)) + ) + .is_ok()); +} + +// --------------------------------------------------------------------------- +// §11.3: `min` is a floor on retained content, never a way to exceed the budget +// --------------------------------------------------------------------------- + +#[test] +fn min_larger_than_content_cannot_exceed_budget() { + let critical = Section::new( + "critical".to_string(), + ValueNode::String("C".repeat(100)), + 100, + ) + .with_limits(0, 0.0, 0.0); + + // `min` (400) is deliberately larger than the section itself (50). + let flexible = Section::new( + "flexible".to_string(), + ValueNode::String("F".repeat(50)), + 50, + ) + .with_priority(900) + .with_limits(400, 0.0, 1.0); + + let model = TokenBoxModel::new(120); + let result = model + .allocate(vec![critical, flexible], &LensRegistry::new()) + .expect("critical load fits, allocation must succeed"); + + assert!( + result.total_size <= 120, + "layout must stay within budget, got {} units", + result.total_size + ); +} + +#[test] +fn flexible_section_still_truncates_to_its_min() { + let critical = Section::new( + "critical".to_string(), + ValueNode::String("C".repeat(100)), + 100, + ) + .with_limits(0, 0.0, 0.0); + + let flexible = Section::new( + "flexible".to_string(), + ValueNode::String("F".repeat(50)), + 50, + ) + .with_priority(900) + .with_limits(0, 0.0, 1.0); + + let model = TokenBoxModel::new(120); + let result = model + .allocate(vec![critical, flexible], &LensRegistry::new()) + .expect("allocation must succeed"); + + assert_eq!(result.total_size, 120); + assert!( + result.sections.iter().any(|s| s.was_truncated), + "the flexible section should be truncated, not dropped, when min allows it" + ); +} + +// --------------------------------------------------------------------------- +// §20 / integration: stdout carries machine-readable output only +// --------------------------------------------------------------------------- + +#[test] +fn run_writes_only_canonical_json_to_stdout() { + use std::process::Command; + + let dir = std::env::temp_dir().join("facet_stdout_purity_test"); + std::fs::create_dir_all(&dir).expect("temp dir"); + let contract = dir.join("stdout_purity.facet"); + std::fs::write( + &contract, + "@context\n budget: 32000\n\n@user\n content: \"hi\"\n", + ) + .expect("write contract"); + + let output = Command::new(env!("CARGO_BIN_EXE_facet-fct")) + .args(["run", "--input"]) + .arg(&contract) + .args(["--exec", "--format", "json"]) + .output() + .expect("run facet-fct"); + + assert!(output.status.success(), "run must succeed"); + + let stdout = String::from_utf8(output.stdout).expect("utf-8 stdout"); + serde_json::from_str::(stdout.trim()) + .expect("stdout must be parseable as Canonical JSON with no log lines mixed in"); + + let _ = std::fs::remove_dir_all(&dir); +}