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