use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
pub(crate) const CLEAN_DIRS: &[&str] = &["vendor"];
pub(crate) fn detect(dir: &Path) -> bool {
find_file(dir).is_some()
}
pub(crate) fn find_file(dir: &Path) -> Option<PathBuf> {
let path = dir.join("go.mod");
path.is_file().then_some(path)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ExtractedTask {
pub name: String,
pub run_target: String,
}
pub(crate) fn extract_tasks(dir: &Path) -> anyhow::Result<Vec<ExtractedTask>> {
let mut tasks = Vec::new();
if contains_main_package(dir)?
&& let Some(name) = module_name(dir)
.or_else(|| dir.file_name().and_then(|n| n.to_str()).map(str::to_string))
{
tasks.push(ExtractedTask {
name,
run_target: ".".to_string(),
});
}
let cmd_dir = dir.join("cmd");
if !cmd_dir.is_dir() {
return Ok(tasks);
}
for entry in fs::read_dir(cmd_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() || !contains_main_package(&path)? {
continue;
}
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
continue;
};
tasks.push(ExtractedTask {
name: name.to_string(),
run_target: format!("./cmd/{name}"),
});
}
tasks.sort_unstable_by(|a, b| a.name.cmp(&b.name));
Ok(tasks)
}
fn module_name(dir: &Path) -> Option<String> {
let content = fs::read_to_string(dir.join("go.mod")).ok()?;
let path = content.lines().find_map(parse_module_line)?;
let mut segments = path.rsplit('/');
let last = segments.next()?;
let name = if is_major_version(last) {
segments.next().unwrap_or(last)
} else {
last
};
(!name.is_empty()).then(|| name.to_string())
}
fn parse_module_line(line: &str) -> Option<&str> {
let rest = line.trim_start().strip_prefix("module")?;
if !rest.starts_with(char::is_whitespace) {
return None;
}
let tok = rest.split_whitespace().next()?.trim_matches('"');
(!tok.is_empty()).then_some(tok)
}
fn is_major_version(seg: &str) -> bool {
seg.strip_prefix('v')
.is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
}
fn contains_main_package(dir: &Path) -> anyhow::Result<bool> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("go") {
continue;
}
if path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with("_test.go"))
{
continue;
}
let content = fs::read_to_string(&path)?;
if content.lines().any(is_main_package_line) {
return Ok(true);
}
}
Ok(false)
}
fn is_main_package_line(line: &str) -> bool {
let Some(rest) = line.trim_start().strip_prefix("package") else {
return false;
};
let rest = rest.trim_start();
let Some(tail) = rest.strip_prefix("main") else {
return false;
};
tail.is_empty()
|| tail.starts_with(char::is_whitespace)
|| tail.starts_with("//")
|| tail.starts_with("/*")
}
pub(crate) fn install_cmd() -> Command {
let mut c = super::program::command("go");
c.arg("mod").arg("download");
c
}
pub(crate) fn exec_cmd(args: &[String]) -> Command {
let mut c = super::program::command("go");
c.arg("run").args(args);
c
}
pub(crate) fn run_cmd(target: &str, args: &[String]) -> Command {
let mut c = super::program::command("go");
c.arg("run").arg(target).args(args);
c
}
#[cfg(test)]
mod tests {
use std::fs;
use super::{ExtractedTask, exec_cmd, extract_tasks, run_cmd};
use crate::tool::test_support::TempDir;
fn task(name: &str, run_target: &str) -> ExtractedTask {
ExtractedTask {
name: name.to_string(),
run_target: run_target.to_string(),
}
}
#[test]
fn exec_uses_go_run_passthrough() {
let args = [
String::from("github.com/foo/tool@latest"),
String::from("--help"),
];
let built: Vec<_> = exec_cmd(&args)
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect();
assert_eq!(built, ["run", "github.com/foo/tool@latest", "--help"]);
}
#[test]
fn extract_tasks_finds_cmd_main_packages() {
let dir = TempDir::new("go-cmd-tasks");
fs::write(dir.path().join("go.mod"), "module example.com/app\n")
.expect("go.mod should be written");
fs::create_dir_all(dir.path().join("cmd").join("serve"))
.expect("serve dir should be created");
fs::create_dir_all(dir.path().join("cmd").join("internal-lib"))
.expect("internal-lib dir should be created");
fs::write(
dir.path().join("cmd").join("serve").join("main.go"),
"package main\n\nfunc main() {}\n",
)
.expect("serve main should be written");
fs::write(
dir.path().join("cmd").join("internal-lib").join("lib.go"),
"package lib\n",
)
.expect("lib source should be written");
let tasks = extract_tasks(dir.path()).expect("go cmd tasks should parse");
assert_eq!(tasks, [task("serve", "./cmd/serve")]);
}
#[test]
fn extract_tasks_root_name_from_go_mod_module() {
let dir = TempDir::new("go-root-main");
fs::write(
dir.path().join("go.mod"),
"module github.com/kjanat/some-cli-app // root\n",
)
.expect("go.mod should be written");
fs::write(
dir.path().join("main.go"),
"package main\n\nfunc main() {}\n",
)
.expect("root main should be written");
let tasks = extract_tasks(dir.path()).expect("go root task should parse");
assert_eq!(tasks, [task("some-cli-app", ".")]);
}
#[test]
fn extract_tasks_root_name_drops_major_version_suffix() {
let dir = TempDir::new("go-root-v2");
fs::write(dir.path().join("go.mod"), "module example.com/widget/v2\n")
.expect("go.mod should be written");
fs::write(
dir.path().join("main.go"),
"package main\n\nfunc main() {}\n",
)
.expect("root main should be written");
let tasks = extract_tasks(dir.path()).expect("go root task should parse");
assert_eq!(tasks, [task("widget", ".")]);
}
#[test]
fn extract_tasks_root_name_falls_back_to_dir_without_module_line() {
let dir = TempDir::new("go-root-no-module");
fs::write(dir.path().join("go.mod"), "go 1.22\n").expect("go.mod should be written");
fs::write(
dir.path().join("main.go"),
"package main\n\nfunc main() {}\n",
)
.expect("root main should be written");
let tasks = extract_tasks(dir.path()).expect("go root task should parse");
let name = dir
.path()
.file_name()
.and_then(|name| name.to_str())
.expect("temp dir should have utf-8 file name");
assert_eq!(tasks, [task(name, ".")]);
}
#[test]
fn extract_tasks_ignores_main_test_packages() {
let dir = TempDir::new("go-cmd-test-only");
fs::write(dir.path().join("go.mod"), "module example.com/app\n")
.expect("go.mod should be written");
fs::create_dir_all(dir.path().join("cmd").join("serve"))
.expect("serve dir should be created");
fs::write(
dir.path().join("cmd").join("serve").join("main_test.go"),
"package main\n\nfunc TestServe() {}\n",
)
.expect("test main should be written");
let tasks = extract_tasks(dir.path()).expect("go cmd tasks should parse");
assert!(tasks.is_empty());
}
#[test]
fn extract_tasks_accepts_commented_main_package_clause() {
let dir = TempDir::new("go-cmd-main-comment");
fs::write(dir.path().join("go.mod"), "module example.com/app\n")
.expect("go.mod should be written");
fs::create_dir_all(dir.path().join("cmd").join("serve"))
.expect("serve dir should be created");
fs::write(
dir.path().join("cmd").join("serve").join("main.go"),
"package main // command\n\nfunc main() {}\n",
)
.expect("main should be written");
let tasks = extract_tasks(dir.path()).expect("go cmd tasks should parse");
assert_eq!(tasks, [task("serve", "./cmd/serve")]);
}
#[test]
fn run_cmd_uses_go_run_target() {
let args = [String::from("--port"), String::from("3000")];
let built: Vec<_> = run_cmd("./cmd/serve", &args)
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect();
assert_eq!(built, ["run", "./cmd/serve", "--port", "3000"]);
}
}