Skip to content

Commit

Permalink
feat: add convert subcommand to convert config file to another form…
Browse files Browse the repository at this point in the history
…at (#156)
  • Loading branch information
wangl-cc authored Dec 25, 2023
1 parent 325fa75 commit 880528f
Show file tree
Hide file tree
Showing 6 changed files with 304 additions and 49 deletions.
8 changes: 8 additions & 0 deletions README-EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,14 @@ You can update `maa-cli` by `maa self update`. For users who install `maa-cli` w

More other commands can be found by `maa help`.

### Other subcommands

- `maa list`: list all available tasks;
- `maa dir <subcommand>`: get the path of a specific directory;
- `maa version`: print the version of `maa-cli` and `MaaCore`;
- `maa convert <input> [output]`: convert a configuration file to another format, like `maa convert daily.toml daily.json`;
- `maa complete <shell>`: generate completion script for specific shell;

## Configurations

### Configuration directory
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,14 @@ OpenSSL 库是 `git2` 在所有平台和 `reqwest` 在 Linux 上的依赖。如

更多其他的命令可以通过 `maa help` 获取。

### 其他子命令

- `maa list`: 列出所有可用的任务;
- `maa dir <dir>`: 获取特定目录的路径,比如 `maa dir config` 可以用来获取配置目录的路径;
- `maa version`: 获取 `maa-cli` 以及 `MaaCore` 的版本信息;
- `maa convert <input> [output]`: 将 `JSON`, `YAML` 或者 `TOML` 格式的文件转换为其他格式;
- `maa complete <shell>`: 生成自动补全脚本;

## 配置

### 配置目录
Expand Down
6 changes: 5 additions & 1 deletion maa-cli/share/fish/vendor_completions.d/maa.fish
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ complete -c maa -l batch -d 'Enable touch mode'
complete -c maa -l log-file -d 'Log to file instead of stderr'

# help
set -l subcommands install update self hot-update dir version run fight copilot roguelike list complete
set -l subcommands install update self hot-update dir version run fight copilot roguelike list complete convert
complete -c maa -s h -l help -d 'Print help (see more with \'--help\')'
complete -c maa -n "__fish_use_subcommand" -f -a "help" -d 'Print help for given subcommand'
complete -c maa -n "__fish_seen_subcommand_from help" -f -a "$subcommands"
Expand All @@ -29,6 +29,7 @@ complete -c maa -n "__fish_use_subcommand" -f -a "copilot" -d 'Run copilot task'
complete -c maa -n "__fish_use_subcommand" -f -a "roguelike" -d 'Run rouge-like task'
complete -c maa -n "__fish_use_subcommand" -f -a "list" -d 'List all available tasks'
complete -c maa -n "__fish_use_subcommand" -f -a "complete" -d 'Generate completion script for given shell'
complete -c maa -n "__fish_use_subcommand" -f -a "convert" -d 'Convert config file to another format'

set -l channels alpha beta stable
# MaaCore installer options
Expand Down Expand Up @@ -66,5 +67,8 @@ complete -c maa -n "__fish_seen_subcommand_from fight" -l startup -d 'Whether to
complete -c maa -n "__fish_seen_subcommand_from fight" -l closedown -d 'Whether to close the game'
complete -c maa -n "__fish_seen_subcommand_from roguelike" -a "phantom mizuki sami"

complete -c maa -n "__fish_seen_subcommand_from complete" -f -a "bash fish zsh powershell"
complete -c maa -n "__fish_seen_subcommand_from convert" -l f -l format -a "j json y yaml t toml" -r

# Subcommand don't require arguments
complete -c maa -n "__fish_seen_subcommand_from hot-update list" -f # prevent fish complete from path
251 changes: 213 additions & 38 deletions maa-cli/src/config/mod.rs
Original file line number Diff line number Diff line change
@@ -1,25 +1,33 @@
use crate::dirs::Ensure;

use std::fs::{self, File};
use std::path::Path;

use clap::ValueEnum;
use serde_json::Value as JsonValue;

#[derive(Debug)]
pub enum Error {
UnknownFiletype,
UnsupportedFiletype(String),
FileNotFound(String),
UnsupportedFiletype,
FormatNotGiven,
Io(std::io::Error),
Json(serde_json::Error),
Toml(toml::de::Error),
TomlDe(toml::de::Error),
TomlSer(toml::ser::Error),
Yaml(serde_yaml::Error),
}

type Result<T, E = Error> = std::result::Result<T, E>;

impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Error::UnknownFiletype => write!(f, "Unknown filetype"),
Error::UnsupportedFiletype(s) => write!(f, "Unsupported filetype: {}", s),
Error::FileNotFound(s) => write!(f, "File not found: {}", s),
Error::UnsupportedFiletype => write!(f, "Unsupported or unknown filetype"),
Error::FormatNotGiven => write!(f, "Format not given"),
Error::Io(e) => write!(f, "IO error, {}", e),
Error::Json(e) => write!(f, "JSON parse error, {}", e),
Error::Toml(e) => write!(f, "TOML parse error, {}", e),
Error::TomlSer(e) => write!(f, "TOML serialize error, {}", e),
Error::TomlDe(e) => write!(f, "TOML deserialize error, {}", e),
Error::Yaml(e) => write!(f, "YAML parse error, {}", e),
}
}
Expand All @@ -41,7 +49,13 @@ impl From<serde_json::Error> for Error {

impl From<toml::de::Error> for Error {
fn from(e: toml::de::Error) -> Self {
Error::Toml(e)
Error::TomlDe(e)
}
}

impl From<toml::ser::Error> for Error {
fn from(e: toml::ser::Error) -> Self {
Error::TomlSer(e)
}
}

Expand All @@ -51,52 +65,113 @@ impl From<serde_yaml::Error> for Error {
}
}

