ctl-core 0.6.12

Shared clap chassis for the *ctl CLIs
Documentation
//! Declared input: parse, validate once, and place every problem.
#![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::{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 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}"
        );
    }
}