feat: add config parsing

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
Kasper Juul Hermansen 2023-07-29 15:27:16 +02:00
parent 72afd0b968
commit 6a3a14ec94
Signed by: kjuulh
GPG Key ID: 9AA7BC13CE474394
11 changed files with 667 additions and 72 deletions

109
Cargo.lock generated
View File

@ -17,6 +17,15 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
[[package]]
name = "aho-corasick"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43f6cb1bf222025340178f382c426f13757b2960e89779dfcb319c32542a5a41"
dependencies = [
"memchr",
]
[[package]]
name = "anstream"
version = "0.3.2"
@ -155,7 +164,7 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
"syn 2.0.27",
]
[[package]]
@ -182,6 +191,7 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
"tracing-test",
]
[[package]]
@ -302,6 +312,15 @@ version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b06a4cde4c0f271a446782e3eff8de789548ce57dbc8eca9292c27f4a42004b4"
[[package]]
name = "matchers"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558"
dependencies = [
"regex-automata 0.1.10",
]
[[package]]
name = "memchr"
version = "2.5.0"
@ -425,6 +444,50 @@ dependencies = [
"bitflags 1.3.2",
]
[[package]]
name = "regex"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2eae68fc220f7cf2532e4494aded17545fce192d59cd996e0fe7887f4ceb575"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata 0.3.4",
"regex-syntax 0.7.4",
]
[[package]]
name = "regex-automata"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
dependencies = [
"regex-syntax 0.6.29",
]
[[package]]
name = "regex-automata"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7b6d6190b7594385f61bd3911cd1be99dfddcfc365a4160cc2ab5bff4aed294"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax 0.7.4",
]
[[package]]
name = "regex-syntax"
version = "0.6.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
[[package]]
name = "regex-syntax"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5ea92a5b6195c6ef2a0295ea818b312502c6fc94dde986c5553242e18fd4ce2"
[[package]]
name = "rustc-demangle"
version = "0.1.23"
@ -473,7 +536,7 @@ checksum = "401797fe7833d72109fedec6bfcbe67c0eed9b99772f26eb8afd261f0abc6fd3"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.27",
]
[[package]]
@ -529,6 +592,17 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]]
name = "syn"
version = "1.0.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "2.0.27"
@ -578,7 +652,7 @@ checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.27",
]
[[package]]
@ -602,7 +676,7 @@ checksum = "5f4f31f56159e98206da9efd823404b79b6ef3143b4a7ab76e67b1751b25a4ab"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.27",
]
[[package]]
@ -632,14 +706,41 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a651bc37f915e81f087d86e62a18eec5f79550c7faff886f7090b4ea757c77"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
]
[[package]]
name = "tracing-test"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a2c0ff408fe918a94c428a3f2ad04e4afd5c95bbc08fcf868eff750c15728a4"
dependencies = [
"lazy_static",
"tracing-core",
"tracing-subscriber",
"tracing-test-macro",
]
[[package]]
name = "tracing-test-macro"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "258bc1c4f8e2e73a977812ab339d503e6feeb92700f6d07a6de4d321522d5c08"
dependencies = [
"lazy_static",
"quote",
"syn 1.0.109",
]
[[package]]
name = "unicode-ident"
version = "1.0.11"

View File

@ -11,3 +11,8 @@ tracing = { version = "0.1", features = ["log"] }
tracing-subscriber = { version = "0.3.17" }
clap = { version = "4.3.4", features = ["derive", "env"] }
dotenv = { version = "0.15.0" }
serde_yaml = {version = "*"}
serde = {version = "*", features = ["derive"]}
tracing-test = "*"

View File

@ -10,5 +10,8 @@ tracing.workspace = true
tracing-subscriber.workspace = true
clap.workspace = true
dotenv.workspace = true
serde_yaml = "0.9.25"
serde = { version = "1.0.177", features = ["derive"] }
serde_yaml.workspace = true
serde.workspace = true
[dev-dependencies]
tracing-test = {workspace = true, features = ["no-env-filter"]}

View File

