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    /// - [`FlockfileError::UnknownKeys`]: named a key no field claims.
219    pub fn parse(source: &str, format: FlockFormat) -> Result<Self, FlockfileError> {
220        let raw = parse_raw_denying_unknown(source, format)?;
221        let RawFlockfile {
222            schema: _schema,
223            // Discarded by name. Whatever a dog wrote under `[dog]` is that
224            // dog's to read out of the file itself; shep only had to stop
225            // refusing the document for containing it.
226            dog: _dog,
227            apps,
228        } = raw;
229        if apps.is_empty() {
230            return Err(FlockfileError::NoApps);
231        }
232        Ok(Self { apps })
233    }
234
235    /// Parses `text` and reports, per app, which keys the document wrote.
236    ///
237    /// Runs the same per-format parse and validation [`Flockfile::parse`]
238    /// does, then separately deserializes the same source into a
239    /// [`serde_json::Value`] and reads each app table's keys off it:
240    /// `AppConfig`'s `#[serde(default)]` erases which keys a document
241    /// actually named, which is exactly what the value pass recovers.
242    ///
243    /// # Errors
244    /// Every error [`Flockfile::parse`] returns, for the same inputs.
245    pub fn parse_declared(
246        text: &str,
247        format: FlockFormat,
248    ) -> Result<Vec<DeclaredApp>, FlockfileError> {
249        // Same reasoning as `Flockfile::parse`: this reads a Flockfile off
250        // disk (the reload/muster path in shep-cli), not a value off the
251        // wire, so a typo here must still be loud.
252        let raw = parse_raw_denying_unknown(text, format)?;
253        let RawFlockfile {
254            schema: _schema,
255            dog: _dog,
256            apps,
257        } = raw;
258        if apps.is_empty() {
259            return Err(FlockfileError::NoApps);
260        }
261
262        // A document that reached this point already parsed successfully
263        // into `RawFlockfile` above, so the same source deserializing into a
264        // generic `Value` cannot fail for a reason the `RawFlockfile` pass
265        // would not already have caught.
266        let value = parse_into::<serde_json::Value>(text, format)?;
267        let tables: Vec<Option<&serde_json::Map<String, serde_json::Value>>> = value
268            .get("app")
269            .and_then(serde_json::Value::as_array)
270            .map(|apps| apps.iter().map(serde_json::Value::as_object).collect())
271            .unwrap_or_default();
272
273        Ok(apps
274            .into_iter()
275            .enumerate()
276            .map(|(index, config)| {
277                let table = tables.get(index).copied().flatten();
278                let declared = table
279                    .map(|t| t.keys().cloned().collect())
280                    .unwrap_or_default();
281                let declared_env = table
282                    .and_then(|t| t.get("env"))
283                    .and_then(serde_json::Value::as_object)
284                    .map(|e| e.keys().cloned().collect())
285                    .unwrap_or_default();
286                DeclaredApp {
287                    config,
288                    declared,
289                    declared_env,
290                }
291            })
292            .collect())
293    }
294}
295
296// Shared by `Flockfile::parse` and `parse_declared`: both read a Flockfile
297// off disk (never a value off the wire), so both refuse a typo the same
298// way. `deny_unknown_fields` used to live on `AppConfig` itself, which made
299// every new Flockfile field a protocol event: the same type rides the
300// wire, where an unknown field means a newer peer rather than a typo. The
301// denial belongs here instead.
302fn parse_raw_denying_unknown(
303    source: &str,
304    format: FlockFormat,
305) -> Result<RawFlockfile, FlockfileError> {
306    let mut unknown = Vec::new();
307    let raw: RawFlockfile = parse_into_ignoring(source, format, |path| {
308        // `dog` is a map of `IgnoredAny` by design (see its doc comment):
309        // shep does not read or validate what a dog wrote there, so
310        // serde_ignored's callback for a key inside it is not a typo, it
311        // is the field doing exactly what it is for.
312        if !path.starts_with("dog.") {
313            unknown.push(path.to_string());
314        }
315    })?;
316    if !unknown.is_empty() {
317        return Err(FlockfileError::UnknownKeys { keys: unknown });
318    }
319    Ok(raw)
320}
321
322// Its one caller deserializes into `serde_json::Value`, which claims every
323// key, so `parse_into_ignoring`'s callback never fires here; delegating
324// costs nothing behaviorally and drops a second four-arm format dispatch.
325fn parse_into<T: serde::de::DeserializeOwned>(
326    source: &str,
327    format: FlockFormat,
328) -> Result<T, FlockfileError> {
329    parse_into_ignoring(source, format, |_| {})
330}
331
332// The one four-arm format dispatch, shared by `parse_into` (an empty
333// `on_ignored`) and `parse_raw_denying_unknown` (a real one). Each format's
334// `Deserializer` routes through `serde_ignored::deserialize`, calling
335// `on_ignored` once per key the target type did not claim, recursing into
336// nested structs (a Flockfile's `readiness_probe`/`liveness_probe` tables
337// included). The real callback is used only by `Flockfile::parse` and
338// `parse_declared`: those are the two places a document really is a
339// hand-written file, where an unrecognized key means a typo. Everywhere else
340// the same `AppConfig`/`ProbeConfig` shape rides the wire, where it means a
341// newer peer instead, which is why the two types dropped
342// `deny_unknown_fields` rather than this function replacing it everywhere.
343fn parse_into_ignoring<T: serde::de::DeserializeOwned>(
344    source: &str,
345    format: FlockFormat,
346    mut on_ignored: impl FnMut(&str),
347) -> Result<T, FlockfileError> {
348    match format {
349        FlockFormat::Toml => serde_ignored::deserialize(toml::Deserializer::new(source), |path| {
350            on_ignored(&path.to_string());
351        })
352        .map_err(|e| FlockfileError::Toml(e.to_string())),
353        // Default options, so YAML 1.1 resolution stays on for the whole
354        // document: `autorestart: yes` is a bool, and an unquoted `yes` under
355        // `env` is the string "true". Quote an env value whose text matters.
356        FlockFormat::Yaml => serde_saphyr::with_deserializer_from_str(source, |de| {
357            serde_ignored::deserialize(de, |path| on_ignored(&path.to_string()))
358        })
359        .map_err(|e| FlockfileError::Yaml(e.to_string())),
360        FlockFormat::Json => {
361            let mut de = serde_json::Deserializer::from_str(source);
362            let value = serde_ignored::deserialize(&mut de, |path| on_ignored(&path.to_string()))
363                .map_err(|e| FlockfileError::Json(e.to_string()))?;
364            // `serde_json::from_str` checks this too, to catch trailing
365            // garbage after a value that otherwise parsed fine.
366            de.end().map_err(|e| FlockfileError::Json(e.to_string()))?;
367            Ok(value)
368        }
369        FlockFormat::Json5 => {
370            if json5_nesting_depth(source) > MAX_JSON5_NESTING_DEPTH {
371                return Err(FlockfileError::Json5(
372                    "nesting depth exceeds 64".to_string(),
373                ));
374            }
375            let mut de = json5::Deserializer::from_str(source)
376                .map_err(|e| FlockfileError::Json5(e.to_string()))?;
377            serde_ignored::deserialize(&mut de, |path| on_ignored(&path.to_string()))
378                .map_err(|e| FlockfileError::Json5(e.to_string()))
379        }
380    }
381}
382
383// json5's recursive-descent parser stack-overflows (SIGABRT, uncatchable)
384// around ~4500 levels of nesting. 64 is far beyond the deepest legitimate
385// Flockfile nesting (4) and comfortably clear of the crash threshold.
386const MAX_JSON5_NESTING_DEPTH: u32 = 64;
387
388// Scans for the maximum concurrently-open `[`/`{` depth, skipping quoted
389// strings and `//`/`/* */` comments so bracket-like characters inside
390// them don't distort the count. Fails closed: an unterminated string or
391// comment returns `u32::MAX`, which always exceeds the depth cap.
392fn json5_nesting_depth(source: &str) -> u32 {
393    let mut depth: u32 = 0;
394    let mut max_depth: u32 = 0;
395    let mut in_string: Option<char> = None;
396    let mut chars = source.chars().peekable();
397    while let Some(c) = chars.next() {
398        if let Some(quote) = in_string {
399            match c {
400                '\\' => {
401                    chars.next(); // skip the escaped character
402                }
403                q if q == quote => in_string = None,
404                _ => {}
405            }
406            continue;
407        }
408        match c {
409            '/' if chars.peek() == Some(&'/') => {
410                chars.next(); // consume the second '/'
411                for c2 in chars.by_ref() {
412                    if c2 == '\n' {
413                        break;
414                    }
415                }
416            }
417            '/' if chars.peek() == Some(&'*') => {
418                chars.next(); // consume the '*'
419                let mut prev = '\0';
420                let mut closed = false;
421                for c2 in chars.by_ref() {
422                    if prev == '*' && c2 == '/' {
423                        closed = true;
424                        break;
425                    }
426                    prev = c2;
427                }
428                if !closed {
429                    return u32::MAX; // unterminated block comment
430                }
431            }
432            '"' | '\'' => in_string = Some(c),
433            '[' | '{' => {
434                depth = depth.saturating_add(1);
435                max_depth = max_depth.max(depth);
436            }
437            ']' | '}' => depth = depth.saturating_sub(1),
438            _ => {}
439        }
440    }
441    if in_string.is_some() {
442        return u32::MAX; // unterminated string
443    }
444    max_depth
445}
446
447const DISCOVERY_ORDER: [&str; 10] = [
448    "Flockfile.toml",
449    "Flockfile.yaml",
450    "Flockfile.yml",
451    "Flockfile.json",
452    "Flockfile.json5",
453    "flockfile.toml",
454    "flockfile.yaml",
455    "flockfile.yml",
456    "flockfile.json",
457    "flockfile.json5",
458];
459
460/// Finds the Flockfile in a directory (spec §5 ten-name order)
461#[must_use]
462pub fn discover(dir: &Path) -> Option<PathBuf> {
463    DISCOVERY_ORDER
464        .iter()
465        .map(|name| dir.join(name))
466        .find(|p| p.is_file())
467}
468
469/// Error type returned from [`Flockfile::parse`].
470///
471/// `#[non_exhaustive]`: an out-of-tree consumer could otherwise match
472/// this exhaustively and a new variant would break them silently. Growth
473/// is anticipated per backend, not per format: `.js` Flockfiles never
474/// appear here, since shep-core never executes anything. The node bridge
475/// lives in shep-cli, which feeds its output back through
476/// [`FlockFormat::Json`].
477#[non_exhaustive]
478#[derive(Debug, Clone, PartialEq, Eq)]
479pub enum FlockfileError {
480    /// TOML backend rejected the source (carries its message)
481    Toml(String),
482    /// YAML backend rejected the source
483    Yaml(String),
484    /// JSON backend rejected the source
485    Json(String),
486    /// JSON5 backend rejected the source
487    Json5(String),
488    /// The document parsed but declared no apps
489    NoApps,
490    /// The document named one or more keys no field claims.
491    ///
492    /// A Flockfile is hand-written, unlike the same [`AppConfig`]/
493    /// [`ProbeConfig`](crate::config::ProbeConfig) shape riding the wire,
494    /// where an unknown field means a newer peer rather than a typo.
495    /// `keys` names every offending key (dotted path for a nested one, e.g.
496    /// `app.0.readiness_probe.<the misspelled key>`) so one refusal lists
497    /// every typo instead of one refusal per run.
498    UnknownKeys {
499        /// Every key the document named that no field claimed.
500        keys: Vec<String>,
501    },
502}
503
504impl fmt::Display for FlockfileError {
505    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
506        match self {
507            Self::Toml(m) => write!(f, "invalid TOML Flockfile: {m}"),
508            Self::Yaml(m) => write!(f, "invalid YAML Flockfile: {m}"),
509            Self::Json(m) => write!(f, "invalid JSON Flockfile: {m}"),
510            Self::Json5(m) => write!(f, "invalid JSON5 Flockfile: {m}"),
511            Self::NoApps => f.write_str("Flockfile declares no apps"),
512            Self::UnknownKeys { keys } => {
513                write!(f, "Flockfile names unrecognized key")?;
514                if keys.len() != 1 {
515                    f.write_str("s")?;
516                }
517                write!(f, ": {}", keys.join(", "))
518            }
519        }
520    }
521}
522
523impl core::error::Error for FlockfileError {}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    #[test]
530    fn toml_array_of_tables() {
531        let src = r#"
532[[app]]
533name = "web"
534script = "./srv"
535
536[[app]]
537name = "worker"
538script = "python3"
539args = ["job.py"]
540"#;
541        let flock = Flockfile::parse(src, FlockFormat::Toml).unwrap();
542        assert_eq!(flock.apps.len(), 2);
543        assert_eq!(flock.apps[1].name, "worker");
544    }
545
546    #[test]
547    fn json_and_json5_and_yaml() {
548        let json = r#"{ "app": [{ "name": "web", "script": "./srv" }] }"#;
549        assert_eq!(
550            Flockfile::parse(json, FlockFormat::Json)
551                .unwrap()
552                .apps
553                .len(),
554            1
555        );
556
557        let json5 = r#"{ app: [{ name: "web", script: "./srv" }], /* comment */ }"#;
558        assert_eq!(
559            Flockfile::parse(json5, FlockFormat::Json5)
560                .unwrap()
561                .apps
562                .len(),
563            1
564        );
565
566        let yaml = "app:\n  - name: web\n    script: ./srv\n";
567        assert_eq!(
568            Flockfile::parse(yaml, FlockFormat::Yaml)
569                .unwrap()
570                .apps
571                .len(),
572            1
573        );
574    }
575
576    #[test]
577    fn empty_app_list_is_an_error() {
578        assert_eq!(
579            Flockfile::parse("app: []\n", FlockFormat::Yaml).unwrap_err(),
580            FlockfileError::NoApps
581        );
582    }
583
584    #[test]
585    fn parse_errors_carry_the_backend_message() {
586        match Flockfile::parse("not toml [[", FlockFormat::Toml).unwrap_err() {
587            FlockfileError::Toml(msg) => assert!(!msg.is_empty()),
588            other => panic!("expected Toml error, got {other:?}"),
589        }
590    }
591
592    #[test]
593    fn format_from_path() {
594        use std::path::Path;
595        assert_eq!(
596            FlockFormat::from_path(Path::new("Flockfile.toml")),
597            Some(FlockFormat::Toml)
598        );
599        assert_eq!(
600            FlockFormat::from_path(Path::new("f.yml")),
601            Some(FlockFormat::Yaml)
602        );
603        assert_eq!(
604            FlockFormat::from_path(Path::new("f.json5")),
605            Some(FlockFormat::Json5)
606        );
607        assert_eq!(FlockFormat::from_path(Path::new("f.js")), None);
608    }
609
610    #[test]
611    fn discover_prefers_toml_then_capitalized() {
612        // tempdir gives RAII cleanup instead of a manual remove_dir_all, so
613        // a failing assertion above can't leak the directory.
614        let dir = tempfile::tempdir().unwrap();
615        std::fs::write(dir.path().join("flockfile.json"), "{}").unwrap();
616        std::fs::write(dir.path().join("Flockfile.yaml"), "").unwrap();
617        assert_eq!(
618            discover(dir.path()),
619            Some(dir.path().join("Flockfile.yaml"))
620        );
621        std::fs::write(dir.path().join("Flockfile.toml"), "").unwrap();
622        assert_eq!(
623            discover(dir.path()),
624            Some(dir.path().join("Flockfile.toml"))
625        );
626    }
627
628    /// fails if a `.js` name is ever added to the discovery order. Reading
629    /// one runs node on it, and discovery is the path with no operator in
630    /// the loop, so it must never reach node.
631    #[test]
632    fn discovery_never_names_a_js_file_and_stays_ten_names() {
633        assert_eq!(DISCOVERY_ORDER.len(), 10);
634        for name in DISCOVERY_ORDER {
635            assert!(
636                !name.ends_with(".js"),
637                "{name} would let `shep start` execute a repo's JavaScript"
638            );
639            assert!(FlockFormat::from_path(Path::new(name)).is_some());
640        }
641    }
642
643    #[test]
644    fn yaml_deep_nesting_is_rejected_without_crashing() {
645        // 5000-deep flow-style nesting must return Err from serde-saphyr,
646        // never overflow the stack.
647        let deep = "[".repeat(5000);
648        let result = Flockfile::parse(&deep, FlockFormat::Yaml);
649        assert!(matches!(result, Err(FlockfileError::Yaml(_))));
650    }
651
652    #[test]
653    fn yaml_alias_bomb_is_bounded() {
654        // Billion-laughs shape: each level aliases the previous twice. The
655        // backend must reject it or resolve it bounded; completing
656        // quickly is the assertion.
657        let mut bomb = String::from("a: &a [\"x\",\"x\"]\n");
658        for i in 1..9 {
659            bomb.push_str(&format!(
660                "{c}: &{c} [*{p},*{p}]\n",
661                c = (b'a' + i) as char,
662                p = (b'a' + i - 1) as char
663            ));
664        }
665        let result = Flockfile::parse(&bomb, FlockFormat::Yaml);
666        assert!(result.is_err(), "alias bomb must not produce a valid flock");
667    }
668
669    #[test]
670    fn json5_beyond_max_nesting_depth_is_rejected_without_crashing() {
671        // json5 stack-overflows (SIGABRT) around ~4500 levels, so the depth
672        // guard must reject this before calling into it. 5000 unclosed `[`
673        // is nonsense JSON5, but the guard runs before any real parsing.
674        let src = "[".repeat(5000);
675        assert_eq!(
676            Flockfile::parse(&src, FlockFormat::Json5).unwrap_err(),
677            FlockfileError::Json5("nesting depth exceeds 64".to_string())
678        );
679    }
680
681    #[test]
682    fn json5_nesting_depth_counts_concurrently_open_brackets() {
683        let nested = format!("{}{}", "[".repeat(10), "]".repeat(10));
684        assert_eq!(json5_nesting_depth(&nested), 10);
685    }
686
687    #[test]
688    fn json5_nesting_depth_ignores_brackets_inside_strings() {
689        let src = r#"{ "a": "[[[[[[[[[[", "b": "esc\"aped [ too" }"#;
690        assert_eq!(json5_nesting_depth(src), 1); // only the outer `{`
691    }
692
693    #[test]
694    fn json5_legitimately_nested_doc_still_parses() {
695        // A probe object nested inside an app object inside the app array
696        // inside the root object: depth 4, the deepest a real Flockfile
697        // schema allows, well under the depth-64 guard.
698        let src = r#"{
699            app: [{
700                name: "web",
701                script: "./srv",
702                readiness_probe: { kind: "http", target: "http://localhost/x" },
703            }],
704        }"#;
705        let flock = Flockfile::parse(src, FlockFormat::Json5).unwrap();
706        assert_eq!(flock.apps.len(), 1);
707    }
708
709    #[test]
710    fn json5_line_comment_apostrophe_does_not_hide_deep_nesting() {
711        // Regression: a `'` inside a `//` comment must not flip the scanner
712        // into string mode and make it ignore every bracket that follows,
713        // letting an over-deep document slip past the guard.
714        let src = format!("// don't nest\n{}", "[".repeat(5000));
715        assert_eq!(
716            Flockfile::parse(&src, FlockFormat::Json5).unwrap_err(),
717            FlockfileError::Json5("nesting depth exceeds 64".to_string())
718        );
719    }
720
721    #[test]
722    fn json5_block_comment_apostrophe_does_not_hide_deep_nesting() {
723        let src = format!("/* it's fine */\n{}", "[".repeat(5000));
724        assert_eq!(
725            Flockfile::parse(&src, FlockFormat::Json5).unwrap_err(),
726            FlockfileError::Json5("nesting depth exceeds 64".to_string())
727        );
728    }
729
730    #[test]
731    fn json5_benign_comment_does_not_undercount_a_real_document() {
732        // Same depth-4 document as `json5_legitimately_nested_doc_still_parses`,
733        // plus a comment (apostrophe included) that must be skipped cleanly
734        // rather than throwing off the count.
735        let src = r#"{
736            /* it's the app list */
737            app: [{
738                name: "web",
739                script: "./srv",
740                readiness_probe: { kind: "http", target: "http://localhost/x" },
741            }],
742        }"#;
743        let flock = Flockfile::parse(src, FlockFormat::Json5).unwrap();
744        assert_eq!(flock.apps.len(), 1);
745    }
746
747    /// Resolves a `$ref` into `$defs`, one hop, and returns the subschema.
748    /// Everything with a `schema_name` is referenced rather than inlined, so
749    /// an assertion that does not follow the ref is asserting about a
750    /// `{"$ref": …}` object and passes or fails for the wrong reason.
751    #[cfg(feature = "schema")]
752    fn resolved<'a>(
753        root: &'a serde_json::Value,
754        node: &'a serde_json::Value,
755    ) -> &'a serde_json::Value {
756        match node.get("$ref").and_then(serde_json::Value::as_str) {
757            Some(r) => {
758                let name = r
759                    .strip_prefix("#/$defs/")
760                    .expect("every $ref in this schema points into $defs");
761                &root["$defs"][name]
762            }
763            None => node,
764        }
765    }
766
767    /// fails whenever the Flockfile grammar changes and the committed schema
768    /// does not. That includes a doc-comment edit: schemars reads `///` into
769    /// `description`, which becomes hover text in the operator's editor, so
770    /// a docs-only change is a real schema change, and regenerating is the
771    /// correct response, not a sign anything broke.
772    #[cfg(feature = "schema")]
773    #[test]
774    fn the_committed_schema_is_current() {
775        assert_eq!(
776            flockfile_schema_string(),
777            COMMITTED,
778            "crates/shep-core/assets/flockfile.schema.json is stale. Regenerate it:\n    {REGENERATE}\n\
779             A doc-comment edit on AppConfig counts; schemars puts doc comments \
780             into `description`."
781        );
782    }
783
784    /// fails if the artefact goes back to describing one app. The document is
785    /// `{"app": [ … ]}`; a schema whose own `required` names `name` and
786    /// `script` is an AppConfig schema under a Flockfile filename, and every
787    /// real Flockfile would fail against it.
788    #[cfg(feature = "schema")]
789    #[test]
790    fn the_schema_describes_a_document_not_one_app() {
791        let schema: serde_json::Value = serde_json::from_str(&flockfile_schema_string()).unwrap();
792        assert!(schema["properties"]["app"].is_object(), "{schema}");
793        assert_eq!(schema["properties"]["app"]["type"], "array", "{schema}");
794        assert!(
795            schema["properties"]["name"].is_null(),
796            "root must not be an app: {schema}"
797        );
798        assert!(schema["$defs"]["AppConfig"].is_object(), "{schema}");
799    }
800
801    /// fails if the schema starts describing `normalize`'s grammar instead of
802    /// serde's. The four signal names belong to a validation step elsewhere;
803    /// a schema that listed them would be describing something it cannot see.
804    #[cfg(feature = "schema")]
805    #[test]
806    fn kill_signal_stays_an_unconstrained_string() {
807        let schema: serde_json::Value = serde_json::from_str(&flockfile_schema_string()).unwrap();
808        let field = resolved(
809            &schema,
810            &schema["$defs"]["AppConfig"]["properties"]["kill_signal"],
811        );
812        let types = field["type"]
813            .as_array()
814            .unwrap_or_else(|| panic!("kill_signal must carry a type array: {field}"));
815        assert!(
816            types.iter().any(|t| t == "string"),
817            "kill_signal must accept a string: {field}"
818        );
819        assert!(
820            field.get("enum").is_none(),
821            "kill_signal must not become an enum of the four signal names: {field}"
822        );
823        assert!(
824            field.get("pattern").is_none(),
825            "kill_signal must not become pattern-constrained: {field}"
826        );
827    }
828
829    /// fails if MemSize or UpDuration reverts to a derive and starts
830    /// describing its inner integer. Follows the `$ref`: the fields are
831    /// references into `$defs`, not inline schemas.
832    #[cfg(feature = "schema")]
833    #[test]
834    fn duration_and_memory_fields_are_string_shaped() {
835        let schema: serde_json::Value = serde_json::from_str(&flockfile_schema_string()).unwrap();
836        let app = &schema["$defs"]["AppConfig"]["properties"];
837
838        // `min_uptime: UpDuration` (not `Option`) is a bare `$ref`.
839        let min_uptime = resolved(&schema, &app["min_uptime"]);
840        assert_eq!(min_uptime["type"], "string", "{min_uptime}");
841        assert_eq!(min_uptime["pattern"], r"^\d+(ms|h|m|s)?$", "{min_uptime}");
842
843        // `max_memory: Option<MemSize>` is a `$ref` under `anyOf` beside `"null"`.
844        let any_of = app["max_memory"]["anyOf"]
845            .as_array()
846            .unwrap_or_else(|| panic!("max_memory must be anyOf: {}", app["max_memory"]));
847        let ref_node = any_of
848            .iter()
849            .find(|v| v.get("$ref").is_some())
850            .unwrap_or_else(|| panic!("max_memory's anyOf must carry a $ref: {any_of:?}"));
851        let max_memory = resolved(&schema, ref_node);
852        assert_eq!(max_memory["type"], "string", "{max_memory}");
853        assert_eq!(max_memory["pattern"], r"^\d+(G|M|K)?$", "{max_memory}");
854    }
855
856    /// fails if a Flockfile carrying a dog's own configuration is refused.
857    ///
858    /// A dog with per-app configuration has nowhere else to put it: it
859    /// belongs beside the app's declaration, in the repository the dog
860    /// deploys.
861    ///
862    /// The contents are not validated: shep does not know what a dog's
863    /// keys mean, and refusing a document for not recognising another
864    /// program's config is a coupling neither side wants.
865    #[test]
866    fn a_dog_table_is_accepted_and_ignored() {
867        let src = r#"
868[dog.deploy]
869command = "npm run build"
870artifacts = ["dist/app.js"]
871
872[dog.some-other-dog]
873anything = { nested = true, count = 3 }
874
875[[app]]
876name = "web"
877script = "./srv"
878"#;
879        let flock =
880            Flockfile::parse(src, FlockFormat::Toml).expect("a dog's table is not an error");
881        assert_eq!(flock.apps.len(), 1);
882        assert_eq!(flock.apps[0].name, "web");
883    }
884
885    /// fails if `dog` accepts something that is not a table.
886    ///
887    /// Not reading what a dog wrote does not mean not caring whether it
888    /// wrote a table: that would make this the one key where a typo does
889    /// not fail loudly.
890    #[test]
891    fn a_dog_that_is_not_a_table_is_refused() {
892        for value in ["5", "\"nope\"", "[1, 2]", "true"] {
893            let src = format!("dog = {value}\n\n[[app]]\nname = \"web\"\nscript = \"./srv\"\n");
894            assert!(
895                Flockfile::parse(&src, FlockFormat::Toml).is_err(),
896                "`dog = {value}` is not a table and must be refused"
897            );
898        }
899    }
900
901    /// fails if a typo anywhere else stops failing loudly.
902    ///
903    /// Exactly one more key is legal, which is the whole reason the table is
904    /// nested under one name rather than allowing loose top-level keys.
905    #[test]
906    fn a_key_that_is_not_dog_still_fails() {
907        let src = r#"
908[build]
909command = "npm run build"
910
911[[app]]
912name = "web"
913script = "./srv"
914"#;
915        let err = Flockfile::parse(src, FlockFormat::Toml)
916            .expect_err("an unknown top-level key must still be refused");
917        assert!(
918            format!("{err}").contains("build"),
919            "the refusal must name the key: {err}"
920        );
921    }
922
923    #[test]
924    fn a_schema_key_is_accepted_and_ignored() {
925        let src = r#"{ "$schema": "./flockfile.schema.json",
926                       "app": [{ "name": "web", "script": "./srv" }] }"#;
927        let flock = Flockfile::parse(src, FlockFormat::Json).unwrap();
928        assert_eq!(flock.apps.len(), 1);
929    }
930
931    /// fails if the new field is implemented by relaxing
932    /// `deny_unknown_fields` instead of naming one more key, which would
933    /// silently accept every typo the document lock exists to catch.
934    #[test]
935    fn one_more_key_is_legal_and_no_others_are() {
936        let src = r#"{ "schema": "x", "app": [{ "name": "w", "script": "./s" }] }"#;
937        assert!(
938            matches!(
939                Flockfile::parse(src, FlockFormat::Json),
940                Err(FlockfileError::Json(_))
941            ),
942            "bare `schema` (no $) must still be an unknown field"
943        );
944    }
945
946    #[test]
947    fn a_toml_flockfile_takes_the_key_too() {
948        let src = "\"$schema\" = \"./flockfile.schema.json\"\n\
949                   [[app]]\nname = \"web\"\nscript = \"./srv\"\n";
950        assert_eq!(
951            Flockfile::parse(src, FlockFormat::Toml).unwrap().apps.len(),
952            1
953        );
954    }
955
956    /// fails if the declared key set is inferred from values rather than read
957    /// from the document. `autorestart = true` is also the default, so a
958    /// parser that reports "fields that differ from Default" would miss it,
959    /// and a later file load would then overwrite an operator who had
960    /// turned it off.
961    #[test]
962    fn declared_reports_keys_the_document_wrote_even_at_their_default() {
963        let text = r#"
964[[app]]
965name = "web"
966script = "./srv"
967autorestart = true
968"#;
969        let apps = Flockfile::parse_declared(text, FlockFormat::Toml).unwrap();
970        assert_eq!(apps.len(), 1);
971        let declared = &apps[0].declared;
972        assert!(declared.contains("autorestart"), "declared: {declared:?}");
973        assert!(declared.contains("name"));
974        assert!(declared.contains("script"));
975        assert!(
976            !declared.contains("max_memory"),
977            "a key nobody wrote is not declared"
978        );
979        assert_eq!(declared.len(), 3);
980    }
981
982    /// fails if env keys are not reported separately. `env` is the only map
983    /// of user-supplied keys in `AppConfig`, so the merge treats it one
984    /// level deeper than every other field.
985    #[test]
986    fn declared_env_reports_the_keys_inside_the_env_table() {
987        let text = r#"
988[[app]]
989name = "web"
990script = "./srv"
991env = { DB_HOST = "", NODE_ENV = "production" }
992"#;
993        let apps = Flockfile::parse_declared(text, FlockFormat::Toml).unwrap();
994        assert_eq!(
995            apps[0].declared_env.iter().collect::<Vec<_>>(),
996            vec!["DB_HOST", "NODE_ENV"]
997        );
998        assert!(apps[0].declared.contains("env"));
999    }
1000
1001    /// A raw boolean or number anywhere under `env` reads as its string form,
1002    /// in every format. This is the format-dispatch half of the coercion:
1003    /// the `deserialize_with` on `AppConfig::env` is the one code path, so a
1004    /// raw scalar must land at `config.env` through TOML, YAML, JSON, and
1005    /// JSON5 without a format-specific branch.
1006    #[test]
1007    fn a_raw_scalar_env_value_is_a_string_in_every_format() {
1008        let cases: [(FlockFormat, &str); 4] = [
1009            (
1010                FlockFormat::Toml,
1011                "[[app]]\nname = \"web\"\nscript = \"./srv\"\nenv = { SOME_BOOL = true, PORT = 8080 }\n",
1012            ),
1013            (
1014                FlockFormat::Yaml,
1015                "app:\n  - name: web\n    script: ./srv\n    env:\n      SOME_BOOL: true\n      PORT: 8080\n",
1016            ),
1017            (
1018                FlockFormat::Json,
1019                r#"{"app":[{"name":"web","script":"./srv","env":{"SOME_BOOL":true,"PORT":8080}}]}"#,
1020            ),
1021            (
1022                FlockFormat::Json5,
1023                "{ app: [{ name: \"web\", script: \"./srv\", env: { SOME_BOOL: true, PORT: 8080 } }] }",
1024            ),
1025        ];
1026        for (format, text) in cases {
1027            let flock = Flockfile::parse(text, format)
1028                .unwrap_or_else(|e| panic!("{format:?} refused a raw scalar env value: {e}"));
1029            let env = &flock.apps[0].env;
1030            assert_eq!(
1031                env.get("SOME_BOOL").map(String::as_str),
1032                Some("true"),
1033                "{format:?}: SOME_BOOL"
1034            );
1035            assert_eq!(
1036                env.get("PORT").map(String::as_str),
1037                Some("8080"),
1038                "{format:?}: PORT"
1039            );
1040        }
1041    }
1042
1043    /// YAML resolves a bare scalar before serde sees it, so an unquoted `yes`
1044    /// under `env` arrives as the string "true" and `0x1F` as "31". That is
1045    /// the cost of the same resolution making `autorestart: yes` a bool, and
1046    /// quoting is the operator's way out. Pinned so it cannot drift unseen.
1047    #[test]
1048    fn yaml_resolves_a_bare_env_scalar_before_serde_sees_it() {
1049        let text = "app:\n  - name: web\n    script: ./srv\n    autorestart: yes\n    env:\n      BARE: yes\n      HEX: 0x1F\n      QUOTED: 'yes'\n";
1050        let flock = Flockfile::parse(text, FlockFormat::Yaml).unwrap();
1051        let app = &flock.apps[0];
1052        assert!(app.autorestart, "`autorestart: yes` must stay a bool");
1053        assert_eq!(app.env.get("BARE").map(String::as_str), Some("true"));
1054        assert_eq!(app.env.get("HEX").map(String::as_str), Some("31"));
1055        assert_eq!(app.env.get("QUOTED").map(String::as_str), Some("yes"));
1056    }
1057
1058    /// A typo in a Flockfile must still be loud. This is the whole reason
1059    /// `deny_unknown_fields` was there.
1060    #[test]
1061    fn a_misspelled_flockfile_key_is_refused_and_named() {
1062        let err = Flockfile::parse(
1063            "[[app]]\nname = \"web\"\nscript = \"./srv\"\nmax_restrts = 5\n",
1064            FlockFormat::Toml,
1065        )
1066        .expect_err("a typo must be refused");
1067        let FlockfileError::UnknownKeys { keys } = err else {
1068            panic!("expected UnknownKeys, got {err:?}");
1069        };
1070        assert!(
1071            keys.iter().any(|k| k.contains("max_restrts")),
1072            "got {keys:?}"
1073        );
1074    }
1075
1076    /// Nesting is why this uses serde_ignored rather than a key list.
1077    ///
1078    /// `kind`/`target` are supplied alongside the typo: both are required by
1079    /// `ProbeConfig` with no default, and omitting them would surface a
1080    /// missing-field error instead of the unknown-key one this test means to
1081    /// exercise.
1082    #[test]
1083    fn a_misspelled_key_inside_a_probe_is_also_named() {
1084        let err = Flockfile::parse(
1085            "[[app]]\nname = \"web\"\nscript = \"./srv\"\n[app.readiness_probe]\nkind = \"http\"\ntarget = \"http://localhost/x\"\ntimeuot = \"5s\"\n",
1086            FlockFormat::Toml,
1087        )
1088        .expect_err("a nested typo must be refused");
1089        let FlockfileError::UnknownKeys { keys } = err else {
1090            panic!("expected UnknownKeys, got {err:?}");
1091        };
1092        // The variant alone would pass on any path at all, including one
1093        // from a different app. Nesting is the reason this parse goes
1094        // through `serde_ignored` rather than a flat key list, so the
1095        // path is the thing worth asserting.
1096        assert!(
1097            keys.iter()
1098                .any(|key| key.contains("readiness_probe") && key.contains("timeuot")),
1099            "the nested path should name both the probe and the key: {keys:?}"
1100        );
1101    }
1102
1103    /// The design goal is one refusal naming every typo, not one refusal
1104    /// per run. Two misspelled keys in one document must both show up in
1105    /// the single [`FlockfileError::UnknownKeys`] this produces.
1106    #[test]
1107    fn two_misspelled_keys_are_both_named_in_one_error() {
1108        let err = Flockfile::parse(
1109            "[[app]]\nname = \"web\"\nscript = \"./srv\"\nmax_restrts = 5\ninstences = 2\n",
1110            FlockFormat::Toml,
1111        )
1112        .expect_err("both typos must be refused");
1113        let FlockfileError::UnknownKeys { keys } = err else {
1114            panic!("expected UnknownKeys, got {err:?}");
1115        };
1116        assert!(
1117            keys.iter().any(|k| k.contains("max_restrts")),
1118            "got {keys:?}"
1119        );
1120        assert!(keys.iter().any(|k| k.contains("instences")), "got {keys:?}");
1121    }
1122
1123    /// fails if a format other than TOML loses the unknown-key refusal.
1124    /// `parse_into_ignoring` dispatches over all four; a regression here
1125    /// means a format bypassed `serde_ignored` on the way through.
1126    #[test]
1127    fn a_misspelled_key_is_refused_in_every_parse_format() {
1128        let cases: [(FlockFormat, &str); 4] = [
1129            (
1130                FlockFormat::Toml,
1131                "[[app]]\nname = \"web\"\nscript = \"./srv\"\nmax_restrts = 5\n",
1132            ),
1133            (
1134                FlockFormat::Yaml,
1135                "app:\n  - name: web\n    script: ./srv\n    max_restrts: 5\n",
1136            ),
1137            (
1138                FlockFormat::Json,
1139                r#"{"app":[{"name":"web","script":"./srv","max_restrts":5}]}"#,
1140            ),
1141            (
1142                FlockFormat::Json5,
1143                "{ app: [{ name: \"web\", script: \"./srv\", max_restrts: 5 }] }",
1144            ),
1145        ];
1146        for (format, text) in cases {
1147            let err = Flockfile::parse(text, format).expect_err("a typo must be refused");
1148            assert!(
1149                matches!(err, FlockfileError::UnknownKeys { .. }),
1150                "{format:?}: got {err:?}"
1151            );
1152        }
1153    }
1154
1155    /// fails if a format other than TOML loses the key set. All four go
1156    /// through one generic intermediate, so a regression here means the
1157    /// intermediate was bypassed for a format.
1158    #[test]
1159    fn declared_survives_every_parse_format() {
1160        let cases: [(FlockFormat, &str); 4] = [
1161            (
1162                FlockFormat::Toml,
1163                "[[app]]\nname = \"web\"\nscript = \"./srv\"\nautorestart = true\n",
1164            ),
1165            (
1166                FlockFormat::Yaml,
1167                "app:\n  - name: web\n    script: ./srv\n    autorestart: true\n",
1168            ),
1169            (
1170                FlockFormat::Json,
1171                r#"{"app":[{"name":"web","script":"./srv","autorestart":true}]}"#,
1172            ),
1173            (
1174                FlockFormat::Json5,
1175                "{ app: [{ name: \"web\", script: \"./srv\", autorestart: true }] }",
1176            ),
1177        ];
1178        for (format, text) in cases {
1179            let apps = Flockfile::parse_declared(text, format)
1180                .unwrap_or_else(|e| panic!("{format:?} failed to parse: {e}"));
1181            assert!(
1182                apps[0].declared.contains("autorestart"),
1183                "{format:?}: declared {:?}",
1184                apps[0].declared
1185            );
1186        }
1187    }
1188}