fn file_not_found(path: impl AsRef<Path>) -> Error {
std::io::Error::new(
std::io::ErrorKind::NotFound,
path.as_ref()
.to_str()
.map_or("File not found".to_owned(), |s| {
format!("File not found: {}", s)
}),
)
.into()
}

const SUPPORTED_EXTENSION: [&str; 4] = ["json", "yaml", "yml", "toml"];

#[derive(Clone, Copy, ValueEnum)]
pub enum Filetype {
#[clap(alias = "j")]
Json,
#[clap(alias = "y")]
Yaml,
#[clap(alias = "t")]
Toml,
}

impl Filetype {
fn parse_filetype(path: impl AsRef<Path>) -> Option<Self> {
path.as_ref()
.extension()
.and_then(|ext| ext.to_str())
.and_then(Self::parse_extension)
}

fn parse_extension(ext: &str) -> Option<Self> {
match ext.to_lowercase().as_ref() {
"json" => Some(Filetype::Json),
"yaml" | "yml" => Some(Filetype::Yaml),
"toml" => Some(Filetype::Toml),
_ => None,
}
}

fn read<T>(&self, path: impl AsRef<Path>) -> Result<T>
where
T: serde::de::DeserializeOwned,
{
use Filetype::*;
Ok(match self {
Json => serde_json::from_reader(File::open(path)?)?,
Yaml => serde_yaml::from_reader(File::open(path)?)?,
Toml => toml::from_str(&fs::read_to_string(path)?)?,
})
}

fn write<T>(&self, mut writer: impl std::io::Write, value: &T) -> Result<()>
where
T: serde::Serialize,
{
use Filetype::*;
match self {
Json => serde_json::to_writer_pretty(writer, value)?,
Yaml => serde_yaml::to_writer(writer, value)?,
Toml => writer.write_all(toml::to_string_pretty(value)?.as_bytes())?,
};
Ok(())
}

fn to_str(self) -> &'static str {
use Filetype::*;
match self {
Json => "json",
Yaml => "yaml",
Toml => "toml",
}
}
}

