diff --git a/Cargo.toml b/Cargo.toml index 26edbca..ce391d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,3 +4,4 @@ version = "0.1.0" authors = ["Per Lundberg "] [dependencies] +semver = "0.5.0" \ No newline at end of file diff --git a/src/changelog_generator.rs b/src/changelog_generator.rs new file mode 100644 index 0000000..1287b3b --- /dev/null +++ b/src/changelog_generator.rs @@ -0,0 +1,84 @@ +use std::process::Command; + +pub struct ChangelogGenerator { + pub repository_path: String, + pub from_revision: String, + pub to_revision: String +} + +impl ChangelogGenerator { + pub fn generate_changelog(self) { + let output = self.get_log_output(); + let lines = ChangelogGenerator::get_lines_from(&output); + let mut lines_iterator = lines.iter(); + + println!("## {}", self.to_revision); + print!("[Full Changelog](https://github.com/{}/compare/{}...{})\n\n", + self.get_repo_slug(), + self.from_revision, + self.to_revision); + + loop { + match lines_iterator.next() { + Some(line) => { + if line.is_empty() { + break; + } + println!("* {}", line) + } + None => break, + } + } + + print!("\n"); + } + + fn get_log_output(&self) -> String { + let output = Command::new("git") + .arg("log") + .arg("--oneline") + .arg(self.range()) + .current_dir(&self.repository_path) + .output() + .unwrap_or_else(|e| panic!("Failed to run 'git log' with error: {}", e)); + String::from_utf8_lossy(&output.stdout).into_owned() + } + + fn range(&self) -> String { + format!("{}..{}", self.from_revision, self.to_revision) + } + + fn get_lines_from(output: &str) -> Vec<&str> { + output.split('\n') + .collect() + } + + fn get_repo_slug(&self) -> String { + let output = Command::new("git") + .arg("remote") + .arg("get-url") + .arg("origin") + .current_dir(&self.repository_path) + .output() + .unwrap_or_else(|e| panic!("Failed to run 'git log' with error: {}", e)); + + let url = String::from_utf8_lossy(&output.stdout).into_owned(); + + // The command output contains a trailing newline that we want to get rid of. + let trimmed_url = url.trim(); + + self.get_repo_slug_from(trimmed_url) + } + + fn get_repo_slug_from(&self, url: &str) -> String { + // This very simplistic and stupid algorithm works for repos of these forms: + // https://github.com/dekellum/fishwife.git + // git@github.com:chaos4ever/chaos.git + let mangled_url = url.replace(":", "/").replace(".git", ""); + let mut url_parts: Vec<_> = mangled_url.split('/').collect(); + let repo_name = url_parts.pop().unwrap(); + let org_name = url_parts.pop().unwrap(); + + format!("{}/{}", org_name, repo_name) + } +} diff --git a/src/git_tag_parser.rs b/src/git_tag_parser.rs new file mode 100644 index 0000000..ba22002 --- /dev/null +++ b/src/git_tag_parser.rs @@ -0,0 +1,75 @@ +use semver::Version; +use std::process::Command; + +pub struct GitTagParser { + pub repository_path: String +} + +impl GitTagParser { + // Returns a vector of "from", "to" tuples for each tag found in the repository. The "from" revision is the previous semver + // tag, the "to" revision is the current semver tag. + pub fn get_version_tag_pairs(&self) -> Vec<(String, String)> { + let mut from_version = self.get_root_ancestor(); + let mut tag_pairs: Vec<(String, String)> = self.semver_tags().into_iter().rev().map(|tag| { + let old_from_version = from_version.clone(); + let to_version = tag; + from_version = to_version.clone(); + + (old_from_version, to_version) + }).collect(); + + // TODO: Add the pair from "last tag to HEAD" if they do not point to the same rev. + tag_pairs.reverse(); + + tag_pairs + } + + fn semver_tags(&self) -> Vec { + let tags = self.get_tags(); + tags.into_iter().filter(|e| match Version::parse(e.replace("v", "").as_str()) { + Ok(_) => true, + Err(_) => false + }).collect() + } + + // A lot of parameters to this one. 'git tag -l' is much simpler, but the problem is that it produces a list of + // tags that is sorted in the wrong order. We want them in the order that they exist in the repo. + fn get_tags_args() -> Vec { + [ + "for-each-ref", + "--format=%(refname)", + "refs/tags/*" + ].iter().map(|e| e.to_string()).collect() + } + + fn get_tags(&self) -> Vec { + let output = Command::new("git") + .args(&GitTagParser::get_tags_args()) + .current_dir(&self.repository_path) + .output() + .unwrap_or_else(|e| panic!("Failed to run 'git tag' with error: {}", e)); + let output_stdout = String::from_utf8_lossy(&output.stdout); + let output_lines = output_stdout.split('\n'); + + output_lines.map(|e| + e.to_string() + .replace("refs/tags/", "") + .trim() + .to_string() + ).rev().collect() + } + + fn get_root_ancestor(&self) -> String { + let output = Command::new("git") + .args(&[ + "rev-list", + "--max-parents=0", + "HEAD" + ]) + .current_dir(&self.repository_path) + .output() + .unwrap_or_else(|e| panic!("Failed to run 'git tag' with error: {}", e)); + let output_stdout = String::from_utf8_lossy(&output.stdout); + output_stdout.trim().to_string() + } +} diff --git a/src/main.rs b/src/main.rs index e300b6b..e09cbb5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,101 +1,48 @@ #![deny(unused_imports)] +#![deny(unused_variables)] +extern crate semver; + +mod changelog_generator; +mod git_tag_parser; + +use changelog_generator::ChangelogGenerator; +use git_tag_parser::GitTagParser; use std::env; -use std::process::Command; use std::process::exit; -struct Changelog { - repository_path: String, - from_revision: String, - to_revision: String -} +fn main() { + let args: Vec<_> = env::args().collect(); -impl Changelog { - pub fn generate_changelog(self) { - let output = self.get_log_output(); - let lines = Changelog::get_lines_from(&output); - let mut lines_iterator = lines.iter(); + if args.len() == 2 { + let ref repository_path = args[1]; + let git_tag_parser = GitTagParser { + repository_path: repository_path.clone() + }; - println!("## {}", self.to_revision); - print!("[Full Changelog](https://github.com/{}/compare/{}...{})\n\n", - self.get_repo_slug(), self.from_revision, self.to_revision); + let version_tag_pairs = git_tag_parser.get_version_tag_pairs(); - loop { - match lines_iterator.next() { - Some(line) => { - if line.is_empty() { return; } - println!("* {}", line) - }, - None => break - } + for (from_tag, to_tag) in version_tag_pairs.into_iter() { + let generator = ChangelogGenerator { + repository_path: repository_path.clone(), + from_revision: from_tag, + to_revision: to_tag + }; + generator.generate_changelog(); } - } - - fn get_log_output(&self) -> String { - let output = Command::new("git") - .arg("log") - .arg("--oneline") - .arg(self.range()) - .current_dir(&self.repository_path) - .output() - .unwrap_or_else(|e| panic!("Failed to run 'git log' with error: {}", e)); - String::from_utf8_lossy(&output.stdout).into_owned() - } - - fn range(&self) -> String { - format!("{}..{}", self.from_revision, self.to_revision) - } - - fn get_lines_from(output: &str) -> Vec<&str> { - output - .split('\n') - .collect() - } - - fn get_repo_slug(&self) -> String { - let output = Command::new("git") - .arg("remote") - .arg("get-url") - .arg("origin") - .current_dir(&self.repository_path) - .output() - .unwrap_or_else(|e| panic!("Failed to run 'git log' with error: {}", e)); - - let url = String::from_utf8_lossy(&output.stdout).into_owned(); - - // The command output contains a trailing newline that we want to get rid of. - let trimmed_url = url.trim(); - - self.get_repo_slug_from(trimmed_url) - } - - fn get_repo_slug_from(&self, url: &str) -> String { - // This very simplistic and stupid algorithm works for repos of these forms: - // https://github.com/dekellum/fishwife.git - // git@github.com:chaos4ever/chaos.git - let mangled_url = url.replace(":", "/").replace(".git", ""); - let mut url_parts: Vec<_> = mangled_url.split('/').collect(); - let repo_name = url_parts.pop().unwrap(); - let org_name = url_parts.pop().unwrap(); - - format!("{}/{}", org_name, repo_name) - } -} - -fn main() { - let args: Vec<_> = env::args().collect(); - - if args.len() == 4 { - let changelog = Changelog { + } else if args.len() == 4 { + let generator = ChangelogGenerator { repository_path: args[1].clone(), from_revision: args[2].clone(), to_revision: args[3].clone() }; - changelog.generate_changelog(); - } - else { - println!("Usage: {} \n", args[0]); + generator.generate_changelog(); + } else { + println!("Usage: {} [ ]\n", + args[0]); println!("The path must be a clone of valid git repository."); + println!("If the 'from_revision' and 'to_revision' are not provided, a full log \ + including all SemVer-tagged versions will be produced."); exit(1); } }