use clap::{crate_version, App, AppSettings, Arg, ArgMatches, SubCommand};
use cloud_storage::Object;
use colored::*;
use std::env;
use std::fs;
use std::fs::read_to_string;
use std::io::{Error, ErrorKind, Result};
use std::process::exit;
use toml::Value;
use zamm::commands::run_command;
use zamm::generate_code;
use zamm::intermediate_build::CodegenConfig;
use zamm::parse::ParseOutput;
use zamm::{commands, warn};
const INPUT_HELP_TEXT: &str =
"The input file containing relevant information to generate code for. Currently only Markdown \
(extension .md) is supported. If no input file is provided, yang will look for a file named \
`yin` with one of the above extensions, in the current directory.";
const GCS_BUCKET: &str = "api.zamm.dev";
const RELEASE_BRANCH: &str = "releases";
const TEMP_BRANCH: &str = "zamm-temp-release";
const CARGO_FILE: &str = "Cargo.toml";
struct ProjectInfo {
pub name: String,
pub version: String,
pub toml: Value,
}
fn release_pre_build() -> Result<()> {
if !run_command("git", &["status", "--porcelain"])?.is_empty() {
return Err(Error::new(
ErrorKind::InvalidData,
format!(
"{}",
"Git repo dirty, commit changes before releasing."
.red()
.bold()
),
));
}
commands::clean()?;
Ok(())
}
fn update_cargo_lock(package_name: &str, new_version: &str) -> Result<()> {
let cargo_lock = "Cargo.lock";
let lock_contents = read_to_string(cargo_lock)?;
let mut lock_cfg = lock_contents.parse::<Value>().unwrap();
for table_value in lock_cfg["package"].as_array_mut().unwrap() {
let table = table_value.as_table_mut().unwrap();
if table["name"].as_str().unwrap() == package_name {
table["version"] = toml::Value::String(new_version.to_owned());
}
}
fs::write(cargo_lock, lock_cfg.to_string())?;
Ok(())
}
fn load_project_info() -> Result<ProjectInfo> {
let build_contents = read_to_string(CARGO_FILE)?;
let build_cfg = build_contents.parse::<Value>().unwrap();
Ok(ProjectInfo {
name: build_cfg["package"]["name"].as_str().unwrap().to_owned(),
version: build_cfg["package"]["version"].as_str().unwrap().to_owned(),
toml: build_cfg,
})
}
fn update_project_version(new_info: &mut ProjectInfo) -> Result<()> {
new_info.toml["package"]["version"] = toml::Value::String(new_info.version.clone());
update_cargo_lock(&new_info.name, &new_info.version)?;
fs::write(CARGO_FILE, new_info.toml.to_string())
}
fn branch_exists(branch: &str) -> bool {
run_command("git", &["rev-parse", "--verify", branch]).is_ok()
}
fn get_commit_sha(branch: &str) -> Result<String> {
run_command("git", &["rev-parse", "--short", branch]).map(|b| b.trim().to_owned())
}
fn commit_all(message: &str) -> Result<String> {
run_command("git", &["add", "-A"])?;
run_command("git", &["commit", "-m", message])
}
fn next_version_string(current_version: &str) -> String {
let mut next_version = semver::Version::parse(current_version).unwrap();
next_version.increment_patch();
next_version.to_string()
}
fn release_post_build(output: &ParseOutput) -> Result<()> {
let mut project = load_project_info()?;
if project.version.contains('-') {
project.version = project.version.split('-').next().unwrap().to_owned();
update_project_version(&mut project)?;
}
let build_commit = get_commit_sha("HEAD")?;
if branch_exists(TEMP_BRANCH) {
run_command("git", &["branch", "-D", TEMP_BRANCH])?;
}
run_command("git", &["checkout", "-b", TEMP_BRANCH])?;
run_command("git", &["rm", "-f", "build.rs"])?;
run_command("cargo", &["fmt"])?;
let commit_message = format!("Creating release v{}", project.version);
commit_all(&commit_message)?;
if branch_exists(RELEASE_BRANCH) {
run_command("git", &["checkout", RELEASE_BRANCH])?;
run_command("git", &["merge", "-s", "ours", "main", "-m", "Dummy merge"])?;
run_command("git", &["checkout", TEMP_BRANCH])?;
let release_head = format!("refs/heads/{}", RELEASE_BRANCH);
run_command("git", &["symbolic-ref", "HEAD", &release_head])?;
run_command("git", &["commit", "-a", "--amend", "-C", TEMP_BRANCH])?;
} else {
run_command("git", &["checkout", "-b", RELEASE_BRANCH])?;
}
let version_tag = format!("v{}", project.version);
run_command("git", &["tag", &version_tag])?;
run_command("git", &["branch", "-D", TEMP_BRANCH])?;
match env::var("SERVICE_ACCOUNT") {
Ok(_) => {
let canonical_name = project.name.replace("zamm_", "");
let gcs_path = format!("v1/books/zamm/{}/{}/{}", canonical_name, project.version, output.filename);
let url = format!("https://api.zamm.dev/{}", gcs_path);
if Object::read_sync(GCS_BUCKET, &gcs_path).is_ok() {
warn!("Not uploading build file because there already exists one at {}", url);
} else {
Object::create_sync(
GCS_BUCKET,
output.markdown.as_bytes().to_vec(),
&gcs_path,
"text/markdown; charset=UTF-8",
).unwrap();
println!("Uploaded input file to {}", url);
}
},
Err(_) =>
warn!("Not uploading build file to zamm.dev because the SERVICE_ACCOUNT environment variable is not set for GCS access."),
};
run_command("git", &["checkout", &build_commit])?;
let next_version = next_version_string(&project.version);
project.version = format!("{}-beta", next_version);
update_project_version(&mut project)?;
let next_version_branch = format!("bump-version-{}", next_version);
run_command("git", &["checkout", "-b", &next_version_branch])?;
commit_all(&format!("Bump version to {}", next_version))?;
Ok(())
}
fn build(args: &ArgMatches) -> Result<()> {
let input = args.value_of("INPUT");
let codegen_cfg = CodegenConfig {
comment_autogen: args
.value_of("COMMENT_AUTOGEN")
.unwrap_or("true")
.parse::<bool>()
.unwrap(),
add_rustfmt_attributes: true,
track_autogen: args.is_present("TRACK_AUTOGEN"),
yin: args.is_present("YIN"),
release: false,
};
generate_code(input, &codegen_cfg)?;
Ok(())
}
fn release(args: &ArgMatches) -> Result<()> {
let input = args.value_of("INPUT");
let codegen_cfg = CodegenConfig {
comment_autogen: false,
add_rustfmt_attributes: true,
track_autogen: false,
yin: args.is_present("YIN"),
release: true,
};
release_pre_build()?;
let parse_output = generate_code(input, &codegen_cfg)?;
release_post_build(&parse_output)?;
Ok(())
}
fn clean(_: &ArgMatches) -> Result<()> {
commands::clean()?;
Ok(())
}
fn test(args: &ArgMatches) -> Result<()> {
let yang = args.is_present("YANG");
println!("Formatting...");
run_command("cargo", &["fmt"])?;
println!("Running tests...");
run_command("cargo", &["test"])?;
println!("Running lints...");
run_command(
"cargo",
&[
"clippy",
"--all-features",
"--all-targets",
"--",
"-D",
"warnings",
],
)?;
if yang {
println!("Running yang build...");
run_command("cargo", &["run", "build"])?;
}
Ok(())
}
fn main() {
let args = App::new("zamm")
.setting(AppSettings::VersionlessSubcommands)
.setting(AppSettings::ColoredHelp)
.version(crate_version!())
.author("Amos Ng <me@amos.ng>")
.about("Literate code generation for Yin and Yang.")
.subcommand(
SubCommand::with_name("build")
.setting(AppSettings::ColoredHelp)
.about("Generate code from an input file")
.arg(
Arg::with_name("INPUT")
.value_name("INPUT")
.help(INPUT_HELP_TEXT)
.takes_value(true),
)
.arg(
Arg::with_name("COMMENT_AUTOGEN")
.short("c")
.long("comment_autogen")
.value_name("COMMENT_AUTOGEN")
.help(
"Whether or not to add an autogeneration comment to each generated \
line of code. Defaults to true.",
)
.takes_value(true),
)
.arg(
Arg::with_name("TRACK_AUTOGEN")
.short("t")
.long("track-autogen")
.help(
"Whether or not we want Cargo to track autogenerated files and \
rebuild when they change. Can result in constant rebuilds.",
),
)
.arg(
Arg::with_name("YIN")
.short("y")
.long("yin")
.help("Set to generate code for Yin instead"),
),
)
.subcommand(
SubCommand::with_name("release")
.setting(AppSettings::ColoredHelp)
.about("Prepare repo for a Cargo release")
.arg(
Arg::with_name("INPUT")
.value_name("INPUT")
.help(INPUT_HELP_TEXT)
.takes_value(true),
)
.arg(
Arg::with_name("YIN")
.short("y")
.long("yin")
.help("Set to generate code for Yin instead"),
),
)
.subcommand(
SubCommand::with_name("clean")
.setting(AppSettings::ColoredHelp)
.about("Clean up autogenerated files"),
)
.subcommand(
SubCommand::with_name("test")
.setting(AppSettings::ColoredHelp)
.about("Make sure the project will pass CI tests")
.arg(
Arg::with_name("YANG")
.short("y")
.long("yang")
.help("Set when testing yang itself"),
),
)
.setting(AppSettings::SubcommandRequiredElseHelp)
.get_matches();
let result = if let Some(build_args) = args.subcommand_matches("build") {
build(build_args)
} else if let Some(release_args) = args.subcommand_matches("release") {
release(release_args)
} else if let Some(clean_args) = args.subcommand_matches("clean") {
clean(clean_args)
} else if let Some(test_args) = args.subcommand_matches("test") {
test(test_args)
} else {
panic!("Arg not found. Did you reconfigure clap recently?");
};
exit(match result {
Ok(_) => 0,
Err(e) => {
eprintln!("{}", e.to_string().red().bold());
1
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_next_version() {
assert_eq!(next_version_string("0.1.0"), "0.1.1");
assert_eq!(next_version_string("0.1.9"), "0.1.10");
}
}