use std::io::{Read, Write};
use std::path::PathBuf;
use std::process::ExitCode;
use crate::extract::format::{SUPPORTED_FORMATS, resolve_format};
use crate::scan::{self, FileReport, ScanOptions};
use crate::walk::{self, WalkOptions};
const USAGE: &str = "usage: urls-le [options] <file|dir>...
urls-le [options] --stdin --format <format>
urls-le mcp
urls-le --version | --help
Extracts every URL from a document, with its protocol and 1-based line
and column. One JSON report per line on stdout, human summary on stderr.
It reports what is there and nothing else: nothing is fetched, nothing
is filtered, nothing is scored. What the URLs mean is yours to decide.
Options:
--dedupe collapse repeated URLs to their first occurrence
--format <format> force a format instead of inferring it from the
file name; required with --stdin
--stdin read one document from stdin
--follow-symlinks descend symlinked directories when walking a tree
--hidden walk hidden files and directories too
--no-ignore walk files that .gitignore excludes
Exit codes follow grep: 0 URLs found · 1 none found · 2 malformed
question. Finding none is an answer, not an error.";
const FLAGS: [&str; 6] = [
"--dedupe",
"--format",
"--stdin",
"--follow-symlinks",
"--hidden",
"--no-ignore",
];
#[derive(Debug)]
struct Options {
inputs: Vec<PathBuf>,
stdin: bool,
format: Option<&'static str>,
scan: ScanOptions,
walk: WalkOptions,
}
pub(crate) fn run() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
if let Some(first) = args.first() {
match first.as_str() {
"mcp" => return crate::mcp::serve(),
"--help" | "-h" => {
println!("{USAGE}");
return ExitCode::SUCCESS;
}
"--version" | "-V" => {
println!("urls-le {}", env!("CARGO_PKG_VERSION"));
return ExitCode::SUCCESS;
}
_ => {}
}
}
match execute(&args) {
Ok(code) => ExitCode::from(code),
Err(message) => {
eprintln!("urls-le: {message}");
ExitCode::from(2)
}
}
}
fn execute(args: &[String]) -> Result<u8, String> {
let options = parse(args)?;
let reports = if options.stdin {
vec![scan_stdin(&options)?]
} else {
let targets = walk::collect(&options.inputs, &options.walk)?;
targets
.iter()
.map(|target| scan::scan_file(target, options.scan))
.collect()
};
let mut stdout = std::io::stdout().lock();
for report in &reports {
let line = serde_json::to_string(report).expect("a report serializes");
writeln!(stdout, "{line}")
.map_err(|error| format!("could not write the report: {error}"))?;
}
drop(stdout);
summarise(&reports);
Ok(scan::exit_code(&reports))
}
fn scan_stdin(options: &Options) -> Result<FileReport, String> {
let format = options.format.ok_or_else(|| {
"reading from stdin needs --format: the name that would carry the format is not there"
.to_string()
})?;
let mut content = String::new();
std::io::stdin()
.read_to_string(&mut content)
.map_err(|error| format!("could not read stdin: {error}"))?;
Ok(scan::scan_content(
&content,
"<stdin>".to_string(),
format,
options.scan,
))
}
fn parse(args: &[String]) -> Result<Options, String> {
let mut options = Options {
inputs: Vec::new(),
stdin: false,
format: None,
scan: ScanOptions { dedupe: false },
walk: WalkOptions::default(),
};
let mut rest = args.iter();
while let Some(arg) = rest.next() {
if arg.starts_with('-') && !FLAGS.contains(&arg.as_str()) {
return Err(format!("{arg} is not an option. Try --help."));
}
match arg.as_str() {
"--dedupe" => options.scan.dedupe = true,
"--stdin" => options.stdin = true,
"--hidden" => options.walk.hidden = true,
"--no-ignore" => options.walk.respect_ignore = false,
"--follow-symlinks" => options.walk.follow_symlinks = true,
"--format" => {
let value = rest
.next()
.ok_or_else(|| "--format needs a format".to_string())?;
let resolved = resolve_format(Some(value), None).ok_or_else(|| {
format!(
"{value} is not a format this understands; one of: {}",
SUPPORTED_FORMATS.join(", ")
)
})?;
options.format = Some(resolved);
options.walk.format = Some(resolved);
}
path => options.inputs.push(PathBuf::from(path)),
}
}
if options.stdin && !options.inputs.is_empty() {
return Err("reading from stdin takes no file arguments".to_string());
}
if !options.stdin && options.inputs.is_empty() {
return Err("name a file or a directory to scan. Try --help.".to_string());
}
Ok(options)
}
fn summarise(reports: &[FileReport]) {
let mut stderr = std::io::stderr().lock();
let mut urls = 0;
for report in reports {
for diagnostic in &report.diagnostics {
let _ = writeln!(stderr, "{}: {}", report.file, diagnostic.message);
}
for url in &report.urls {
urls += 1;
let _ = writeln!(stderr, "{}", scan::describe(report, url));
}
}
let _ = writeln!(
stderr,
"{} in {}",
plural(urls, "URL", "URLs"),
plural(reports.len(), "file", "files")
);
}
fn plural(count: usize, one: &str, many: &str) -> String {
format!("{count} {}", if count == 1 { one } else { many })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_documented_flag_is_parsed_and_the_reverse() {
let mut documented: Vec<&str> = USAGE
.split_whitespace()
.filter(|word| word.starts_with("--"))
.map(|word| word.trim_end_matches([',', '.', ':', ';']))
.filter(|word| !matches!(*word, "--version" | "--help"))
.collect();
documented.sort_unstable();
documented.dedup();
let mut implemented = FLAGS.to_vec();
implemented.sort_unstable();
assert_eq!(documented, implemented);
}
#[test]
fn the_parser_accepts_every_flag_it_lists() {
for flag in FLAGS {
let args: Vec<String> = match flag {
"--format" => vec![flag.into(), "markdown".into(), "x".into()],
"--stdin" => vec![flag.into()],
_ => vec![flag.into(), "x".into()],
};
assert!(parse(&args).is_ok(), "{flag}");
}
}
#[test]
fn an_unknown_flag_is_refused_rather_than_ignored() {
let error = parse(&["--dedup".into(), "x".into()]).expect_err("a refusal");
assert!(error.contains("--dedup"), "{error}");
}
#[test]
fn no_flag_asks_for_a_judgment() {
for attempt in ["--strict", "--check", "--insecure", "--fail-on", "--allow"] {
assert!(
parse(&[attempt.into(), "x".into()]).is_err(),
"{attempt} was accepted"
);
}
for word in ["insecure", "credential", "scored\u{20}as"] {
assert!(!USAGE.contains(word), "the usage text offers {word}");
}
}
#[test]
fn an_unknown_format_is_refused_by_name() {
let error =
parse(&["--format".into(), "python".into(), "x".into()]).expect_err("a refusal");
assert!(error.contains("python"), "{error}");
}
#[test]
fn naming_nothing_is_refused() {
assert!(parse(&[]).is_err());
}
#[test]
fn stdin_and_file_arguments_together_are_refused() {
assert!(parse(&["--stdin".into(), "x".into()]).is_err());
}
#[test]
fn the_usage_text_states_greps_convention() {
assert!(USAGE.contains("grep"));
for code in ["0", "1", "2"] {
assert!(USAGE.contains(code), "exit code {code} is undocumented");
}
}
}