Skip to main content

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}