use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
use anyhow::Context as _;
use serde::Deserialize;
use crate::tool::files;
pub(crate) const FILENAMES: &[&str] = &["bacon.toml"];
pub(crate) fn detect(dir: &Path) -> bool {
files::find_first(dir, FILENAMES).is_some()
}
pub(crate) fn extract_tasks(dir: &Path) -> anyhow::Result<Vec<(String, Option<String>)>> {
if let Some(tasks) = extract_tasks_with_bacon(dir) {
return Ok(tasks);
}
extract_tasks_from_source(dir)
}
fn extract_tasks_with_bacon(dir: &Path) -> Option<Vec<(String, Option<String>)>> {
let output = super::program::command("bacon")
.arg("--list-jobs")
.current_dir(dir)
.env("COLUMNS", "10000")
.output()
.ok()?;
if !output.status.success() {
return None;
}
parse_list_jobs_table(&output.stdout)
}
fn parse_list_jobs_table(stdout: &[u8]) -> Option<Vec<(String, Option<String>)>> {
let stripped = strip_csi_m(&String::from_utf8_lossy(stdout));
let mut tasks: Vec<(String, Option<String>)> = Vec::new();
let mut in_body = false;
for line in stripped.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with('├') {
in_body = true;
continue;
}
if !in_body {
continue;
}
if trimmed.starts_with('└') {
break;
}
if !trimmed.starts_with('│') {
continue;
}
let cells: Vec<&str> = trimmed.split('│').collect();
if cells.len() < 4 {
continue;
}
let name = cells[1].trim();
let command = cells[2].trim();
if name.is_empty() || name == "job" || name.starts_with('_') {
continue;
}
let desc = (!command.is_empty()).then(|| command.to_string());
tasks.push((name.to_string(), desc));
}
tasks.sort_by(|a, b| a.0.cmp(&b.0));
(!tasks.is_empty()).then_some(tasks)
}
fn strip_csi_m(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' && chars.peek() == Some(&'[') {
chars.next();
for inner in chars.by_ref() {
if inner == 'm' {
break;
}
}
} else {
out.push(c);
}
}
out
}
fn extract_tasks_from_source(dir: &Path) -> anyhow::Result<Vec<(String, Option<String>)>> {
let Some(path) = files::find_first(dir, FILENAMES) else {
return Ok(vec![]);
};
let content = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
let doc: BaconDoc =
toml::from_str(&content).with_context(|| format!("failed to parse {}", path.display()))?;
let mut tasks: Vec<(String, Option<String>)> = doc
.jobs
.into_iter()
.filter(|(name, _)| !name.starts_with('_'))
.map(|(name, job)| {
let desc = job
.desc
.or_else(|| (!job.command.is_empty()).then(|| job.command.join(" ")));
(name, desc)
})
.collect();
tasks.sort_by(|a, b| a.0.cmp(&b.0));
Ok(tasks)
}
pub(crate) fn run_cmd(task: &str, args: &[String]) -> Command {
let mut c = super::program::command("bacon");
c.arg(task);
if !args.is_empty() {
c.arg("--").args(args);
}
c
}
#[derive(Deserialize)]
struct BaconDoc {
#[serde(default)]
jobs: HashMap<String, JobConfig>,
}
#[derive(Deserialize)]
struct JobConfig {
#[serde(default, alias = "description")]
desc: Option<String>,
#[serde(default)]
command: Vec<String>,
}
#[cfg(test)]
mod tests {
use std::fs;
use super::{
detect, extract_tasks, extract_tasks_from_source, parse_list_jobs_table, run_cmd,
strip_csi_m,
};
use crate::tool::test_support::TempDir;
#[test]
fn run_cmd_omits_separator_when_no_args() {
let cmd = run_cmd("check", &[]);
let argv: Vec<&std::ffi::OsStr> = cmd.get_args().collect();
assert_eq!(argv, ["check"]);
}
#[test]
fn run_cmd_inserts_separator_before_forwarded_args() {
let cmd = run_cmd("test", &["--ignored".into(), "my_test".into()]);
let argv: Vec<&std::ffi::OsStr> = cmd.get_args().collect();
assert_eq!(argv, ["test", "--", "--ignored", "my_test"]);
}
#[test]
fn detect_finds_bacon_toml() {
let dir = TempDir::new("bacon-detect");
fs::write(dir.path().join("bacon.toml"), "").expect("bacon.toml should be written");
assert!(detect(dir.path()));
}
#[test]
fn detect_returns_false_without_bacon_toml() {
let dir = TempDir::new("bacon-detect-missing");
assert!(!detect(dir.path()));
}
#[test]
fn parse_list_jobs_extracts_name_and_command() {
let raw = "\u{1b}[38;5;239m┌─┬─┐\u{1b}[39m\n\
\u{1b}[38;5;239m│\u{1b}[39m \u{1b}[1mjob\u{1b}[0m \u{1b}[38;5;239m│\u{1b}[39m\u{1b}[1mcommand\u{1b}[0m │\n\
\u{1b}[38;5;239m├─┼─┤\u{1b}[39m\n\
\u{1b}[38;5;239m│\u{1b}[39m check \u{1b}[38;5;239m│\u{1b}[39mcargo check │\n\
\u{1b}[38;5;239m│\u{1b}[39m clippy \u{1b}[38;5;239m│\u{1b}[39mcargo clippy │\n\
\u{1b}[38;5;239m└─┴─┘\u{1b}[39m\n\
default job: check\n";
let tasks = parse_list_jobs_table(raw.as_bytes()).expect("table should parse");
assert_eq!(
tasks,
[
("check".to_string(), Some("cargo check".to_string())),
("clippy".to_string(), Some("cargo clippy".to_string())),
]
);
}
#[test]
fn parse_list_jobs_skips_underscore_prefixed_rows() {
let raw = "┌─┬─┐\n\
│ job │command │\n\
├─┼─┤\n\
│_helper │true │\n\
│ check │cargo check │\n\
└─┴─┘\n";
let tasks = parse_list_jobs_table(raw.as_bytes()).expect("table should parse");
let names: Vec<&str> = tasks.iter().map(|(n, _)| n.as_str()).collect();
assert_eq!(names, ["check"]);
}
#[test]
fn parse_list_jobs_returns_none_for_empty_table() {
let raw = "┌─┬─┐\n│ job │command│\n├─┼─┤\n└─┴─┘\n";
assert!(parse_list_jobs_table(raw.as_bytes()).is_none());
}
#[test]
fn strip_csi_m_removes_sgr_sequences() {
let raw = "\u{1b}[38;5;239m│\u{1b}[39m\u{1b}[1mhello\u{1b}[0m";
assert_eq!(strip_csi_m(raw), "│hello");
}
#[test]
fn extract_tasks_from_source_parses_jobs_table() {
let dir = TempDir::new("bacon-jobs");
fs::write(
dir.path().join("bacon.toml"),
"[jobs.check]\ncommand = [\"cargo\", \"check\"]\n",
)
.expect("bacon.toml should be written");
let tasks = extract_tasks_from_source(dir.path()).expect("bacon.toml should parse");
assert_eq!(
tasks,
[("check".to_string(), Some("cargo check".to_string()))]
);
}
#[test]
fn extract_tasks_from_source_handles_multiple_jobs_sorted() {
let dir = TempDir::new("bacon-multi");
fs::write(
dir.path().join("bacon.toml"),
"[jobs.test]\ncommand = [\"cargo\", \"test\"]\n\n[jobs.check]\ncommand = [\"cargo\", \"check\"]\n\n[jobs.clippy]\ncommand = [\"cargo\", \"clippy\"]\n",
)
.expect("bacon.toml should be written");
let tasks = extract_tasks_from_source(dir.path()).expect("bacon.toml should parse");
let names: Vec<&str> = tasks.iter().map(|(n, _)| n.as_str()).collect();
assert_eq!(names, ["check", "clippy", "test"]);
}
#[test]
fn extract_tasks_from_source_returns_empty_when_no_jobs_table() {
let dir = TempDir::new("bacon-empty");
fs::write(dir.path().join("bacon.toml"), "default_job = \"check\"\n")
.expect("bacon.toml should be written");
let tasks = extract_tasks_from_source(dir.path()).expect("bacon.toml should parse");
assert!(tasks.is_empty());
}
#[test]
fn extract_tasks_from_source_surfaces_optional_desc_field() {
let dir = TempDir::new("bacon-desc");
fs::write(
dir.path().join("bacon.toml"),
"[jobs.check]\ncommand = [\"cargo\", \"check\"]\ndesc = \"Type-check the workspace\"\n",
)
.expect("bacon.toml should be written");
let tasks = extract_tasks_from_source(dir.path()).expect("bacon.toml should parse");
assert_eq!(
tasks,
[(
"check".to_string(),
Some("Type-check the workspace".to_string()),
)]
);
}
#[test]
fn extract_tasks_from_source_accepts_description_alias() {
let dir = TempDir::new("bacon-description-alias");
fs::write(
dir.path().join("bacon.toml"),
"[jobs.check]\ncommand = [\"cargo\", \"check\"]\ndescription = \"Long form\"\n",
)
.expect("bacon.toml should be written");
let tasks = extract_tasks_from_source(dir.path()).expect("bacon.toml should parse");
assert_eq!(
tasks,
[("check".to_string(), Some("Long form".to_string()))]
);
}
#[test]
fn extract_tasks_from_source_skips_underscore_prefixed_jobs() {
let dir = TempDir::new("bacon-private");
fs::write(
dir.path().join("bacon.toml"),
"[jobs._helper]\ncommand = [\"true\"]\n\n[jobs.check]\ncommand = [\"cargo\", \"check\"]\n",
)
.expect("bacon.toml should be written");
let tasks = extract_tasks_from_source(dir.path()).expect("bacon.toml should parse");
let names: Vec<&str> = tasks.iter().map(|(n, _)| n.as_str()).collect();
assert_eq!(names, ["check"]);
}
#[test]
fn extract_tasks_from_source_surfaces_parse_error_for_malformed_toml() {
let dir = TempDir::new("bacon-malformed");
fs::write(dir.path().join("bacon.toml"), "[jobs.broken")
.expect("bacon.toml should be written");
let err =
extract_tasks_from_source(dir.path()).expect_err("malformed bacon.toml should error");
assert!(
err.to_string().contains("failed to parse"),
"error chain should mention parse failure: {err:#}"
);
}
#[test]
fn extract_tasks_uses_bacon_cli_when_available() {
if std::process::Command::new("bacon")
.arg("--version")
.output()
.is_err()
{
eprintln!("skipping: bacon unavailable");
return;
}
let dir = TempDir::new("bacon-cli-fast-path");
fs::write(dir.path().join("bacon.toml"), "default_job = \"check\"\n")
.expect("bacon.toml should be written");
let tasks = extract_tasks(dir.path()).expect("bacon CLI should succeed");
let names: Vec<&str> = tasks.iter().map(|(n, _)| n.as_str()).collect();
assert!(
names.contains(&"check"),
"fast path should surface the built-in `check` job; got {names:?}"
);
}
}