@ -0,0 +1,293 @@
use std::{
io::Read,
ops::Deref,
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use anyhow::Context;
use clap::{Parser, Subcommand};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use crate::ui::{ConsoleUi, DynUi};
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
pub struct Command {
/// token is the personal access token from gitea.
#[arg(
env = "CUDDLE_PLEASE_TOKEN",
long,
long_help = "token is the personal access token from gitea. It requires at least repository write access, it isn't required by default, but for most usecases the flow will fail without it",
global = true
)]
token: Option<String>,
/// Which repository to publish against. If not supplied remote url will be used.
#[arg(long, global = true)]
repo_url: Option<String>,
/// which source directory to use, if not set `std::env::current_dir` is used instead.
#[arg(long, global = true)]
source: Option<PathBuf>,
#[arg(long, global = true)]
config_stdin: bool,
#[command(subcommand)]
commands: Option<Commands>,
#[clap(skip)]
ui: DynUi,
#[clap(skip)]
stdin: Option<Arc<Mutex<dyn Fn() -> anyhow::Result<String> + Send + Sync + 'static>>>,
}
impl Command {
pub fn new() -> Self {
let args = std::env::args();
Self::new_from_args_with_stdin(Some(ConsoleUi::default()), args, || {
let mut input = String::new();
std::io::stdin().read_to_string(&mut input)?;
Ok(input)
})
}
pub fn new_from_args<I, T, UIF>(ui: Option<UIF>, i: I) -> Self
where
I: IntoIterator<Item = T>,
T: Into<std::ffi::OsString> + Clone,
UIF: Into<DynUi>,
{
let mut s = Self::parse_from(i);
if let Some(ui) = ui {
s.ui = ui.into();
}
s
}
pub fn new_from_args_with_stdin<I, T, F, UIF>(ui: Option<UIF>, i: I, input: F) -> Self
where
I: IntoIterator<Item = T>,
T: Into<std::ffi::OsString> + Clone,
F: Fn() -> anyhow::Result<String> + Send + Sync + 'static,
UIF: Into<DynUi>,
{
let mut s = Self::parse_from(i);
if let Some(ui) = ui {
s.ui = ui.into();
}
s.stdin = Some(Arc::new(Mutex::new(input)));
s
}
fn get_config(
&self,
current_dir: &Path,
stdin: Option<String>,
) -> anyhow::Result<PleaseConfig> {
let config = get_config(current_dir, stdin)?;
Ok(config)
}
pub fn execute(self, current_dir: Option<&Path>) -> anyhow::Result<()> {
// 1. Parse the current directory
let current_dir = get_current_path(current_dir, self.source.clone())?;
let stdin = if self.config_stdin {
if let Some(stdin_fn) = self.stdin.clone() {
let output = (stdin_fn.lock().unwrap().deref())();
Some(output.unwrap())
} else {
None
}
} else {
None
};
match &self.commands {
Some(Commands::Config { command }) => match command {
ConfigCommand::List {} => {
tracing::debug!("running command: config list");
let _config = self.get_config(current_dir.as_path(), stdin)?;
self.ui.write_str_ln(&format!("cuddle-config"));
}
},
None => {
tracing::debug!("running bare command");
// 2. Parse the cuddle.please.yaml let cuddle.please.yaml take precedence
// 2a. if not existing use default.
// 2b. if not in a git repo abort. (unless --no-vcs is turned added)
// 3. Create gitea client and do a health check
// 4. Fetch git tags for the current repository
// 5. Fetch git commits since last git tag
// 6. Slice commits since last git tag
// 7. Create a versioning client
// 8. Parse conventional commits and determine next version
// 9a. Check for open pr.
// 10a. If exists parse history, rebase from master and rewrite pr
// 9b. check for release commit and release, if release exists continue
// 10b. create release
}
}
Ok(())
}
}
#[derive(Debug, Clone, Subcommand)]
enum Commands {
Config {
#[command(subcommand)]
command: ConfigCommand,
},
}
#[derive(Subcommand, Debug, Clone)]
enum ConfigCommand {
List {},
}
fn get_current_path(
optional_current_dir: Option<&Path>,
optional_source_path: Option<PathBuf>,
) -> anyhow::Result<PathBuf> {
let path = optional_source_path
.or_else(|| optional_current_dir.map(|p| p.to_path_buf())) // fall back on current env from environment
.filter(|v| v.to_string_lossy() != "") // make sure we don't get empty values
//.and_then(|p| p.canonicalize().ok()) // Make sure we get the absolute path
.context("could not find current dir, pass --source as a replacement")?;
if !path.exists() {
anyhow::bail!("path doesn't exist {}", path.display());
}
Ok(path)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PleaseConfig {}
impl PleaseConfig {
fn merge(self, _config: PleaseConfig) -> Self {
self
}
}
impl Default for PleaseConfig {
fn default() -> Self {
Self {}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CuddleEmbeddedPleaseConfig {
please: PleaseConfig,
}
impl From<CuddleEmbeddedPleaseConfig> for PleaseConfig {
fn from(value: CuddleEmbeddedPleaseConfig) -> Self {
value.please
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CuddlePleaseConfig {
#[serde(flatten)]
please: PleaseConfig,
}
impl From<CuddlePleaseConfig> for PleaseConfig {
fn from(value: CuddlePleaseConfig) -> Self {
value.please
}
}
const CUDDLE_FILE_NAME: &'static str = "cuddle";
const CUDDLE_CONFIG_FILE_NAME: &'static str = "cuddle.please";
const YAML_EXTENSION: &'static str = "yaml";
fn get_config(current_dir: &Path, stdin: Option<String>) -> anyhow::Result<PleaseConfig> {
let current_cuddle_path = current_dir
.clone()
.join(format!("{CUDDLE_FILE_NAME}.{YAML_EXTENSION}"));
let current_cuddle_config_path = current_dir
.clone()
.join(format!("{CUDDLE_CONFIG_FILE_NAME}.{YAML_EXTENSION}"));
let mut please_config = PleaseConfig::default();
if let Some(config) = get_config_from_file::<CuddleEmbeddedPleaseConfig>(current_cuddle_path) {
please_config = please_config.merge(config);
}
if let Some(config) = get_config_from_file::<CuddlePleaseConfig>(current_cuddle_config_path) {
please_config = please_config.merge(config);
}
if let Some(input_config) = get_config_from_stdin::<CuddlePleaseConfig>(stdin.as_ref()) {
please_config = please_config.merge(input_config);
}
Ok(please_config)
}
fn get_config_from_file<'d, T>(current_cuddle_path: PathBuf) -> Option<PleaseConfig>
where
T: DeserializeOwned,
T: Into<PleaseConfig>,
{
match std::fs::File::open(&current_cuddle_path) {
Ok(file) => match serde_yaml::from_reader::<_, T>(file) {
Ok(config) => {
return Some(config.into());
}
Err(e) => {
tracing::debug!(
"{} doesn't contain a valid please config: {}",
&current_cuddle_path.display(),
e
);
}
},
Err(e) => {
tracing::debug!(
"did not find or was not allowed to read {}, error: {}",
&current_cuddle_path.display(),
e,
);
}
}
None
}
fn get_config_from_stdin<'d, T>(stdin: Option<&'d String>) -> Option<PleaseConfig>
where
T: Deserialize<'d>,
T: Into<PleaseConfig>,
{
match stdin {
Some(content) => match serde_yaml::from_str::<'d, T>(&content) {
Ok(config) => {
return Some(config.into());
}
Err(e) => {
tracing::debug!("stdin doesn't contain a valid please config: {}", e);
}
},
None => {
tracing::trace!("Stdin was not set continueing",);
}
}
None
}

View File

@ -0,0 +1,2 @@
pub mod command;
pub mod ui;

View File

@ -1,78 +1,20 @@
use std::{net::SocketAddr, path::PathBuf};
pub mod command;
pub mod ui;
use anyhow::Context;
use clap::{Parser, Subcommand};
use command::Command;
use ui::{ConsoleUi};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenv::dotenv().ok();
tracing_subscriber::fmt::init();
let cli = Command::parse();
let current_dir = std::env::current_dir().ok();
let current_dir = current_dir.as_ref().map(|p| p.as_path());
// 1. Parse the current directory
let current_dir = get_current_path(cli.source.clone())?;
let get_config = get_config(&current_dir)?;
let _ui = ConsoleUi::new();
// 2. Parse the cuddle.please.yaml let cuddle.please.yaml take precedence
// 2a. if not existing use default.
// 2b. if not in a git repo abort. (unless --no-vcs is turned added)
// 3. Create gitea client and do a health check
// 4. Fetch git tags for the current repository
// 5. Fetch git commits since last git tag
// 6. Slice commits since last git tag
// 7. Create a versioning client
// 8. Parse conventional commits and determine next version
// 9a. Check for open pr.
// 10a. If exists parse history, rebase from master and rewrite pr
// 9b. check for release commit and release, if release exists continue
// 10b. create release
Command::new().execute(current_dir)?;
Ok(())
}
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Command {
/// token is the personal access token from gitea.
#[arg(
env = "CUDDLE_PLEASE_TOKEN",
long,
long_help = "token is the personal access token from gitea. It requires at least repository write access, it isn't required by default, but for most usecases the flow will fail without it"
)]
token: Option<String>,
/// Which repository to publish against. If not supplied remote url will be used.
#[arg(long)]
repo_url: Option<String>,
/// which source directory to use, if not set `std::env::current_dir` is used instead.
#[arg(long)]
source: Option<PathBuf>,
}
fn get_current_path(optional_source_path: Option<PathBuf>) -> anyhow::Result<PathBuf> {
let path = optional_source_path
.or_else(|| std::env::current_dir().ok()) // fall back on current env from environment
.filter(|v| v.to_string_lossy() != "") // make sure we don't get empty values
.and_then(|p| p.canonicalize().ok()) // Make sure we get the absolute path
.context("could not find current dir, pass --source as a replacement")?;
if !path.exists() {
anyhow::bail!("path doesn't exist {}", path.display());
}
Ok(path)
}
struct PleaseConfig {}
const CUDDLE_FILE_NAME: &'static str = "cuddle";
const CUDDLE_CONFIG_FILE_NAME: &'static str = "cuddle.please";
const YAML_EXTENSIONS: Vec<&'static str> = vec!["yaml", "yml"];
fn get_config(current_dir: &PathBuf) -> anyhow::Result<()> {}