pub trait FromFile: Sized + serde::de::DeserializeOwned {
fn from_file(path: impl AsRef<Path>) -> Result<Self, Error> {
fn from_file(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
if !path.exists() {
return Err(Error::FileNotFound(path.to_str().unwrap().to_string()));
}
let filetype = path.extension().ok_or(Error::UnknownFiletype)?;
match filetype.to_str().unwrap() {
"json" => {
let task_list = serde_json::from_reader(std::fs::File::open(path)?)?;
Ok(task_list)
}
"toml" => {
let task_list = toml::from_str(&std::fs::read_to_string(path)?)?;
Ok(task_list)
}
"yml" | "yaml" => {
let task_list = serde_yaml::from_reader(std::fs::File::open(path)?)?;
Ok(task_list)
}
_ => {
return Err(Error::UnsupportedFiletype(String::from(
filetype.to_str().unwrap_or("Unknown"),
)))
}
if path.exists() {
Filetype::parse_filetype(path)
.ok_or(Error::UnsupportedFiletype)?
.read(path)
} else {
Err(file_not_found(path))
}
}
}

pub trait FindFile: FromFile {
fn find_file(path: impl AsRef<Path>) -> Result<Self, Error> {
/// Find file with supported extension and deserialize it.
///
/// The file should not have extension. If it has extension, it will be ignored.
fn find_file(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
for filetype in SUPPORTED_EXTENSION.iter() {
let path = path.with_extension(filetype);
if path.exists() {
return Self::from_file(&path);
}
}
Err(Error::FileNotFound(path.to_str().unwrap().to_string()))
Err(file_not_found(path))
}
}

pub trait FindFileOrDefault: FromFile + Default {
fn find_file_or_default(path: &Path) -> Result<Self, Error> {
fn find_file_or_default(path: &Path) -> Result<Self> {
for filetype in SUPPORTED_EXTENSION.iter() {
let path = path.with_extension(filetype);
if path.exists() {
Expand All @@ -111,6 +186,32 @@ impl<T> FindFile for T where T: FromFile {}

impl<T> FindFileOrDefault for T where T: FromFile + Default {}

impl FromFile for JsonValue {}

pub fn convert(file: &Path, out: Option<&Path>, ft: Option<Filetype>) -> Result<()> {
let ft = ft.or_else(|| {
out.and_then(|path| path.extension())
.and_then(|ext| ext.to_str())
.and_then(Filetype::parse_extension)
});

let value = JsonValue::from_file(file)?;

if let Some(format) = ft {
if let Some(file) = out {
let file = file.with_extension(format.to_str());
if let Some(dir) = file.parent() {
dir.ensure()?;
}
format.write(File::create(file)?, &value)
} else {
format.write(std::io::stdout().lock(), &value)
}
} else {
Err(Error::FormatNotGiven)
}
}

pub mod asst;

pub mod cli;
Expand All @@ -119,12 +220,55 @@ pub mod task;

#[cfg(test)]
mod tests {
use crate::assert_matches;

use super::*;
use std::env::temp_dir;

use crate::assert_matches;

use serde::Deserialize;
use serde_json::{json, Value as JsonValue};

#[test]
fn filetype() {
use Filetype::*;
assert_matches!(Filetype::parse_filetype("test.toml"), Some(Toml));
assert!(Filetype::parse_filetype("test").is_none());

assert_matches!(Filetype::parse_extension("toml"), Some(Toml));
assert_matches!(Filetype::parse_extension("yml"), Some(Yaml));
assert_matches!(Filetype::parse_extension("yaml"), Some(Yaml));
assert_matches!(Filetype::parse_extension("json"), Some(Json));
assert!(Filetype::parse_extension("txt").is_none());

assert_eq!(Toml.to_str(), "toml");
assert_eq!(Yaml.to_str(), "yaml");
assert_eq!(Json.to_str(), "json");

let test_root = temp_dir().join("maa-test-filetype");
std::fs::create_dir_all(&test_root).unwrap();

let value = json!({
"a": 1,
"b": "test"
});

let test_file = test_root.join("test");
let test_json = test_file.with_extension("json");
Json.write(File::create(&test_json).unwrap(), &value)
.unwrap();
assert_eq!(Json.read::<JsonValue>(&test_json).unwrap(), value);

let test_yaml = test_file.with_extension("yaml");
Yaml.write(File::create(&test_yaml).unwrap(), &value)
.unwrap();
assert_eq!(Yaml.read::<JsonValue>(&test_yaml).unwrap(), value);

let test_toml = test_file.with_extension("toml");
Toml.write(File::create(&test_toml).unwrap(), &value)
.unwrap();
assert_eq!(Toml.read::<JsonValue>(&test_toml).unwrap(), value);
std::fs::remove_dir_all(&test_root).unwrap();
}

#[test]
fn find_file() {
Expand Down Expand Up @@ -161,7 +305,7 @@ mod tests {

assert_matches!(
TestConfig::find_file(&non_exist_file).unwrap_err(),
Error::FileNotFound(s) if s == non_exist_file.to_str().unwrap()
Error::Io(e) if e.kind() == std::io::ErrorKind::NotFound
);

assert_eq!(
Expand All @@ -179,4 +323,35 @@ mod tests {

std::fs::remove_dir_all(&test_root).unwrap();
}

#[test]
fn test_convert() {
use Filetype::*;

let test_root = temp_dir().join("maa-test-convert");
std::fs::create_dir_all(&test_root).unwrap();

let input = test_root.join("test.json");
let toml = test_root.join("test.toml");
let yaml = test_root.join("test.yaml");

let value = json!({
"a": 1,
"b": "test"
});

Json.write(File::create(&input).unwrap(), &value).unwrap();

convert(&input, None, Some(Json)).unwrap();
convert(&input, Some(&toml), None).unwrap();
convert(&input, Some(&toml), Some(Yaml)).unwrap();

assert_eq!(Toml.read::<JsonValue>(&toml).unwrap(), value);
assert_eq!(Yaml.read::<JsonValue>(&yaml).unwrap(), value);

assert_matches!(
convert(&input, None, None).unwrap_err(),
Error::FormatNotGiven
);
}
}
Loading

0 comments on commit 880528f

Please sign in to comment.