use std::ffi::OsStr;
use std::io;
use std::ops::RangeInclusive;
use std::path::PathBuf;
use std::process::Command;
use thiserror::Error;
use crate::command::internal_command::InternalScarbCommandBuilder;
use crate::{Metadata, VersionPin};
#[derive(Error, Debug)]
#[non_exhaustive]
pub enum MetadataCommandError {
#[error("`scarb metadata` command did not produce any metadata")]
NotFound {
stdout: String,
},
#[error("failed to read `scarb metadata` output")]
Io(#[from] io::Error),
#[error("failed to deserialize `scarb metadata` output")]
Json(#[from] serde_json::Error),
#[error("`scarb metadata` exited with error\n\nstdout:\n{stdout}\nstderr:\n{stderr}")]
ScarbError {
stdout: String,
stderr: String,
},
}
impl MetadataCommandError {
pub const fn did_not_found(&self) -> bool {
matches!(self, Self::NotFound { .. })
}
}
#[derive(Clone, Debug, Default)]
pub struct MetadataCommand {
inner: InternalScarbCommandBuilder,
no_deps: bool,
inherit_stdout: bool,
json: bool,
}
impl MetadataCommand {
pub fn new() -> Self {
Self::default()
}
pub fn scarb_path(&mut self, path: impl Into<PathBuf>) -> &mut Self {
self.inner.scarb_path(path);
self
}
pub fn manifest_path(&mut self, path: impl Into<PathBuf>) -> &mut Self {
self.inner.manifest_path(path);
self
}
pub fn current_dir(&mut self, path: impl Into<PathBuf>) -> &mut Self {
self.inner.current_dir(path);
self
}
pub fn no_deps(&mut self) -> &mut Self {
self.no_deps = true;
self
}
pub fn profile(&mut self, profile: impl AsRef<OsStr>) -> &mut Self {
self.env("SCARB_PROFILE", profile)
}
pub fn dev(&mut self) -> &mut Self {
self.profile("dev")
}
pub fn release(&mut self) -> &mut Self {
self.profile("release")
}
pub fn env(&mut self, key: impl AsRef<OsStr>, val: impl AsRef<OsStr>) -> &mut Self {
self.inner.env(key, val);
self
}
pub fn envs<I, K, V>(&mut self, vars: I) -> &mut Self
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<OsStr>,
V: AsRef<OsStr>,
{
self.inner.envs(vars);
self
}
pub fn env_remove(&mut self, key: impl AsRef<OsStr>) -> &mut Self {
self.inner.env_remove(key);
self
}
pub fn env_clear(&mut self) -> &mut Self {
self.inner.env_clear();
self
}
pub fn inherit_stderr(&mut self) -> &mut Self {
self.inner.inherit_stderr();
self
}
pub fn inherit_stdout(&mut self) -> &mut Self {
self.inherit_stdout = true;
self
}
pub fn json(&mut self) -> &mut Self {
self.json = true;
self
}
fn scarb_command(&self) -> Command {
let mut builder = self.inner.clone();
if self.json {
builder.json();
}
builder.args(["metadata", "--format-version"]);
builder.arg(VersionPin.numeric().to_string());
if self.no_deps {
builder.arg("--no-deps");
}
builder.command()
}
pub fn exec(&self) -> Result<Metadata, MetadataCommandError> {
let mut cmd = self.scarb_command();
let output = cmd.output()?;
let stdout_string = String::from_utf8_lossy(&output.stdout).to_string();
if output.status.success() {
let parse_result = parse_stream(stdout_string.clone());
let data = parse_result
.as_ref()
.map(|parse_result| {
stdout_string
.split('\n')
.enumerate()
.filter(|(n, _)| !parse_result.used_lines.contains(n))
.map(|(_, line)| line)
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or(stdout_string);
self.print(&data);
parse_result.map(|result| result.metadata)
} else {
self.print(&stdout_string);
Err(MetadataCommandError::ScarbError {
stdout: stdout_string,
stderr: String::from_utf8_lossy(&output.stderr).into(),
})
}
}
fn print(&self, data: &str) {
if self.inherit_stdout {
print!("{data}");
}
}
}
#[derive(Debug)]
struct ParseResult {
metadata: Metadata,
used_lines: RangeInclusive<usize>,
}
impl ParseResult {
fn new(metadata: Metadata, used_lines: RangeInclusive<usize>) -> Self {
Self {
metadata,
used_lines,
}
}
}
fn parse_stream(stdout: String) -> Result<ParseResult, MetadataCommandError> {
const OPEN_BRACKET: &str = "{";
const CLOSE_BRACKET: &str = "}";
let mut err = None;
let mut lines = stdout.split('\n').map(|line| line.trim_end()).enumerate();
for (n, line) in lines
.clone()
.filter(|(_, line)| line.starts_with(OPEN_BRACKET) && line.ends_with(CLOSE_BRACKET))
{
match serde_json::from_str(line) {
Ok(metadata) => return Ok(ParseResult::new(metadata, n..=n)),
Err(serde_err) => err = Some(serde_err.into()),
}
}
loop {
let json_lines = lines
.by_ref()
.skip_while(|(_, line)| *line != OPEN_BRACKET)
.skip(1)
.take_while(|(_, line)| *line != CLOSE_BRACKET);
let json_lines = json_lines.collect::<Vec<_>>();
let used_lines = match (json_lines.first(), json_lines.last()) {
(Some((first, _)), Some((last, _))) => *first - 1..=*last + 1,
_ => break,
};
let json_string = json_lines
.into_iter()
.map(|(_, line)| line)
.collect::<Vec<_>>()
.join("");
match serde_json::from_str(&format!("{OPEN_BRACKET}{json_string}{CLOSE_BRACKET}")) {
Ok(metadata) => return Ok(ParseResult::new(metadata, used_lines)),
Err(serde_err) => err = Some(serde_err.into()),
}
}
Err(err.unwrap_or(MetadataCommandError::NotFound { stdout }))
}
#[cfg(test)]
mod tests {
use semver::Version;
use std::ffi::OsStr;
use crate::{
CairoVersionInfo, Metadata, MetadataCommand, MetadataCommandError, VersionInfo,
WorkspaceMetadata,
};
macro_rules! check_parse_stream {
($input:expr, $expected:pat) => {{
#![allow(clippy::redundant_pattern_matching)]
let actual = crate::command::metadata_command::parse_stream(
$input
.to_string()
.replace("{meta}", &minimal_metadata_json()),
);
assert!(matches!(actual, $expected));
let actual = crate::command::metadata_command::parse_stream(
$input
.to_string()
.replace("{meta}", &minimal_metadata_json_pretty()),
);
assert!(matches!(actual, $expected));
}};
}
#[test]
fn parse_stream_ok() {
check_parse_stream!("{meta}", Ok(_));
}
#[test]
fn parse_stream_ok_nl() {
check_parse_stream!("{meta}\n", Ok(_));
}
#[test]
fn parse_stream_trailing_nl() {
check_parse_stream!("\n\n\n\n{meta}\n\n\n", Ok(_));
}
#[test]
fn parse_stream_ok_random_text_around() {
check_parse_stream!("abcde\n{meta}\nghjkl", Ok(_));
}
#[test]
fn parse_stream_empty() {
check_parse_stream!("", Err(MetadataCommandError::NotFound { .. }));
}
#[test]
fn parse_stream_empty_nl() {
check_parse_stream!('\n', Err(MetadataCommandError::NotFound { .. }));
}
#[test]
fn parse_stream_garbage_message() {
check_parse_stream!("{\"foo\":1}", Err(MetadataCommandError::Json(_)));
}
#[test]
fn parse_stream_garbage_message_nl() {
check_parse_stream!("{\"foo\":1}\n", Err(MetadataCommandError::Json(_)));
}
#[test]
fn parse_stream_garbage_messages() {
check_parse_stream!(
"{\"foo\":1}\n{\"bar\":1}",
Err(MetadataCommandError::Json(_))
);
}
#[test]
fn parse_stream_not_serializable() {
check_parse_stream!(
"{\"version\":\"x\",\"foo\":1}",
Err(MetadataCommandError::Json(_))
);
}
#[test]
fn parse_stream_version_0() {
check_parse_stream!(
"{\"version\":0,\"foo\":1}",
Err(MetadataCommandError::Json(_))
);
}
#[test]
fn parse_stream_impersonator() {
check_parse_stream!("{\"version\":0,\"foo\":1}\n{meta}", Ok(_));
}
#[test]
fn parse_stream_crlf() {
check_parse_stream!(
"{\"foo\":1}\r\n{\"foo\":1}\r\n{meta}\r\n{\"foo\":1}\r\n",
Ok(_)
);
}
fn minimal_metadata_json() -> String {
serde_json::to_string(&minimal_metadata()).unwrap()
}
fn minimal_metadata_json_pretty() -> String {
serde_json::to_string_pretty(&minimal_metadata()).unwrap()
}
fn minimal_metadata() -> Metadata {
Metadata {
version: Default::default(),
app_exe: Default::default(),
app_version_info: VersionInfo {
version: Version::new(1, 0, 0),
commit_info: Default::default(),
cairo: CairoVersionInfo {
version: Version::new(1, 0, 0),
commit_info: Default::default(),
extra: Default::default(),
},
extra: Default::default(),
},
target_dir: Default::default(),
runtime_manifest: Default::default(),
workspace: WorkspaceMetadata {
manifest_path: Default::default(),
root: Default::default(),
members: Default::default(),
extra: Default::default(),
},
packages: Default::default(),
compilation_units: Default::default(),
current_profile: "dev".into(),
profiles: vec!["dev".into()],
extra: Default::default(),
}
}
#[test]
fn can_define_profile() {
let mut cmd = MetadataCommand::new();
cmd.profile("test");
assert_profile(cmd, "test");
let mut cmd = MetadataCommand::new();
cmd.dev();
assert_profile(cmd, "dev");
let mut cmd = MetadataCommand::new();
cmd.profile("test");
cmd.release();
assert_profile(cmd, "release");
}
fn assert_profile(cmd: MetadataCommand, profile: impl AsRef<OsStr>) {
let cmd = cmd.scarb_command();
let (_key, Some(val)) = cmd.get_envs().find(|(k, _)| k == &"SCARB_PROFILE").unwrap() else {
panic!("profile not defined")
};
assert_eq!(val, profile.as_ref());
}
}