View File

@ -0,0 +1,53 @@
pub trait Ui {
fn write_str(&self, content: &str);
fn write_err_str(&self, content: &str);
fn write_str_ln(&self, content: &str);
fn write_err_str_ln(&self, content: &str);
}
pub type DynUi = Box<dyn Ui + Send + Sync>;
impl Default for DynUi {
fn default() -> Self {
Box::new(ConsoleUi::default())
}
}
pub(crate) struct ConsoleUi {}
impl ConsoleUi {
pub fn new() -> Self {
Self::default()
}
}
impl Default for ConsoleUi {
fn default() -> Self {
Self {}
}
}
impl From<ConsoleUi> for DynUi {
fn from(value: ConsoleUi) -> Self {
Box::new(value)
}
}
impl Ui for ConsoleUi {
fn write_str(&self, content: &str) {
print!("{}", content)
}
fn write_err_str(&self, content: &str) {
eprint!("{}", content)
}
fn write_str_ln(&self, content: &str) {
println!("{}", content)
}
fn write_err_str_ln(&self, content: &str) {
eprintln!("{}", content)
}
}

View File

@ -0,0 +1,113 @@
use cuddle_release::ui::{DynUi, Ui};
use std::{
io::Write,
sync::{Arc, Mutex},
};
struct BufferInner {
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
}
impl BufferInner {
fn write_str(&mut self, content: &str) {
write!(&mut self.stdout, "{}", content).unwrap();
}
fn write_err_str(&mut self, content: &str) {
write!(&mut self.stderr, "{}", content).unwrap();
}
fn write_str_ln(&mut self, content: &str) {
writeln!(&mut self.stdout, "{}", content).unwrap();
}
fn write_err_str_ln(&mut self, content: &str) {
writeln!(&mut self.stderr, "{}", content).unwrap();
}
}
#[derive(Clone)]
pub struct BufferUi {
inner: Arc<Mutex<BufferInner>>,
}
impl BufferUi {
pub fn get_stdout(&self) -> String {
let inner = self.inner.lock().unwrap();
let output = std::str::from_utf8(&inner.stdout).unwrap();
output.to_string()
}
pub fn get_stderr(&self) -> String {
let inner = self.inner.lock().unwrap();
let output = std::str::from_utf8(&inner.stderr).unwrap();
output.to_string()
}
pub fn get_output(&self) -> (String, String) {
let inner = self.inner.lock().unwrap();
let stdout = std::str::from_utf8(&inner.stdout).unwrap();
let stderr = std::str::from_utf8(&inner.stderr).unwrap();
(stdout.to_string(), stderr.to_string())
}
}
impl Ui for BufferUi {
fn write_str(&self, content: &str) {
let mut inner = self.inner.lock().unwrap();
print!("{}", content);
inner.write_str(content)
}
fn write_err_str(&self, content: &str) {
let mut inner = self.inner.lock().unwrap();
eprint!("{}", content);
inner.write_err_str(content)
}
fn write_str_ln(&self, content: &str) {
let mut inner = self.inner.lock().unwrap();
println!("{}", content);
inner.write_str_ln(content)
}
fn write_err_str_ln(&self, content: &str) {
let mut inner = self.inner.lock().unwrap();
eprintln!("{}", content);
inner.write_err_str_ln(content)
}
}
impl Default for BufferInner {
fn default() -> Self {
Self {
stdout: Vec::new(),
stderr: Vec::new(),
}
}
}
impl Default for BufferUi {
fn default() -> Self {
Self {
inner: Arc::new(Mutex::new(BufferInner::default())),
}
}
}
impl From<BufferUi> for DynUi {
fn from(value: BufferUi) -> Self {
Box::new(value)
}
}
impl From<&BufferUi> for DynUi {
fn from(value: &BufferUi) -> Self {
value.clone().into()
}
}

