orion-server 1.5.1

Turn business logic into live REST/Kafka services, declared as JSON
//! `orion-server fmt`: one canonical layout for every definition file.
//!
//! The formatter has one style and no configuration — see [`style`] for the
//! numbers and the key tables, [`roles`] for how a node is recognised, and
//! [`printer`] for the layout itself. This module is the entry point and,
//! more importantly, the **guards**: a formatter that runs unattended in a
//! pre-commit hook must never change what a document means, so
//! [`format_str`] re-parses its own output and compares it with the input as
//! `serde_json::Value` — the runtime's view — before anything is returned.
//! A mismatch is a formatter bug, and it is reported as one rather than
//! written.
//!
//! What the formatter changes: whitespace, string escapes (re-emitted
//! canonically), and the order of *known* keys in *known* object shapes.
//! What it never changes: values, number spellings, array order, or the
//! order of keys it does not recognise.

pub mod printer;
pub mod roles;
pub mod style;

use crate::definitions::json::{Document, ParseError};

/// What formatting one document produced.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
    /// Already in the house style, byte for byte with what was read — a file
    /// that differs only in a BOM or its line endings is `Changed`.
    Unchanged,
    Changed(String),
}

#[derive(Debug)]
pub enum FmtError {
    /// Not a JSON document. Reported with `line, column`.
    Parse(ParseError),
    /// The formatted text does not convert to the same value as the input.
    /// A bug in this module; the file must not be written.
    RoundTrip { origin: String },
    /// Formatting the output again changed it. Also a bug; checked in debug
    /// builds and tests, where the cost of a second pass is irrelevant.
    NotIdempotent { origin: String },
}

impl std::fmt::Display for FmtError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            FmtError::Parse(e) => write!(f, "{e}"),
            FmtError::RoundTrip { origin } => write!(
                f,
                "formatter bug: the formatted output of '{origin}' does not parse to the same \
                 document — nothing was written; please report this with the file"
            ),
            FmtError::NotIdempotent { origin } => write!(
                f,
                "formatter bug: formatting '{origin}' twice gives two different results — \
                 nothing was written; please report this with the file"
            ),
        }
    }
}

impl std::error::Error for FmtError {}

impl From<ParseError> for FmtError {
    fn from(e: ParseError) -> Self {
        FmtError::Parse(e)
    }
}

/// Format one document. `origin` names it in an error.
pub fn format_str(text: &str, origin: &str) -> Result<Outcome, FmtError> {
    let doc = Document::parse(text)?;
    let formatted = format_document(&doc);

    // The guard. One extra parse of a small file, every time, in every
    // build: this is the line between "the formatter has a bug" and "the
    // formatter corrupted a workflow".
    let reparsed = Document::parse(&formatted).map_err(|_| FmtError::RoundTrip {
        origin: origin.to_string(),
    })?;
    if reparsed.to_value() != doc.to_value() {
        return Err(FmtError::RoundTrip {
            origin: origin.to_string(),
        });
    }
    if cfg!(any(test, debug_assertions)) && format_document(&reparsed) != formatted {
        return Err(FmtError::NotIdempotent {
            origin: origin.to_string(),
        });
    }

    // Byte-for-byte against what was read, so `--check` is exact about BOMs
    // and line endings — they are part of what `fmt` fixes.
    if text == formatted {
        Ok(Outcome::Unchanged)
    } else {
        Ok(Outcome::Changed(formatted))
    }
}

