diff --git a/Cargo.lock b/Cargo.lock index db4f44398..51d2bfcaf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -574,6 +574,7 @@ version = "0.1.0" dependencies = [ "clap", "divan", + "memchr", "rayon", "serde", "serde_json", @@ -1228,6 +1229,7 @@ version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ + "indexmap", "itoa", "memchr", "serde", diff --git a/Cargo.toml b/Cargo.toml index 184531c7a..936278255 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,9 +8,10 @@ edition = "2024" [dependencies] clap = { version = "4.6.7", features = ["derive"], optional = true } +memchr = { version = "2.8.3", optional = true } rayon = "1.12.0" serde = { version = "1.0.229", features = ["derive"] } -serde_json = { version = "1.0.151", optional = true } +serde_json = { version = "1.0.151", features = ["preserve_order"], optional = true } wasmtime = { version = "49.0.2", default-features = false, features = ["runtime", "cranelift", "component-model", "parallel-compilation", "std"], optional = true } wasmtime-wasi = { version = "49.0.2", default-features = false, features = ["p2"], optional = true } @@ -26,7 +27,7 @@ harness = false default = ["cli"] # The `forge` command. Library users can turn it off with # `default-features = false`. -cli = ["dep:clap", "dep:serde_json"] +cli = ["dep:clap", "dep:memchr", "dep:serde_json"] # `forge::wasm`, which runs plugins compiled to WebAssembly components. wasm = ["dep:wasmtime", "dep:wasmtime-wasi"] diff --git a/packages/forge/README.md b/packages/forge/README.md index 39bc2163f..b50b84bab 100644 --- a/packages/forge/README.md +++ b/packages/forge/README.md @@ -16,6 +16,77 @@ npx @cloudflare/forge openapi.json --target ts --out sdk Pass `-` instead of a path to read the document from standard input. Run `forge --help` for every option. +## Normalizing a document + +`forge normalize` rewrites metadata extensions from other tools into Forge's +`x-forge-*` vocabulary. This command does not change which extensions Forge +reads; adopting the native vocabulary in the parser is a separate change. + +```sh +# Print the document with its keys rewritten. Nothing is changed. +npx forge normalize openapi.json + +# Rewrite the files in place. +npx forge normalize --write specs/*.json + +# Rewrite only Fern compatibility keys. --from may be repeated. +npx forge normalize --write --from fern specs/*.json + +# Keep the old keys beside the native ones, for what still reads them. +npx forge normalize --write --leave-old-extensions openapi.json + +# Write nothing, and fail if anything would be rewritten: for a check in CI. +npx forge normalize --check specs/*.json + +# Read from standard input and write to standard output. +cat openapi.json | npx forge normalize - +``` + +Without `--write` it changes nothing and prints one document to standard +output, so several files need `--write` or `--check`. A file with nothing to +rewrite is left as it is, a link is followed and stays a link, and a read-only +file is left as it is, with an error. `-v` says how many times each key was +rewritten. Documents have to be JSON: YAML is not supported yet. + +By default every compatibility source is normalized. Use `--from cloudflare`, +`--from deprecated`, `--from fern`, or `--from stainless` to select only that +source; repeat `--from` to select more than one. For example, +`--from fern --from cloudflare` leaves Stainless extensions untouched. +`deprecated` is the migration path for old Forge keys after they are renamed; +it currently has no keys. + +What is rewritten: + +- A group path written as an array becomes the names joined by dots + (`["a", "b"]` becomes `"a.b"`), unless a name has a dot in it. +- A skip for only some targets, written as a list, has no native form, since + `x-forge-skip` is for every target: it is left as it is. A skip that is plainly + true or false is rewritten. +- Native keys always win: when a document says the same thing twice, the native + key is kept. + +
+Known limitations + +`forge normalize` reads a document as JSON and writes it again, so a file that +has a key to rewrite is not a minimal edit. The order of its keys is kept. + +- **A key written twice** keeps only its last copy, with no error or warning. +- **Numbers** that do not fit in 64 bits are rounded (`100000000000000000001` + becomes `1e+20`), a decimal of more than about 17 digits is shortened, and + `1E5`, `0.10` and `-0` are written `100000.0`, `0.1` and `-0.0`. +- **Formatting** is the writer's: the indentation of the first indented line is + used for the whole file, arrays and objects on one line are written on + several, blank lines, spaces at the end of a line and anything before the + first `{` are dropped, `\r\n` becomes `\n`, and escapes such as `\u00e9` and + `\/` are written as the characters. +- **Errors**: JSON with comments, a byte order mark, and a document nested + deeper than 128 levels are not read. +- **Memory**: a document is held in memory at about 6.5 times its size, one file + at a time. + +
+ ## Plugins A plugin generates an SDK, or anything else, from the document: forge parses diff --git a/src/bin/forge/main.rs b/src/bin/forge/main.rs index 6781dd8ea..b036b487a 100644 --- a/src/bin/forge/main.rs +++ b/src/bin/forge/main.rs @@ -3,6 +3,7 @@ //! ```text //! forge openapi.json --target ts --out sdk //! forge openapi.json --target wasm://plugin.wasm --out sdk +//! forge normalize specs/*.json //! ``` use std::fs; @@ -13,21 +14,37 @@ use std::process::ExitCode; use clap::Parser; use forge::{Err, Forge}; +mod normalize; mod ts; /// Generates an SDK from an OpenAPI document. #[derive(Parser)] -#[command(version)] +#[command(version, args_conflicts_with_subcommands = true)] struct Args { + #[command(subcommand)] + command: Option, /// The OpenAPI JSON document, or - to read it from standard input. - spec: PathBuf, + #[arg(required = true)] + spec: Option, /// The language of the SDK: `ts` for TypeScript, or `wasm://PATH` for /// the plugin compiled to a WebAssembly component at PATH. - #[arg(long, value_parser = Target::parse)] - target: Target, + #[arg(long, value_parser = Target::parse, required = true)] + target: Option, /// The directory to write the SDK to. - #[arg(long)] - out: PathBuf, + #[arg(long, required = true)] + out: Option, +} + +/// What `forge` does other than generate an SDK. +#[derive(clap::Subcommand)] +enum Command { + /// Rewrite other tools' extensions into the native x-forge-* ones. + /// + /// One document is printed to standard output, and nothing is changed, + /// unless --write is given, which rewrites the files in place. A document + /// that has none is left as it is. A native key is never replaced: when a + /// document says the same thing twice, the native key is kept. + Normalize(normalize::Args), } /// What forge generates an SDK with. @@ -68,20 +85,32 @@ fn main() -> ExitCode { } } -/// Generates the SDK that `args` ask for. +/// Does what `args` ask for. fn run(args: Args) -> Result<(), Err> { - match args.target { + if let Some(Command::Normalize(args)) = args.command { + return normalize::run(args); + } + // They are required, which a command is the only way around. + let (Some(spec), Some(target), Some(out)) = (args.spec, args.target, args.out) else { + unreachable!("clap asks for a spec, a target and an out when there is no command"); + }; + generate(&spec, target, &out) +} + +/// Generates the SDK for the document at `spec`, and writes it to `out`. +fn generate(spec: &Path, target: Target, out: &Path) -> Result<(), Err> { + match target { Target::Ts => { - let forge = read(&args.spec)?; - forge::write(&args.out, forge::run(&forge, &ts::Generator)?) + let forge = read(spec)?; + forge::write(out, forge::run(&forge, &ts::Generator)?) } #[cfg(feature = "wasm")] Target::Wasm(path) => { // Compiling the plugin and reading the document are both slow, // so they run at the same time. - let (plugin, forge) = rayon::join(|| forge::wasm::WasmPlugin::from_file(&path), || read(&args.spec)); + let (plugin, forge) = rayon::join(|| forge::wasm::WasmPlugin::from_file(&path), || read(spec)); let (plugin, forge) = (plugin?, forge?); - forge::write(&args.out, forge::run(&forge, &plugin)?) + forge::write(out, forge::run(&forge, &plugin)?) } } } @@ -104,11 +133,32 @@ fn read(path: &Path) -> Result { #[cfg(test)] mod tests { use clap::CommandFactory; + use clap::Parser; + use std::path::Path; use super::Args; + use super::Command; #[test] fn args() { Args::command().debug_assert(); } + + #[test] + fn a_command_and_a_document_are_told_apart() { + let parse = |args: &[&str]| Args::try_parse_from(std::iter::once("forge").chain(args.iter().copied())); + + let command = parse(&["normalize", "--check", "a.json"]).unwrap(); + assert!(matches!(command.command, Some(Command::Normalize(_))) && command.spec.is_none()); + + let document = parse(&["./normalize", "--target", "ts", "--out", "sdk"]).unwrap(); + assert!(document.command.is_none() && document.spec.as_deref() == Some(Path::new("./normalize"))); + + // What a command or a document needs is not waived for the other. + assert!(parse(&["normalize"]).is_err()); + assert!(parse(&["a.json"]).is_err()); + assert!(parse(&["normalize", "--target", "ts", "a.json"]).is_err()); + assert!(parse(&["--target", "ts", "normalize", "a.json"]).is_err()); + assert!(parse(&[]).is_err()); + } } diff --git a/src/bin/forge/normalize.rs b/src/bin/forge/normalize.rs new file mode 100644 index 000000000..a799f5868 --- /dev/null +++ b/src/bin/forge/normalize.rs @@ -0,0 +1,363 @@ +//! `forge normalize`, which rewrites the extensions other tools' metadata is +//! written with into the native ones, in the files it is given. + +use std::collections::HashSet; +use std::fs; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; + +use forge::Err; +use forge::normalize::{Normalized, Options, Report, Source, Sources, normalize}; + +/// A compatibility source named on the command line. +#[derive(Clone, Copy, clap::ValueEnum)] +enum SourceArg { + Cloudflare, + Deprecated, + Fern, + Stainless, +} + +impl From for Source { + fn from(source: SourceArg) -> Source { + match source { + SourceArg::Cloudflare => Source::Cloudflare, + SourceArg::Deprecated => Source::Deprecated, + SourceArg::Fern => Source::Fern, + SourceArg::Stainless => Source::Stainless, + } + } +} + +/// What `forge normalize` is given, which says what it does in `main.rs`. +#[derive(clap::Args)] +pub struct Args { + /// The OpenAPI JSON documents, as paths. A pattern such as `specs/*.json` + /// is expanded by the shell. One document is printed to standard output, + /// with nothing changed, unless `--write` is given. Or - to read one from + /// standard input and print it. + #[arg(required = true)] + inputs: Vec, + /// Rewrite the files in place, each only if there is something to rewrite + /// in it. Without it nothing is changed, and one document is printed. + #[arg(short, long, conflicts_with = "check")] + write: bool, + /// Keep each key that is rewritten, beside the native key that replaces + /// it, for what still reads the old one. + #[arg(long)] + leave_old_extensions: bool, + /// Rewrite only keys from this compatibility source. May be repeated. + /// Without it, keys from every source are rewritten. + #[arg(long = "from", value_enum)] + from: Vec, + /// Say what would be rewritten, and write nothing. It fails if there is + /// anything to rewrite, to check that documents are normalized. + #[arg(long)] + check: bool, + /// Say how many times each key was rewritten. + #[arg(short, long)] + verbose: bool, +} + +/// A file to rewrite. +struct Input { + /// The path it was given by, which is how it is named. + shown: PathBuf, + /// The file it is, with every link followed, which is what is read and + /// written: a link stays a link, and a file given twice is rewritten once. + real: PathBuf, +} + +/// How a run says what it did. +#[derive(Clone, Copy)] +struct Say { + check: bool, + verbose: bool, + /// Whether the document is printed to standard output, and no file is + /// rewritten, so that is what is said. + printed: bool, +} + +pub fn run(args: Args) -> Result<(), Err> { + let sources = if args.from.is_empty() { + Sources::ALL + } else { + args.from.into_iter().map(Source::from).collect() + }; + let options = Options { + leave_old_extensions: args.leave_old_extensions, + sources, + }; + let say = Say { + check: args.check, + verbose: args.verbose, + printed: !args.write && !args.check, + }; + if args.inputs.iter().any(|input| input == "-") { + if args.inputs.len() > 1 { + return Err("- cannot be given with other inputs".into()); + } + if args.write { + return Err("- cannot be given with --write: standard input is not a file to rewrite".into()); + } + return filter(&options, say); + } + + let files = unique_files(&args.inputs)?; + if !args.write && !args.check { + // Nothing is changed, so there is one document to print. + let [file] = &files[..] else { + return Err("several files are only rewritten with --write, or checked with --check".into()); + }; + return print_file(file, &options, say); + } + + let (mut rewritten, mut unchanged, mut failed) = (0, 0, 0); + // One file at a time: each is held as a tree of several times its size. + for input in files { + let name = input.shown.display().to_string(); + let result = rewrite(&input.real, &options, say).map_err(|err| format!("{name}: {err}")); + match result { + Ok(report) => { + if report.changed() { + rewritten += 1; + } else { + unchanged += 1; + } + print(&name, &report, say); + } + Err(err) => { + failed += 1; + eprintln!("forge: {err}"); + } + } + } + let total = rewritten + unchanged; + if failed > 0 { + return Err(format!("{failed} {} could not be processed", plural(failed, "file", "files")).into()); + } + if say.check { + if rewritten > 0 { + return Err(format!( + "{rewritten} of {total} {} would be rewritten", + plural(total, "file", "files") + ) + .into()); + } + eprintln!( + "forge: {total} {} checked, none would be rewritten", + plural(total, "file", "files") + ); + } else { + eprintln!( + "forge: {total} {}: {rewritten} rewritten, {unchanged} unchanged", + plural(total, "file", "files") + ); + } + Ok(()) +} + +/// Returns `one` if `n` is 1, and `many` if it is not. +fn plural<'a>(n: usize, one: &'a str, many: &'a str) -> &'a str { + if n == 1 { one } else { many } +} + +/// Rewrites the document read from standard input to standard output. +fn filter(options: &Options, say: Say) -> Result<(), Err> { + let mut json = Vec::new(); + io::stdin().read_to_end(&mut json)?; + document(&json, "stdin", options, say) +} + +/// Prints the document in the file to standard output, rewritten, and leaves +/// the file as it is. +fn print_file(input: &Input, options: &Options, say: Say) -> Result<(), Err> { + let name = input.shown.display().to_string(); + reject_yaml(&input.real).map_err(|err| format!("{name}: {err}"))?; + let json = fs::read(&input.real).map_err(|err| format!("{name}: {err}"))?; + document(&json, &name, options, say) +} + +/// Rewrites `json`, the document called `name`, to standard output. +fn document(json: &[u8], name: &str, options: &Options, say: Say) -> Result<(), Err> { + let normalized = normalize(json, options).map_err(|err| format!("{name}: {err}"))?; + if say.check { + print(name, &normalized.report, say); + if normalized.report.changed() { + return Err("the document would be rewritten".into()); + } + return Ok(()); + } + let mut out = io::stdout().lock(); + match &normalized.json { + Some(edited) => out.write_all(edited)?, + None => out.write_all(json)?, + } + // Standard output is held until a line is whole, and what is left at the + // end, which is all of a document that is one line, is not otherwise + // known to have been written. + out.flush()?; + print(name, &normalized.report, say); + Ok(()) +} + +/// Returns the files `inputs` name, each once. A path that is not there is +/// left for reading it to say so, and a pattern such as `specs/*.json` is for +/// the shell to expand, so one that was not is a path that is not there. +fn unique_files(inputs: &[String]) -> Result, Err> { + let mut seen = HashSet::new(); + let mut files = Vec::new(); + for input in inputs { + if Path::new(input).is_dir() { + return Err(format!("{input}: is a folder, not a file").into()); + } + let shown = PathBuf::from(input); + let real = fs::canonicalize(&shown).unwrap_or_else(|_| shown.clone()); + if seen.insert(Identity::of(&real)) { + files.push(Input { shown, real }); + } + } + Ok(files) +} + +/// Which file a path is, so that a file with more than one name, which a hard +/// link is, is found to be one and rewritten once. +#[derive(PartialEq, Eq, Hash)] +enum Identity { + /// The device it is on, and where on it. + #[cfg(unix)] + Inode(u64, u64), + Path(PathBuf), +} + +impl Identity { + fn of(real: &Path) -> Identity { + #[cfg(unix)] + if let Ok(metadata) = fs::metadata(real) { + use std::os::unix::fs::MetadataExt; + + return Identity::Inode(metadata.dev(), metadata.ino()); + } + Identity::Path(real.to_owned()) + } +} + +/// Refuses the file at `path` if it is YAML, which is not supported yet. +fn reject_yaml(path: &Path) -> Result<(), Err> { + let yaml = path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("yml") || extension.eq_ignore_ascii_case("yaml")); + if yaml { + return Err("YAML is not supported yet: convert the document to JSON first".into()); + } + Ok(()) +} + +/// Rewrites the file at `path`, if it has a key to rewrite, and says what it +/// found, which is all it does when it is only checking. +fn rewrite(path: &Path, options: &Options, say: Say) -> Result { + reject_yaml(path)?; + let json = fs::read(path)?; + let Normalized { json, report } = normalize(&json, options)?; + if let Some(edited) = &json + && !say.check + { + if fs::metadata(path)?.permissions().readonly() { + return Err("the file is read-only, so it is not rewritten".into()); + } + write(path, edited)?; + } + Ok(report) +} + +/// Writes `json` to the file at `path`. It is written beside it first, and put +/// in its place, so that it is never left half written. Where a new file cannot +/// take the place of this one, because it has other links, which would be left +/// with the old one, or because its folder cannot be written to, it is written +/// in place. +fn write(path: &Path, edited: &[u8]) -> Result<(), Err> { + if linked(path) { + return Ok(write_in_place(path, edited)?); + } + match write_atomically(path, edited) { + Err(err) if err.kind() == io::ErrorKind::PermissionDenied => Ok(write_in_place(path, edited)?), + written => Ok(written?), + } +} + +fn write_in_place(path: &Path, edited: &[u8]) -> io::Result<()> { + fs::File::create(path)?.write_all(edited) +} + +/// Returns whether the file at `path` has other names. +#[cfg(unix)] +fn linked(path: &Path) -> bool { + use std::os::unix::fs::MetadataExt; + + fs::metadata(path).is_ok_and(|metadata| metadata.nlink() > 1) +} + +#[cfg(not(unix))] +fn linked(_: &Path) -> bool { + false +} + +fn write_atomically(path: &Path, edited: &[u8]) -> io::Result<()> { + let name = path.file_name().map(|name| name.to_string_lossy()).unwrap_or_default(); + let temporary = path.with_file_name(format!(".{name}.forge-normalize-{}", std::process::id())); + let permissions = fs::metadata(path)?.permissions(); + let written = create_private(&temporary) + .and_then(|mut file| { + // Before it has anything in it, so that it is not readable by more + // than the file it replaces is, at any time. + file.set_permissions(permissions)?; + file.write_all(edited) + }) + .and_then(|()| fs::rename(&temporary, path)); + if written.is_err() { + let _ = fs::remove_file(&temporary); + } + written +} + +/// Creates the file at `path`, which is not there, and is not a link to +/// another file that is, and that only its owner can read. +fn create_private(path: &Path) -> io::Result { + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + + options.mode(0o600); + } + options.open(path) +} + +/// Says what was done to the document called `name`, and what was not. +fn print(name: &str, report: &Report, say: Say) { + if report.changed() { + let total: usize = report.migrated.values().sum(); + let keys = plural(total, "key", "keys"); + if say.check { + eprintln!("{name}: would rewrite {total} {keys}"); + } else if say.printed { + eprintln!("{name}: {total} {keys} rewritten in the output"); + } else { + eprintln!("{name}: rewrote {total} {keys}"); + } + if say.verbose { + for (key, count) in &report.migrated { + eprintln!(" {key}: {count}"); + } + if report.joined > 0 { + eprintln!( + " {} group {} written as a dotted string", + report.joined, + plural(report.joined, "path", "paths") + ); + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 55e1611d5..7fbd30c40 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,8 @@ mod json; mod link; mod method; mod name; +#[cfg(feature = "cli")] +pub mod normalize; mod plugin; mod types; pub use json::Json; diff --git a/src/normalize/mod.rs b/src/normalize/mod.rs new file mode 100644 index 000000000..53f4062ff --- /dev/null +++ b/src/normalize/mod.rs @@ -0,0 +1,228 @@ +//! Rewrites the extensions other tools' metadata is written with into the +//! native `x-forge-*` ones, in a JSON document. +//! +//! The key a tool writes is found with the tables of [`vendor`], at the kinds +//! of OpenAPI object where its native replacement belongs. A key elsewhere is +//! left alone, since rewriting it would give it a meaning it did not have. +//! +//! A native key is never replaced. When a document says the same thing with a +//! native key and another, the native one is kept. +//! +//! The document is read with `serde_json` and written again, in the order its +//! keys were in, with the indentation of its first indented line. That is +//! simple, and it is not a minimal edit: what is lost is listed in the README +//! of the package under "Known limitations", and each one is pinned by a test in +//! `tests/normalize/known_gaps.rs`. + +mod vendor; +mod walk; + +use std::collections::BTreeMap; +use std::fmt; + +use serde::Serialize; +use serde_json::Value; +use serde_json::ser::PrettyFormatter; + +/// A source of compatibility keys that [`normalize`] can rewrite. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Source { + /// Cloudflare's compatibility keys. + Cloudflare, + /// Deprecated Forge keys that have a current replacement. + Deprecated, + /// Fern's compatibility keys. + Fern, + /// Stainless's compatibility keys. + Stainless, +} + +impl Source { + const fn bit(self) -> u8 { + match self { + Source::Cloudflare => 1, + Source::Deprecated => 2, + Source::Fern => 4, + Source::Stainless => 8, + } + } +} + +/// The sources of compatibility keys that [`normalize`] rewrites. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Sources(u8); + +impl Sources { + /// Every compatibility source. + pub const ALL: Sources = Sources(1 | 2 | 4 | 8); + + /// Returns only `source`. + pub const fn only(source: Source) -> Sources { + Sources(source.bit()) + } + + /// Returns whether `source` is included. + pub const fn contains(self, source: Source) -> bool { + self.0 & source.bit() != 0 + } +} + +impl FromIterator for Sources { + fn from_iter>(sources: T) -> Sources { + Sources(sources.into_iter().fold(0, |bits, source| bits | source.bit())) + } +} + +/// What [`normalize`] does. +#[derive(Clone, Copy, Debug)] +pub struct Options { + /// Keeps each key that is rewritten, beside the native key that replaces + /// it, for what still reads the old one. + pub leave_old_extensions: bool, + /// The compatibility sources to rewrite. + pub sources: Sources, +} + +impl Default for Options { + fn default() -> Options { + Options { + leave_old_extensions: false, + sources: Sources::ALL, + } + } +} + +/// What [`normalize`] did. +#[derive(Debug, Default)] +pub struct Report { + /// How many keys were rewritten, or removed for a native key that + /// already said it, by the name of the key. + pub migrated: BTreeMap, + /// How many group paths were written again, as the names joined by dots, + /// which is the one value that is not copied as it was. + pub joined: usize, +} + +impl Report { + /// Returns whether the document is different from what it was. + pub fn changed(&self) -> bool { + !self.migrated.is_empty() + } +} + +/// A document after [`normalize`]. +#[derive(Debug)] +pub struct Normalized { + /// The document, if it is different from what was read. + pub json: Option>, + /// What was done to it. + pub report: Report, +} + +/// Why a document could not be normalized. +#[derive(Debug)] +pub struct Error(String); + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for Error {} + +impl From for Error { + fn from(err: serde_json::Error) -> Error { + Error(err.to_string()) + } +} + +/// Rewrites the keys of the document `json` that a native key replaces. +pub fn normalize(json: &[u8], options: &Options) -> Result { + let mut document: Value = serde_json::from_slice(json)?; + let report = walk::walk(&mut document, options); + if !report.changed() { + return Ok(Normalized { json: None, report }); + } + Ok(Normalized { + json: Some(write(&document, json)), + report, + }) +} + +/// Writes `document` as `original` was written, as far as it is known: with +/// its indentation, which is that of the first line that is indented, on one +/// line if it was, and with a newline at the end if it had one. +fn write(document: &Value, original: &[u8]) -> Vec { + let mut out = Vec::with_capacity(original.len() + original.len() / 16); + match indentation(original) { + Some(indent) => { + let mut serializer = serde_json::Serializer::with_formatter(&mut out, PrettyFormatter::with_indent(indent)); + document + .serialize(&mut serializer) + .expect("a document is written as JSON to the memory"); + } + None => serde_json::to_writer(&mut out, document).expect("a document is written as JSON to the memory"), + } + if original.ends_with(b"\n") { + out.push(b'\n'); + } + out +} + +/// Returns what `original` is indented with, or none if it is on one line. +fn indentation(original: &[u8]) -> Option<&[u8]> { + let first = memchr::memchr(b'\n', original)?; + for line in original[first + 1..].split(|byte| *byte == b'\n') { + let blanks = line.iter().take_while(|byte| matches!(byte, b' ' | b'\t')).count(); + if blanks > 0 && blanks < line.len() { + return Some(&line[..blanks]); + } + } + Some(b"") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_sources_includes_every_compatibility_adapter() { + for source in [Source::Cloudflare, Source::Deprecated, Source::Fern, Source::Stainless] { + assert!(Sources::ALL.contains(source), "{source:?}"); + } + } + + #[test] + fn the_indentation_is_that_of_the_first_line_that_is_indented() { + assert_eq!(indentation(b"{\n \"a\": {\n \"b\": 1\n }\n}"), Some(&b" "[..])); + assert_eq!(indentation(b"{\n\t\"a\": 1\n}"), Some(&b"\t"[..])); + assert_eq!(indentation(b"{\n \"a\": 1\n}"), Some(&b" "[..])); + } + + #[test] + fn a_document_on_one_line_is_written_on_one_line() { + assert_eq!(indentation(br#"{"a":1}"#), None); + assert_eq!( + write(&serde_json::json!({"a": 1, "b": [1, 2]}), br#"{"a":1}"#), + br#"{"a":1,"b":[1,2]}"# + ); + } + + #[test] + fn a_document_with_no_indented_line_is_written_with_none() { + assert_eq!(indentation(b"{\n\"a\": 1\n}\n"), Some(&b""[..])); + } + + #[test] + fn a_blank_line_with_spaces_is_not_the_indentation() { + assert_eq!(indentation(b"{\n \n \"a\": 1\n}"), Some(&b" "[..])); + } + + #[test] + fn the_newline_at_the_end_is_kept_if_there_was_one() { + let document = serde_json::json!({"a": 1}); + assert!(write(&document, b"{\n \"a\": 1\n}\n").ends_with(b"}\n")); + assert!(write(&document, b"{\n \"a\": 1\n}").ends_with(b"}")); + } +} diff --git a/src/normalize/vendor/README.md b/src/normalize/vendor/README.md new file mode 100644 index 000000000..f79023723 --- /dev/null +++ b/src/normalize/vendor/README.md @@ -0,0 +1,53 @@ +# The vendor tables + +`forge normalize` rewrites the extensions other tools write (Cloudflare's +`x-sensitive`, Fern's `x-fern-*`, and Stainless's `x-stainless-*`) into the +native `x-forge-*` vocabulary. This folder owns those normalization mappings: +their source, native replacement, and value form. + +The parser still reads the compatibility extensions directly. Moving it to the +native vocabulary is a separate change, so vendor names remain in `src/link/` +for now. Normalization is explicit; `forge` does not run it before parsing. + +`deprecated` is also treated as a vendor here, though it is not literally one: +its adapter holds old Forge keys that have a current replacement. This keeps +renames out of the parser by giving them the same explicit migration path. + +## Rules + +- **Normalization mappings live only here.** The current parser still names + compatibility extensions in `src/link/`; do not add another normalization + table outside this folder. Normalization tests live in `tests/normalize/`. +- **Mapping, not semantics.** A table says which native key a key becomes, + and whether its value is written as the native key's is, as data. + Anything structural, such as how aliases become methods, belongs to the + builder, not here. The one value that is written again is a group path, which + becomes the names joined by dots (`walk.rs`). +- **One adapter per source.** `--from` selects the `cloudflare`, `deprecated`, + `fern`, and `stainless` adapters, and no `--from` selects them all. +- **A key is rewritten only at the level where Forge reads the corresponding + metadata**: an operation or an alias, a parameter, or a schema. The same key + on another kind of object is left alone, since rewriting it would give it a + meaning it did not have. +- **Native wins.** When a document has the native key, the vendor one is + dropped, whatever order they appear in. When a vendor key has no native form, it is left as it is written: on a + property, `x-stainless-skip: true` and `false` become `x-forge-skip` with the + same value, but a list of targets such as `["terraform"]` says "skip for + these targets", which `x-forge-skip` cannot, as it is for every target. On an + operation, `x-stainless-skip` is not read at all. + +## Adding a vendor key + +1. Identify the Forge primitive the vendor key represents and the native + `x-forge-*` key for that primitive. Normalization does not introduce new + semantics; native parser support belongs in a separate change. +2. Map it in the table of the adapter for its source, or add an adapter next to + `cloudflare.rs`, `deprecated.rs`, `fern.rs` and `stainless.rs` and include it + from `mod.rs`. A renamed Forge key goes in `deprecated.rs`, with its exact + old name in the table for that object kind. +3. Add a test in `tests/normalize/` showing the exact native document produced + from the vendor-keyed input. + +Until the parser moves to the native vocabulary, parser support for a new +compatibility key remains a separate concern from adding its normalization +mapping. diff --git a/src/normalize/vendor/cloudflare.rs b/src/normalize/vendor/cloudflare.rs new file mode 100644 index 000000000..3c75c6841 --- /dev/null +++ b/src/normalize/vendor/cloudflare.rs @@ -0,0 +1,6 @@ +//! The adapter for Cloudflare's compatibility extensions. + +use super::Key; + +/// The keys of a Schema Object. +pub(super) const SCHEMA: [(&str, Key); 1] = [("x-sensitive", Key::same("x-forge-sensitive"))]; diff --git a/src/normalize/vendor/deprecated.rs b/src/normalize/vendor/deprecated.rs new file mode 100644 index 000000000..451d04d77 --- /dev/null +++ b/src/normalize/vendor/deprecated.rs @@ -0,0 +1,15 @@ +//! The adapter for deprecated Forge extensions that have a current name. +//! +//! It starts empty. A rename adds the old key to the table for its kind of +//! object and maps it to the current native key. + +use super::Key; + +/// Deprecated keys of an operation or alias. +pub(super) const DEFINITION: [(&str, Key); 0] = []; + +/// Deprecated keys of a Parameter Object. +pub(super) const PARAMETER: [(&str, Key); 0] = []; + +/// Deprecated keys of a Schema Object. +pub(super) const SCHEMA: [(&str, Key); 0] = []; diff --git a/src/normalize/vendor/fern.rs b/src/normalize/vendor/fern.rs new file mode 100644 index 000000000..a9a59f1b4 --- /dev/null +++ b/src/normalize/vendor/fern.rs @@ -0,0 +1,25 @@ +//! The adapter for Fern's `x-fern-*` extensions. +//! +//! A group path written as one string is split on dots, as for +//! `x-forge-group-name`, which Fern itself does not do: Fern reads a string as a +//! single group. A document written for Fern uses the array, which means the +//! same in both. + +use super::Key; + +/// The keys of an operation or an alias. +pub(super) const DEFINITION: [(&str, Key); 4] = [ + ("x-fern-sdk-method-name", Key::same("x-forge-method-name")), + ("x-fern-sdk-group-name", Key::group("x-forge-group-name")), + ("x-fern-ignore", Key::same("x-forge-skip")), + ("x-fern-availability", Key::same("x-forge-availability")), +]; + +/// The keys of a Parameter Object. +pub(super) const PARAMETER: [(&str, Key); 1] = [("x-fern-parameter-name", Key::same("x-forge-parameter-name"))]; + +/// The keys of a Schema Object. +pub(super) const SCHEMA: [(&str, Key); 2] = [ + ("x-fern-type-name", Key::same("x-forge-model-name")), + ("x-fern-property-name", Key::same("x-forge-property-name")), +]; diff --git a/src/normalize/vendor/mod.rs b/src/normalize/vendor/mod.rs new file mode 100644 index 000000000..de34e542c --- /dev/null +++ b/src/normalize/vendor/mod.rs @@ -0,0 +1,95 @@ +//! The normalization mappings from keys other tools write to the native +//! extensions that say the same thing. The current parser still reads the +//! compatibility keys directly; moving it to native keys is a separate change. +//! +//! Each adapter, [`cloudflare`], [`deprecated`], [`fern`] and [`stainless`], is +//! a table per kind of object that maps its compatibility keys onto native +//! ones, and says whether the value is written as the native key's is. +//! +//! A key has to be in the table of the kind of object `forge` reads its native +//! key at, so that a rewrite does not give a key a meaning it did not have. An +//! extension that says something `forge` has no primitive for is not here. + +mod cloudflare; +mod deprecated; +mod fern; +mod stainless; +#[cfg(test)] +mod tests; + +use super::{Source, Sources}; + +/// What a key of another tool becomes. +#[derive(Clone, Copy)] +pub(super) struct Key { + /// The native key that says the same. + pub(super) native: &'static str, + /// How the value is written when it is written as the native key's is. + pub(super) form: Form, +} + +/// How the value of a key is written as the native key's is. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum Form { + /// As it is. + Same, + /// A group path: an array of names is the names joined by dots. + Group, + /// A boolean, or a list of the targets, which a native key does not say. + BoolOrTargets, +} + +impl Key { + /// Returns a key whose value is written as the native key's is. + const fn same(native: &'static str) -> Key { + Key { + native, + form: Form::Same, + } + } + + /// Returns a key whose value is a group path. + const fn group(native: &'static str) -> Key { + Key { + native, + form: Form::Group, + } + } + + /// Returns a key whose value is a boolean, or a list of targets. + const fn bool_or_targets(native: &'static str) -> Key { + Key { + native, + form: Form::BoolOrTargets, + } + } +} + +/// Returns what `key` becomes on an operation or an alias, if an adapter reads +/// it there. +pub(super) fn definition(key: &str, sources: Sources) -> Option { + find(Source::Deprecated, sources, &deprecated::DEFINITION, key) + .or_else(|| find(Source::Fern, sources, &fern::DEFINITION, key)) +} + +/// Returns what `key` becomes on a Parameter Object, if an adapter reads it +/// there. +pub(super) fn parameter(key: &str, sources: Sources) -> Option { + find(Source::Deprecated, sources, &deprecated::PARAMETER, key) + .or_else(|| find(Source::Fern, sources, &fern::PARAMETER, key)) +} + +/// Returns what `key` becomes on a Schema Object, if an adapter reads it there. +pub(super) fn schema(key: &str, sources: Sources) -> Option { + find(Source::Deprecated, sources, &deprecated::SCHEMA, key) + .or_else(|| find(Source::Cloudflare, sources, &cloudflare::SCHEMA, key)) + .or_else(|| find(Source::Fern, sources, &fern::SCHEMA, key)) + .or_else(|| find(Source::Stainless, sources, &stainless::SCHEMA, key)) +} + +fn find(source: Source, sources: Sources, table: &[(&str, Key)], key: &str) -> Option { + if !sources.contains(source) { + return None; + } + table.iter().find(|(name, _)| *name == key).map(|(_, key)| *key) +} diff --git a/src/normalize/vendor/stainless.rs b/src/normalize/vendor/stainless.rs new file mode 100644 index 000000000..144cb2e1b --- /dev/null +++ b/src/normalize/vendor/stainless.rs @@ -0,0 +1,9 @@ +//! The adapter for Stainless's `x-stainless-*` extensions. +//! +//! `x-stainless-skip` may be a list of the targets to skip a property for, +//! which a native skip, that is for every target, does not say. + +use super::Key; + +/// The keys of a Schema Object. +pub(super) const SCHEMA: [(&str, Key); 1] = [("x-stainless-skip", Key::bool_or_targets("x-forge-skip"))]; diff --git a/src/normalize/vendor/tests.rs b/src/normalize/vendor/tests.rs new file mode 100644 index 000000000..08bdc66ae --- /dev/null +++ b/src/normalize/vendor/tests.rs @@ -0,0 +1,100 @@ +//! The vendor tables. They are here, and not in `tests/normalize/`, as they are +//! of what is private to `normalize`, and name the keys of other tools, which +//! only this module may. + +use super::*; + +/// A kind of object that has keys of other tools. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Level { + Definition, + Parameter, + Schema, +} + +/// Every key of the tables, with the kind of object it is read at. +fn tables() -> Vec<(Source, Level, &'static str, Key)> { + let table = |source: Source, level: Level, table: &[(&'static str, Key)]| -> Vec<_> { + table.iter().map(|&(name, key)| (source, level, name, key)).collect() + }; + [ + table(Source::Cloudflare, Level::Schema, &cloudflare::SCHEMA), + table(Source::Deprecated, Level::Definition, &deprecated::DEFINITION), + table(Source::Deprecated, Level::Parameter, &deprecated::PARAMETER), + table(Source::Deprecated, Level::Schema, &deprecated::SCHEMA), + table(Source::Fern, Level::Definition, &fern::DEFINITION), + table(Source::Fern, Level::Parameter, &fern::PARAMETER), + table(Source::Fern, Level::Schema, &fern::SCHEMA), + table(Source::Stainless, Level::Schema, &stainless::SCHEMA), + ] + .concat() +} + +fn read(level: Level, key: &str, sources: Sources) -> Option { + match level { + Level::Definition => definition(key, sources), + Level::Parameter => parameter(key, sources), + Level::Schema => schema(key, sources), + } +} + +#[test] +fn a_key_is_read_only_at_its_level() { + for (_, level, name, _) in tables() { + for other in [Level::Definition, Level::Parameter, Level::Schema] { + assert_eq!( + read(other, name, Sources::ALL).is_some(), + other == level, + "{name} at {other:?}" + ); + } + } +} + +#[test] +fn a_key_is_read_only_when_its_source_is_selected() { + let sources = [Source::Cloudflare, Source::Deprecated, Source::Fern, Source::Stainless]; + for (source, level, name, _) in tables() { + for selected in sources { + assert_eq!( + read(level, name, Sources::only(selected)).is_some(), + selected == source, + "{name} from {source:?}, with {selected:?} selected" + ); + } + } +} + +#[test] +fn what_a_key_becomes_is_a_native_key_and_no_key_of_another_tool_is_one() { + for (_, _, name, key) in tables() { + assert!(key.native.starts_with("x-forge-"), "{name} becomes {}", key.native); + assert!(!name.starts_with("x-forge-"), "{name}"); + } +} + +#[test] +fn only_the_skip_of_a_schema_that_stainless_writes_may_be_a_list_of_targets() { + for (_, _, name, key) in tables() { + assert_eq!(key.form == Form::BoolOrTargets, name == "x-stainless-skip", "{name}"); + } +} + +#[test] +fn the_other_extensions_of_these_tools_are_not_read() { + for key in [ + "x-fern-enum", + "x-fern-audiences", + "x-fern-pagination", + "x-fern-sdk-variables", + "x-stainless-terraform-configurability", + "x-stainless-deprecation-message", + // The native keys, which are not for a rewrite. + "x-forge-skip", + "x-forge-method-name", + ] { + assert!(definition(key, Sources::ALL).is_none(), "{key}"); + assert!(parameter(key, Sources::ALL).is_none(), "{key}"); + assert!(schema(key, Sources::ALL).is_none(), "{key}"); + } +} diff --git a/src/normalize/walk.rs b/src/normalize/walk.rs new file mode 100644 index 000000000..da00b587f --- /dev/null +++ b/src/normalize/walk.rs @@ -0,0 +1,226 @@ +//! What a document means, which is where a key is to be rewritten. +//! +//! The walk follows the document as `forge` reads it: down from `paths` and +//! `components` to the operations, the parameters and the schemas, which are +//! the objects that have keys to rewrite, and no further. Everything else is +//! passed over. What is below an object is found by what kind of object it is +//! and what the key is, in [`child`], which is the one place that says where +//! `forge` reads. + +use serde_json::{Map, Value}; + +use super::vendor::{self, Form, Key}; +use super::{Options, Report}; + +/// Returns the names of an array joined by dots, if each is a name that a +/// dotted string reads back as it is. +fn dotted(names: &[Value]) -> Option { + let mut path = String::new(); + for (i, name) in names.iter().enumerate() { + let name = name.as_str().filter(|name| !name.is_empty() && !name.contains('.'))?; + if i > 0 { + path.push('.'); + } + path.push_str(name); + } + Some(path) +} + +/// A kind of object that is read. +#[derive(Clone, Copy)] +enum Kind { + Document, + Components, + PathItem, + Operation, + /// An alias of an operation, which has the keys of one. + Alias, + Parameter, + /// A request body or a response, which has nothing but a `content` to read. + Body, + /// A media type, which has nothing but a `schema` to read. + Media, + Schema, +} + +/// How what is read is held by the key that has it. +#[derive(Clone, Copy)] +enum Shape { + /// The value is one object. + One(Kind), + /// The value is an object, and each of its members is. + Values(Kind), + /// The value is an object, and each member that is not an extension is. + Entries(Kind), + /// The value is an array, and each of its elements is. + Elements(Kind), +} + +/// Returns what the member `key` of an object of the kind `kind` holds, if +/// `forge` reads it. +fn child(kind: Kind, key: &str, value: &Value) -> Option { + use Kind::*; + use Shape::*; + Some(match (kind, key) { + (Document, "paths") => Entries(PathItem), + (Document, "components") => One(Components), + (Components, "schemas") => Values(Schema), + (Components, "parameters") => Values(Parameter), + (Components, "requestBodies" | "responses") => Values(Body), + (Components, "pathItems") => Values(PathItem), + (PathItem, "parameters") => Elements(Parameter), + ( + PathItem, + "get" | "put" | "post" | "delete" | "options" | "head" | "patch" | "trace" | "query" | "connect", + ) => One(Operation), + (PathItem, "additionalOperations") => Values(Operation), + (Operation, "parameters") => Elements(Parameter), + (Operation, "requestBody") => One(Body), + (Operation, "responses") => Entries(Body), + (Operation, "x-forge-aliases") => Elements(Alias), + (Parameter, "schema") => One(Schema), + (Parameter, "content") => Values(Media), + (Body, "content") => Values(Media), + (Media, "schema") => One(Schema), + (Schema, "properties" | "$defs") => Values(Schema), + (Schema, "additionalProperties" | "unevaluatedProperties") => One(Schema), + (Schema, "items") if value.is_object() => One(Schema), + (Schema, "items" | "allOf" | "anyOf" | "oneOf") => Elements(Schema), + _ => return None, + }) +} + +/// What is done to a member of an object. +enum Action { + /// The key is written as the native key, with its value, or with this one. + Rename(&'static str, Option), + /// The member is removed, since a native key says what it says. + Remove, + /// The native key is written before it, with its value, or with this one, + /// and it stays. + Insert(&'static str, Option), +} + +/// Walks a document, which is changed, and returns what was done. +pub(super) fn walk(document: &mut Value, options: &Options) -> Report { + let mut walk = Walk { + options, + report: Report::default(), + }; + walk.visit(Kind::Document, document); + walk.report +} + +struct Walk<'o> { + options: &'o Options, + report: Report, +} + +impl Walk<'_> { + /// Reads an object of the kind `kind`: its keys that are to be rewritten + /// are, and then what is in it that is read. + fn visit(&mut self, kind: Kind, value: &mut Value) { + let Some(members) = value.as_object_mut() else { + return; + }; + match kind { + Kind::Operation | Kind::Alias => self.rewrite(members, vendor::definition), + Kind::Parameter => self.rewrite(members, vendor::parameter), + Kind::Schema => self.rewrite(members, vendor::schema), + Kind::Document | Kind::Components | Kind::PathItem | Kind::Body | Kind::Media => {} + } + for (key, member) in members.iter_mut() { + let Some(shape) = child(kind, key, member) else { + continue; + }; + match shape { + Shape::One(kind) => self.visit(kind, member), + Shape::Values(kind) => { + if let Some(members) = member.as_object_mut() { + for member in members.values_mut() { + self.visit(kind, member); + } + } + } + Shape::Entries(kind) => { + if let Some(members) = member.as_object_mut() { + for (key, member) in members { + if !key.starts_with("x-") { + self.visit(kind, member); + } + } + } + } + Shape::Elements(kind) => { + if let Some(elements) = member.as_array_mut() { + for element in elements.iter_mut() { + self.visit(kind, element); + } + } + } + } + } + } + + /// Decides what to do to the keys of the object that a native key replaces, + /// as `read` says which they are for the kind of object it is, and does it. + fn rewrite(&mut self, members: &mut Map, read: fn(&str, super::Sources) -> Option) { + let mut actions: Vec<(String, Action)> = Vec::new(); + for (key, value) in members.iter() { + if !key.starts_with("x-") { + continue; + } + let Some(hit) = read(key, self.options.sources) else { + continue; + }; + // A list of targets is something no native key says: it is left + // as it is written, and not for a rewrite to say some other way + // or to drop. + if hit.form == Form::BoolOrTargets && value.is_array() { + continue; + } + // The native key that is beside is the one that decides. + let writes = !members.contains_key(hit.native); + let written = match value { + Value::Array(names) if writes && hit.form == Form::Group => dotted(names).map(Value::String), + _ => None, + }; + if written.is_some() { + self.report.joined += 1; + } + let action = match (self.options.leave_old_extensions, writes) { + (false, true) => Action::Rename(hit.native, written), + (false, false) => Action::Remove, + (true, true) => Action::Insert(hit.native, written), + (true, false) => continue, + }; + *self.report.migrated.entry(key.clone()).or_insert(0) += 1; + actions.push((key.clone(), action)); + } + if actions.is_empty() { + return; + } + + // The members are written again in the order they were in. If two keys + // of other tools said the same native field, the last would win here, + // where a rewrite of bytes would have written the native key twice, for + // `forge` to reject. No table has two keys for a field today. + for (key, value) in std::mem::take(members) { + match actions.iter().position(|(name, _)| *name == key) { + None => { + members.insert(key, value); + } + Some(at) => match actions.swap_remove(at).1 { + Action::Rename(native, written) => { + members.insert(native.to_owned(), written.unwrap_or(value)); + } + Action::Remove => {} + Action::Insert(native, written) => { + members.insert(native.to_owned(), written.unwrap_or_else(|| value.clone())); + members.insert(key, value); + } + }, + } + } + } +} diff --git a/tests/normalize/known_gaps.rs b/tests/normalize/known_gaps.rs new file mode 100644 index 000000000..3883525b3 --- /dev/null +++ b/tests/normalize/known_gaps.rs @@ -0,0 +1,142 @@ +//! What `forge normalize` gets wrong because a document is read with +//! `serde_json` and written again, as it happens today. +//! +//! Each test says what a document becomes, which is not what it should be, and +//! passes. When one of them fails, a gap was fixed: move it out of here and +//! out of "Known limitations" in the README of the package, if it is in it. + +use forge::normalize::{Options, normalize}; + +/// A document with one key to rewrite, so that it is written again, and +/// `extra` as more of the members of the operation. +fn with_a_key_to_rewrite(extra: &str) -> String { + format!( + "{{\n \"openapi\": \"3.0.3\",\n \"info\": {{\"title\": \"t\", \"version\": \"1\"}},\n \"paths\": {{\"/a\": {{\"get\": {{\n \"x-fern-sdk-method-name\": \"get\",\n {extra}\n \"responses\": {{\"204\": {{\"description\": \"ok\"}}}}}}}}}}\n}}\n" + ) +} + +#[track_caller] +fn rewritten(document: &str) -> String { + let normalized = normalize(document.as_bytes(), &Options::default()).unwrap(); + String::from_utf8(normalized.json.expect("there is a key to rewrite")).unwrap() +} + +/// GAP: a key written twice is not an error, and not left: the first of the +/// two is dropped, without a word. `forge` itself says `duplicate field`. +#[test] +fn a_key_written_twice_loses_the_first_copy_without_a_word() { + let text = rewritten(&with_a_key_to_rewrite( + r#""description": "first", "description": "second","#, + )); + + assert!(text.contains("second") && !text.contains("first"), "{text}"); +} + +/// GAP: a number is read as a 64-bit float, or an integer that fits in 64 +/// bits, so a longer one is rounded, and one written as `1E5` is written as +/// `100000.0`. `forge` reads them as floats too, but the document is what is +/// rewritten, and what is read by every other tool. +#[test] +fn a_number_that_does_not_fit_is_rounded_and_the_others_are_written_their_own_way() { + let text = rewritten(&with_a_key_to_rewrite( + r#""x-ids": [100000000000000000001, 3.14159265358979323846, 1E5, 0.10, 2.50],"#, + )); + + assert!(text.contains("1e+20"), "{text}"); + assert!( + text.contains("3.141592653589793") && !text.contains("3.1415926535897932"), + "{text}" + ); + assert!(text.contains("100000.0") && !text.contains("1E5"), "{text}"); + assert!(text.contains("0.1") && !text.contains("0.10"), "{text}"); + assert!(text.contains("2.5") && !text.contains("2.50"), "{text}"); +} + +/// GAP: line endings are not kept: a document with `\r\n` is written with +/// `\n`, so every line of it is in the diff. +#[test] +fn windows_line_endings_are_written_as_unix_ones() { + let text = rewritten(&with_a_key_to_rewrite("").replace('\n', "\r\n")); + + assert!(!text.contains('\r'), "{text:?}"); +} + +/// GAP: an array or an object that is written on one line is written on +/// several, which is a diff of every one of them in a document that is +/// formatted that way, as most formatters of JSON do. +#[test] +fn an_array_on_one_line_is_written_on_several() { + let text = rewritten(&with_a_key_to_rewrite(r#""tags": ["a", "b"],"#)); + + assert!(!text.contains(r#"["a", "b"]"#), "{text}"); + assert!(text.contains("\"tags\": [\n"), "{text}"); +} + +/// GAP: a document on one line is written on one line, with no space after a +/// comma or a colon, which is not how it was written if it had them. +#[test] +fn spaces_in_a_document_on_one_line_are_not_kept() { + let document = r#"{"openapi": "3.0.3", "info": {"title": "t", "version": "1"}, "paths": {"/a": {"get": {"x-fern-ignore": true, "responses": {"204": {"description": "ok"}}}}}}"#; + + let text = rewritten(document); + + assert!(!text.contains(", ") && !text.contains(": "), "{text}"); +} + +/// GAP: an escape is written as the character, so `\u00e9`, `\/` and `\u003c` +/// are not what they were, and a document with them is a diff of every line +/// that has one. +#[test] +fn escapes_in_strings_are_written_as_the_characters() { + let text = rewritten(&with_a_key_to_rewrite( + r#""description": "caf\u00e9 a\/b \u003ctag\u003e","#, + )); + + assert!(text.contains("café a/b "), "{text}"); +} + +/// GAP: JSON with comments, which some call JSONC, is not JSON, and is an error. +#[test] +fn json_with_comments_is_an_error() { + let err = normalize(b"// api\n{\"openapi\": \"3.0.3\"}", &Options::default()).unwrap_err(); + + assert!(!err.to_string().is_empty()); +} + +/// GAP: a document nested deeper than `serde_json` allows, 128 levels, is an +/// error, wherever it is nested. +#[test] +fn a_document_nested_deeper_than_128_levels_is_an_error() { + let document = format!( + r#"{{"x-fern-ignore": true, "x-other": {}{}}}"#, + "[".repeat(200), + "]".repeat(200) + ); + + let err = normalize(document.as_bytes(), &Options::default()).unwrap_err(); + + assert!(err.to_string().contains("recursion limit"), "{err}"); +} + +/// GAP: the whitespace outside strings is the writer's: one indentation for +/// the file, no blank lines, no spaces at the end of a line, nothing before the +/// first `{`, and one newline at the end. +#[test] +fn whitespace_is_the_writers() { + let document = "\n\n {\n \"openapi\": \"3.0.3\", \n\t\"paths\": {\"/a\": {\"get\": {\n\n \"x-fern-ignore\": true}}}\n} \n\n\n"; + + let text = rewritten(document); + + assert_eq!( + text, + "{\n \"openapi\": \"3.0.3\",\n \"paths\": {\n \"/a\": {\n \"get\": {\n \"x-forge-skip\": true\n }\n }\n }\n}\n" + ); +} + +/// GAP: `-0` is read as a float, and written as one. +#[test] +fn minus_zero_is_written_as_a_float() { + let text = rewritten(&with_a_key_to_rewrite(r#""x-n": -0,"#)); + + assert!(text.contains("-0.0"), "{text}"); +} diff --git a/tests/normalize/main.rs b/tests/normalize/main.rs new file mode 100644 index 000000000..fcf070fed --- /dev/null +++ b/tests/normalize/main.rs @@ -0,0 +1,9 @@ +//! Tests for `forge normalize`: the keys other tools write, rewritten into the +//! native extensions that say the same things. What the rewrite gets wrong, +//! because a document is read and written again, is in `known_gaps`. + +#![cfg(feature = "cli")] + +mod known_gaps; +#[cfg(feature = "cli")] +mod normalize_cli; diff --git a/tests/normalize/normalize_cli.rs b/tests/normalize/normalize_cli.rs new file mode 100644 index 000000000..14848580d --- /dev/null +++ b/tests/normalize/normalize_cli.rs @@ -0,0 +1,692 @@ +//! `forge normalize`, the command, on files and on standard input. + +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +use serde_json::Value; + +/// A document with one operation, written on one line, with no final newline. +const FERN: &str = r#"{"openapi":"3.0.3","info":{"title":"t","version":"1"},"paths":{"/a":{"get":{"x-fern-sdk-group-name":["a"],"x-fern-sdk-method-name":"get","responses":{"204":{"description":"ok"}}}}}}"#; + +/// The same document, written native. +const NATIVE: &str = r#"{"openapi":"3.0.3","info":{"title":"t","version":"1"},"paths":{"/a":{"get":{"x-forge-group-name":"a","x-forge-method-name":"get","responses":{"204":{"description":"ok"}}}}}}"#; + +/// A document with a key from each compatibility source. +const SOURCES: &str = r##"{"openapi":"3.0.3","info":{"title":"t","version":"1"},"paths":{"/a":{"get":{"x-fern-sdk-group-name":["a"],"x-fern-sdk-method-name":"get","responses":{"200":{"description":"ok","content":{"application/json":{"schema":{"$ref":"#/components/schemas/S"}}}}}}}},"components":{"schemas":{"S":{"type":"object","properties":{"secret":{"type":"string","x-sensitive":true},"hidden":{"type":"string","x-stainless-skip":true}}}}}}"##; + +/// A folder of its own to write files in, which is empty. +fn dir(name: &str) -> PathBuf { + let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join("normalize").join(name); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir +} + +/// Runs `forge normalize` with `args`, writing `stdin` to it. +fn normalize(args: &[&str], stdin: &str) -> Output { + forge(&[&["normalize"], args].concat(), stdin) +} + +/// Runs `forge normalize --write` with `args`, which rewrites the files in place. +fn rewrite(args: &[&str], stdin: &str) -> Output { + normalize(&[&["--write"], args].concat(), stdin) +} + +/// Runs `forge` with `args`, writing `stdin` to it. +fn forge(args: &[&str], stdin: &str) -> Output { + let mut child = Command::new(env!("CARGO_BIN_EXE_forge")) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + // Beside the output being read: a child that has written more than a pipe + // holds is not reading its input until that is read. + let mut input = child.stdin.take().unwrap(); + let stdin = stdin.to_owned(); + // A child that exits before it has read it all is not a failure to write. + let writer = std::thread::spawn(move || input.write_all(stdin.as_bytes())); + let output = child.wait_with_output().unwrap(); + let _ = writer.join().unwrap(); + output +} + +fn text(bytes: &[u8]) -> &str { + std::str::from_utf8(bytes).unwrap() +} + +#[track_caller] +fn succeeded(out: &Output) { + assert!(out.status.success(), "forge failed: {}", text(&out.stderr)); +} + +#[track_caller] +fn failed(out: &Output) -> &str { + assert!(!out.status.success(), "forge succeeded"); + text(&out.stderr) +} + +#[test] +fn a_file_is_rewritten_in_place_as_it_was_written() { + let dir = dir("in-place"); + let file = dir.join("spec.json"); + fs::write(&file, FERN).unwrap(); + + let out = rewrite(&[file.to_str().unwrap()], ""); + + succeeded(&out); + assert_eq!( + fs::read_to_string(&file).unwrap(), + NATIVE, + "still on one line, with no final newline" + ); + let err = text(&out.stderr); + assert!(err.contains("rewrote 2 keys"), "{err}"); + assert!( + !err.contains("x-fern-sdk-method-name: 1"), + "the keys are listed only with -v: {err}" + ); + assert!(err.contains("1 file: 1 rewritten, 0 unchanged"), "{err}"); + let leftovers: Vec<_> = fs::read_dir(&dir).unwrap().collect(); + assert_eq!(leftovers.len(), 1, "no file is left beside it"); +} + +#[test] +fn a_file_that_needs_nothing_is_left_as_it_is() { + let dir = dir("native"); + let file = dir.join("spec.json"); + let written = format!("{NATIVE}\n\n"); + fs::write(&file, &written).unwrap(); + + let out = rewrite(&[file.to_str().unwrap()], ""); + + succeeded(&out); + assert_eq!(fs::read_to_string(&file).unwrap(), written); + assert!( + text(&out.stderr).contains("1 file: 0 rewritten, 1 unchanged"), + "{}", + text(&out.stderr) + ); +} + +#[test] +fn the_old_keys_can_be_left() { + let dir = dir("leave"); + let file = dir.join("spec.json"); + fs::write(&file, FERN).unwrap(); + + succeeded(&rewrite(&["--leave-old-extensions", file.to_str().unwrap()], "")); + + let value: Value = serde_json::from_str(&fs::read_to_string(&file).unwrap()).unwrap(); + let operation = &value["paths"]["/a"]["get"]; + assert_eq!(operation["x-fern-sdk-method-name"], "get"); + assert_eq!(operation["x-forge-method-name"], "get"); +} + +#[test] +fn standard_input_is_written_to_standard_output() { + let out = normalize(&["-"], FERN); + + succeeded(&out); + assert_eq!(text(&out.stdout), NATIVE); +} + +#[test] +fn from_rewrites_only_the_selected_compatibility_sources_and_may_be_repeated() { + let out = normalize(&["--from", "fern", "--from", "cloudflare", "-"], SOURCES); + + succeeded(&out); + let value: Value = serde_json::from_slice(&out.stdout).unwrap(); + let operation = &value["paths"]["/a"]["get"]; + assert_eq!(operation["x-forge-method-name"], "get"); + assert!(operation.get("x-fern-sdk-method-name").is_none()); + let properties = &value["components"]["schemas"]["S"]["properties"]; + assert_eq!(properties["secret"]["x-forge-sensitive"], true); + assert!(properties["secret"].get("x-sensitive").is_none()); + assert_eq!(properties["hidden"]["x-stainless-skip"], true); + assert!(properties["hidden"].get("x-forge-skip").is_none()); +} + +#[test] +fn deprecated_is_a_compatibility_source_and_currently_has_no_keys() { + let out = normalize(&["--from", "deprecated", "-"], SOURCES); + + succeeded(&out); + assert_eq!(text(&out.stdout), SOURCES); +} + +#[test] +fn without_from_every_compatibility_source_is_rewritten() { + let out = normalize(&["-"], SOURCES); + + succeeded(&out); + let text = text(&out.stdout); + for old in ["x-fern-", "x-sensitive", "x-stainless-"] { + assert!(!text.contains(old), "{old} is left in {text}"); + } +} + +#[test] +fn standard_input_that_needs_nothing_comes_out_as_it_went_in() { + let document = format!(" {NATIVE} \n"); + + let out = normalize(&["-"], &document); + + succeeded(&out); + assert_eq!(text(&out.stdout), document); +} + +#[test] +fn standard_input_cannot_be_given_with_files() { + let out = normalize(&["-", "spec.json"], ""); + + let err = failed(&out); + + assert!(err.contains("- cannot be given with other inputs"), "{err}"); +} + +#[test] +fn a_file_that_is_missing_is_an_error_that_names_it() { + let out = rewrite(&["missing.json"], ""); + + let err = failed(&out); + assert!(err.contains("forge: missing.json: "), "{err}"); +} + +#[test] +fn a_file_that_is_not_json_is_an_error_and_the_others_are_still_rewritten() { + let dir = dir("invalid"); + fs::write(dir.join("a.json"), FERN).unwrap(); + // It has to look like it might change, or it is not parsed. + fs::write(dir.join("b.json"), r#"{"x-fern-ignore": "#).unwrap(); + + let (a, b) = (dir.join("a.json"), dir.join("b.json")); + + let out = rewrite(&[a.to_str().unwrap(), b.to_str().unwrap()], ""); + + let err = failed(&out); + assert!(err.contains("b.json"), "{err}"); + assert!(err.contains("1 file could not be processed"), "{err}"); + assert_eq!(fs::read_to_string(dir.join("a.json")).unwrap(), NATIVE); + assert_eq!(fs::read_to_string(dir.join("b.json")).unwrap(), r#"{"x-fern-ignore": "#); +} + +#[test] +fn a_native_key_wins_and_the_other_is_dropped() { + let dir = dir("conflict"); + let file = dir.join("spec.json"); + fs::write( + &file, + r#"{"openapi":"3.0.3","info":{"title":"t","version":"1"},"paths":{"/a":{"get":{"x-fern-sdk-method-name":"old","x-forge-method-name":"new","x-forge-group-name":["a"],"responses":{"204":{"description":"ok"}}}}}}"#, + ) + .unwrap(); + + let out = rewrite(&[file.to_str().unwrap()], ""); + + succeeded(&out); + let written = fs::read_to_string(&file).unwrap(); + assert!(written.contains(r#""x-forge-method-name":"new""#), "{written}"); + assert!(!written.contains("x-fern"), "{written}"); +} + +#[test] +fn a_group_that_cannot_be_a_dotted_string_is_kept_and_nothing_is_said_about_it() { + let dir = dir("kept"); + let file = dir.join("spec.json"); + fs::write(&file, FERN.replace(r#"["a"]"#, r#"["a.b"]"#)).unwrap(); + + let out = rewrite(&[file.to_str().unwrap()], ""); + + succeeded(&out); + let err = text(&out.stderr); + assert!( + !err.contains("note") && !err.contains("warning"), + "an array that is kept is expected: {err}" + ); + assert_eq!( + fs::read_to_string(&file).unwrap(), + NATIVE.replace(r#""x-forge-group-name":"a""#, r#""x-forge-group-name":["a.b"]"#), + "the array is kept, and only the keys are renamed" + ); +} + +#[test] +fn what_it_did_is_a_line_for_each_file_and_the_keys_are_listed_with_v() { + let dir = dir("verbose"); + for name in ["a.json", "b.json"] { + fs::write(dir.join(name), FERN).unwrap(); + } + let (a, b) = (dir.join("a.json"), dir.join("b.json")); + + let quiet = rewrite(&[a.to_str().unwrap()], ""); + let verbose = rewrite(&["-v", b.to_str().unwrap()], ""); + + let (quiet, verbose) = (text(&quiet.stderr), text(&verbose.stderr)); + assert_eq!(quiet.lines().count(), 2, "{quiet}"); + assert!(verbose.contains(" x-fern-sdk-group-name: 1"), "{verbose}"); + assert!(verbose.contains(" x-fern-sdk-method-name: 1"), "{verbose}"); +} + +#[test] +fn check_writes_nothing_and_fails_when_there_is_something_to_rewrite() { + let dir = dir("check"); + let (old, native) = (dir.join("old.json"), dir.join("native.json")); + fs::write(&old, FERN).unwrap(); + fs::write(&native, NATIVE).unwrap(); + + let out = normalize(&["--check", old.to_str().unwrap(), native.to_str().unwrap()], ""); + + let err = failed(&out); + assert!(err.contains("old.json: would rewrite 2 keys"), "{err}"); + assert!(err.contains("forge: 1 of 2 files would be rewritten"), "{err}"); + assert_eq!(fs::read_to_string(&old).unwrap(), FERN, "nothing is written"); +} + +#[test] +fn check_succeeds_when_there_is_nothing_to_rewrite() { + let dir = dir("check-clean"); + let file = dir.join("spec.json"); + fs::write(&file, NATIVE).unwrap(); + + let out = normalize(&["--check", file.to_str().unwrap()], ""); + + succeeded(&out); + assert!( + text(&out.stderr).contains("1 file checked, none would be rewritten"), + "{}", + text(&out.stderr) + ); +} + +#[test] +fn check_on_standard_input_writes_nothing_and_fails_when_there_is_something_to_rewrite() { + let out = normalize(&["--check", "-"], FERN); + + let err = failed(&out); + assert!(err.contains("the document would be rewritten"), "{err}"); + assert!(out.stdout.is_empty(), "nothing is written"); + assert!(normalize(&["--check", "-"], NATIVE).status.success()); +} + +#[test] +fn a_yaml_file_is_an_error_and_is_not_reported_as_native() { + let dir = dir("yaml"); + for name in ["spec.yml", "spec.YAML"] { + fs::write(dir.join(name), "openapi: 3.0.3\nx-fern-ignore: true\n").unwrap(); + let file = dir.join(name); + + let out = rewrite(&[file.to_str().unwrap()], ""); + + let err = failed(&out); + assert!(err.contains("YAML is not supported yet"), "{name}: {err}"); + assert!(err.contains(name), "{name}: {err}"); + assert!(!err.contains("unchanged"), "{name}: {err}"); + } +} + +#[test] +fn a_file_that_is_not_json_is_an_error_whatever_it_is_named() { + let dir = dir("not-an-object"); + for (name, content) in [ + ("yaml.json", "openapi: 3.0.3\nx-fern-ignore: true\n"), + ("empty.json", ""), + ("blank.json", " \n"), + ] { + fs::write(dir.join(name), content).unwrap(); + let file = dir.join(name); + + let out = rewrite(&[file.to_str().unwrap()], ""); + + let err = failed(&out); + assert!(err.contains(name), "{name}: {err}"); + assert!(!err.contains("unchanged"), "{name}: {err}"); + assert_eq!(fs::read_to_string(&file).unwrap(), content, "{name} is left as it is"); + } +} + +#[test] +fn the_usage_says_forge_normalize_and_the_help_of_forge_lists_the_command() { + let help = normalize(&["--help"], ""); + let general = forge(&["--help"], ""); + + assert!( + text(&help.stdout).contains("Usage: forge normalize"), + "{}", + text(&help.stdout) + ); + assert!(text(&help.stdout).contains("--check"), "{}", text(&help.stdout)); + assert!( + text(&help.stdout).contains("--from ") + && text(&help.stdout).contains("cloudflare") + && text(&help.stdout).contains("deprecated") + && text(&help.stdout).contains("fern") + && text(&help.stdout).contains("stainless"), + "{}", + text(&help.stdout) + ); + assert!( + text(&general.stdout).contains("Commands:") && text(&general.stdout).contains(" normalize "), + "{}", + text(&general.stdout) + ); + assert!( + text(&general.stdout).contains("forge "), + "{}", + text(&general.stdout) + ); + let missing = normalize(&[], ""); + assert!(failed(&missing).contains("Usage: forge normalize")); +} + +#[cfg(unix)] +#[test] +fn a_link_stays_a_link_and_the_file_it_points_to_is_rewritten() { + let dir = dir("symlink"); + fs::create_dir_all(dir.join("real")).unwrap(); + fs::create_dir_all(dir.join("links")).unwrap(); + fs::write(dir.join("real/spec.json"), FERN).unwrap(); + std::os::unix::fs::symlink("../real/spec.json", dir.join("links/spec.json")).unwrap(); + + let out = rewrite(&[dir.join("links/spec.json").to_str().unwrap()], ""); + + succeeded(&out); + assert!( + fs::symlink_metadata(dir.join("links/spec.json")) + .unwrap() + .file_type() + .is_symlink(), + "the link was replaced by a file" + ); + assert_eq!(fs::read_to_string(dir.join("real/spec.json")).unwrap(), NATIVE); + assert!( + text(&out.stderr).contains("links/spec.json: rewrote 2 keys"), + "{}", + text(&out.stderr) + ); +} + +#[test] +fn a_file_given_more_than_once_is_rewritten_once() { + let dir = dir("twice"); + let file = dir.join("spec.json"); + fs::write(&file, FERN).unwrap(); + let again = format!("{}/./spec.json", dir.display()); + + for _ in 0..5 { + fs::write(&file, FERN).unwrap(); + let out = rewrite(&[file.to_str().unwrap(), &again], ""); + + succeeded(&out); + assert!( + text(&out.stderr).contains("1 file: 1 rewritten"), + "{}", + text(&out.stderr) + ); + assert_eq!(fs::read_to_string(&file).unwrap(), NATIVE); + } +} + +/// Nothing is changed unless `--write` says so. +#[test] +fn one_file_is_printed_rewritten_and_is_not_changed_without_write() { + let dir = dir("print"); + let file = dir.join("spec.json"); + fs::write(&file, FERN).unwrap(); + + let out = normalize(&[file.to_str().unwrap()], ""); + + succeeded(&out); + assert_eq!(text(&out.stdout), NATIVE); + assert_eq!(fs::read_to_string(&file).unwrap(), FERN, "the file is as it was"); + assert!( + text(&out.stderr).contains("spec.json: 2 keys rewritten in the output"), + "{}", + text(&out.stderr) + ); + assert!(!text(&out.stderr).contains("rewrote"), "nothing was written"); +} + +#[test] +fn a_file_with_nothing_to_rewrite_is_printed_as_it_is() { + let dir = dir("print-nothing"); + let file = dir.join("spec.json"); + fs::write(&file, NATIVE).unwrap(); + + let out = normalize(&[file.to_str().unwrap()], ""); + + succeeded(&out); + assert_eq!(text(&out.stdout), NATIVE); + assert_eq!(text(&out.stderr), ""); +} + +#[test] +fn several_files_are_only_rewritten_with_write_or_checked_with_check() { + let dir = dir("several"); + let (a, b) = (dir.join("a.json"), dir.join("b.json")); + fs::write(&a, FERN).unwrap(); + fs::write(&b, FERN).unwrap(); + let both = [a.to_str().unwrap(), b.to_str().unwrap()]; + + let out = normalize(&both, ""); + + let err = failed(&out); + assert!(err.contains("--write") && err.contains("--check"), "{err}"); + assert_eq!(text(&out.stdout), "", "nothing is printed"); + assert_eq!(fs::read_to_string(&a).unwrap(), FERN); + assert_eq!(fs::read_to_string(&b).unwrap(), FERN); + + // The same file twice is one file, which is printed. + succeeded(&normalize(&[both[0], both[0]], "")); +} + +#[test] +fn write_and_check_cannot_be_given_together() { + let dir = dir("write-check"); + let file = dir.join("spec.json"); + fs::write(&file, FERN).unwrap(); + + let out = normalize(&["--write", "--check", file.to_str().unwrap()], ""); + + let err = failed(&out); + assert!(err.contains("--write") && err.contains("--check"), "{err}"); + assert_eq!(fs::read_to_string(&file).unwrap(), FERN); +} + +#[test] +fn standard_input_cannot_be_rewritten_in_place() { + let out = normalize(&["--write", "-"], FERN); + + let err = failed(&out); + assert!(err.contains("- cannot be given with --write"), "{err}"); + assert_eq!(text(&out.stdout), ""); +} + +#[test] +fn write_rewrites_each_file_in_place_and_says_so() { + let dir = dir("write"); + let (a, b) = (dir.join("a.json"), dir.join("b.json")); + fs::write(&a, FERN).unwrap(); + fs::write(&b, NATIVE).unwrap(); + + let out = rewrite(&[a.to_str().unwrap(), b.to_str().unwrap()], ""); + + succeeded(&out); + assert_eq!(text(&out.stdout), "", "what is written is the files"); + assert_eq!(fs::read_to_string(&a).unwrap(), NATIVE); + assert!( + text(&out.stderr).contains("2 files: 1 rewritten, 1 unchanged"), + "{}", + text(&out.stderr) + ); +} + +#[test] +fn a_folder_is_an_error_that_says_so() { + let dir = dir("a-folder"); + fs::write(dir.join("a.json"), FERN).unwrap(); + + let out = rewrite(&[dir.to_str().unwrap()], ""); + + let err = failed(&out); + assert!(err.contains("is a folder, not a file"), "{err}"); + assert_eq!( + fs::read_to_string(dir.join("a.json")).unwrap(), + FERN, + "nothing in it is rewritten" + ); +} + +#[test] +fn a_pattern_is_for_the_shell_and_is_not_expanded() { + let dir = dir("pattern"); + fs::write(dir.join("a.json"), FERN).unwrap(); + + let out = rewrite(&[&format!("{}/*.json", dir.display())], ""); + + let err = failed(&out); + assert!(err.contains("*.json: "), "{err}"); + assert_eq!(fs::read_to_string(dir.join("a.json")).unwrap(), FERN); +} + +#[test] +fn a_file_whose_name_has_glob_characters_is_that_file() { + let dir = dir("brackets"); + let file = dir.join("spec[1].json"); + fs::write(&file, FERN).unwrap(); + + let out = rewrite(&[file.to_str().unwrap()], ""); + + succeeded(&out); + assert_eq!(fs::read_to_string(&file).unwrap(), NATIVE); +} + +#[cfg(unix)] +#[test] +fn a_read_only_file_is_not_rewritten() { + use std::os::unix::fs::PermissionsExt; + + let dir = dir("read-only"); + let file = dir.join("spec.json"); + fs::write(&file, FERN).unwrap(); + fs::set_permissions(&file, fs::Permissions::from_mode(0o444)).unwrap(); + + let out = rewrite(&[file.to_str().unwrap()], ""); + let check = normalize(&["--check", file.to_str().unwrap()], ""); + + let err = failed(&out); + assert!( + err.contains("spec.json: the file is read-only, so it is not rewritten"), + "{err}" + ); + assert_eq!(fs::read_to_string(&file).unwrap(), FERN); + assert!( + text(&check.stderr).contains("would rewrite 2 keys"), + "a check only reads: {}", + text(&check.stderr) + ); +} + +#[test] +fn a_byte_order_mark_is_an_error_and_the_file_is_left_as_it_is() { + let dir = dir("bom"); + let file = dir.join("spec.json"); + let content = format!("\u{feff}{FERN}"); + fs::write(&file, &content).unwrap(); + + let out = rewrite(&[file.to_str().unwrap()], ""); + + let err = failed(&out); + assert!(err.contains("spec.json: expected value at line 1 column 1"), "{err}"); + assert_eq!(fs::read_to_string(&file).unwrap(), content); +} + +#[test] +fn standard_input_is_called_stdin_and_a_failure_on_it_says_so() { + let out = normalize(&["-"], FERN); + let broken = normalize(&["-"], "{\"x-fern-ignore\": "); + + assert!( + text(&out.stderr).starts_with("stdin: 2 keys rewritten in the output"), + "{}", + text(&out.stderr) + ); + assert!(failed(&broken).contains("forge: stdin: "), "{}", text(&broken.stderr)); +} + +#[test] +fn v_says_how_many_group_paths_were_joined() { + let dir = dir("joined"); + let file = dir.join("spec.json"); + fs::write(&file, FERN).unwrap(); + + let out = rewrite(&["-v", file.to_str().unwrap()], ""); + + assert!( + text(&out.stderr).contains(" 1 group path written as a dotted string"), + "{}", + text(&out.stderr) + ); +} + +#[cfg(unix)] +#[test] +fn a_hard_link_stays_a_link_to_the_file_that_is_rewritten() { + let dir = dir("hard-link"); + let (a, b) = (dir.join("a.json"), dir.join("b.json")); + fs::write(&a, FERN).unwrap(); + fs::hard_link(&a, &b).unwrap(); + + succeeded(&rewrite(&[a.to_str().unwrap()], "")); + + assert_eq!(fs::read_to_string(&a).unwrap(), NATIVE); + assert_eq!( + fs::read_to_string(&b).unwrap(), + NATIVE, + "the other name is the same file, which is rewritten" + ); +} + +#[cfg(unix)] +#[test] +fn two_names_of_one_file_are_one_file_and_are_rewritten_once() { + let dir = dir("hard-links-given"); + let (a, b) = (dir.join("a.json"), dir.join("b.json")); + fs::write(&a, FERN).unwrap(); + fs::hard_link(&a, &b).unwrap(); + + let out = rewrite(&[a.to_str().unwrap(), b.to_str().unwrap()], ""); + + succeeded(&out); + assert!( + text(&out.stderr).contains("1 file: 1 rewritten"), + "{}", + text(&out.stderr) + ); + assert_eq!(fs::read_to_string(&b).unwrap(), NATIVE); +} + +#[cfg(unix)] +#[test] +fn a_file_in_a_folder_that_cannot_be_written_to_is_rewritten_in_place() { + use std::os::unix::fs::PermissionsExt; + + let dir = dir("read-only-folder"); + let file = dir.join("spec.json"); + fs::write(&file, FERN).unwrap(); + fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap(); + // The folder cannot be written to, unless this is run as the owner of + // everything, which it is not a test of. + if fs::write(dir.join("probe"), "").is_ok() { + fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap(); + return; + } + + let out = rewrite(&[file.to_str().unwrap()], ""); + + fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap(); + succeeded(&out); + assert_eq!(fs::read_to_string(&file).unwrap(), NATIVE); +}