use std::path::Path;
use crate::error::TarnError;
use crate::model::Location;
use crate::parser;
const BUFFER_PATH: &str = "<buffer>";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FormatError {
pub message: String,
pub location: Option<Location>,
}
impl FormatError {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
location: None,
}
}
}
impl std::fmt::Display for FormatError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for FormatError {}
pub fn format_document(source: &str) -> Result<String, FormatError> {
if source.is_empty() {
return Ok(String::new());
}
if source.chars().all(|c| c.is_whitespace()) {
return Ok(source.to_string());
}
if let Err(parse_err) = serde_yaml::from_str::<serde_yaml::Value>(source) {
eprintln!("tarn::format: skipping format_document on unparseable buffer: {parse_err}");
return Ok(source.to_string());
}
match parser::format_str(source, Path::new(BUFFER_PATH)) {
Ok(formatted) => Ok(formatted),
Err(err) => Err(tarn_error_to_format_error(err)),
}
}
fn tarn_error_to_format_error(err: TarnError) -> FormatError {
FormatError {
message: err.to_string(),
location: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
const CANONICAL: &str = "name: Canonical\nsteps:\n- name: ping\n request:\n method: GET\n url: http://localhost:3000/ping\n assert:\n status: 200\n";
#[test]
fn format_document_on_already_canonical_input_is_identity() {
let formatted = format_document(CANONICAL).expect("canonical input must format");
assert_eq!(
formatted, CANONICAL,
"canonical input should round-trip unchanged"
);
}
#[test]
fn format_document_normalizes_field_order_and_whitespace() {
let input = r#"
steps:
- request:
url: http://localhost:3000
method: GET
name: Example
assert:
body:
"$.email":
not_empty: true
type: string
contains: "@"
status: 200
name: Format me
"#;
let formatted = format_document(input).expect("parseable input must format");
assert!(
formatted.starts_with("name: Format me\n"),
"name: should come first, got: {formatted}"
);
let name_idx = formatted
.find("name: Example")
.expect("name: Example present");
let method_idx = formatted.find("method: GET").expect("method: GET present");
let url_idx = formatted
.find("http://localhost:3000")
.expect("url present");
assert!(name_idx < method_idx, "name should precede method");
assert!(method_idx < url_idx, "method should precede url");
let type_idx = formatted.find("type: string").expect("type operator");
let contains_idx = formatted.find("contains: '@'").expect("contains operator");
let not_empty_idx = formatted
.find("not_empty: true")
.expect("not_empty operator");
assert!(type_idx < contains_idx);
assert!(contains_idx < not_empty_idx);
}
#[test]
fn format_document_preserves_schema_directive_comment() {
let input = r#"# yaml-language-server: $schema=https://raw.githubusercontent.com/NazarKalytiuk/hive/main/schemas/v1/testfile.json
name: With schema
steps:
- name: ping
request:
method: GET
url: http://localhost:3000/ping
"#;
let formatted = format_document(input).expect("directive input must format");
assert!(
formatted.starts_with(
"# yaml-language-server: $schema=https://raw.githubusercontent.com/NazarKalytiuk/hive/main/schemas/v1/testfile.json\n"
),
"schema directive must stay at the top of the formatted buffer, got: {formatted}"
);
assert!(formatted.contains("name: With schema\n"));
}
#[test]
fn format_document_on_unparseable_yaml_returns_identity_no_error() {
let broken = "name: broken\nsteps: [\n - name: oops\n";
let formatted = format_document(broken).expect("broken input must NOT error");
assert_eq!(formatted, broken, "broken YAML should be returned verbatim");
}
#[test]
fn format_document_on_empty_input_returns_empty_string() {
let formatted = format_document("").expect("empty input must not error");
assert_eq!(formatted, "", "empty input should produce empty output");
}
#[test]
fn format_document_on_whitespace_only_input_returns_input_verbatim() {
let ws = " \n\t\n ";
let formatted = format_document(ws).expect("whitespace input must not error");
assert_eq!(formatted, ws, "whitespace-only should be preserved");
}
#[test]
fn format_document_on_schema_invalid_input_returns_format_error() {
let input = "steps:\n - name: orphan\n request:\n method: GET\n url: http://localhost:3000\n";
let err = format_document(input).expect_err("schema-invalid input must return FormatError");
assert!(
!err.message.is_empty(),
"FormatError must carry a non-empty message, got: {err:?}"
);
}
#[test]
fn format_document_is_idempotent() {
let input = r#"
name: Idempotent
steps:
- request:
url: http://localhost:3000
method: GET
name: step
"#;
let once = format_document(input).expect("first format must succeed");
let twice = format_document(&once).expect("second format must succeed");
assert_eq!(
once, twice,
"format_document must be idempotent: re-formatting a formatted buffer is a no-op"
);
}
#[test]
fn format_error_display_renders_message_verbatim() {
let err = FormatError::new("boom");
assert_eq!(err.to_string(), "boom");
assert!(err.location.is_none());
}
}