use std::collections::HashSet;
use std::env;
use std::fs::{self, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
const VERSION: &str = env!("CARGO_PKG_VERSION");
const MANIFEST: &str = "DINAH.toml";
const BENCHMARKS: &[&str] = &[
"runtime",
"memory",
"compute",
"ecs",
"simulation",
"network",
"storage",
];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum OutputFormat {
Text,
Json,
}
#[derive(Debug)]
struct CliOptions {
command: String,
path: Option<String>,
format: OutputFormat,
verbose: bool,
task_name: Option<String>,
}
fn usage() {
println!("dinah {VERSION} — DINAH project utility\n\nUSAGE:\n dinah [--help | --version]\n dinah [--format text|json] [--verbose] <COMMAND> [PATH]\n\nCOMMANDS:\n init Create DINAH.toml without overwriting\n run Validate the manifest, or execute an explicitly selected task\n task List task declarations in [tasks]\n inspect Show project metadata\n benchmark List available benchmark suites\n doctor Check basic project health\n\nOPTIONS:\n --format text|json Select output format (default: text)\n --verbose Print diagnostic logging to stderr\n --task NAME Execute task NAME (only valid with run)\n -h, --help Show help\n -V, --version Show version\n\nPATH defaults to the current directory. The manifest parser supports only the documented starter subset, not full TOML.");
}
fn json_string(value: &str) -> String {
let mut result = String::from("\"");
for character in value.chars() {
match character {
'"' => result.push_str("\\\""),
'\\' => result.push_str("\\\\"),
'\n' => result.push_str("\\n"),
'\r' => result.push_str("\\r"),
'\t' => result.push_str("\\t"),
c if c.is_control() => result.push_str(&format!("\\u{:04x}", c as u32)),
c => result.push(c),
}
}
result.push('"');
result
}
fn emit(format: OutputFormat, text: &str, fields: &[(&str, String)]) {
match format {
OutputFormat::Text => println!("{text}"),
OutputFormat::Json => {
let body = fields
.iter()
.map(|(key, value)| format!("{}:{}", json_string(key), json_string(value)))
.collect::<Vec<_>>()
.join(",");
println!("{{{body}}}");
}
}
}
fn parse_options(arguments: &[String]) -> io::Result<Option<CliOptions>> {
if arguments.is_empty() || arguments.iter().any(|arg| arg == "--help" || arg == "-h") {
usage();
return Ok(None);
}
if arguments
.iter()
.any(|arg| arg == "--version" || arg == "-V")
{
println!("dinah {VERSION}");
return Ok(None);
}
let mut command = None;
let mut path = None;
let mut format = OutputFormat::Text;
let mut verbose = false;
let mut task_name = None;
let mut index = 0;
while index < arguments.len() {
match arguments[index].as_str() {
"--format" => {
index += 1;
format = match arguments.get(index).map(String::as_str) {
Some("text") => OutputFormat::Text,
Some("json") => OutputFormat::Json,
Some(_) => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"invalid --format value; choose text or json",
))
}
None => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"--format requires text or json",
))
}
};
}
"--verbose" => verbose = true,
"--task" => {
index += 1;
let value = arguments
.get(index)
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"--task requires a non-empty task name",
)
})?;
if task_name.replace(value.clone()).is_some() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"--task may be specified only once",
));
}
}
value if value.starts_with('-') => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unknown option {value}"),
))
}
value if command.is_none() => command = Some(value.to_owned()),
value if path.is_none() => path = Some(value.to_owned()),
_ => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"too many positional arguments; expected COMMAND [PATH]",
))
}
}
index += 1;
}
let command = command.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"missing command; run dinah --help",
)
})?;
if !matches!(
command.as_str(),
"init" | "run" | "task" | "inspect" | "benchmark" | "doctor"
) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unknown command {command}; run dinah --help"),
));
}
if task_name.is_some() && command != "run" {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"--task is only valid with run",
));
}
Ok(Some(CliOptions {
command,
path,
format,
verbose,
task_name,
}))
}
fn project_path(argument: Option<&String>) -> io::Result<PathBuf> {
match argument {
Some(path) => Ok(PathBuf::from(path)),
None => env::current_dir(),
}
}
fn init(path: &Path, format: OutputFormat) -> io::Result<()> {
fs::create_dir_all(path).map_err(|e| friendly_io(e, "create project directory"))?;
let manifest = path.join(MANIFEST);
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&manifest)
.map_err(|error| {
if error.kind() == io::ErrorKind::AlreadyExists {
io::Error::new(
io::ErrorKind::AlreadyExists,
format!("{} already exists; it was not changed", manifest.display()),
)
} else {
friendly_io(error, "create project manifest")
}
})?;
file.write_all(b"# DINAH project configuration\n[project]\nname = \"my-dinah-project\"\nversion = \"0.1.0\"\n\n[tasks]\n# build = \"Describe the task here\"\n")
.map_err(|e| friendly_io(e, "write project manifest"))?;
emit(
format,
&format!("Created {}", manifest.display()),
&[
("status", "created".into()),
("manifest", manifest.display().to_string()),
],
);
Ok(())
}
fn friendly_io(error: io::Error, action: &str) -> io::Error {
let message = match error.kind() {
io::ErrorKind::NotFound => {
format!("{action} failed: path not found; check the PATH or run dinah init")
}
io::ErrorKind::PermissionDenied => {
format!("{action} failed: permission denied; check filesystem permissions")
}
io::ErrorKind::AlreadyExists => {
format!("{action} failed: the file already exists; DINAH will not overwrite it")
}
io::ErrorKind::InvalidData => {
format!("{action} failed: invalid project configuration; check [project].name")
}
_ => format!("{action} failed: {error}"),
};
io::Error::new(error.kind(), message)
}
const MAX_MANIFEST_BYTES: u64 = 1024 * 1024;
fn read_manifest(path: &Path) -> io::Result<(String, String, Vec<String>)> {
let manifest = path.join(MANIFEST);
let metadata = fs::metadata(&manifest).map_err(|error| {
if error.kind() == io::ErrorKind::NotFound {
io::Error::new(
io::ErrorKind::NotFound,
format!("{} not found; run dinah init first", manifest.display()),
)
} else {
friendly_io(error, "inspect project manifest")
}
})?;
if metadata.len() > MAX_MANIFEST_BYTES {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("{} exceeds the 1 MiB manifest limit", manifest.display()),
));
}
let contents = fs::read_to_string(&manifest).map_err(|error| {
if error.kind() == io::ErrorKind::NotFound {
io::Error::new(
io::ErrorKind::NotFound,
format!("{} not found; run dinah init first", manifest.display()),
)
} else {
friendly_io(error, "read project manifest")
}
})?;
let invalid = |line: usize, reason: &str| {
io::Error::new(
io::ErrorKind::InvalidData,
format!(
"{}:{line}: invalid starter manifest: {reason}",
manifest.display()
),
)
};
let mut section = String::new();
let mut sections = HashSet::new();
let mut project_keys = HashSet::new();
let mut task_keys = HashSet::new();
let mut name = None;
let mut version = None;
let mut tasks = Vec::new();
for (line_index, source_line) in contents.lines().enumerate() {
let line_number = line_index + 1;
let line = strip_manifest_comment(source_line).trim();
if line.is_empty() {
continue;
}
if line.starts_with('[') || line.ends_with(']') {
if !line.starts_with('[')
|| !line.ends_with(']')
|| line.len() < 3
|| line[1..line.len() - 1].contains(['[', ']'])
{
return Err(invalid(line_number, "malformed section header"));
}
section = line[1..line.len() - 1].trim().to_owned();
if section.is_empty() {
return Err(invalid(line_number, "section name must not be empty"));
}
if matches!(section.as_str(), "project" | "tasks") && !sections.insert(section.clone())
{
return Err(invalid(line_number, "duplicate section"));
}
continue;
}
let Some((key, value)) = line.split_once('=') else {
return Err(invalid(line_number, "expected key = value"));
};
let key = key.trim();
let value = value.trim();
if key.is_empty()
|| !key
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
{
return Err(invalid(
line_number,
"keys must use letters, digits, '_' or '-'",
));
}
if section == "project" {
if !project_keys.insert(key.to_owned()) {
return Err(invalid(line_number, "duplicate project key"));
}
match key {
"name" => {
name = Some(parse_quoted(value).ok_or_else(|| {
invalid(line_number, "project.name must be a quoted string")
})?)
}
"version" => {
version = Some(parse_quoted(value).ok_or_else(|| {
invalid(line_number, "project.version must be a quoted string")
})?)
}
_ => {}
}
} else if section == "tasks" {
if !task_keys.insert(key.to_owned()) {
return Err(invalid(line_number, "duplicate task key"));
}
parse_quoted(value)
.ok_or_else(|| invalid(line_number, "task descriptions must be quoted strings"))?;
tasks.push(key.to_owned());
}
}
let name = name
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
format!(
"{} must define a non-empty quoted project.name",
manifest.display()
),
)
})?;
Ok((name, version.unwrap_or_else(|| "unspecified".into()), tasks))
}
fn strip_manifest_comment(line: &str) -> &str {
let mut quoted = false;
let mut escaped = false;
for (index, character) in line.char_indices() {
if escaped {
escaped = false;
continue;
}
match character {
'\\' if quoted => escaped = true,
'"' => quoted = !quoted,
'#' if !quoted => return &line[..index],
_ => {}
}
}
line
}
fn parse_quoted(value: &str) -> Option<String> {
if value.len() < 2 || !value.starts_with('"') || !value.ends_with('"') {
return None;
}
let inner = &value[1..value.len() - 1];
let mut decoded = String::with_capacity(inner.len());
let mut characters = inner.chars();
while let Some(character) = characters.next() {
if character == '\\' {
decoded.push(match characters.next()? {
'"' => '"',
'\\' => '\\',
'n' => '\n',
'r' => '\r',
't' => '\t',
'b' => '\u{0008}',
'f' => '\u{000c}',
'u' => {
let mut codepoint = 0u32;
for _ in 0..4 {
codepoint = codepoint
.checked_mul(16)?
.checked_add(characters.next()?.to_digit(16)?)?;
}
char::from_u32(codepoint)?
}
'U' => {
let mut codepoint = 0u32;
for _ in 0..8 {
codepoint = codepoint
.checked_mul(16)?
.checked_add(characters.next()?.to_digit(16)?)?;
}
char::from_u32(codepoint)?
}
_ => return None,
});
} else if character == '"' || character.is_control() {
return None;
} else {
decoded.push(character);
}
}
Some(decoded)
}
fn task_command(path: &Path, requested: &str) -> io::Result<String> {
let (_, _, _) = read_manifest(path)?;
let manifest = path.join(MANIFEST);
let contents =
fs::read_to_string(&manifest).map_err(|error| friendly_io(error, "read task command"))?;
let mut in_tasks = false;
for (index, source_line) in contents.lines().enumerate() {
let line_number = index + 1;
let line = strip_manifest_comment(source_line).trim();
if line.starts_with('[') && line.ends_with(']') {
in_tasks = &line[1..line.len() - 1] == "tasks";
continue;
}
if !in_tasks || line.is_empty() {
continue;
}
if let Some((key, value)) = line.split_once('=') {
if key.trim() == requested {
return parse_quoted(value.trim())
.filter(|command| !command.trim().is_empty())
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
format!(
"{}:{line_number}: task command must be a non-empty quoted string",
manifest.display()
),
)
});
}
}
}
Err(io::Error::new(
io::ErrorKind::NotFound,
format!("task '{requested}' is not declared in [tasks]"),
))
}
fn run(path: &Path, format: OutputFormat, selected_task: Option<&str>) -> io::Result<i32> {
let (name, version, _) = read_manifest(path)?;
let Some(task_name) = selected_task else {
emit(
format,
&format!("Project {name} ({version}) validated. Select a task with --task NAME to execute it."),
&[("status", "validated".into()), ("name", name), ("version", version), ("task_execution", "explicit_opt_in".into())],
);
return Ok(0);
};
let command_text = task_command(path, task_name)?;
if format == OutputFormat::Text {
println!("Running task '{task_name}' for {name} ({version})");
}
// Task values are explicitly executable shell command strings. Selecting --task is the opt-in boundary.
#[cfg(windows)]
let output = Command::new("cmd")
.args(["/C", &command_text])
.current_dir(path)
.output()?;
#[cfg(not(windows))]
let output = Command::new("sh")
.arg("-c")
.arg(&command_text)
.current_dir(path)
.output()?;
let code = output.status.code().unwrap_or(1);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if format == OutputFormat::Json {
println!(
"{{\"status\":{},\"task\":{},\"exit_code\":{},\"stdout\":{},\"stderr\":{}}}",
json_string(if output.status.success() {
"success"
} else {
"failure"
}),
json_string(task_name),
code,
json_string(&stdout),
json_string(&stderr)
);
} else {
print!("{stdout}");
eprint!("{stderr}");
}
Ok(code)
}
fn task(path: &Path, format: OutputFormat) -> io::Result<()> {
let (_, _, tasks) = read_manifest(path)?;
if format == OutputFormat::Json {
let values = tasks
.iter()
.map(|task| json_string(task))
.collect::<Vec<_>>()
.join(",");
println!("{{\"tasks\":[{values}],\"execution\":\"explicit_task_selection\"}}");
} else if tasks.is_empty() {
println!("No tasks declared in [tasks]. Add task names to DINAH.toml.");
} else {
println!("Declared tasks (run one with dinah --task NAME run [PATH]):");
for name in tasks {
println!(" - {name}");
}
}
Ok(())
}
fn inspect(path: &Path, format: OutputFormat) -> io::Result<()> {
let (name, version, tasks) = read_manifest(path)?;
let manifest = path.join(MANIFEST);
emit(
format,
&format!(
"Project: {name}\nVersion: {version}\nManifest: {}\nDeclared tasks: {}",
manifest.display(),
tasks.len()
),
&[
("name", name),
("version", version),
("manifest", manifest.display().to_string()),
("declared_tasks", tasks.len().to_string()),
],
);
Ok(())
}
fn benchmark(format: OutputFormat) {
if format == OutputFormat::Json {
let values = BENCHMARKS
.iter()
.map(|name| json_string(name))
.collect::<Vec<_>>()
.join(",");
println!("{{\"suites\":[{values}],\"execution\":\"use cargo bench --bench <suite>\",\"status\":\"listed_only\"}}");
} else {
println!("Available benchmark suites (this command only lists them):");
for suite in BENCHMARKS {
println!(" - {suite}");
}
println!("Run a suite with cargo bench --bench <suite> in the repository.");
}
}
fn doctor(path: &Path, format: OutputFormat) -> io::Result<()> {
let manifest = path.join(MANIFEST);
let directory_ok = path.is_dir();
let (name, version, _) = read_manifest(path)?;
let checks = [
("project_directory", directory_ok),
("manifest_readable", manifest.is_file()),
("project_name", !name.trim().is_empty()),
];
let passed = checks.iter().filter(|(_, ok)| *ok).count();
let status = if passed == checks.len() {
"healthy"
} else {
"warning"
};
if format == OutputFormat::Json {
println!(
"{{\"status\":{},\"name\":{},\"version\":{},\"checks\":{{{}}}}}",
json_string(status),
json_string(&name),
json_string(&version),
checks
.iter()
.map(|(key, ok)| format!("{}:{}", json_string(key), ok))
.collect::<Vec<_>>()
.join(",")
);
} else {
println!("DINAH doctor: {status}");
for (key, ok) in checks {
println!(" [{}] {key}", if ok { "ok" } else { "fail" });
}
println!(" project: {name} ({version})");
println!("Note: this is a basic manifest check, not a security audit.");
}
Ok(())
}
fn execute(arguments: &[String]) -> io::Result<i32> {
let Some(options) = parse_options(arguments)? else {
return Ok(0);
};
if options.verbose {
eprintln!(
"dinah: command={} format={:?}",
options.command, options.format
);
}
match options.command.as_str() {
"init" => init(&project_path(options.path.as_ref())?, options.format)?,
"run" => {
return run(
&project_path(options.path.as_ref())?,
options.format,
options.task_name.as_deref(),
)
}
"task" => task(&project_path(options.path.as_ref())?, options.format)?,
"inspect" => inspect(&project_path(options.path.as_ref())?, options.format)?,
"benchmark" => benchmark(options.format),
"doctor" => doctor(&project_path(options.path.as_ref())?, options.format)?,
_ => unreachable!("command validated by parse_options"),
}
Ok(0)
}
fn main() {
let arguments: Vec<String> = env::args().skip(1).collect();
match execute(&arguments) {
Ok(code) => std::process::exit(code),
Err(error) => {
eprintln!("dinah: {error}");
eprintln!("Try dinah --help for usage.");
std::process::exit(2);
}
}
}
#[cfg(test)]
mod tests {
use super::{
execute, init, json_string, parse_options, read_manifest, run, task_command, OutputFormat,
};
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir() -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path =
std::env::temp_dir().join(format!("dinah-cli-test-{}-{unique}", std::process::id()));
fs::create_dir_all(&path).unwrap();
path
}
#[test]
fn help_and_version_are_successful() {
assert_eq!(execute(&["--help".into()]).unwrap(), 0);
assert_eq!(execute(&["--version".into()]).unwrap(), 0);
}
#[test]
fn parses_format_and_verbose_options() {
let parsed = parse_options(&[
"--format".into(),
"json".into(),
"--verbose".into(),
"benchmark".into(),
])
.unwrap()
.unwrap();
assert_eq!(parsed.format, OutputFormat::Json);
assert!(parsed.verbose);
assert!(parse_options(&["--format".into(), "xml".into(), "doctor".into()]).is_err());
assert!(parse_options(&["run".into(), "--task".into()]).is_err());
assert!(parse_options(&["--task".into(), "build".into(), "doctor".into()]).is_err());
let task_run = parse_options(&["run".into(), "--task".into(), "build".into()])
.unwrap()
.unwrap();
assert_eq!(task_run.task_name.as_deref(), Some("build"));
}
#[test]
fn init_creates_manifest_and_refuses_to_overwrite() {
let path = temp_dir();
init(&path, OutputFormat::Text).unwrap();
let manifest = path.join("DINAH.toml");
let original = fs::read_to_string(&manifest).unwrap();
assert!(original.contains("name = \"my-dinah-project\""));
assert!(init(&path, OutputFormat::Text).is_err());
assert_eq!(fs::read_to_string(&manifest).unwrap(), original);
fs::remove_dir_all(path).unwrap();
}
#[test]
fn reads_project_metadata_and_task_names() {
let path = temp_dir();
fs::write(path.join("DINAH.toml"), "[project]\nname = \"sample\"\nversion = \"0.2.0\"\n[tasks]\nbuild = \"echo build\"\ntest = \"echo test\"\n").unwrap();
let (name, version, tasks) = read_manifest(&path).unwrap();
assert_eq!(name, "sample");
assert_eq!(version, "0.2.0");
assert_eq!(tasks, vec!["build", "test"]);
fs::remove_dir_all(path).unwrap();
}
#[test]
fn rejects_manifest_larger_than_one_megabyte() {
let path = temp_dir();
let contents = format!("[project]\nname = \"sample\"\n#{}", "x".repeat(1024 * 1024));
fs::write(path.join("DINAH.toml"), contents).unwrap();
let error = read_manifest(&path).unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
assert!(error.to_string().contains("1 MiB manifest limit"));
fs::remove_dir_all(path).unwrap();
}
#[test]
fn manifest_parser_rejects_empty_quoted_project_name() {
let path = temp_dir();
fs::write(path.join("DINAH.toml"), "[project]\nname = \" \"\n").unwrap();
assert_eq!(
read_manifest(&path).unwrap_err().kind(),
std::io::ErrorKind::InvalidData
);
fs::remove_dir_all(path).unwrap();
}
#[test]
fn manifest_parser_rejects_unquoted_project_name() {
let path = temp_dir();
fs::write(path.join("DINAH.toml"), "[project]\nname = sample\n").unwrap();
assert_eq!(
read_manifest(&path).unwrap_err().kind(),
std::io::ErrorKind::InvalidData
);
fs::remove_dir_all(path).unwrap();
}
#[test]
fn manifest_parser_rejects_duplicate_project_fields_deterministically() {
let path = temp_dir();
fs::write(
path.join("DINAH.toml"),
"[project]\nname = \"first\"\nname = \"second\"\nversion = \"1\"\n",
)
.unwrap();
let error = read_manifest(&path).unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
assert!(error
.to_string()
.contains(":3: invalid starter manifest: duplicate project key"));
fs::remove_dir_all(path).unwrap();
}
#[test]
fn manifest_parser_ignores_assignments_outside_known_sections() {
let path = temp_dir();
fs::write(
path.join("DINAH.toml"),
"name = \"ignored\"\n[project]\nname = \"valid\"\n[other]\nversion = \"ignored\"\n",
)
.unwrap();
let parsed = read_manifest(&path).unwrap();
assert_eq!(parsed.0, "valid");
assert_eq!(parsed.1, "unspecified");
fs::remove_dir_all(path).unwrap();
}
#[test]
fn json_string_escapes_all_json_control_characters() {
let escaped = json_string("quote: \" slash: \\ newline: \n tab: \t backspace: \u{0008} formfeed: \u{000c} nul: \u{0000}");
assert_eq!(escaped, "\"quote: \\\" slash: \\\\ newline: \\n tab: \\t backspace: \\u0008 formfeed: \\u000c nul: \\u0000\"");
}
#[test]
fn manifest_comments_do_not_truncate_hashes_inside_quoted_values() {
let path = temp_dir();
fs::write(
path.join("DINAH.toml"),
"[project]\nname = \"sample#one\" # actual comment\nversion = \"0.2.0\"\n",
)
.unwrap();
let parsed = read_manifest(&path).unwrap();
assert_eq!(parsed.0, "sample#one");
assert_eq!(parsed.1, "0.2.0");
fs::remove_dir_all(path).unwrap();
}
#[test]
fn deterministic_adversarial_manifest_corpus_never_panics() {
let path = temp_dir();
let corpus = [
"",
"# only comment",
"[",
"]",
"[]",
"[[project]]",
"[project]\nname = \"x\"",
"[project]\nname = \"x#y\"",
"[project]\nname = \"unterminated",
"[project]\nname = \"\\\"\"",
"[tasks]\n\"quoted-key\" = \"value\"",
"[project]\nname = \"a\"\n[project]\nname = \"b\"",
"[project]\nname = \"\u{0000}\"",
"[unknown]\nname = \"ignored\"",
];
for input in corpus {
fs::write(path.join("DINAH.toml"), input).unwrap();
let result = std::panic::catch_unwind(|| read_manifest(&path));
assert!(result.is_ok(), "parser panicked for input {input:?}");
}
fs::remove_dir_all(path).unwrap();
}
#[test]
fn manifest_parser_rejects_malformed_section_headers_and_assignments() {
let path = temp_dir();
for input in [
"[project\nname = \"sample\"",
"[]\n[project]\nname = \"sample\"",
"[project]\nname \"sample\"",
"[project]]\nname = \"sample\"",
] {
fs::write(path.join("DINAH.toml"), input).unwrap();
let error = read_manifest(&path).unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData, "{input:?}");
assert!(
error.to_string().contains("invalid starter manifest"),
"{error}"
);
}
fs::remove_dir_all(path).unwrap();
}
#[test]
fn manifest_parser_rejects_duplicate_project_and_task_keys() {
let path = temp_dir();
for input in [
"[project]\nname = \"first\"\nname = \"second\"",
"[project]\nname = \"sample\"\n[tasks]\nbuild = \"first\"\nbuild = \"second\"",
"[project]\nname = \"sample\"\n[project]\nversion = \"1\"",
] {
fs::write(path.join("DINAH.toml"), input).unwrap();
assert_eq!(
read_manifest(&path).unwrap_err().kind(),
std::io::ErrorKind::InvalidData
);
}
fs::remove_dir_all(path).unwrap();
}
#[test]
fn manifest_parser_requires_quoted_task_descriptions_and_valid_strings() {
let path = temp_dir();
for input in [
"[project]\nname = \"sample\"\n[tasks]\nbuild = cargo build",
"[project]\nname = \"sample\n",
"[project]\nname = \"bad\\qescape\"",
"[project]\nname = \"bad\"tail\"",
] {
fs::write(path.join("DINAH.toml"), input).unwrap();
assert_eq!(
read_manifest(&path).unwrap_err().kind(),
std::io::ErrorKind::InvalidData
);
}
fs::remove_dir_all(path).unwrap();
}
#[test]
fn manifest_parser_keeps_unknown_sections_ignored_but_validates_their_syntax() {
let path = temp_dir();
fs::write(
path.join("DINAH.toml"),
"[project]\nname = \"sample\"\n[metadata]\nowner = \"team\"\n",
)
.unwrap();
let parsed = read_manifest(&path).unwrap();
assert_eq!(parsed.0, "sample");
assert!(parsed.2.is_empty());
fs::remove_dir_all(path).unwrap();
}
#[test]
fn run_rejects_missing_project_name() {
let path = temp_dir();
fs::write(path.join("DINAH.toml"), "[project]\nversion = \"0.1.0\"\n").unwrap();
assert!(run(&path, OutputFormat::Text, None).is_err());
fs::remove_dir_all(path).unwrap();
}
#[test]
fn quoted_values_decode_supported_escapes_for_shell_commands() {
assert_eq!(
super::parse_quoted(r#""printf \"hello\"\n""#).as_deref(),
Some("printf \"hello\"\n")
);
assert!(super::parse_quoted(r#""bad \q escape""#).is_none());
assert_eq!(
super::parse_quoted(r#""unicode \u263A \U0001F680""#).as_deref(),
Some("unicode ☺ 🚀")
);
assert_eq!(
super::parse_quoted(r#"" keep whitespace ""#).as_deref(),
Some(" keep whitespace ")
);
for invalid in [
r#""\uD800""#,
r#""\U00110000""#,
r#""\u12XZ""#,
r#""\U1234567""#,
] {
assert!(super::parse_quoted(invalid).is_none(), "{invalid}");
}
}
#[test]
fn selected_task_rejects_empty_or_whitespace_only_commands() {
let path = temp_dir();
fs::write(
path.join("DINAH.toml"),
"[project]\nname = \"sample\"\n[tasks]\nempty = \"\"\nblank = \" \"\n",
)
.unwrap();
for task in ["empty", "blank"] {
let error = task_command(&path, task).unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
assert!(error
.to_string()
.contains("task command must be a non-empty quoted string"));
}
fs::remove_dir_all(path).unwrap();
}
#[test]
fn selected_task_command_is_resolved_without_running_other_tasks() {
let path = temp_dir();
fs::write(path.join("DINAH.toml"), "[project]\nname = \"sample\"\n[tasks]\nbuild = \"printf build\"\ntest = \"printf test\"\n").unwrap();
assert_eq!(task_command(&path, "build").unwrap(), "printf build");
assert_eq!(task_command(&path, "test").unwrap(), "printf test");
assert_eq!(
task_command(&path, "missing").unwrap_err().kind(),
std::io::ErrorKind::NotFound
);
fs::remove_dir_all(path).unwrap();
}
#[test]
fn selected_task_propagates_exit_status_and_json_result() {
let path = temp_dir();
fs::write(
path.join("DINAH.toml"),
"[project]\nname = \"sample\"\n[tasks]\npass = \"exit 0\"\nfail = \"exit 7\"\n",
)
.unwrap();
assert_eq!(run(&path, OutputFormat::Json, Some("pass")).unwrap(), 0);
assert_eq!(run(&path, OutputFormat::Text, Some("fail")).unwrap(), 7);
fs::remove_dir_all(path).unwrap();
}
}