use std::env;
use std::path::Path;
use std::process::Command;
#[derive(Debug)]
pub struct Config {
pub remote: Option<String>,
pub filename: String,
pub range_start: Option<u32>,
pub range_end: Option<u32>,
}
impl Config {
pub fn new(args: &Vec<String>) -> Result<Config, String> {
if args.len() < 2 {
return Err("Not enough arguments.".to_string());
}
let filename = args[1].clone();
if !Path::new(&filename).exists() {
return Err(format!("File {} doesn't exist.", filename));
}
let range_start = match args.get(2) {
Some(value) => {
if let Ok(v) = value.parse() {
Some(v)
} else {
return Err(format!("Invalid range {}: not a number.", value));
}
}
None => None,
};
let range_end = match args.get(3) {
Some(value) => {
if let Ok(v) = value.parse() {
Some(v)
} else {
return Err(format!("Invalid range {}: not a number.", value));
}
}
None => None,
};
let remote = if let Ok(value) = env::var("REMOTE") {
Some(value.to_lowercase())
} else {
None
};
Ok(Config {
remote,
filename,
range_start,
range_end,
})
}
}
pub fn run(config: Config) -> Result<String, String> {
let command = Command::new("git");
let url = get_remote_url(command, &config.remote)?;
let web_url = normalize(url);
let command = Command::new("git");
let blob_url = get_blob_url(command, web_url, &config.filename)?;
let link = add_range(blob_url, &config.range_start, &config.range_end);
Ok(link)
}
#[cfg_attr(test, mockall::automock)]
trait GitCommand {
fn run_config_get_local(&mut self, remote: &str) -> Result<String, String>;
fn run_rev_parse_head(&mut self) -> Result<String, String>;
}
impl GitCommand for Command {
fn run_config_get_local(&mut self, remote: &str) -> Result<String, String> {
let command = self.args(&[
"config",
"--get",
"--local",
format!("remote.{}.url", remote).as_str(),
]);
let output = command
.output()
.expect("failed to execute git config command");
match String::from_utf8(output.stdout) {
Ok(value) => Ok(value.trim().to_string()),
_ => Err("Invalid String from git config output.".to_string()),
}
}
fn run_rev_parse_head(&mut self) -> Result<String, String> {
let command = self.args(&["rev-parse", "HEAD"]);
let output = command
.output()
.expect("failed to execute git config command");
match String::from_utf8(output.stdout) {
Ok(value) => Ok(value.trim().to_string()),
_ => Err("Invalid String from git config output.".to_string()),
}
}
}
fn get_remote_url<T: GitCommand>(mut command: T, remote: &Option<String>) -> Result<String, String> {
let remote = if let Some(value) = remote {
value
} else {
"origin"
};
let url = command.run_config_get_local(&remote)?;
if url.is_empty() {
return Err(format!("Remote {} doesn't exist.", remote));
} else if !url.starts_with("https://") && !url.starts_with("git@") {
return Err("Remote URL is invalid. Scheme should be https or git".to_string());
} else if !url.contains("github") && !url.contains("gitlab") {
return Err("Only github or gitlab URLs supported.".to_string());
}
Ok(url)
}
fn normalize(url: String) -> String {
if url.starts_with("https") {
return url;
}
let mut url = url[4..].to_string().replace(":", "/").replace(".git", "");
url.insert_str(0, "https://");
url
}
fn get_blob_url<T: GitCommand>(
mut command: T,
web_url: String,
filename: &String,
) -> Result<String, String> {
let sha_head = command.run_rev_parse_head()?;
let blob_url = vec![web_url, "blob".to_string(), sha_head, filename.to_string()];
Ok(blob_url.join("/"))
}
fn add_range(blob_url: String, range_start: &Option<u32>, range_end: &Option<u32>) -> String {
match range_start {
None => blob_url,
Some(start) => match range_end {
None => format!("{}#L{}", blob_url, start),
Some(end) => {
if blob_url.contains("gitlab") {
format!("{}#L{}-{}", blob_url, start, end)
} else {
format!("{}#L{}-L{}", blob_url, start, end)
}
}
},
}
}
#[cfg(test)]
use mockall::predicate::*;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_needs_filename() {
let argv = vec!["program".to_string()];
let result = Config::new(&argv);
assert_eq!(result.unwrap_err(), "Not enough arguments.");
let argv = vec!["program".to_string(), "foo".to_string()];
let result = Config::new(&argv);
assert_eq!(result.unwrap_err(), "File foo doesn't exist.");
let argv = vec!["program".to_string(), "src/lib.rs".to_string()];
let result = Config::new(&argv);
assert!(result.is_ok());
}
#[test]
fn config_rejects_invalid_ranges() {
let argv = vec!["program", "src/lib.rs", "foo"]
.iter()
.map(|s| s.to_string())
.collect();
let result = Config::new(&argv);
assert_eq!(result.unwrap_err(), "Invalid range foo: not a number.");
let argv = vec!["program", "src/lib.rs", "1", "bar"]
.iter()
.map(|s| s.to_string())
.collect();
let result = Config::new(&argv);
assert_eq!(result.unwrap_err(), "Invalid range bar: not a number.");
let argv = vec!["program", "src/lib.rs", "1", "2"]
.iter()
.map(|s| s.to_string())
.collect();
let result = Config::new(&argv);
assert!(result.is_ok());
}
#[test]
fn get_remote_url_rejects_invalid_remotes() {
let mut mock_command = MockGitCommand::new();
mock_command
.expect_run_config_get_local()
.returning(|_| Ok(String::from("")));
let result = get_remote_url(mock_command, &Some("foo".to_string()));
assert_eq!(result.unwrap_err(), "Remote foo doesn't exist.");
}
#[test]
fn get_remote_url_rejects_unsupported_schemas() {
let mut mock_command = MockGitCommand::new();
mock_command
.expect_run_config_get_local()
.returning(|_| Ok(String::from("ftp://bitbucket.org")));
let result = get_remote_url(mock_command, &None);
assert_eq!(
result.unwrap_err(),
"Remote URL is invalid. Scheme should be https or git"
);
}
#[test]
fn get_remote_url_rejects_unsupported_hosts() {
let mut mock_command = MockGitCommand::new();
mock_command
.expect_run_config_get_local()
.returning(|_| Ok(String::from("https://bitbucket.org")));
let result = get_remote_url(mock_command, &None);
assert_eq!(result.unwrap_err(), "Only github or gitlab URLs supported.");
}
#[test]
fn normalize_transforms_git_url_into_https() {
let web_url = normalize(String::from("git@gitlab.com:tonchis/repo_link.git"));
assert_eq!(web_url, "https://gitlab.com/tonchis/repo_link");
let web_url = normalize(String::from("git@gitlab.com:tonchis/repo_link"));
assert_eq!(web_url, "https://gitlab.com/tonchis/repo_link");
}
#[test]
fn normalize_honors_https_urls() {
let web_url = normalize(String::from("https://gitlab.com/tonchis/repo_link"));
assert_eq!(web_url, "https://gitlab.com/tonchis/repo_link");
}
#[test]
fn get_blob_url_generates_blob_using_sha_head() {
let mut mock_command = MockGitCommand::new();
mock_command
.expect_run_rev_parse_head()
.returning(|| Ok(String::from("abcd1234")));
let web_url = String::from("https://gitlab.com/tonchis/repo_link");
let filename = String::from("src/lib.rs");
let result = get_blob_url(mock_command, web_url, &filename);
assert_eq!(
result.unwrap(),
"https://gitlab.com/tonchis/repo_link/blob/abcd1234/src/lib.rs"
);
}
#[test]
fn add_range_adds_the_anchor_for_the_line_of_code() {
let blob_url = String::from("https://gitlab.com/tonchis/repo_link/blob/abcd123/src/lib.rs");
let link = add_range(blob_url, &None, &None);
assert_eq!(
link,
"https://gitlab.com/tonchis/repo_link/blob/abcd123/src/lib.rs"
);
let blob_url = String::from("https://gitlab.com/tonchis/repo_link/blob/abcd123/src/lib.rs");
let link = add_range(blob_url, &Some(1), &None);
assert_eq!(
link,
"https://gitlab.com/tonchis/repo_link/blob/abcd123/src/lib.rs#L1"
);
}
#[test]
fn add_range_supports_github_and_gitlab_ranges() {
let blob_url = String::from("https://gitlab.com/tonchis/repo_link/blob/abcd123/src/lib.rs");
let link = add_range(blob_url, &Some(1), &Some(3));
assert_eq!(
link,
"https://gitlab.com/tonchis/repo_link/blob/abcd123/src/lib.rs#L1-3"
);
let blob_url = String::from("https://github.com/tonchis/repo_link/blob/abcd123/src/lib.rs");
let link = add_range(blob_url, &Some(1), &Some(3));
assert_eq!(
link,
"https://github.com/tonchis/repo_link/blob/abcd123/src/lib.rs#L1-L3"
);
}
}