View File

@ -0,0 +1,83 @@
pub mod common;
use std::path::PathBuf;
use common::BufferUi;
use cuddle_release::command::Command;
use tracing_test::traced_test;
fn get_base_args<'a>() -> Vec<&'a str> {
vec!["cuddle-please", "config", "list"]
}
fn assert_output(ui: &BufferUi, expected_stdout: &str, expected_stderr: &str) {
let (stdout, stderr) = ui.get_output();
assert_eq!(expected_stdout, &stdout);
assert_eq!(expected_stderr, &stderr);
}
fn get_test_data_path(item: &str) -> PathBuf {
std::env::current_dir()
.ok()
.map(|p| p.join("testdata").join(item))
.unwrap()
}
#[test]
#[traced_test]
fn test_config_from_current_dir() {
let args = get_base_args();
let ui = &BufferUi::default();
let current_dir = get_test_data_path("cuddle-embed");
Command::new_from_args(Some(ui), args.into_iter())
.execute(Some(&current_dir))
.unwrap();
assert_output(ui, "cuddle-config\n", "");
}
#[test]
#[traced_test]
fn test_config_from_source_dir() {
let mut args = get_base_args();
let ui = &BufferUi::default();
let current_dir = get_test_data_path("cuddle-embed");
args.push("--source");
args.push(current_dir.to_str().unwrap());
Command::new_from_args(Some(ui), args.into_iter())
.execute(None)
.unwrap();
assert_output(ui, "cuddle-config\n", "");
}
#[test]
#[traced_test]
fn test_config_from_stdin() {
let mut args = get_base_args();
let ui = &BufferUi::default();
let current_dir = get_test_data_path("cuddle-embed");
args.push("--source");
args.push(current_dir.to_str().unwrap());
args.push("--config-stdin");
Command::new_from_args_with_stdin(Some(ui), args.into_iter(), || Ok("please".into()))
.execute(None)
.unwrap();
assert_output(ui, "cuddle-config\n", "");
}
#[test]
#[traced_test]
fn test_config_fails_when_not_path_is_set() {
let args = get_base_args();
let ui = &BufferUi::default();
let res = Command::new_from_args(Some(ui), args.into_iter()).execute(None);
assert!(res.is_err())
}