#![allow(
missing_docs,
reason = "an integration test crate has no public API to document"
)]
#![cfg(feature = "input")]
use std::collections::BTreeMap;
use std::path::PathBuf;
use ctl_core::indoc;
use ctl_core::input::{FieldPath, Input, Problem};
use ctl_core::validate::{at_least_one, cannot_be_empty, inside_the_repo, one_file_name};
use garde::Validate;
use serde::Deserialize;
#[derive(Debug, Deserialize, Validate, PartialEq)]
struct Config {
#[garde(custom(cannot_be_empty("it names the release")))]
name: String,
#[garde(custom(at_least_one("label")))]
labels: Vec<String>,
#[garde(dive)]
#[serde(default)]
outputs: Vec<Output>,
}
#[derive(Debug, Deserialize, Validate, PartialEq)]
struct Output {
#[garde(custom(inside_the_repo))]
path: PathBuf,
#[garde(custom(one_file_name))]
file: String,
}
fn problem(line: usize, column: usize, field: &str, message: &str) -> Problem {
Problem {
line: Some(line),
column: Some(column),
field: field.to_owned(),
message: message.to_owned(),
}
}
#[test]
fn a_valid_file_loads() {
let input = Input::new(
"ver.yaml",
indoc! {"
name: release
labels: [ship]
"},
);
let config: Config = input.load().unwrap();
assert_eq!(config.name, "release");
assert_eq!(config.labels, ["ship"]);
}
#[test]
fn every_schema_problem_is_reported_on_its_line() {
let input = Input::new(
"ver.yaml",
indoc! {"
name: ''
labels: []
outputs:
- path: docs
file: a.md
- path: ../outside
file: nested/b.md
"},
);
let error = input.load::<Config>().unwrap_err();
let expected = vec![
problem(1, 7, "name", "cannot be empty — it names the release"),
problem(2, 9, "labels", "must declare at least one label"),
problem(6, 11, "outputs[1].path", "must stay inside the repository"),
problem(7, 11, "outputs[1].file", "must be one file name"),
];
let mut problems = error.problems.clone();
problems.sort_by_key(|problem| problem.line);
assert_eq!(problems, expected);
let shown = error.to_string();
assert!(
shown.contains("ver.yaml:6:11: outputs[1].path: must stay inside the repository"),
"{shown}"
);
}
#[test]
fn the_reader_refuses_loose_booleans_duplicate_keys_and_merge_keys() {
type Flags = BTreeMap<String, bool>;
let loose = Input::new(
"a.yaml",
indoc! {"
flag: yes
"},
);
assert!(loose.parse::<Flags>().is_err());
let duplicate = Input::new(
"b.yaml",
indoc! {"
flag: true
flag: false
"},
);
let error = duplicate.parse::<Flags>().unwrap_err();
assert_eq!(error.problems[0].line, Some(2), "{error}");
let merged = Input::new(
"c.yaml",
indoc! {"
base: &base
flag: true
copy:
<<: *base
"},
);
assert!(merged.parse::<BTreeMap<String, Flags>>().is_err());
}
#[test]
fn frontmatter_problems_keep_the_files_line_numbers() {
#[derive(Debug, Deserialize, Validate)]
struct Fragment {
#[garde(custom(cannot_be_empty("it names the package")))]
package: String,
}
let file = Input::new(
".changeset/a.md",
indoc! {"
---
package: ''
---
A summary.
"},
);
let (matter, body) = file.frontmatter().unwrap();
assert_eq!(body.trim(), "A summary.");
let error = matter.load::<Fragment>().unwrap_err();
assert_eq!(error.problems[0].line, Some(2), "{error}");
let list = Input::new(
".changeset/b.md",
indoc! {"
---
- package
---
"},
);
let (matter, _) = list.frontmatter().unwrap();
let error = matter.parse::<Fragment>().unwrap_err();
assert_eq!(error.problems[0].line, Some(2), "{error}");
assert!(!error.problems[0].message.contains("line"), "{error}");
let plain = Input::new(
"b.md",
indoc! {"
No frontmatter here.
"},
);
assert!(plain.frontmatter().is_none());
}
#[test]
fn an_alias_error_names_where_its_value_is_defined_in_the_file() {
#[derive(Debug, Deserialize)]
#[expect(dead_code, reason = "only the error is read")]
struct Copied {
base: BTreeMap<String, String>,
copy: Port,
}
#[derive(Debug, Deserialize)]
#[expect(dead_code, reason = "only the error is read")]
struct Port {
port: u16,
}
let file = Input::new(
"a.md",
indoc! {"
---
base: &b
port: eighty
copy: *b
---
"},
);
let (matter, _) = file.frontmatter().unwrap();
let error = matter.parse::<Copied>().unwrap_err();
assert_eq!(error.problems[0].line, Some(4), "{error}");
assert_eq!(error.problems[0].column, Some(7), "{error}");
assert_eq!(
error.problems[0].message,
"invalid u16 (defined at line 3, column 9)"
);
}
#[test]
fn an_alias_reached_through_another_alias_names_the_failing_value() {
#[derive(Debug, Deserialize)]
#[expect(dead_code, reason = "only the error is read")]
struct Copied {
base: BTreeMap<String, String>,
outer: BTreeMap<String, BTreeMap<String, String>>,
copy: Wrapped,
}
#[derive(Debug, Deserialize)]
#[expect(dead_code, reason = "only the error is read")]
struct Wrapped {
nested: Port,
}
#[derive(Debug, Deserialize)]
#[expect(dead_code, reason = "only the error is read")]
struct Port {
port: u16,
}
let file = Input::new(
"a.md",
indoc! {"
---
base: &b
port: eighty
outer: &o
nested: *b
copy: *o
---
"},
);
let (matter, _) = file.frontmatter().unwrap();
let error = matter.parse::<Copied>().unwrap_err();
assert_eq!(error.problems[0].line, Some(6), "{error}");
assert_eq!(error.problems[0].column, Some(7), "{error}");
assert_eq!(
error.problems[0].message,
"invalid u16 (defined at line 3, column 9)"
);
}
#[test]
fn a_path_that_leaves_the_repository_is_refused() {
for outside in ["/etc/passwd", "../up", "docs/../../up"] {
assert!(
inside_the_repo(std::path::Path::new(outside), &()).is_err(),
"{outside}"
);
}
for inside in ["docs", "docs/a.md", "./docs"] {
assert!(
inside_the_repo(std::path::Path::new(inside), &()).is_ok(),
"{inside}"
);
}
}
fn report<T: serde::de::DeserializeOwned>(name: &str, text: &str) -> String {
match Input::new(name, text).parse::<T>() {
Ok(_) => format!("{name}: parsed"),
Err(error) => error.to_string(),
}
}
#[test]
fn problem_reports_keep_their_wording() {
#[derive(Debug, Deserialize)]
#[expect(dead_code, reason = "only the error is read")]
struct Ledger {
schema_version: u32,
prefix: String,
#[serde(default)]
queue: Vec<BTreeMap<String, String>>,
}
#[derive(Debug, Deserialize)]
#[expect(dead_code, reason = "only the error is read")]
struct Port {
port: u16,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
#[expect(dead_code, reason = "only the error is read")]
struct Strict {
name: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "lowercase")]
enum Mode {
Fast,
}
#[derive(Debug, Deserialize)]
#[expect(dead_code, reason = "only the error is read")]
struct Moded {
mode: Mode,
}
#[derive(Debug, Deserialize)]
#[expect(dead_code, reason = "only the error is read")]
struct Copied {
base: BTreeMap<String, String>,
copy: Port,
}
type Flags = BTreeMap<String, bool>;
let reports = [
report::<Ledger>(
"twice.yaml",
indoc! {"
schema_version: 5
prefix: QCTL
prefix: QCTL
queue: []
"},
),
report::<Ledger>(
"block.yaml",
indoc! {"
schema_version: 5
prefix: QCTL
queue: [
- id: QCTL-001
]
"},
),
report::<Ledger>(
"open.yaml",
indoc! {"
schema_version: 5
prefix: QCTL
queue: [
"},
),
report::<Flags>("loose.yaml", "flag: yes\n"),
report::<BTreeMap<String, Flags>>(
"merge.yaml",
indoc! {"
base: &base
flag: true
copy:
<<: *base
"},
),
report::<Port>("port.yaml", "port: eighty\n"),
report::<Strict>("strict.yaml", "name: a\n\"ex\\u2028tra\": b\n"),
report::<Moded>("mode.yaml", "mode: \"sl\\aow\"\n"),
report::<Copied>(
"alias.yaml",
indoc! {"
base: &b
port: eighty
copy: *b
"},
),
];
insta::assert_snapshot!("problem_reports", reports.join("\n"));
}
#[derive(Debug, Deserialize, Validate, PartialEq)]
struct Globs {
#[garde(dive)]
files: BTreeMap<String, Entry>,
}
#[derive(Debug, Deserialize, Validate, PartialEq)]
struct Entry {
#[garde(custom(cannot_be_empty("it names the label")))]
label: String,
}
const GLOBS: &str = "\
files:
'docs/**/*.md':
label: ''
src/lib.rs:
label: ''
a[b]:
label: ''
";
fn glob_label(glob: &str) -> FieldPath {
FieldPath::root().key("files").key(glob).key("label")
}
#[test]
fn a_problem_under_a_key_that_holds_dots_stars_and_slashes_lands_on_that_key() {
let error = Input::new("globs.yaml", GLOBS).load::<Globs>().unwrap_err();
let message = "cannot be empty — it names the label";
let mut problems = error.problems.clone();
problems.sort_by_key(|problem| problem.line);
assert_eq!(
problems,
[
problem(3, 12, "files.docs/**/*.md.label", message),
problem(5, 12, "files.src/lib.rs.label", message),
problem(7, 12, "files.a[b].label", message),
]
);
}
#[test]
fn a_later_check_is_placed_through_place() {
let input = Input::new("globs.yaml", GLOBS);
let at = input.place(&glob_label("docs/**/*.md")).unwrap();
assert_eq!((at.line, at.column), (3, 12));
let at = input.place(&glob_label("a[b]")).unwrap();
assert_eq!((at.line, at.column), (7, 12));
assert_eq!(
input.problem_at(&glob_label("docs/**/*.md"), "is not a known label"),
problem(3, 12, "files.docs/**/*.md.label", "is not a known label")
);
}
#[test]
fn a_path_the_file_lacks_is_placed_at_its_nearest_ancestor() {
let input = Input::new("globs.yaml", GLOBS);
let files = input.place(&FieldPath::root().key("files")).unwrap();
assert_eq!(input.place(&glob_label("docs/**/*.rs")), Some(files));
assert_eq!(
input.place(&FieldPath::root().key("files").key("missing")),
Some(files)
);
}
#[test]
fn a_text_that_does_not_parse_places_nothing() {
let input = Input::new("bad.yaml", "files: [\n");
assert_eq!(input.place(&glob_label("docs/**/*.md")), None);
let problem = input.problem_at(&glob_label("docs/**/*.md"), "is not a known label");
assert_eq!((problem.line, problem.column), (None, None));
}
#[test]
fn a_frontmatter_place_keeps_the_line_of_the_whole_file() {
let file = Input::new(
"note.md",
"---\nfiles:\n 'a.b':\n label: x\n---\nbody\n",
);
let (matter, _) = file.frontmatter().unwrap();
let at = matter.place(&glob_label("a.b")).unwrap();
assert_eq!((at.line, at.column), (4, 12));
}