use serde_json::Value;
use shipwright::{
dispatch, write_json, write_plain, BuildInfo, CliError, VersionMode, VersionSpec,
};
use shipwright_manifest::{ExecutableKind, Language, ManifestError};
use std::error::Error;
use std::io::{Error as IoError, ErrorKind, Write};
const FULL_CAPABILITIES: &[&str] = &["lsp", "stdio"];
#[derive(Debug)]
struct AlwaysFailWriter;
impl Write for AlwaysFailWriter {
fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
Err(IoError::new(ErrorKind::BrokenPipe, "closed"))
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[derive(Debug, Default)]
struct NewlineFailWriter {
bytes: Vec<u8>,
}
impl Write for NewlineFailWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
if buf == b"\n" {
return Err(IoError::new(ErrorKind::BrokenPipe, "newline rejected"));
}
self.bytes.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn minimal_spec() -> VersionSpec<'static> {
VersionSpec {
name: "deslop-lsp",
version: "0.4.2",
kind: ExecutableKind::Lsp,
language: Language::Rust,
product: None,
capabilities: &[],
build: BuildInfo {
git_sha: None,
git_dirty: None,
build_time: None,
target: None,
toolchain: None,
},
}
}
fn full_spec() -> VersionSpec<'static> {
VersionSpec {
name: "deslop-lsp",
version: "0.4.2",
kind: ExecutableKind::Lsp,
language: Language::Rust,
product: Some("deslop"),
capabilities: FULL_CAPABILITIES,
build: BuildInfo {
git_sha: Some("7a9b3e1"),
git_dirty: Some(false),
build_time: Some("2026-04-22T10:14:55Z"),
target: Some("aarch64-apple-darwin"),
toolchain: Some("rustc 1.84.0"),
},
}
}
fn invalid_spec() -> VersionSpec<'static> {
VersionSpec {
name: "Deslop",
..minimal_spec()
}
}
fn args(values: &[&str]) -> Vec<String> {
values.iter().copied().map(str::to_string).collect()
}
fn parse_payload(buf: &[u8]) -> Result<Value, serde_json::Error> {
serde_json::from_slice(buf)
}
#[test]
fn version_mode_parses_public_flags() {
assert_eq!(
VersionMode::from_args(&args(&["prog"])),
VersionMode::NotRequested
);
assert_eq!(
VersionMode::from_args(&args(&["prog", "-V"])),
VersionMode::Plain
);
assert_eq!(
VersionMode::from_args(&args(&["prog", "--json", "--version"])),
VersionMode::Json,
);
assert_eq!(
VersionMode::from_args(&args(&["prog", "--json"])),
VersionMode::NotRequested,
);
}
#[test]
fn dispatch_false_path_leaves_output_empty() -> Result<(), CliError> {
let mut out = Vec::new();
let handled = dispatch(&args(&["prog", "serve"]), &mut out, &minimal_spec())?;
assert!(!handled);
assert!(out.is_empty());
Ok(())
}
#[test]
fn write_plain_public_api_matches_contract() -> Result<(), std::io::Error> {
let mut out = Vec::new();
write_plain(&mut out, "deslop-lsp", "0.4.2")?;
assert_eq!(out, b"deslop-lsp 0.4.2\n");
Ok(())
}
#[test]
fn dispatch_plain_path_writes_version_line() -> Result<(), CliError> {
let mut out = Vec::new();
let handled = dispatch(&args(&["prog", "--version"]), &mut out, &minimal_spec())?;
assert!(handled);
assert_eq!(out, b"deslop-lsp 0.4.2\n");
Ok(())
}
#[test]
fn dispatch_json_path_writes_version_payload() -> Result<(), Box<dyn Error>> {
let mut out = Vec::new();
let handled = dispatch(
&args(&["prog", "--version", "--json"]),
&mut out,
&full_spec(),
)?;
let parsed = parse_payload(&out)?;
assert!(handled);
assert_eq!(parsed.get("manifestVersion"), Some(&Value::from(1u32)));
assert_eq!(parsed.get("product"), Some(&Value::from("deslop")));
assert_eq!(
parsed
.get("capabilities")
.and_then(Value::as_array)
.map(Vec::len),
Some(2)
);
Ok(())
}
#[test]
fn write_json_minimal_metadata_omits_optional_fields() -> Result<(), Box<dyn Error>> {
let mut out = Vec::new();
write_json(&mut out, &minimal_spec())?;
let parsed = parse_payload(&out)?;
assert_eq!(parsed.get("name"), Some(&Value::from("deslop-lsp")));
assert!(parsed.get("buildTime").is_none());
assert!(parsed.get("gitSha").is_none());
assert!(parsed.get("product").is_none());
assert!(parsed.get("capabilities").is_none());
Ok(())
}
#[test]
fn write_json_full_metadata_preserves_all_fields() -> Result<(), Box<dyn Error>> {
let mut out = Vec::new();
write_json(&mut out, &full_spec())?;
let parsed = parse_payload(&out)?;
assert_eq!(parsed.get("gitSha"), Some(&Value::from("7a9b3e1")));
assert_eq!(parsed.get("gitDirty"), Some(&Value::from(false)));
assert_eq!(
parsed.get("buildTime"),
Some(&Value::from("2026-04-22T10:14:55Z"))
);
assert_eq!(
parsed.get("target"),
Some(&Value::from("aarch64-apple-darwin"))
);
assert_eq!(parsed.get("toolchain"), Some(&Value::from("rustc 1.84.0")));
Ok(())
}
#[test]
fn dispatch_plain_error_uses_cli_io_variant() {
let mut writer = AlwaysFailWriter;
assert!(writer.flush().is_ok());
let result = dispatch(&args(&["prog", "--version"]), &mut writer, &minimal_spec());
assert!(matches!(result, Err(CliError::Io(_))));
}
#[test]
fn dispatch_json_error_uses_cli_manifest_variant() {
let mut out = Vec::new();
let result = dispatch(
&args(&["prog", "--version", "--json"]),
&mut out,
&invalid_spec(),
);
assert!(matches!(result, Err(CliError::Manifest(_))));
}
#[test]
fn write_json_newline_error_uses_cli_io_variant() {
let mut writer = NewlineFailWriter::default();
assert!(writer.flush().is_ok());
let result = write_json(&mut writer, &minimal_spec());
assert!(matches!(result, Err(CliError::Io(_))));
}
#[test]
fn write_json_body_error_uses_cli_manifest_variant() {
let mut writer = AlwaysFailWriter;
let result = write_json(&mut writer, &minimal_spec());
assert!(matches!(result, Err(CliError::Manifest(_))));
}
#[test]
fn mutable_writer_references_are_supported() -> Result<(), Box<dyn Error>> {
let mut plain = Vec::new();
let mut json = Vec::new();
let mut dispatch_plain = Vec::new();
let mut dispatch_json = Vec::new();
write_plain(&mut plain, "deslop-lsp", "0.4.2")?;
write_json(&mut json, &full_spec())?;
let plain_handled = dispatch(
&args(&["prog", "--version"]),
&mut dispatch_plain,
&minimal_spec(),
)?;
let json_handled = dispatch(
&args(&["prog", "--version", "--json"]),
&mut dispatch_json,
&full_spec(),
)?;
assert!(plain_handled);
assert!(json_handled);
Ok(())
}
#[test]
fn cli_error_variants_format_and_chain_sources() {
let io = CliError::Io(IoError::new(ErrorKind::BrokenPipe, "closed"));
let manifest = CliError::Manifest(ManifestError::InvalidVersion("bad".to_string()));
assert_eq!(io.to_string(), "io: closed");
assert_eq!(manifest.to_string(), "manifest: invalid semver `bad`");
assert!(format!("{io:?}").contains("Io"));
assert!(Error::source(&io).is_some());
assert!(Error::source(&manifest).is_some());
}
#[test]
fn public_types_expose_expected_traits() {
let spec = full_spec();
let cloned = spec.clone();
let build = cloned.build;
let mode = VersionMode::Json;
assert_eq!(cloned.name, "deslop-lsp");
assert_eq!(build.git_dirty, Some(false));
assert_eq!(mode, mode.clone());
assert!(format!("{cloned:?}").contains("deslop-lsp"));
}