/// Lay out an already-parsed document. No guard: [`format_str`] is the
/// entry point that promises correctness.
pub fn format_document(doc: &Document) -> String {
    printer::print(&doc.root.node)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn fmt(text: &str) -> String {
        match format_str(text, "test").expect("test input is valid") {
            Outcome::Unchanged => text.to_string(),
            Outcome::Changed(s) => s,
        }
    }

    #[test]
    fn a_minified_workflow_takes_the_house_layout() {
        let input = r#"{"tasks":[{"function":{"input":{"target":"order","source":"payload"},"name":"parse_json"},"name":"Parse","id":"parse"},{"id":"flag","name":"Flag","condition":{">":[{"var":"data.order.total"},10000]},"function":{"name":"map","input":{"mappings":[{"logic":true,"path":"data.order.flagged"},{"path":"data.order.alert","logic":{"cat":["High-value order: $",{"var":"data.order.total"}]}}]}}}],"name":"High-Value Order","condition":true,"workflow_id":"high-value-order","tags":["pkg:high-value-order"]}"#;
        let expected = r#"{
  "workflow_id": "high-value-order",
  "name": "High-Value Order",
  "tags": ["pkg:high-value-order"],
  "condition": true,
  "tasks": [
    {
      "id": "parse",
      "name": "Parse",
      "function": { "name": "parse_json", "input": { "source": "payload", "target": "order" } }
    },
    {
      "id": "flag",
      "name": "Flag",
      "condition": { ">": [{ "var": "data.order.total" }, 10000] },
      "function": {
        "name": "map",
        "input": {
          "mappings": [
            { "path": "data.order.flagged", "logic": true },
            {
              "path": "data.order.alert",
              "logic": { "cat": ["High-value order: $", { "var": "data.order.total" }] }
            }
          ]
        }
      }
    }
  ]
}
"#;
        assert_eq!(fmt(input), expected);
    }

    #[test]
    fn compound_logic_breaks_one_argument_per_line_and_leaves_inline() {
        let input = r#"{"condition":{"and":[{">=":[{"var":"data.order.amount"},100]},{"<":[{"var":"data.order.amount"},500]}]}}"#;
        let expected = r#"{
  "condition": {
    "and": [
      { ">=": [{ "var": "data.order.amount" }, 100] },
      { "<": [{ "var": "data.order.amount" }, 500] }
    ]
  }
}
"#;
        assert_eq!(fmt(input), expected);
    }

    #[test]
    fn a_unary_node_stays_on_one_line_past_the_width() {
        // The generic root does not fit, so it breaks; the unary read inside
        // it is printed flat regardless of its length.
        let long = "x".repeat(120);
        let input = format!(r#"{{"a":{{"var":"{long}"}}}}"#);
        let out = fmt(&input);
        assert_eq!(out, format!("{{\n  \"a\": {{ \"var\": \"{long}\" }}\n}}\n"));
    }

    #[test]
    fn a_leaf_node_breaks_when_it_does_not_fit() {
        let long = "y".repeat(90);
        let input = format!(r#"{{">=":[{{"var":"{long}"}},500]}}"#);
        let out = fmt(&input);
        assert_eq!(
            out,
            format!("{{\n  \">=\": [\n    {{ \"var\": \"{long}\" }},\n    500\n  ]\n}}\n")
        );
    }

    #[test]
    fn scalar_arrays_inline_up_to_the_cap() {
        assert_eq!(
            fmt(r#"{"methods":["POST"]}"#),
            "{ \"methods\": [\"POST\"] }\n"
        );
        let nine = fmt(r#"{"n":[1,2,3,4,5,6,7,8,9]}"#);
        assert!(nine.starts_with("{\n  \"n\": [\n    1,\n"), "{nine}");
        let eight = fmt(r#"{"n":[1,2,3,4,5,6,7,8]}"#);
        assert_eq!(eight, "{ \"n\": [1, 2, 3, 4, 5, 6, 7, 8] }\n");
    }

    #[test]
    fn empty_containers_and_scalars_at_the_root() {
        assert_eq!(fmt("{}"), "{}\n");
        assert_eq!(fmt("[]"), "[]\n");
        assert_eq!(fmt(r#"{"a":{},"b":[]}"#), "{ \"a\": {}, \"b\": [] }\n");
        assert_eq!(fmt("42"), "42\n");
        assert_eq!(fmt("\"s\""), "\"s\"\n");
    }

    #[test]
    fn unchanged_is_exact_and_normalises_bom_and_crlf() {
        let canonical = "{ \"a\": 1 }\n";
        assert_eq!(
            format_str(canonical, "t").expect("test input is valid"),
            Outcome::Unchanged
        );
        assert_eq!(
            format_str("\u{feff}{ \"a\": 1 }\r\n", "t").expect("test input is valid"),
            Outcome::Changed(canonical.to_string())
        );
        assert_eq!(
            format_str("{ \"a\": 1 }", "t").expect("test input is valid"),
            Outcome::Changed(canonical.to_string()),
            "a missing trailing newline is a change"
        );
    }

    #[test]
    fn numbers_and_unknown_keys_are_preserved() {
        let out = fmt(r#"{"zeta":1.0,"alpha":1e3,"neg":-0,"big":123456789012345678901234567890}"#);
        assert_eq!(
            out,
            "{ \"zeta\": 1.0, \"alpha\": 1e3, \"neg\": -0, \"big\": 123456789012345678901234567890 }\n"
        );
    }

    #[test]
    fn from_sorts_first_everywhere() {
        let out = fmt(r#"{"collection":"users","$from":"constants.db"}"#);
        assert_eq!(
            out,
            "{ \"$from\": \"constants.db\", \"collection\": \"users\" }\n"
        );
    }

    #[test]
    fn a_parse_error_is_reported_with_its_position() {
        let err =
            format_str("{\n  \"a\": 1,\n  \"a\": 2\n}", "dup.json").expect_err("must be refused");
        assert!(matches!(err, FmtError::Parse(_)));
        assert!(err.to_string().starts_with("line 3, column 3"), "{err}");
    }
}