use std::str;
use derive_builder::Builder;
use log::warn;
use crate::api::common::NameOrId;
use crate::api::endpoint_prelude::*;
use crate::api::projects::repository::files::Encoding;
use crate::api::ParamValue;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum CommitActionType {
Create,
Delete,
Move,
Update,
Chmod,
}
impl Default for CommitActionType {
fn default() -> Self {
Self::Create
}
}
impl CommitActionType {
pub fn as_str(self) -> &'static str {
match self {
CommitActionType::Create => "create",
CommitActionType::Delete => "delete",
CommitActionType::Move => "move",
CommitActionType::Update => "update",
CommitActionType::Chmod => "chmod",
}
}
fn validate(self, builder: &CommitActionBuilder) -> Result<(), CommitActionValidationError> {
if builder.content.is_some() {
Ok(())
} else {
match self {
Self::Create => Err(CommitActionValidationError::ContentRequiredByCreate),
Self::Update => Err(CommitActionValidationError::ContentRequiredByUpdate),
_ => Ok(()),
}
}
}
}
impl ParamValue<'static> for CommitActionType {
fn as_value(&self) -> Cow<'static, str> {
self.as_str().into()
}
}
const SAFE_ENCODING: Encoding = Encoding::Base64;
#[derive(Debug, Clone, Builder)]
#[builder(setter(strip_option), build_fn(validate = "Self::validate"))]
pub struct CommitAction<'a> {
action: CommitActionType,
#[builder(setter(into))]
file_path: Cow<'a, str>,
#[builder(setter(into), default)]
previous_path: Option<Cow<'a, str>>,
#[builder(setter(into), default)]
content: Option<Cow<'a, [u8]>>,
#[builder(default)]
encoding: Option<Encoding>,
#[builder(setter(into), default)]
last_commit_id: Option<Cow<'a, str>>,
#[builder(default)]
execute_filemode: Option<bool>,
}
impl<'a> CommitAction<'a> {
pub fn builder() -> CommitActionBuilder<'a> {
CommitActionBuilder::default()
}
#[allow(clippy::incompatible_msrv)]
fn add_query<'b>(&'b self, params: &mut FormParams<'b>) {
let (actual_encoding, actual_content) = self
.content
.as_ref()
.map(|content| {
let str_content = str::from_utf8(content);
let needs_encoding = str_content.is_err();
let encoding = self.encoding.unwrap_or_default();
let actual_encoding = if needs_encoding && !encoding.is_binary_safe() {
warn!(
"forcing the encoding to {} due to utf-8 unsafe content",
SAFE_ENCODING.as_str(),
);
SAFE_ENCODING
} else {
encoding
};
(
actual_encoding,
actual_encoding.encode(str_content.ok(), content),
)
})
.unzip();
params
.push("actions[][action]", self.action.as_value())
.push("actions[][file_path]", self.file_path.as_value())
.push_opt("actions[][previous_path]", self.previous_path.as_ref())
.push_opt("actions[][content]", actual_content)
.push_opt("actions[][encoding]", actual_encoding.or(self.encoding))
.push_opt("actions[][last_commit_id]", self.last_commit_id.as_ref())
.push_opt("actions[][execute_filemode]", self.execute_filemode);
}
}
static CONTENT_REQUIRED_CREATE: &str = "content is required for create.";
static CONTENT_REQUIRED_UPDATE: &str = "content is required for update.";
#[non_exhaustive]
enum CommitActionValidationError {
ContentRequiredByCreate,
ContentRequiredByUpdate,
}
impl From<CommitActionValidationError> for CommitActionBuilderError {
fn from(validation_error: CommitActionValidationError) -> Self {
match validation_error {
CommitActionValidationError::ContentRequiredByCreate => {
CommitActionBuilderError::ValidationError(CONTENT_REQUIRED_CREATE.into())
},
CommitActionValidationError::ContentRequiredByUpdate => {
CommitActionBuilderError::ValidationError(CONTENT_REQUIRED_UPDATE.into())
},
}
}
}
impl CommitActionBuilder<'_> {
fn validate(&self) -> Result<(), CommitActionValidationError> {
if let Some(ref action) = &self.action {
action.validate(self)?;
}
Ok(())
}
}
#[derive(Debug, Builder, Clone)]
#[builder(setter(strip_option), build_fn(validate = "Self::validate"))]
pub struct CreateCommit<'a> {
#[builder(setter(into))]
project: NameOrId<'a>,
#[builder(setter(into))]
branch: Cow<'a, str>,
#[builder(setter(into))]
commit_message: Cow<'a, str>,
#[builder(setter(into), default)]
start_branch: Option<Cow<'a, str>>,
#[builder(setter(into), default)]
start_sha: Option<Cow<'a, str>>,
#[builder(setter(into), default)]
start_project: Option<NameOrId<'a>>,
#[builder(setter(name = "_actions"), private)]
actions: Vec<CommitAction<'a>>,
#[builder(setter(into), default)]
author_email: Option<Cow<'a, str>>,
#[builder(setter(into), default)]
author_name: Option<Cow<'a, str>>,
#[builder(default)]
stats: Option<bool>,
#[builder(default)]
force: Option<bool>,
}
impl<'a> CreateCommit<'a> {
pub fn builder() -> CreateCommitBuilder<'a> {
CreateCommitBuilder::default()
}
}
#[non_exhaustive]
enum CreateCommitValidationError {
AtMostOneStartItem,
}
static AT_MOST_ONE_START_ITEM: &str = "Specify either start_sha or start_branch, not both";
impl From<CreateCommitValidationError> for CreateCommitBuilderError {
fn from(validation_error: CreateCommitValidationError) -> Self {
match validation_error {
CreateCommitValidationError::AtMostOneStartItem => {
CreateCommitBuilderError::ValidationError(AT_MOST_ONE_START_ITEM.into())
},
}
}
}
impl<'a> CreateCommitBuilder<'a> {
pub fn action(&mut self, action: CommitAction<'a>) -> &mut Self {
self.actions.get_or_insert(Vec::new()).push(action);
self
}
pub fn actions<I>(&mut self, iter: I) -> &mut Self
where
I: IntoIterator<Item = CommitAction<'a>>,
{
self.actions.get_or_insert(Vec::new()).extend(iter);
self
}
fn validate(&self) -> Result<(), CreateCommitValidationError> {
let have_start_branch = self
.start_branch
.as_ref()
.map(Option::is_some)
.unwrap_or(false);
let have_start_sha = self
.start_sha
.as_ref()
.map(Option::is_some)
.unwrap_or(false);
if have_start_branch && have_start_sha {
return Err(CreateCommitValidationError::AtMostOneStartItem);
}
Ok(())
}
}
impl Endpoint for CreateCommit<'_> {
fn method(&self) -> Method {
Method::POST
}
fn endpoint(&self) -> Cow<'static, str> {
format!("projects/{}/repository/commits", self.project).into()
}
fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, BodyError> {
let mut params = FormParams::default();
params
.push("branch", self.branch.as_ref())
.push("commit_message", self.commit_message.as_ref())
.push_opt("start_branch", self.start_branch.as_ref())
.push_opt("start_sha", self.start_sha.as_ref())
.push_opt("start_project", self.start_project.as_ref())
.push_opt("author_email", self.author_email.as_ref())
.push_opt("author_name", self.author_name.as_ref())
.push_opt("stats", self.stats)
.push_opt("force", self.force);
for action in self.actions.iter() {
action.add_query(&mut params);
}
params.into_body()
}
}
#[cfg(test)]
mod tests {
use crate::{
api::{self, Query},
test::client::{ExpectedUrl, SingleTestClient},
};
use super::*;
#[test]
fn action_action_type_required() {
let err = CommitAction::builder()
.file_path("path/to/file")
.build()
.unwrap_err();
crate::test::assert_missing_field!(err, CommitActionBuilderError, "action");
}
#[test]
fn action_file_path_required() {
let err = CommitAction::builder()
.action(CommitActionType::Create)
.content(&b"content"[..])
.build()
.unwrap_err();
crate::test::assert_missing_field!(err, CommitActionBuilderError, "file_path");
}
#[test]
fn action_content_required_for_create() {
let action = CommitAction::builder()
.action(CommitActionType::Create)
.file_path("path/to/file")
.build();
if let Err(msg) = action {
assert_eq!(msg.to_string(), CONTENT_REQUIRED_CREATE)
} else {
panic!("unexpected error (expected to be missing content)")
}
}
#[test]
fn action_content_required_for_update() {
let action = CommitAction::builder()
.action(CommitActionType::Update)
.file_path("path/to/file")
.build();
if let Err(msg) = action {
assert_eq!(msg.to_string(), CONTENT_REQUIRED_UPDATE)
} else {
panic!("unexpected error (expected to be missing content)")
}
}
#[test]
fn project_is_required() {
let err = CreateCommit::builder()
.branch("source")
.commit_message("msg")
.action(
CommitAction::builder()
.action(CommitActionType::Create)
.file_path("foo/bar")
.content(&b"content"[..])
.build()
.unwrap(),
)
.build()
.unwrap_err();
crate::test::assert_missing_field!(err, CreateCommitBuilderError, "project");
}
#[test]
fn branch_is_required() {
let err = CreateCommit::builder()
.project(1)
.commit_message("msg")
.action(
CommitAction::builder()
.action(CommitActionType::Create)
.file_path("foo/bar")
.content(&b"content"[..])
.build()
.unwrap(),
)
.build()
.unwrap_err();
crate::test::assert_missing_field!(err, CreateCommitBuilderError, "branch");
}
#[test]
fn commit_message_is_required() {
let err = CreateCommit::builder()
.project(1)
.branch("source")
.action(
CommitAction::builder()
.action(CommitActionType::Create)
.file_path("foo/bar")
.content(&b"content"[..])
.build()
.unwrap(),
)
.build()
.unwrap_err();
crate::test::assert_missing_field!(err, CreateCommitBuilderError, "commit_message");
}
#[test]
fn actions_required() {
let err = CreateCommit::builder()
.project(1)
.branch("source")
.commit_message("msg")
.build()
.unwrap_err();
crate::test::assert_missing_field!(err, CreateCommitBuilderError, "actions");
}
#[test]
fn project_branch_msg_and_action_sufficent() {
CreateCommit::builder()
.project(1)
.branch("source")
.commit_message("msg")
.action(
CommitAction::builder()
.action(CommitActionType::Create)
.file_path("foo/bar")
.content(&b"content"[..])
.build()
.unwrap(),
)
.build()
.unwrap();
}
#[test]
fn endpoint() {
let endpoint = ExpectedUrl::builder()
.method(Method::POST)
.endpoint("projects/simple%2Fproject/repository/commits")
.content_type("application/x-www-form-urlencoded")
.body_str(concat!(
"branch=master",
"&commit_message=message",
"&actions%5B%5D%5Baction%5D=create",
"&actions%5B%5D%5Bfile_path%5D=foo%2Fbar",
"&actions%5B%5D%5Bcontent%5D=content",
"&actions%5B%5D%5Bencoding%5D=text",
"&actions%5B%5D%5Baction%5D=delete",
"&actions%5B%5D%5Bfile_path%5D=foo%2Fbar2",
"&actions%5B%5D%5Baction%5D=move",
"&actions%5B%5D%5Bfile_path%5D=foo%2Fbar3",
"&actions%5B%5D%5Bprevious_path%5D=foo%2Fbar4",
"&actions%5B%5D%5Bcontent%5D=content",
"&actions%5B%5D%5Bencoding%5D=text",
"&actions%5B%5D%5Baction%5D=update",
"&actions%5B%5D%5Bfile_path%5D=foo%2Fbar5",
"&actions%5B%5D%5Bcontent%5D=content",
"&actions%5B%5D%5Bencoding%5D=text",
"&actions%5B%5D%5Baction%5D=chmod",
"&actions%5B%5D%5Bfile_path%5D=foo%2Fbar5",
"&actions%5B%5D%5Bexecute_filemode%5D=true",
))
.build()
.unwrap();
let client = SingleTestClient::new_raw(endpoint, "");
let endpoint = CreateCommit::builder()
.project("simple/project")
.branch("master")
.commit_message("message")
.actions([
CommitAction::builder()
.action(CommitActionType::Create)
.file_path("foo/bar")
.content(&b"content"[..])
.build()
.unwrap(),
CommitAction::builder()
.action(CommitActionType::Delete)
.file_path("foo/bar2")
.build()
.unwrap(),
CommitAction::builder()
.action(CommitActionType::Move)
.file_path("foo/bar3")
.previous_path("foo/bar4")
.content(&b"content"[..])
.build()
.unwrap(),
CommitAction::builder()
.action(CommitActionType::Update)
.file_path("foo/bar5")
.content(&b"content"[..])
.build()
.unwrap(),
CommitAction::builder()
.action(CommitActionType::Chmod)
.file_path("foo/bar5")
.execute_filemode(true)
.build()
.unwrap(),
])
.build()
.unwrap();
api::ignore(endpoint).query(&client).unwrap();
}
#[test]
fn endpoint_start_branch() {
let endpoint = ExpectedUrl::builder()
.method(Method::POST)
.endpoint("projects/simple%2Fproject/repository/commits")
.content_type("application/x-www-form-urlencoded")
.body_str(concat!(
"branch=master",
"&commit_message=message",
"&start_branch=start",
"&actions%5B%5D%5Baction%5D=create",
"&actions%5B%5D%5Bfile_path%5D=foo%2Fbar",
"&actions%5B%5D%5Bcontent%5D=content",
"&actions%5B%5D%5Bencoding%5D=text",
))
.build()
.unwrap();
let client = SingleTestClient::new_raw(endpoint, "");
let endpoint = CreateCommit::builder()
.project("simple/project")
.branch("master")
.commit_message("message")
.action(
CommitAction::builder()
.action(CommitActionType::Create)
.file_path("foo/bar")
.content(&b"content"[..])
.build()
.unwrap(),
)
.start_branch("start")
.build()
.unwrap();
api::ignore(endpoint).query(&client).unwrap();
}
#[test]
fn endpoint_start_sha() {
let endpoint = ExpectedUrl::builder()
.method(Method::POST)
.endpoint("projects/simple%2Fproject/repository/commits")
.content_type("application/x-www-form-urlencoded")
.body_str(concat!(
"branch=new-branch",
"&commit_message=message",
"&start_sha=40b35d15a129e75500bbf3d5db779b6f29376d1a",
"&actions%5B%5D%5Baction%5D=create",
"&actions%5B%5D%5Bfile_path%5D=foo%2Fbar",
"&actions%5B%5D%5Bcontent%5D=content",
"&actions%5B%5D%5Bencoding%5D=text",
))
.build()
.unwrap();
let client = SingleTestClient::new_raw(endpoint, "");
let endpoint = CreateCommit::builder()
.project("simple/project")
.branch("new-branch")
.start_sha("40b35d15a129e75500bbf3d5db779b6f29376d1a")
.commit_message("message")
.action(
CommitAction::builder()
.action(CommitActionType::Create)
.file_path("foo/bar")
.content(&b"content"[..])
.build()
.unwrap(),
)
.build()
.unwrap();
api::ignore(endpoint).query(&client).unwrap();
}
#[test]
fn endpoint_start_branch_and_start_sha() {
let err = CreateCommit::builder()
.project("simple/project")
.branch("master")
.commit_message("message")
.action(
CommitAction::builder()
.action(CommitActionType::Create)
.file_path("foo/bar")
.content(&b"content"[..])
.build()
.unwrap(),
)
.start_branch("start")
.start_sha("start")
.build()
.unwrap_err();
assert_eq!(err.to_string(), AT_MOST_ONE_START_ITEM);
}
#[test]
fn endpoint_start_project() {
let endpoint = ExpectedUrl::builder()
.method(Method::POST)
.endpoint("projects/simple%2Fproject/repository/commits")
.content_type("application/x-www-form-urlencoded")
.body_str(concat!(
"branch=new-branch",
"&commit_message=message",
"&start_project=400",
"&actions%5B%5D%5Baction%5D=create",
"&actions%5B%5D%5Bfile_path%5D=foo%2Fbar",
"&actions%5B%5D%5Bcontent%5D=content",
"&actions%5B%5D%5Bencoding%5D=text",
))
.build()
.unwrap();
let client = SingleTestClient::new_raw(endpoint, "");
let endpoint = CreateCommit::builder()
.project("simple/project")
.branch("new-branch")
.start_project(400)
.commit_message("message")
.action(
CommitAction::builder()
.action(CommitActionType::Create)
.file_path("foo/bar")
.content(&b"content"[..])
.build()
.unwrap(),
)
.build()
.unwrap();
api::ignore(endpoint).query(&client).unwrap();
}
#[test]
fn endpoint_author_email() {
let endpoint = ExpectedUrl::builder()
.method(Method::POST)
.endpoint("projects/simple%2Fproject/repository/commits")
.content_type("application/x-www-form-urlencoded")
.body_str(concat!(
"branch=master",
"&commit_message=message",
"&author_email=me%40mail.com",
"&actions%5B%5D%5Baction%5D=create",
"&actions%5B%5D%5Bfile_path%5D=foo%2Fbar",
"&actions%5B%5D%5Bcontent%5D=content",
"&actions%5B%5D%5Bencoding%5D=text",
))
.build()
.unwrap();
let client = SingleTestClient::new_raw(endpoint, "");
let endpoint = CreateCommit::builder()
.project("simple/project")
.branch("master")
.author_email("me@mail.com")
.commit_message("message")
.action(
CommitAction::builder()
.action(CommitActionType::Create)
.file_path("foo/bar")
.content(&b"content"[..])
.build()
.unwrap(),
)
.build()
.unwrap();
api::ignore(endpoint).query(&client).unwrap();
}
#[test]
fn endpoint_author_name() {
let endpoint = ExpectedUrl::builder()
.method(Method::POST)
.endpoint("projects/simple%2Fproject/repository/commits")
.content_type("application/x-www-form-urlencoded")
.body_str(concat!(
"branch=master",
"&commit_message=message",
"&author_name=me",
"&actions%5B%5D%5Baction%5D=create",
"&actions%5B%5D%5Bfile_path%5D=foo%2Fbar",
"&actions%5B%5D%5Bcontent%5D=content",
"&actions%5B%5D%5Bencoding%5D=text",
))
.build()
.unwrap();
let client = SingleTestClient::new_raw(endpoint, "");
let endpoint = CreateCommit::builder()
.project("simple/project")
.branch("master")
.author_name("me")
.commit_message("message")
.action(
CommitAction::builder()
.action(CommitActionType::Create)
.file_path("foo/bar")
.content(&b"content"[..])
.build()
.unwrap(),
)
.build()
.unwrap();
api::ignore(endpoint).query(&client).unwrap();
}
#[test]
fn endpoint_stats() {
let endpoint = ExpectedUrl::builder()
.method(Method::POST)
.endpoint("projects/simple%2Fproject/repository/commits")
.content_type("application/x-www-form-urlencoded")
.body_str(concat!(
"branch=master",
"&commit_message=message",
"&stats=true",
"&actions%5B%5D%5Baction%5D=create",
"&actions%5B%5D%5Bfile_path%5D=foo%2Fbar",
"&actions%5B%5D%5Bcontent%5D=content",
"&actions%5B%5D%5Bencoding%5D=text",
))
.build()
.unwrap();
let client = SingleTestClient::new_raw(endpoint, "");
let endpoint = CreateCommit::builder()
.project("simple/project")
.branch("master")
.stats(true)
.commit_message("message")
.action(
CommitAction::builder()
.action(CommitActionType::Create)
.file_path("foo/bar")
.content(&b"content"[..])
.build()
.unwrap(),
)
.build()
.unwrap();
api::ignore(endpoint).query(&client).unwrap();
}
#[test]
fn endpoint_force() {
let endpoint = ExpectedUrl::builder()
.method(Method::POST)
.endpoint("projects/simple%2Fproject/repository/commits")
.content_type("application/x-www-form-urlencoded")
.body_str(concat!(
"branch=master",
"&commit_message=message",
"&force=true",
"&actions%5B%5D%5Baction%5D=create",
"&actions%5B%5D%5Bfile_path%5D=foo%2Fbar",
"&actions%5B%5D%5Bcontent%5D=content",
"&actions%5B%5D%5Bencoding%5D=text",
))
.build()
.unwrap();
let client = SingleTestClient::new_raw(endpoint, "");
let endpoint = CreateCommit::builder()
.project("simple/project")
.branch("master")
.force(true)
.commit_message("message")
.action(
CommitAction::builder()
.action(CommitActionType::Create)
.file_path("foo/bar")
.content(&b"content"[..])
.build()
.unwrap(),
)
.build()
.unwrap();
api::ignore(endpoint).query(&client).unwrap();
}
#[test]
fn endpoint_encoding() {
let endpoint = ExpectedUrl::builder()
.method(Method::POST)
.endpoint("projects/simple%2Fproject/repository/commits")
.content_type("application/x-www-form-urlencoded")
.body_str(concat!(
"branch=master",
"&commit_message=message",
"&actions%5B%5D%5Baction%5D=create",
"&actions%5B%5D%5Bfile_path%5D=foo%2Fbar",
"&actions%5B%5D%5Bcontent%5D=Y29udGVudA%3D%3D",
"&actions%5B%5D%5Bencoding%5D=base64",
))
.build()
.unwrap();
let client = SingleTestClient::new_raw(endpoint, "");
let endpoint = CreateCommit::builder()
.project("simple/project")
.branch("master")
.commit_message("message")
.action(
CommitAction::builder()
.action(CommitActionType::Create)
.file_path("foo/bar")
.encoding(Encoding::Base64)
.content(&b"content"[..])
.build()
.unwrap(),
)
.build()
.unwrap();
api::ignore(endpoint).query(&client).unwrap();
}
#[test]
fn endpoint_encoding_fallback() {
let endpoint = ExpectedUrl::builder()
.method(Method::POST)
.endpoint("projects/simple%2Fproject/repository/commits")
.content_type("application/x-www-form-urlencoded")
.body_str(concat!(
"branch=master",
"&commit_message=message",
"&actions%5B%5D%5Baction%5D=create",
"&actions%5B%5D%5Bfile_path%5D=foo%2Fbar",
"&actions%5B%5D%5Bcontent%5D=Y29udGVudP8%3D",
"&actions%5B%5D%5Bencoding%5D=base64",
))
.build()
.unwrap();
let client = SingleTestClient::new_raw(endpoint, "");
let endpoint = CreateCommit::builder()
.project("simple/project")
.branch("master")
.commit_message("message")
.action(
CommitAction::builder()
.action(CommitActionType::Create)
.file_path("foo/bar")
.encoding(Encoding::Text)
.content(&b"content\xff"[..])
.build()
.unwrap(),
)
.build()
.unwrap();
api::ignore(endpoint).query(&client).unwrap();
}
}