shep_core/config/flockfile.rs
1//! Flockfile: discovery and multi-format parsing
2//!
3//! One document shape across formats: a list of app tables under the `app`
4//! key (`[[app]]` in TOML). Parsing is strict serde — no code execution;
5//! `.js` configs are the CLI's job (it shells out to node and feeds the
6//! resulting JSON through [`FlockFormat::Json`]).
7
8use core::fmt;
9
10use std::path::{Path, PathBuf};
11
12#[cfg(feature = "schema")]
13use schemars::Schema;
14use serde::Deserialize;
15
16use crate::config::AppConfig;
17
18/// A parsed Flockfile: the declared flock
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct Flockfile {
21 /// App entries in declaration order
22 pub apps: Vec<AppConfig>,
23}
24
25// Forward-compat decision (Phase 1 final review): the top level is locked to
26// the `app` key on purpose — a typo'd key must fail loudly. A future schema
27// key (e.g. `version`) gets added HERE explicitly; older binaries then
28// reject newer Flockfiles by design instead of silently ignoring config.
29#[derive(Deserialize)]
30#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
31// `rename` sets `schema_name`, which schemars uses as the root schema's
32// `title`. The type is called `RawFlockfile` because it is the
33// pre-validation twin of `Flockfile`; the document an operator writes is a
34// Flockfile, and that is what the title has to say.
35#[cfg_attr(feature = "schema", schemars(rename = "Flockfile"))]
36#[serde(deny_unknown_fields)]
37struct RawFlockfile {
38 /// The editor's schema hint, read and discarded.
39 ///
40 /// This is the "future schema key" the comment above anticipated, added
41 /// HERE explicitly rather than by relaxing `deny_unknown_fields`: a
42 /// typo'd key must still fail loudly, and exactly one more key is now
43 /// legal. shep does not validate against the named schema and makes no
44 /// promise about it — it is a hint for the operator's editor, which is
45 /// the only consumer that ever reads it.
46 ///
47 /// TOML Flockfiles do not need it: taplo's `#:schema <url>` directive is
48 /// a comment, invisible to serde. JSON and JSON5 have no comment an
49 /// editor agrees to look in, which is why this field exists at all.
50 #[serde(default, rename = "$schema")]
51 schema: Option<String>,
52 #[serde(default, rename = "app")]
53 apps: Vec<AppConfig>,
54}
55
56/// The committed Flockfile JSON Schema.
57///
58/// `include_str!` deliberately: it makes the file a compile-time input, so
59/// deleting it fails the build and changing `AppConfig` fails the test
60/// below with the command that fixes it. A committed schema nobody
61/// regenerates is a lie with a filename, and the only reliable guard is one
62/// that runs in `cargo test` rather than in a CI job somebody can forget.
63///
64/// It lives INSIDE this package, not at the repository root. `cargo package`
65/// packs only files under the package directory, and shep-core and shep
66/// are both published (`docs/releasing.md`), so a root-relative
67/// `include_str!` would compile here and fail for everyone who runs
68/// `cargo install shep`.
69///
70/// Read only by `the_committed_schema_is_current` below, so a plain `cargo
71/// build`/`clippy` (no `#[cfg(test)]`) sees no reader and flags it dead.
72/// `#[allow(dead_code)]` says so explicitly rather than moving the
73/// `include_str!` into the test itself, which would trade away the one
74/// property this constant exists for: living outside `#[cfg(test)]` is what
75/// makes deleting the file fail every build, not just `cargo test`.
76#[cfg(feature = "schema")]
77pub const COMMITTED: &str = include_str!("../../assets/flockfile.schema.json");
78
79/// How to regenerate the committed copy. Named in the drift test's own
80/// failure message, so a red test is self-service.
81///
82/// Same `#[allow(dead_code)]` reasoning as [`COMMITTED`] just above: its one
83/// reader is that same test.
84#[cfg(feature = "schema")]
85#[allow(dead_code)]
86const REGENERATE: &str =
87 "cargo run --bin shep -- schema > crates/shep-core/assets/flockfile.schema.json";
88
89/// Renders the Flockfile JSON Schema: the document grammar, pretty-printed
90/// with a trailing newline so the committed file is a well-formed text file.
91///
92/// Generated from `RawFlockfile` — the type serde actually deserializes a
93/// Flockfile into — so the schema and the parser cannot drift: they are the
94/// same declaration. `AppConfig` supplies the per-app half and lands in
95/// `$defs`.
96///
97/// The schema describes the **deserializer**, not the normalizer.
98/// `AppConfig::kill_signal` is `Option<String>` here and stays a plain string
99/// in the schema, even though `config::normalize` accepts only four
100/// spellings: the schema's job is to describe what serde will parse, and a
101/// schema that described a validation step running elsewhere at another time
102/// would be wrong the moment those two diverged, in a way no test could
103/// catch.
104#[cfg(feature = "schema")]
105#[track_caller]
106#[must_use]
107pub fn flockfile_schema_string() -> String {
108 let schema = flockfile_schema_json();
109 let mut rendered =
110 serde_json::to_string_pretty(&schema).expect("a schemars Schema always serializes");
111 rendered.push('\n');
112 rendered
113}
114
115/// Returns the Flockfile JSON Schema.
116///
117/// Generated from `RawFlockfile` — the type serde actually deserializes a
118/// Flockfile into — so the schema and the parser cannot drift: they are the
119/// same declaration. `AppConfig` supplies the per-app half and lands in
120/// `$defs`.
121///
122/// The schema describes the **deserializer**, not the normalizer.
123/// `AppConfig::kill_signal` is `Option<String>` here and stays a plain string
124/// in the schema, even though `config::normalize` accepts only four
125/// spellings: the schema's job is to describe what serde will parse, and a
126/// schema that described a validation step running elsewhere at another time
127/// would be wrong the moment those two diverged, in a way no test could
128/// catch.
129///
130/// # Panics
131///
132/// Never in practice: schemars produces a `serde_json::Value` tree, which
133/// `to_string_pretty` cannot fail on. `#[track_caller]` so a future change
134/// that makes it fallible reports the caller (IR-24).
135#[cfg(feature = "schema")]
136#[track_caller]
137#[must_use]
138pub fn flockfile_schema_json() -> Schema {
139 schemars::schema_for!(RawFlockfile)
140}
141
142/// Input format of a Flockfile
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum FlockFormat {
145 /// `Flockfile.toml` — `[[app]]` tables
146 Toml,
147 /// `.yaml`/`.yml`
148 Yaml,
149 /// Strict JSON
150 Json,
151 /// JSON5 (comments, trailing commas)
152 Json5,
153}
154
155impl FlockFormat {
156 /// Maps a file extension to its format (`None` = unsupported, e.g. `.js`)
157 #[must_use]
158 pub fn from_path(path: &Path) -> Option<Self> {
159 match path.extension()?.to_str()? {
160 "toml" => Some(Self::Toml),
161 "yaml" | "yml" => Some(Self::Yaml),
162 "json" => Some(Self::Json),
163 "json5" => Some(Self::Json5),
164 _ => None,
165 }
166 }
167}
168
169impl Flockfile {
170 /// Parses Flockfile source text in the given format
171 ///
172 /// # Errors
173 ///
174 /// - Format variants ([`FlockfileError::Toml`] etc.) — backend parse
175 /// failure, carrying the backend's message. Json5 additionally rejects
176 /// sources nested past a depth of 64 before ever handing them to the
177 /// backend parser (json5's recursive-descent parser stack-overflows on
178 /// deeply nested input rather than returning an error).
179 /// - [`FlockfileError::NoApps`] — parsed fine but declared no apps.
180 pub fn parse(source: &str, format: FlockFormat) -> Result<Self, FlockfileError> {
181 let raw: RawFlockfile = match format {
182 FlockFormat::Toml => {
183 toml::from_str(source).map_err(|e| FlockfileError::Toml(e.to_string()))?
184 }
185 FlockFormat::Yaml => {
186 serde_saphyr::from_str(source).map_err(|e| FlockfileError::Yaml(e.to_string()))?
187 }
188 FlockFormat::Json => {
189 serde_json::from_str(source).map_err(|e| FlockfileError::Json(e.to_string()))?
190 }
191 FlockFormat::Json5 => {
192 if json5_nesting_depth(source) > MAX_JSON5_NESTING_DEPTH {
193 return Err(FlockfileError::Json5(
194 "nesting depth exceeds 64".to_string(),
195 ));
196 }
197 json5::from_str(source).map_err(|e| FlockfileError::Json5(e.to_string()))?
198 }
199 };
200 let RawFlockfile {
201 schema: _schema,
202 apps,
203 } = raw;
204 if apps.is_empty() {
205 return Err(FlockfileError::NoApps);
206 }
207 Ok(Self { apps })
208 }
209}
210
211// json5's recursive-descent parser stack-overflows (SIGABRT, not a catchable
212// error) on documents nested a few thousand levels deep — reproduced locally
213// around ~4500 levels. 64 is far beyond anything a real Flockfile needs (the
214// deepest legitimate nesting, a probe object inside an app object inside the
215// app array inside the root object, is 4) and comfortably clear of the crash
216// threshold.
217const MAX_JSON5_NESTING_DEPTH: u32 = 64;
218
219// Scans `source` for the maximum number of concurrently open `[`/`{`
220// brackets. Skips characters inside quoted strings (single or double,
221// backslash-escaped) and inside `//`/`/* */` comments, so bracket-like (and
222// quote-like) characters there don't distort the count — a `'` inside a `//
223// don't nest` comment must NOT be able to flip the scanner into string mode
224// and make it ignore real brackets that follow (that was exactly the bug in
225// the first version of this guard: it failed OPEN, letting an over-deep
226// document reach json5 and crash it).
227//
228// Fails CLOSED on anything that isn't clean, well-terminated JSON5 lexing:
229// an unterminated `/* ...` comment or an unterminated string at EOF returns
230// `u32::MAX`, which always exceeds `MAX_JSON5_NESTING_DEPTH` — better to
231// reject a malformed document than to under-count it and let it through.
232// Saturating add/sub: a real document would fail the depth check long
233// before `u32` could overflow.
234fn json5_nesting_depth(source: &str) -> u32 {
235 let mut depth: u32 = 0;
236 let mut max_depth: u32 = 0;
237 let mut in_string: Option<char> = None;
238 let mut chars = source.chars().peekable();
239 while let Some(c) = chars.next() {
240 if let Some(quote) = in_string {
241 match c {
242 '\\' => {
243 chars.next(); // skip the escaped character
244 }
245 q if q == quote => in_string = None,
246 _ => {}
247 }
248 continue;
249 }
250 match c {
251 '/' if chars.peek() == Some(&'/') => {
252 chars.next(); // consume the second '/'
253 for c2 in chars.by_ref() {
254 if c2 == '\n' {
255 break;
256 }
257 }
258 }
259 '/' if chars.peek() == Some(&'*') => {
260 chars.next(); // consume the '*'
261 let mut prev = '\0';
262 let mut closed = false;
263 for c2 in chars.by_ref() {
264 if prev == '*' && c2 == '/' {
265 closed = true;
266 break;
267 }
268 prev = c2;
269 }
270 if !closed {
271 return u32::MAX; // unterminated block comment
272 }
273 }
274 '"' | '\'' => in_string = Some(c),
275 '[' | '{' => {
276 depth = depth.saturating_add(1);
277 max_depth = max_depth.max(depth);
278 }
279 ']' | '}' => depth = depth.saturating_sub(1),
280 _ => {}
281 }
282 }
283 if in_string.is_some() {
284 return u32::MAX; // unterminated string
285 }
286 max_depth
287}
288
289const DISCOVERY_ORDER: [&str; 10] = [
290 "Flockfile.toml",
291 "Flockfile.yaml",
292 "Flockfile.yml",
293 "Flockfile.json",
294 "Flockfile.json5",
295 "flockfile.toml",
296 "flockfile.yaml",
297 "flockfile.yml",
298 "flockfile.json",
299 "flockfile.json5",
300];
301
302/// Finds the Flockfile in a directory (spec §5 ten-name order)
303#[must_use]
304pub fn discover(dir: &Path) -> Option<PathBuf> {
305 DISCOVERY_ORDER
306 .iter()
307 .map(|name| dir.join(name))
308 .find(|p| p.is_file())
309}
310
311/// Error type returned from [`Flockfile::parse`]
312///
313/// `#[non_exhaustive]`: shep-core is a library crate, so an out-of-tree
314/// consumer can match this exhaustively and a new variant would break them
315/// with no version bump to say so (IR-20). Growth is anticipated per
316/// backend, not per format: `.js` Flockfiles do NOT appear here, because
317/// shep-core never executes anything — the node bridge lives in shep-cli
318/// (`commands::lifecycle`) and feeds its output back through
319/// [`FlockFormat::Json`], which is what this module's own doc promises.
320#[non_exhaustive]
321#[derive(Debug, Clone, PartialEq, Eq)]
322pub enum FlockfileError {
323 /// TOML backend rejected the source (carries its message)
324 Toml(String),
325 /// YAML backend rejected the source
326 Yaml(String),
327 /// JSON backend rejected the source
328 Json(String),
329 /// JSON5 backend rejected the source
330 Json5(String),
331 /// The document parsed but declared no apps
332 NoApps,
333}
334
335impl fmt::Display for FlockfileError {
336 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
337 match self {
338 Self::Toml(m) => write!(f, "invalid TOML Flockfile: {m}"),
339 Self::Yaml(m) => write!(f, "invalid YAML Flockfile: {m}"),
340 Self::Json(m) => write!(f, "invalid JSON Flockfile: {m}"),
341 Self::Json5(m) => write!(f, "invalid JSON5 Flockfile: {m}"),
342 Self::NoApps => f.write_str("Flockfile declares no apps"),
343 }
344 }
345}
346
347impl core::error::Error for FlockfileError {}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 #[test]
354 fn toml_array_of_tables() {
355 let src = r#"
356[[app]]
357name = "web"
358script = "./srv"
359
360[[app]]
361name = "worker"
362script = "python3"
363args = ["job.py"]
364"#;
365 let flock = Flockfile::parse(src, FlockFormat::Toml).unwrap();
366 assert_eq!(flock.apps.len(), 2);
367 assert_eq!(flock.apps[1].name, "worker");
368 }
369
370 #[test]
371 fn json_and_json5_and_yaml() {
372 let json = r#"{ "app": [{ "name": "web", "script": "./srv" }] }"#;
373 assert_eq!(
374 Flockfile::parse(json, FlockFormat::Json)
375 .unwrap()
376 .apps
377 .len(),
378 1
379 );
380
381 let json5 = r#"{ app: [{ name: "web", script: "./srv" }], /* comment */ }"#;
382 assert_eq!(
383 Flockfile::parse(json5, FlockFormat::Json5)
384 .unwrap()
385 .apps
386 .len(),
387 1
388 );
389
390 let yaml = "app:\n - name: web\n script: ./srv\n";
391 assert_eq!(
392 Flockfile::parse(yaml, FlockFormat::Yaml)
393 .unwrap()
394 .apps
395 .len(),
396 1
397 );
398 }
399
400 #[test]
401 fn empty_app_list_is_an_error() {
402 assert_eq!(
403 Flockfile::parse("app: []\n", FlockFormat::Yaml).unwrap_err(),
404 FlockfileError::NoApps
405 );
406 }
407
408 #[test]
409 fn parse_errors_carry_the_backend_message() {
410 match Flockfile::parse("not toml [[", FlockFormat::Toml).unwrap_err() {
411 FlockfileError::Toml(msg) => assert!(!msg.is_empty()),
412 other => panic!("expected Toml error, got {other:?}"),
413 }
414 }
415
416 #[test]
417 fn format_from_path() {
418 use std::path::Path;
419 assert_eq!(
420 FlockFormat::from_path(Path::new("Flockfile.toml")),
421 Some(FlockFormat::Toml)
422 );
423 assert_eq!(
424 FlockFormat::from_path(Path::new("f.yml")),
425 Some(FlockFormat::Yaml)
426 );
427 assert_eq!(
428 FlockFormat::from_path(Path::new("f.json5")),
429 Some(FlockFormat::Json5)
430 );
431 assert_eq!(FlockFormat::from_path(Path::new("f.js")), None);
432 }
433
434 #[test]
435 fn discover_prefers_toml_then_capitalized() {
436 // tempdir gives RAII cleanup instead of a manual remove_dir_all, so
437 // a failing assertion above can't leak the directory.
438 let dir = tempfile::tempdir().unwrap();
439 std::fs::write(dir.path().join("flockfile.json"), "{}").unwrap();
440 std::fs::write(dir.path().join("Flockfile.yaml"), "").unwrap();
441 assert_eq!(
442 discover(dir.path()),
443 Some(dir.path().join("Flockfile.yaml"))
444 );
445 std::fs::write(dir.path().join("Flockfile.toml"), "").unwrap();
446 assert_eq!(
447 discover(dir.path()),
448 Some(dir.path().join("Flockfile.toml"))
449 );
450 }
451
452 /// fails if a `.js` name is ever added to the discovery order. Rin's
453 /// ruling, 2026-08-15: a `.js` Flockfile is read only when named
454 /// explicitly on the command line, because reading one runs node on it,
455 /// and `cd` into a cloned repo followed by `shep start` must not execute
456 /// a stranger's JavaScript. Discovery is the path with no operator in
457 /// the loop, so it is the path that must never reach node.
458 #[test]
459 fn discovery_never_names_a_js_file_and_stays_ten_names() {
460 assert_eq!(DISCOVERY_ORDER.len(), 10);
461 for name in DISCOVERY_ORDER {
462 assert!(
463 !name.ends_with(".js"),
464 "{name} would let `shep start` execute a repo's JavaScript"
465 );
466 assert!(FlockFormat::from_path(Path::new(name)).is_some());
467 }
468 }
469
470 #[test]
471 fn yaml_deep_nesting_is_rejected_without_crashing() {
472 // Adversarial probe locked in as a regression test (json5 taught us
473 // to distrust backends here): 5000-deep flow-style nesting must
474 // return Err from serde-saphyr, never overflow the stack.
475 let deep = "[".repeat(5000);
476 let result = Flockfile::parse(&deep, FlockFormat::Yaml);
477 assert!(matches!(result, Err(FlockfileError::Yaml(_))));
478 }
479
480 #[test]
481 fn yaml_alias_bomb_is_bounded() {
482 // Billion-laughs shape: each level aliases the previous twice. The
483 // backend must reject or resolve it bounded — this test completing
484 // quickly (and the doc failing schema-wise) is the assertion.
485 let mut bomb = String::from("a: &a [\"x\",\"x\"]\n");
486 for i in 1..9 {
487 bomb.push_str(&format!(
488 "{c}: &{c} [*{p},*{p}]\n",
489 c = (b'a' + i) as char,
490 p = (b'a' + i - 1) as char
491 ));
492 }
493 let result = Flockfile::parse(&bomb, FlockFormat::Yaml);
494 assert!(result.is_err(), "alias bomb must not produce a valid flock");
495 }
496
497 #[test]
498 fn json5_beyond_max_nesting_depth_is_rejected_without_crashing() {
499 // json5's backend parser stack-overflows (SIGABRT) around ~4500
500 // levels of nesting rather than returning an error — the depth
501 // guard must reject this before ever calling into it. 5000 unclosed
502 // `[` is nonsense JSON5, but the guard runs before any real parsing
503 // is attempted, so that's fine.
504 let src = "[".repeat(5000);
505 assert_eq!(
506 Flockfile::parse(&src, FlockFormat::Json5).unwrap_err(),
507 FlockfileError::Json5("nesting depth exceeds 64".to_string())
508 );
509 }
510
511 #[test]
512 fn json5_nesting_depth_counts_concurrently_open_brackets() {
513 let nested = format!("{}{}", "[".repeat(10), "]".repeat(10));
514 assert_eq!(json5_nesting_depth(&nested), 10);
515 }
516
517 #[test]
518 fn json5_nesting_depth_ignores_brackets_inside_strings() {
519 let src = r#"{ "a": "[[[[[[[[[[", "b": "esc\"aped [ too" }"#;
520 assert_eq!(json5_nesting_depth(src), 1); // only the outer `{`
521 }
522
523 #[test]
524 fn json5_legitimately_nested_doc_still_parses() {
525 // A probe object nested inside an app object inside the app array
526 // inside the root object — depth 4, the deepest a real Flockfile
527 // schema allows, and well under the depth-64 guard.
528 let src = r#"{
529 app: [{
530 name: "web",
531 script: "./srv",
532 readiness_probe: { kind: "http", target: "http://localhost/x" },
533 }],
534 }"#;
535 let flock = Flockfile::parse(src, FlockFormat::Json5).unwrap();
536 assert_eq!(flock.apps.len(), 1);
537 }
538
539 #[test]
540 fn json5_line_comment_apostrophe_does_not_hide_deep_nesting() {
541 // Regression: a `'` inside a `//` comment must not flip the scanner
542 // into string mode and make it ignore every bracket that follows —
543 // that would let an over-deep document slip past the guard straight
544 // into json5's stack overflow.
545 let src = format!("// don't nest\n{}", "[".repeat(5000));
546 assert_eq!(
547 Flockfile::parse(&src, FlockFormat::Json5).unwrap_err(),
548 FlockfileError::Json5("nesting depth exceeds 64".to_string())
549 );
550 }
551
552 #[test]
553 fn json5_block_comment_apostrophe_does_not_hide_deep_nesting() {
554 let src = format!("/* it's fine */\n{}", "[".repeat(5000));
555 assert_eq!(
556 Flockfile::parse(&src, FlockFormat::Json5).unwrap_err(),
557 FlockfileError::Json5("nesting depth exceeds 64".to_string())
558 );
559 }
560
561 #[test]
562 fn json5_benign_comment_does_not_undercount_a_real_document() {
563 // Same depth-4 document as `json5_legitimately_nested_doc_still_parses`,
564 // plus a comment (apostrophe included) that must be skipped cleanly
565 // rather than throwing off the count.
566 let src = r#"{
567 /* it's the app list */
568 app: [{
569 name: "web",
570 script: "./srv",
571 readiness_probe: { kind: "http", target: "http://localhost/x" },
572 }],
573 }"#;
574 let flock = Flockfile::parse(src, FlockFormat::Json5).unwrap();
575 assert_eq!(flock.apps.len(), 1);
576 }
577
578 /// Resolves a `$ref` into `$defs`, one hop, and returns the subschema.
579 /// Everything with a `schema_name` is referenced rather than inlined, so
580 /// an assertion that does not follow the ref is asserting about a
581 /// `{"$ref": …}` object and passes or fails for the wrong reason.
582 #[cfg(feature = "schema")]
583 fn resolved<'a>(
584 root: &'a serde_json::Value,
585 node: &'a serde_json::Value,
586 ) -> &'a serde_json::Value {
587 match node.get("$ref").and_then(serde_json::Value::as_str) {
588 Some(r) => {
589 let name = r
590 .strip_prefix("#/$defs/")
591 .expect("every $ref in this schema points into $defs");
592 &root["$defs"][name]
593 }
594 None => node,
595 }
596 }
597
598 /// fails whenever the Flockfile grammar changes and the committed schema
599 /// does not. That includes a doc-comment edit: schemars reads `///` into
600 /// `description`, which is the point — those become hover text in the
601 /// operator's editor — so a docs-only change is a real schema change and
602 /// regenerating is the correct response, not a sign anything broke.
603 #[cfg(feature = "schema")]
604 #[test]
605 fn the_committed_schema_is_current() {
606 assert_eq!(
607 flockfile_schema_string(),
608 COMMITTED,
609 "crates/shep-core/assets/flockfile.schema.json is stale. Regenerate it:\n {REGENERATE}\n\
610 A doc-comment edit on AppConfig counts; schemars puts doc comments \
611 into `description`."
612 );
613 }
614
615 /// fails if the artefact goes back to describing ONE APP. The document is
616 /// `{"app": [ … ]}`; a schema whose own `required` names `name` and
617 /// `script` is an AppConfig schema under a Flockfile filename, and every
618 /// real Flockfile would fail against it.
619 #[cfg(feature = "schema")]
620 #[test]
621 fn the_schema_describes_a_document_not_one_app() {
622 let schema: serde_json::Value = serde_json::from_str(&flockfile_schema_string()).unwrap();
623 assert!(schema["properties"]["app"].is_object(), "{schema}");
624 assert_eq!(schema["properties"]["app"]["type"], "array", "{schema}");
625 assert!(
626 schema["properties"]["name"].is_null(),
627 "root must not be an app: {schema}"
628 );
629 assert!(schema["$defs"]["AppConfig"].is_object(), "{schema}");
630 }
631
632 /// fails if the schema starts describing `normalize`'s grammar instead of
633 /// serde's. The four signal names belong to a validation step elsewhere;
634 /// a schema that listed them would be describing something it cannot see.
635 #[cfg(feature = "schema")]
636 #[test]
637 fn kill_signal_stays_an_unconstrained_string() {
638 let schema: serde_json::Value = serde_json::from_str(&flockfile_schema_string()).unwrap();
639 let field = resolved(
640 &schema,
641 &schema["$defs"]["AppConfig"]["properties"]["kill_signal"],
642 );
643 let types = field["type"]
644 .as_array()
645 .unwrap_or_else(|| panic!("kill_signal must carry a type array: {field}"));
646 assert!(
647 types.iter().any(|t| t == "string"),
648 "kill_signal must accept a string: {field}"
649 );
650 assert!(
651 field.get("enum").is_none(),
652 "kill_signal must not become an enum of the four signal names: {field}"
653 );
654 assert!(
655 field.get("pattern").is_none(),
656 "kill_signal must not become pattern-constrained: {field}"
657 );
658 }
659
660 /// fails if MemSize or UpDuration reverts to a derive and starts
661 /// describing its inner integer. Follows the `$ref` — the fields are
662 /// references into `$defs`, not inline schemas.
663 #[cfg(feature = "schema")]
664 #[test]
665 fn duration_and_memory_fields_are_string_shaped() {
666 let schema: serde_json::Value = serde_json::from_str(&flockfile_schema_string()).unwrap();
667 let app = &schema["$defs"]["AppConfig"]["properties"];
668
669 // `min_uptime: UpDuration` (not `Option`) is a bare `$ref`.
670 let min_uptime = resolved(&schema, &app["min_uptime"]);
671 assert_eq!(min_uptime["type"], "string", "{min_uptime}");
672 assert_eq!(min_uptime["pattern"], r"^\d+(h|m|s)?$", "{min_uptime}");
673
674 // `max_memory: Option<MemSize>` is a `$ref` under `anyOf` beside `"null"`.
675 let any_of = app["max_memory"]["anyOf"]
676 .as_array()
677 .unwrap_or_else(|| panic!("max_memory must be anyOf: {}", app["max_memory"]));
678 let ref_node = any_of
679 .iter()
680 .find(|v| v.get("$ref").is_some())
681 .unwrap_or_else(|| panic!("max_memory's anyOf must carry a $ref: {any_of:?}"));
682 let max_memory = resolved(&schema, ref_node);
683 assert_eq!(max_memory["type"], "string", "{max_memory}");
684 assert_eq!(max_memory["pattern"], r"^\d+(G|M|K)?$", "{max_memory}");
685 }
686
687 #[test]
688 fn a_schema_key_is_accepted_and_ignored() {
689 let src = r#"{ "$schema": "./flockfile.schema.json",
690 "app": [{ "name": "web", "script": "./srv" }] }"#;
691 let flock = Flockfile::parse(src, FlockFormat::Json).unwrap();
692 assert_eq!(flock.apps.len(), 1);
693 }
694
695 /// fails if the new field is implemented by relaxing
696 /// `deny_unknown_fields` instead of naming one more key — which would
697 /// silently accept every typo the document lock exists to catch.
698 #[test]
699 fn one_more_key_is_legal_and_no_others_are() {
700 let src = r#"{ "schema": "x", "app": [{ "name": "w", "script": "./s" }] }"#;
701 assert!(
702 matches!(
703 Flockfile::parse(src, FlockFormat::Json),
704 Err(FlockfileError::Json(_))
705 ),
706 "bare `schema` (no $) must still be an unknown field"
707 );
708 }
709
710 #[test]
711 fn a_toml_flockfile_takes_the_key_too() {
712 let src = "\"$schema\" = \"./flockfile.schema.json\"\n\
713 [[app]]\nname = \"web\"\nscript = \"./srv\"\n";
714 assert_eq!(
715 Flockfile::parse(src, FlockFormat::Toml).unwrap().apps.len(),
716 1
717 );
718 }
719}