use std::collections::BTreeMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use serde::Deserialize;
use crate::answer::Answer;
use crate::dialects::{Dialect, Dialects};
use crate::locator::{Locator, RawLocator};
use crate::measurement::{OutputFormat, read_output};
use crate::per_line::PerLineFormat;
use crate::recorded::check_it_is_a_bare_key;
pub const ADAPTERS_DIR: &str = "adapters";
pub const CHANNELS: [&str; 3] = [CRATES_IO, GITHUB_RELEASE_ASSET, GITHUB_RELEASE_FILE];
pub const CRATES_IO: &str = "crates-io";
pub const GITHUB_RELEASE_ASSET: &str = "github-release-asset";
pub const GITHUB_RELEASE_FILE: &str = "github-release-file";
pub const OTHER_SYSTEM: &str = "other";
pub const VERSION_PLACEHOLDER: &str = "{version}";
pub const EXPLAIN_SCRIPTS_DIR: &str = "explain-scripts";
pub const UNKNOWN_VERSION: &str = "unknown version";
const ADAPTER_EXTENSION: &str = "toml";
const BINARY_PLACEHOLDER: &str = "{binary}";
const FILE_PLACEHOLDER: &str = "{file}";
const EXPLAIN_SCRIPTS_PLACEHOLDER: &str = "{explain-scripts}";
const RELEASE_FILE_SYSTEMS: [&str; 4] = ["windows", "linux", "macos", OTHER_SYSTEM];
const VERSION_FLAG: &str = "--version";
#[derive(Debug)]
pub struct Adapter {
pub name_of_counter: String,
pub repository: Option<String>,
pub args: Vec<String>,
pub explain_args: Option<Vec<String>>,
pub explain_command: Option<String>,
pub explain_output: Option<PerLineFormat>,
pub version_flag: Option<String>,
pub acquisition: Option<Acquisition>,
pub variations: Vec<Variation>,
}
impl Adapter {
pub fn read_one(
dirs: &[PathBuf],
name_of_counter: &str,
dialects: &Dialects,
) -> Result<Adapter, String> {
let named = format!("{name_of_counter}.{ADAPTER_EXTENSION}");
match dirs.iter().rev().map(|dir| dir.join(&named)).find(|path| path.is_file()) {
Some(path) => Adapter::read(&path, dialects),
None => Err(format!(
"no {named} in {}, so {name_of_counter} is a counter this suite cannot run",
name_every_one_of(dirs)
)),
}
}
pub fn read_all(dirs: &[PathBuf], dialects: &Dialects) -> Result<Vec<Adapter>, String> {
let mut found: BTreeMap<String, PathBuf> = BTreeMap::new();
for dir in dirs {
let entries = fs::read_dir(dir)
.map_err(|e| format!("{} could not be opened: {e}", dir.display()))?;
for path in entries
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.extension().is_some_and(|e| e == ADAPTER_EXTENSION))
{
let stem = path.file_stem().map(|s| s.to_string_lossy().into_owned());
found.insert(stem.unwrap_or_default(), path);
}
}
found.values().map(|path| Adapter::read(path, dialects)).collect()
}
fn read(path: &Path, dialects: &Dialects) -> Result<Adapter, String> {
let text = fs::read_to_string(path)
.map_err(|e| format!("{} could not be read: {e}", path.display()))?;
let raw: RawAdapter = toml::from_str(&text)
.map_err(|e| format!("{} does not parse: {e}", path.display()))?;
let stem = path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
if stem != raw.name {
return Err(format!(
"{} names the counter {}, and a counter's adapter is the file named after it",
path.display(),
raw.name
));
}
if !raw.args.iter().any(|a| a == FILE_PLACEHOLDER) {
return Err(format!("{} names no {FILE_PLACEHOLDER} to count", path.display()));
}
if let Some(explain) = &raw.explain_args
&& !explain.iter().any(|a| a == FILE_PLACEHOLDER)
{
return Err(format!(
"{} names no {FILE_PLACEHOLDER} in its explain-args",
path.display()
));
}
if raw.explain_output.is_some() && raw.explain_args.is_none() {
return Err(format!(
"{} declares explain-output with no explain-args to print it",
path.display()
));
}
if raw.explain_args.is_some() && raw.explain_output.is_none() {
return Err(format!(
"{} declares explain-args with no explain-output to read what they print",
path.display()
));
}
if raw.explain_command.is_some() && raw.explain_args.is_none() {
return Err(format!(
"{} declares explain-command with no explain-args to give it",
path.display()
));
}
if raw.explain_command.is_none()
&& let Some(explain) = &raw.explain_args
&& let Some(misplaced) =
explain.iter().find(|a| a.contains(BINARY_PLACEHOLDER) || a.contains(EXPLAIN_SCRIPTS_PLACEHOLDER))
{
return Err(format!(
"{} writes {misplaced} with no explain-command, and those two stand for something \
only in the arguments of a wrapper",
path.display()
));
}
if let Some(how) = &raw.acquisition {
if how.channel == GITHUB_RELEASE_FILE && how.file.is_none() {
return Err(format!(
"{}: {GITHUB_RELEASE_FILE} needs an [acquisition.file] table saying which \
release file to take",
path.display()
));
}
if how.channel != GITHUB_RELEASE_FILE && how.file.is_some() {
return Err(format!(
"{}: an [acquisition.file] table belongs to {GITHUB_RELEASE_FILE}, and {} \
picks its file itself",
path.display(),
how.channel
));
}
for (system, named) in how.file.iter().flatten() {
if !RELEASE_FILE_SYSTEMS.contains(&system.as_str()) {
return Err(format!(
"{}: {system} is not a system a release file can be named for: it knows {}",
path.display(),
RELEASE_FILE_SYSTEMS.join(", ")
));
}
if !named.contains(VERSION_PLACEHOLDER) && named.contains(&how.version) {
return Err(format!(
"{}: {named} writes the version out and goes stale the day it is raised, \
so say {VERSION_PLACEHOLDER}",
path.display()
));
}
}
}
if raw.dialect.is_some() {
return Err(format!(
"{}: a [dialect.<name>] block is now [variation.<name>] carrying a dialect = line \
that names the rules file judging it, and major = true on the one variation each \
dialect is scored by",
path.display()
));
}
let read_of_the_major = find_the_read_of_every_major(&raw, path, dialects)?;
let mut variations = Vec::new();
let mut output_is_read = false;
for (name, mut variation) in raw.variation {
let found = read_of_the_major[&variation.dialect].0;
check_it_is_a_bare_key(&name).map_err(|e| format!("{}: {e}", path.display()))?;
if let Some(twin) = variations.iter().find(|one: &&Variation| {
one.name_of_dialect == variation.dialect && one.args == variation.args
}) {
return Err(format!(
"{}: {name} and {} ask for the dialect {} with the same command line, so one \
of them measures nothing the other does not",
path.display(),
twin.name,
variation.dialect
));
}
if name != variation.dialect && dialects.find(&raw.name, &name).is_some() {
return Err(format!(
"{}: the variation {name} is named after another dialect of {} and declares \
the dialect {}, so its name says one thing and its rules another",
path.display(),
raw.name,
variation.dialect
));
}
if let Some(misplaced) = variation.args.iter().find(|a| a.contains(FILE_PLACEHOLDER)) {
return Err(format!(
"{}: {name} names {misplaced} among its own arguments, and the file belongs in \
the arguments every variation shares",
path.display()
));
}
let inherited =
|| read_of_the_major.get(&variation.dialect).and_then(|found| found.1.clone());
let reader = match variation.read.take().or_else(inherited) {
Some(read) => Reader::Declared(Box::new(
Locator::of(read, &found.buckets).map_err(|e| {
format!("{}: the read block of {name} {e}", path.display())
})?,
)),
None => match raw.output {
Some(format) => {
output_is_read = true;
Reader::Written(format)
}
None => {
return Err(format!(
"{}: {name} has no read block, the major of its dialect declares none \
to inherit, and the adapter names no output, so nothing says how what \
the counter prints is read",
path.display()
));
}
},
};
variations.push(Variation {
name,
name_of_dialect: variation.dialect,
is_major: variation.major,
args: variation.args,
buckets: found.buckets.clone(),
reader,
});
}
if variations.is_empty() {
return Err(format!("{} declares no variation to run", path.display()));
}
if raw.output.is_some() && !output_is_read {
return Err(format!(
"{} names an output, and every variation is read by a block of its own, so leave \
the field out",
path.display()
));
}
variations.sort_by(|a, b| {
a.name_of_dialect
.cmp(&b.name_of_dialect)
.then_with(|| b.is_major.cmp(&a.is_major))
.then_with(|| a.name.cmp(&b.name))
});
Ok(Adapter {
name_of_counter: raw.name,
repository: raw.repository,
args: raw.args,
explain_args: raw.explain_args,
explain_command: raw.explain_command,
explain_output: raw.explain_output,
version_flag: match raw.version_flag {
Some(flag) if flag.is_empty() => None,
Some(flag) => Some(flag),
None => Some(VERSION_FLAG.to_string()),
},
acquisition: raw.acquisition,
variations,
})
}
pub fn measure(
&self,
variation: &Variation,
binary: &Path,
file: &Path,
) -> Result<Option<Answer>, String> {
let args = self.build_args(variation, file);
let printed = run_counter(binary, &args)?;
match &variation.reader {
Reader::Written(format) => read_output(*format, &variation.buckets, &printed),
Reader::Declared(locator) => locator.read(&printed),
}
.map_err(|e| format!("{} on {}: {e}", self.name_of_counter, file.display()))
}
pub fn read_version_or_unknown(&self, binary: &Path) -> String {
self.read_version(binary).unwrap_or_else(|_| UNKNOWN_VERSION.to_string())
}
pub fn read_version(&self, binary: &Path) -> Result<String, String> {
let Some(flag) = &self.version_flag else { return Ok(UNKNOWN_VERSION.to_string()) };
let printed = run_counter(binary, std::slice::from_ref(flag))?;
let trimmed = printed.trim();
Ok(match trimmed.is_empty() {
true => UNKNOWN_VERSION.to_string(),
false => trimmed.to_string(),
})
}
pub fn run_explain(
&self,
variation: &Variation,
binary: &Path,
file: &Path,
scripts: &[PathBuf],
) -> Option<Result<String, String>> {
let base = self.explain_args.as_ref()?;
let (program, args) = match &self.explain_command {
Some(program) => (
PathBuf::from(program),
build_wrapper_args(base, variation, binary, file, scripts),
),
None => (binary.to_path_buf(), build_command_args(base, variation, file)),
};
Some(run_counter(&program, &args))
}
pub fn format_command(&self, variation: &Variation, binary: &Path, file: &Path) -> String {
let args = self.build_args(variation, &shorten_the_path(file));
format!("{} {}", shorten(binary), args.join(" "))
}
fn build_args(&self, variation: &Variation, file: &Path) -> Vec<String> {
build_command_args(&self.args, variation, file)
}
}
#[derive(Debug)]
pub struct Variation {
pub name: String,
pub name_of_dialect: String,
pub is_major: bool,
pub args: Vec<String>,
pub buckets: Vec<String>,
pub(crate) reader: Reader,
}
#[derive(Debug)]
pub(crate) enum Reader {
Written(OutputFormat),
Declared(Box<Locator>),
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Acquisition {
pub channel: String,
pub name: String,
pub version: String,
pub file: Option<BTreeMap<String, String>>,
}
pub fn is_the_declared_version(declared: &str, printed: &str) -> bool {
let stands_alone = |beside: Option<char>| !beside.is_some_and(|c| c.is_ascii_digit() || c == '.');
printed.match_indices(declared).any(|(at, _)| {
stands_alone(printed[..at].chars().next_back())
&& stands_alone(printed[at + declared.len()..].chars().next())
})
}
fn find_the_read_of_every_major<'a>(
raw: &RawAdapter,
path: &Path,
dialects: &'a Dialects,
) -> Result<BTreeMap<String, (&'a Dialect, Option<RawLocator>)>, String> {
let mut led_by: BTreeMap<&str, &str> = BTreeMap::new();
let mut read_of_the_major = BTreeMap::new();
for (name, variation) in &raw.variation {
let Some(found) = dialects.find(&raw.name, &variation.dialect) else {
return Err(format!(
"{}: the variation {name} names the dialect {}, and this suite has no buckets for \
it",
path.display(),
variation.dialect
));
};
if !variation.major {
continue;
}
if let Some(other) = led_by.insert(&variation.dialect, name) {
return Err(format!(
"{}: {other} and {name} both say major = true for the dialect {}, and a dialect \
has exactly one major",
path.display(),
variation.dialect
));
}
read_of_the_major.insert(variation.dialect.clone(), (found, variation.read.clone()));
}
for variation in raw.variation.values() {
if !read_of_the_major.contains_key(&variation.dialect) {
return Err(format!(
"{}: no variation of the dialect {} says major = true, and every dialect is \
scored and badged through exactly one",
path.display(),
variation.dialect
));
}
}
Ok(read_of_the_major)
}
fn shorten(path: &Path) -> String {
shorten_the_path(path).display().to_string()
}
fn shorten_the_path(path: &Path) -> PathBuf {
let Ok(here) = env::current_dir() else { return path.to_path_buf() };
path.strip_prefix(&here).unwrap_or(path).to_path_buf()
}
fn name_every_one_of(dirs: &[PathBuf]) -> String {
dirs.iter().map(|dir| dir.display().to_string()).collect::<Vec<_>>().join(" or ")
}
fn build_command_args(base: &[String], variation: &Variation, file: &Path) -> Vec<String> {
let mut args: Vec<String> = base
.iter()
.map(|a| a.replace(FILE_PLACEHOLDER, &file.display().to_string()))
.collect();
args.extend(variation.args.iter().cloned());
args
}
fn build_wrapper_args(
base: &[String],
variation: &Variation,
binary: &Path,
file: &Path,
scripts: &[PathBuf],
) -> Vec<String> {
build_command_args(base, variation, file)
.iter()
.map(|a| a.replace(BINARY_PLACEHOLDER, &binary.display().to_string()))
.map(|a| fill_in_the_scripts_dir(&a, scripts))
.collect()
}
fn fill_in_the_scripts_dir(arg: &str, scripts: &[PathBuf]) -> String {
let Some((_, after)) = arg.split_once(EXPLAIN_SCRIPTS_PLACEHOLDER) else {
return arg.to_string();
};
let script = after.trim_start_matches(['/', '\\']);
let fill = |dir: &PathBuf| arg.replace(EXPLAIN_SCRIPTS_PLACEHOLDER, &dir.display().to_string());
scripts
.iter()
.rev()
.find(|dir| dir.join(script).is_file())
.map(&fill)
.or_else(|| scripts.first().map(&fill))
.unwrap_or_else(|| arg.to_string())
}
fn run_counter(binary: &Path, args: &[String]) -> Result<String, String> {
let output = Command::new(binary)
.args(args)
.output()
.map_err(|e| format!("{} could not be run: {e}", binary.display()))?;
if !output.status.success() {
let printed = String::from_utf8_lossy(&output.stderr);
let said: Vec<&str> =
printed.lines().map(str::trim).filter(|line| !line.is_empty()).collect();
let exited = format!("{} exited with {}", binary.display(), output.status);
return Err(match said.is_empty() {
true => exited,
false => format!("{exited}: {}", said.join(" ")),
});
}
String::from_utf8(output.stdout).map_err(|e| format!("{} printed no text: {e}", binary.display()))
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawAdapter {
name: String,
repository: Option<String>,
output: Option<OutputFormat>,
args: Vec<String>,
#[serde(rename = "explain-args")]
explain_args: Option<Vec<String>>,
#[serde(rename = "explain-command")]
explain_command: Option<String>,
#[serde(rename = "explain-output")]
explain_output: Option<PerLineFormat>,
#[serde(rename = "version-flag")]
version_flag: Option<String>,
acquisition: Option<Acquisition>,
#[serde(default)]
variation: BTreeMap<String, RawVariation>,
dialect: Option<toml::Value>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawVariation {
dialect: String,
#[serde(default)]
major: bool,
args: Vec<String>,
read: Option<RawLocator>,
}
#[cfg(test)]
mod tests {
use std::env;
use crate::dialects::read_the_shipped_dialects;
use super::*;
#[test]
fn every_shipped_adapter_is_read_and_names_a_dialect_this_suite_knows() {
let dirs = vec![Path::new(env!("CARGO_MANIFEST_DIR")).join("adapters")];
let adapters = Adapter::read_all(&dirs, &read_the_shipped_dialects()).unwrap();
assert!(!adapters.is_empty(), "no adapter was read");
let names: Vec<&str> = adapters.iter().map(|a| a.name_of_counter.as_str()).collect();
let mut in_order = names.clone();
in_order.sort_unstable();
assert_eq!(names, in_order, "the roster came back out of order");
for adapter in &adapters {
let who = &adapter.name_of_counter;
assert!(!adapter.variations.is_empty(), "{who} declares no variation");
for variation in &adapter.variations {
assert!(!variation.buckets.is_empty(), "{who}.{} names no bucket", variation.name);
}
let home = adapter.repository.as_deref().unwrap_or_default();
assert!(home.starts_with("https://"), "{who}: {home}");
if let Some(how) = &adapter.acquisition {
assert!(CHANNELS.contains(&how.channel.as_str()), "{who}: {}", how.channel);
assert!(!how.version.is_empty(), "{who} names no version to fetch");
}
}
}
#[test]
fn the_command_a_report_prints_is_written_from_where_the_run_works() {
let dirs = vec![Path::new(env!("CARGO_MANIFEST_DIR")).join("adapters")];
let adapters = Adapter::read_all(&dirs, &read_the_shipped_dialects()).unwrap();
let tokei = adapters.iter().find(|a| a.name_of_counter == "tokei").unwrap();
let here = env::current_dir().unwrap();
let under = here.join("cases").join("0400-a_case").join("input.c");
let printed = tokei.format_command(&tokei.variations[0], Path::new("tokei"), &under);
assert!(printed.contains(&format!("cases{}0400-a_case", std::path::MAIN_SEPARATOR)),
"{printed}");
assert!(!printed.contains(&here.display().to_string()), "{printed}");
let elsewhere = Path::new("/somewhere/of/its/own/input.c");
let whole = tokei.format_command(&tokei.variations[0], Path::new("tokei"), elsewhere);
assert!(whole.contains("somewhere"), "{whole}");
}
#[test]
fn a_dialect_nothing_says_how_to_read_is_refused_and_so_is_an_output_nobody_reads() {
let unread = write_an_adapter(
"an_adapter_saying_nothing_about_reading",
"name = \"tokei\"\nargs = [\"{file}\"]\n\
[variation.default]\ndialect = \"default\"\nmajor = true\nargs = []\n",
);
let refused = Adapter::read(&unread, &read_the_shipped_dialects()).unwrap_err();
fs::remove_dir_all(unread.parent().unwrap()).unwrap();
assert!(refused.contains("nothing says how"), "{refused}");
let unused = write_an_adapter(
"an_adapter_whose_output_nobody_reads",
"name = \"tokei\"\noutput = \"tokei-json\"\nargs = [\"{file}\"]\n\
[variation.default]\ndialect = \"default\"\nmajor = true\nargs = []\n\
[variation.default.read]\neach = \"[]\"\nlines = \"Lines\"\ncode = \"Code\"\n\
comments = \"Comment\"\nblanks = \"Blank\"\n",
);
let refused = Adapter::read(&unused, &read_the_shipped_dialects()).unwrap_err();
fs::remove_dir_all(unused.parent().unwrap()).unwrap();
assert!(refused.contains("leave the field out"), "{refused}");
}
#[test]
fn a_broken_read_block_is_refused_with_the_file_and_the_dialect_named() {
let path = write_an_adapter(
"an_adapter_with_a_broken_read_block",
"name = \"tokei\"\nargs = [\"{file}\"]\n\
[variation.default]\ndialect = \"default\"\nmajor = true\nargs = []\n\
[variation.default.read]\neach = \"[]\"\nlines = \"Lines\"\ncode = \"Code\"\n\
comments = \"Comment\"\n",
);
let refused = Adapter::read(&path, &read_the_shipped_dialects()).unwrap_err();
fs::remove_dir_all(path.parent().unwrap()).unwrap();
assert!(refused.contains("the read block of default"), "{refused}");
assert!(refused.contains("no path for blanks"), "{refused}");
}
#[test]
fn one_counter_named_opens_its_own_file_and_no_other() {
let dirs = vec![Path::new(env!("CARGO_MANIFEST_DIR")).join("adapters")];
let dialects = read_the_shipped_dialects();
assert_eq!(Adapter::read_one(&dirs, "scc", &dialects).unwrap().name_of_counter, "scc");
let missing = Adapter::read_one(&dirs, "sloccount", &dialects).unwrap_err();
assert!(missing.contains("cannot run"), "{missing}");
}
#[test]
fn an_adapter_under_a_name_that_is_not_its_own_is_refused() {
let path = write_an_adapter(
"an_adapter_under_the_wrong_name",
"name = \"scc\"\noutput = \"tokei-json\"\nargs = [\"{file}\"]\n\
[variation.default]\ndialect = \"default\"\nmajor = true\nargs = []\n",
);
let refused = Adapter::read(&path, &read_the_shipped_dialects()).unwrap_err();
fs::remove_dir_all(path.parent().unwrap()).unwrap();
assert!(refused.contains("the file named after it"), "{refused}");
}
#[test]
fn a_wrapper_is_taken_from_the_last_directory_that_holds_it() {
let path = write_an_adapter(
"an_adapter_bringing_a_wrapper",
"name = \"tokei\"\noutput = \"tokei-json\"\nargs = [\"{file}\"]\n\
explain-command = \"perl\"\n\
explain-args = [\"{explain-scripts}/probe.pl\", \"{binary}\", \"{file}\"]\n\
explain-output = \"linejudge-per-line\"\n\
[variation.default]\ndialect = \"default\"\nmajor = true\nargs = []\n",
);
let adapter = Adapter::read(&path, &read_the_shipped_dialects()).unwrap();
fs::remove_dir_all(path.parent().unwrap()).unwrap();
let carried = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(EXPLAIN_SCRIPTS_DIR);
let theirs = env::temp_dir().join("linejudge-a_wrapper_of_their_own");
let _ = fs::remove_dir_all(&theirs);
fs::create_dir_all(&theirs).unwrap();
fs::write(theirs.join("probe.pl"), "not really a wrapper").unwrap();
let asked = |scripts: &[PathBuf]| {
build_wrapper_args(adapter.explain_args.as_ref().unwrap(), &adapter.variations[0],
Path::new("t.exe"), Path::new("a.py"), scripts)
};
let layered = asked(&[carried.clone(), theirs.clone()]);
let empty = env::temp_dir().join("linejudge-a_scripts_dir_with_nothing_in_it");
let _ = fs::remove_dir_all(&empty);
fs::create_dir_all(&empty).unwrap();
let missing = asked(&[carried, empty.clone()]);
fs::remove_dir_all(&theirs).unwrap();
fs::remove_dir_all(&empty).unwrap();
assert!(layered[0].contains("a_wrapper_of_their_own"), "{layered:?}");
assert_eq!(layered[1..], ["t.exe".to_string(), "a.py".to_string()]);
assert!(missing[0].replace('\\', "/").ends_with("explain-scripts/probe.pl"), "{missing:?}");
}
#[test]
fn a_placeholder_inside_a_longer_token_still_finds_the_script() {
let theirs = env::temp_dir().join("linejudge-embedded_placeholder_theirs");
let empty = env::temp_dir().join("linejudge-embedded_placeholder_empty");
for dir in [&theirs, &empty] {
let _ = fs::remove_dir_all(dir);
fs::create_dir_all(dir).unwrap();
}
fs::write(theirs.join("cloc.pl"), "not really a wrapper").unwrap();
let filled =
fill_in_the_scripts_dir("--script={explain-scripts}/cloc.pl", &[empty.clone(), theirs.clone()]);
fs::remove_dir_all(&theirs).unwrap();
fs::remove_dir_all(&empty).unwrap();
assert!(filled.starts_with("--script="), "{filled}");
assert!(filled.contains("theirs"), "{filled}");
assert!(!filled.contains("empty"), "{filled}");
}
#[test]
fn every_acquisition_mistake_is_refused_when_the_adapter_is_read() {
let base = "name = \"tokei\"\noutput = \"tokei-json\"\nargs = [\"{file}\"]\n\
[variation.default]\ndialect = \"default\"\nmajor = true\nargs = []\n\
[acquisition]\nchannel = \"github-release-file\"\nname = \"a/b\"\n\
version = \"2.10\"\n";
let read = |dir: &str, text: &str| {
let path = write_an_adapter(dir, text);
let outcome = Adapter::read(&path, &read_the_shipped_dialects());
fs::remove_dir_all(path.parent().unwrap()).unwrap();
outcome
};
let table = "[acquisition.file]\nother = \"t-{version}.pl\"\n";
let unknown = read("a_channel_nobody_knows",
&base.replace("github-release-file", "gitlab-release"));
assert_eq!(unknown.unwrap().acquisition.unwrap().channel, "gitlab-release");
let bare = read("a_release_file_channel_with_no_table", base);
assert!(bare.unwrap_err().contains("needs an [acquisition.file] table"));
let mispaired = read("a_file_table_on_another_channel",
&format!("{}{table}", base.replace("github-release-file", "crates-io")));
assert!(mispaired.unwrap_err().contains("belongs to github-release-file"));
let crooked = read("a_system_nobody_ships_for",
&format!("{base}[acquisition.file]\nosx = \"t-{{version}}.pl\"\n"));
assert!(crooked.unwrap_err().contains("osx is not a system"));
let stale = read("a_file_name_with_the_version_written_out",
&format!("{base}[acquisition.file]\nother = \"t-2.10.pl\"\n"));
assert!(stale.unwrap_err().contains("say {version}"));
let both = read("a_name_holding_the_version_and_the_placeholder",
&format!("{base}[acquisition.file]\nother = \"t2.10-{{version}}.pl\"\n"));
assert!(both.is_ok(), "{:?}", both.unwrap_err());
let sound = read("an_acquisition_with_nothing_wrong", &format!("{base}{table}"));
let how = sound.unwrap().acquisition.unwrap();
assert_eq!(how.file.unwrap()["other"], "t-{version}.pl");
}
#[test]
fn the_file_takes_the_place_of_its_placeholder_and_the_dialect_speaks_last() {
let dirs = vec![Path::new(env!("CARGO_MANIFEST_DIR")).join("adapters")];
let mezura = &Adapter::read_all(&dirs, &read_the_shipped_dialects()).unwrap()[1];
let args = mezura.build_args(&mezura.variations[1], Path::new("a/case/input.rs"));
assert_eq!(args[0], "a/case/input.rs");
assert_eq!(args[args.len() - 2..], ["--counting".to_string(), "region".to_string()]);
}
#[test]
fn the_per_line_command_is_declared_per_counter_and_has_to_name_the_file() {
let dirs = vec![Path::new(env!("CARGO_MANIFEST_DIR")).join("adapters")];
let adapters = Adapter::read_all(&dirs, &read_the_shipped_dialects()).unwrap();
let scc = &adapters[2];
let scripts = [PathBuf::from(EXPLAIN_SCRIPTS_DIR)];
let scripts = scripts.as_slice();
assert_eq!(
scc.explain_args.as_deref().unwrap(),
["-t", "--no-cocomo", "--format", "json", "{file}"]
);
assert_eq!(scc.explain_output, Some(PerLineFormat::SccTrace));
let cloc = &adapters[0];
assert_eq!(cloc.explain_args.as_deref().unwrap(), ["--print-filter-stages", "--json", "{file}"]);
assert_eq!(cloc.explain_output, Some(PerLineFormat::ClocStages));
let tokei = &adapters[3];
assert!(tokei.explain_args.is_none());
assert!(
tokei
.run_explain(&tokei.variations[0], Path::new("t.exe"), Path::new("a.py"), scripts)
.is_none()
);
let path = write_an_adapter(
"an_explain_command_with_no_file",
"name = \"tokei\"\noutput = \"tokei-json\"\nargs = [\"{file}\"]\n\
explain-args = [\"-t\"]\n\
[variation.default]\ndialect = \"default\"\nmajor = true\nargs = []\n",
);
let refused = Adapter::read(&path, &read_the_shipped_dialects()).unwrap_err();
fs::remove_dir_all(path.parent().unwrap()).unwrap();
assert!(refused.contains("no {file} in its explain-args"), "{refused}");
}
#[test]
fn explain_args_and_explain_output_are_declared_together_or_not_at_all() {
let dirs = vec![Path::new(env!("CARGO_MANIFEST_DIR")).join("adapters")];
let adapters = Adapter::read_all(&dirs, &read_the_shipped_dialects()).unwrap();
assert_eq!(adapters[1].explain_output, Some(PerLineFormat::LinejudgePerLine));
assert_eq!(adapters[3].explain_output, None);
let alone = write_an_adapter(
"a_format_with_no_command",
"name = \"tokei\"\noutput = \"tokei-json\"\nargs = [\"{file}\"]\n\
explain-output = \"linejudge-per-line\"\n\
[variation.default]\ndialect = \"default\"\nmajor = true\nargs = []\n",
);
let refused = Adapter::read(&alone, &read_the_shipped_dialects()).unwrap_err();
fs::remove_dir_all(alone.parent().unwrap()).unwrap();
assert!(refused.contains("no explain-args to print it"), "{refused}");
let unreadable = write_an_adapter(
"a_command_with_no_format",
"name = \"tokei\"\noutput = \"tokei-json\"\nargs = [\"{file}\"]\n\
explain-args = [\"-t\", \"{file}\"]\n\
[variation.default]\ndialect = \"default\"\nmajor = true\nargs = []\n",
);
let refused = Adapter::read(&unreadable, &read_the_shipped_dialects()).unwrap_err();
fs::remove_dir_all(unreadable.parent().unwrap()).unwrap();
assert!(refused.contains("no explain-output to read what they print"), "{refused}");
}
#[test]
fn a_version_nobody_answers_is_unknown_and_stops_nothing() {
let dirs = vec![Path::new(env!("CARGO_MANIFEST_DIR")).join("adapters")];
let tokei = &Adapter::read_all(&dirs, &read_the_shipped_dialects()).unwrap()[3];
assert_eq!(tokei.version_flag.as_deref(), Some("--version"));
assert_eq!(tokei.read_version_or_unknown(Path::new("a-binary-that-does-not-exist")), "unknown version");
let path = write_an_adapter(
"an_adapter_with_no_version_flag",
"name = \"tokei\"\noutput = \"tokei-json\"\nargs = [\"{file}\"]\nversion-flag = \"\"\n\
[variation.default]\ndialect = \"default\"\nmajor = true\nargs = []\n",
);
let unversioned = Adapter::read(&path, &read_the_shipped_dialects()).unwrap();
fs::remove_dir_all(path.parent().unwrap()).unwrap();
assert_eq!(unversioned.version_flag, None);
assert_eq!(unversioned.read_version_or_unknown(Path::new("irrelevant")), "unknown version");
}
#[test]
fn a_declared_version_is_recognised_inside_whatever_the_counter_prints_around_it() {
let says = is_the_declared_version;
assert!(says("14.0.0", "tokei 14.0.0 compiled with serialization support: json"));
assert!(says("3.7.0", "scc version 3.7.0"));
assert!(!says("14.0.0", "tokei 13.0.0 compiled with serialization support: json"));
assert!(!says("4.0.0", "tokei 14.0.0 compiled with serialization support: json"));
assert!(!says("3.7", "scc version 3.7.1"));
}
#[test]
fn an_adapter_that_declares_no_variation_is_refused() {
let path = write_an_adapter(
"an_adapter_with_no_variation",
"name = \"tokei\"\noutput = \"tokei-json\"\nargs = [\"{file}\"]\n",
);
let refused = Adapter::read(&path, &read_the_shipped_dialects()).unwrap_err();
fs::remove_dir_all(path.parent().unwrap()).unwrap();
assert!(refused.contains("no variation to run"), "{refused}");
}
#[test]
fn an_adapter_still_written_with_dialect_blocks_is_told_what_to_write() {
let path = write_an_adapter(
"an_adapter_of_the_older_shape",
"name = \"tokei\"\noutput = \"tokei-json\"\nargs = [\"{file}\"]\n\
[dialect.default]\nargs = []\n",
);
let refused = Adapter::read(&path, &read_the_shipped_dialects()).unwrap_err();
fs::remove_dir_all(path.parent().unwrap()).unwrap();
assert!(refused.contains("[variation.<name>]"), "{refused}");
assert!(refused.contains("major = true"), "{refused}");
}
#[test]
fn a_dialect_with_no_major_and_one_with_two_are_both_refused() {
let none = write_an_adapter(
"an_adapter_whose_dialect_has_no_major",
"name = \"tokei\"\noutput = \"tokei-json\"\nargs = [\"{file}\"]\n\
[variation.default]\ndialect = \"default\"\nargs = []\n",
);
let refused = Adapter::read(&none, &read_the_shipped_dialects()).unwrap_err();
fs::remove_dir_all(none.parent().unwrap()).unwrap();
assert!(refused.contains("says major = true"), "{refused}");
let both = write_an_adapter(
"an_adapter_whose_dialect_has_two_majors",
"name = \"tokei\"\noutput = \"tokei-json\"\nargs = [\"{file}\"]\n\
[variation.default]\ndialect = \"default\"\nmajor = true\nargs = []\n\
[variation.loud]\ndialect = \"default\"\nmajor = true\nargs = [\"--loud\"]\n",
);
let refused = Adapter::read(&both, &read_the_shipped_dialects()).unwrap_err();
fs::remove_dir_all(both.parent().unwrap()).unwrap();
assert!(refused.contains("both say major = true"), "{refused}");
}
#[test]
fn a_variation_named_after_another_dialect_of_the_same_counter_is_refused() {
let path = write_an_adapter_for(
"mezura",
"an_adapter_whose_variation_wears_another_name",
"name = \"mezura\"\nargs = [\"{file}\"]\n\
[variation.content]\ndialect = \"content\"\nmajor = true\nargs = []\n\
[variation.content.read]\nclaims = \"languages[]\"\nlines = \"total.lines\"\n\
code = \"total.code\"\ncomments = \"total.comments\"\nextra = \"total.extra\"\n\
[variation.region]\ndialect = \"content\"\nargs = [\"--fast\"]\n",
);
let refused = Adapter::read(&path, &read_the_shipped_dialects()).unwrap_err();
fs::remove_dir_all(path.parent().unwrap()).unwrap();
assert!(refused.contains("named after another dialect"), "{refused}");
}
#[test]
fn a_variation_named_with_anything_a_toml_key_refuses_is_refused() {
for wrong in ["strip str", "strip.str", "strip+str", "strip/str", ""] {
let path = write_an_adapter(
"an_adapter_with_an_unwritable_name",
&format!(
"name = \"tokei\"\noutput = \"tokei-json\"\nargs = [\"{{file}}\"]\n\
[variation.default]\ndialect = \"default\"\nmajor = true\nargs = []\n\
[variation.\"{wrong}\"]\ndialect = \"default\"\nargs = [\"--x\"]\n"
),
);
let refused = Adapter::read(&path, &read_the_shipped_dialects()).unwrap_err();
fs::remove_dir_all(path.parent().unwrap()).unwrap();
assert!(refused.contains("letters, digits"), "{wrong}: {refused}");
}
}
#[test]
fn two_variations_of_one_dialect_asking_the_same_thing_are_refused() {
let path = write_an_adapter(
"an_adapter_with_a_twin",
"name = \"tokei\"\noutput = \"tokei-json\"\nargs = [\"{file}\"]\n\
[variation.default]\ndialect = \"default\"\nmajor = true\nargs = []\n\
[variation.twin]\ndialect = \"default\"\nargs = []\n",
);
let refused = Adapter::read(&path, &read_the_shipped_dialects()).unwrap_err();
fs::remove_dir_all(path.parent().unwrap()).unwrap();
assert!(refused.contains("the same command line"), "{refused}");
}
#[test]
fn the_variations_of_one_dialect_come_out_together_with_their_major_leading() {
let path = write_an_adapter_for(
"cloc",
"an_adapter_over_two_dialects",
"name = \"cloc\"\nargs = [\"{file}\"]\n\
[variation.loud]\ndialect = \"docstring-as-code\"\nmajor = true\nargs = [\"--d\"]\n\
[variation.loud.read]\nclaims = \"SUM\"\nlines = \"header.n_lines\"\n\
code = \"SUM.code\"\ncomments = \"SUM.comment\"\nblanks = \"SUM.blank\"\n\
[variation.aaa]\ndialect = \"default\"\nargs = [\"--a\"]\n\
[variation.plain]\ndialect = \"default\"\nmajor = true\nargs = []\n\
[variation.plain.read]\nclaims = \"SUM\"\nlines = \"header.n_lines\"\n\
code = \"SUM.code\"\ncomments = \"SUM.comment\"\nblanks = \"SUM.blank\"\n",
);
let adapter = Adapter::read(&path, &read_the_shipped_dialects()).unwrap();
fs::remove_dir_all(path.parent().unwrap()).unwrap();
let order: Vec<&str> = adapter.variations.iter().map(|one| one.name.as_str()).collect();
assert_eq!(order, ["plain", "aaa", "loud"], "the dialects did not come out grouped");
assert!(adapter.variations[0].is_major, "a minor opens every page");
}
#[test]
fn a_variation_with_no_read_block_is_read_like_the_major_of_its_dialect() {
let path = write_an_adapter_for(
"scc",
"an_adapter_whose_minor_inherits_its_reading",
"name = \"scc\"\nargs = [\"{file}\"]\n\
[variation.default]\ndialect = \"default\"\nmajor = true\nargs = []\n\
[variation.default.read]\neach = \"[]\"\nlines = \"Lines\"\ncode = \"Code\"\n\
comments = \"Comment\"\nblanks = \"Blank\"\n\
[variation.counted]\ndialect = \"default\"\nargs = [\"--no-large\"]\n",
);
let adapter = Adapter::read(&path, &read_the_shipped_dialects()).unwrap();
fs::remove_dir_all(path.parent().unwrap()).unwrap();
let printed = "[{\"Lines\":3,\"Code\":2,\"Comment\":1,\"Blank\":0}]";
let names: Vec<&str> = adapter.variations.iter().map(|one| one.name.as_str()).collect();
assert_eq!(names, ["default", "counted"], "the major leads whatever its name sorts as");
let minor = &adapter.variations[1];
assert_eq!(minor.name_of_dialect, "default");
assert!(!minor.is_major);
let Reader::Declared(locator) = &minor.reader else { panic!("it read nothing") };
let read = locator.read(printed).unwrap().unwrap();
assert_eq!(read.counts.lines, 3);
assert_eq!(read.counts.buckets["code"], 2);
}
#[test]
fn the_file_named_among_one_variations_own_arguments_is_refused() {
let path = write_an_adapter(
"an_adapter_naming_the_file_twice",
"name = \"tokei\"\noutput = \"tokei-json\"\nargs = [\"{file}\"]\n\
[variation.default]\ndialect = \"default\"\nmajor = true\nargs = [\"{file}\"]\n",
);
let refused = Adapter::read(&path, &read_the_shipped_dialects()).unwrap_err();
fs::remove_dir_all(path.parent().unwrap()).unwrap();
assert!(refused.contains("the arguments every variation shares"), "{refused}");
}
#[test]
fn an_adapter_that_never_names_the_file_is_refused() {
let path = write_an_adapter(
"an_adapter_with_no_file",
"name = \"tokei\"\noutput = \"tokei-json\"\nargs = [\"--output\", \"json\"]\n\
[variation.default]\ndialect = \"default\"\nmajor = true\nargs = []\n",
);
let refused = Adapter::read(&path, &read_the_shipped_dialects()).unwrap_err();
fs::remove_dir_all(path.parent().unwrap()).unwrap();
assert!(refused.contains("{file}"), "{refused}");
}
#[test]
fn an_adapter_naming_a_dialect_with_no_buckets_is_refused() {
let path = write_an_adapter(
"an_adapter_with_an_unknown_dialect",
"name = \"tokei\"\noutput = \"tokei-json\"\nargs = [\"{file}\"]\n\
[variation.strict]\ndialect = \"strict\"\nmajor = true\nargs = []\n",
);
let refused = Adapter::read(&path, &read_the_shipped_dialects()).unwrap_err();
fs::remove_dir_all(path.parent().unwrap()).unwrap();
assert!(refused.contains("no buckets for"), "{refused}");
}
fn write_an_adapter(name: &str, text: &str) -> PathBuf {
write_an_adapter_for("tokei", name, text)
}
fn write_an_adapter_for(name_of_counter: &str, name: &str, text: &str) -> PathBuf {
let dir = env::temp_dir().join(format!("linejudge-{name}"));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let path = dir.join(format!("{name_of_counter}.toml"));
fs::write(&path, text).unwrap();
path
}
}