use std::num::NonZero;
use clap::Parser;
use forgelink::Lines;
#[derive(Parser)]
#[command(
name = "forgelink",
version,
about = "Generate shareable URLs to files in hosted git repositories"
)]
struct Args {
file: Option<String>,
#[arg(long)]
branch: bool,
#[arg(long, default_value = "origin")]
remote: String,
#[arg(long)]
project: bool,
#[cfg(feature = "clipboard")]
#[arg(long)]
copy: bool,
#[cfg(feature = "browser")]
#[arg(long)]
open: bool,
#[arg(long)]
quiet: bool,
}
fn parse_file_arg(raw: &str) -> anyhow::Result<(&str, Option<Lines>)> {
let Some(colon) = raw.rfind(':') else {
return Ok((raw, None));
};
match parse_line_spec(&raw[colon + 1..])? {
Some(lines) => Ok((&raw[..colon], Some(lines))),
None => Ok((raw, None)),
}
}
fn is_digits(s: &str) -> bool {
!s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
}
fn parse_line_spec(spec: &str) -> anyhow::Result<Option<Lines>> {
if let Some((start, end)) = spec.split_once('-')
&& is_digits(start)
&& is_digits(end)
{
Ok(Some(Lines::range(parse_line(start)?, parse_line(end)?)?))
} else if is_digits(spec) {
Ok(Some(Lines::single(parse_line(spec)?)))
} else {
Ok(None)
}
}
fn parse_line(s: &str) -> anyhow::Result<NonZero<u32>> {
s.parse()
.map_err(|_| anyhow::anyhow!("invalid line number '{s}': line numbers start at 1"))
}
fn main() -> anyhow::Result<()> {
let args = Args::parse();
let cwd = std::env::current_dir()?;
let url = if args.project {
forgelink::project_link(&cwd, &args.remote)?
} else {
let raw = args
.file
.as_deref()
.ok_or_else(|| anyhow::anyhow!("a file argument is required (or use --project)"))?;
let (file, lines) = parse_file_arg(raw)?;
let git_ref = if args.branch {
forgelink::RefSpec::Branch
} else {
forgelink::RefSpec::Commit
};
forgelink::build_link(&cwd, &args.remote, file, lines, git_ref)?
};
if !args.quiet {
println!("{url}");
}
#[cfg(feature = "clipboard")]
if args.copy {
copy_to_clipboard(&url)?;
}
#[cfg(feature = "browser")]
if args.open {
open::that(&url)?;
}
Ok(())
}
#[cfg(feature = "clipboard")]
fn copy_to_clipboard(url: &str) -> anyhow::Result<()> {
let mut clipboard = arboard::Clipboard::new()?;
clipboard.set_text(url)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_colon_returns_full_path() {
let (file, lines) = parse_file_arg("src/main.rs").unwrap();
assert_eq!(file, "src/main.rs");
assert!(lines.is_none());
}
#[test]
fn colon_with_single_line() {
let (file, lines) = parse_file_arg("src/main.rs:42").unwrap();
assert_eq!(file, "src/main.rs");
let lines = lines.unwrap();
assert_eq!(lines.start().get(), 42);
assert_eq!(lines.end().get(), 42);
}
#[test]
fn colon_with_line_range() {
let (file, lines) = parse_file_arg("src/main.rs:42-55").unwrap();
assert_eq!(file, "src/main.rs");
let lines = lines.unwrap();
assert_eq!(lines.start().get(), 42);
assert_eq!(lines.end().get(), 55);
}
#[test]
fn colon_with_non_numeric_spec_returns_full_string() {
let (file, lines) = parse_file_arg("src/main.rs:notanumber").unwrap();
assert_eq!(file, "src/main.rs:notanumber");
assert!(lines.is_none());
}
#[test]
fn absolute_path_with_line() {
let (file, lines) = parse_file_arg("/home/user/project/src/main.rs:10").unwrap();
assert_eq!(file, "/home/user/project/src/main.rs");
let lines = lines.unwrap();
assert_eq!(lines.start().get(), 10);
}
#[test]
fn line_zero_is_an_error() {
assert!(parse_file_arg("src/main.rs:0").is_err());
}
#[test]
fn backwards_range_is_an_error() {
assert!(parse_file_arg("src/main.rs:55-42").is_err());
}
}