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