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