use std::fmt::Write as _;
use std::io::Write as _;
use std::path::Path;
use anyhow::{Context as _, Result, bail};
use schemars::{JsonSchema, Schema};
use serde_json::{Map, Value, json};
use crate::schema::project::TaskListView;
const SCHEMA_DIR: &str = "schemas";
struct SchemaDocument {
filename: &'static str,
value: Value,
}
pub(crate) fn write_schema(all: bool, output: Option<&Path>) -> Result<()> {
if all {
let dir = output.unwrap_or_else(|| Path::new(SCHEMA_DIR));
write_all_schemas(dir)
} else {
write_json(output, &config_schema()?)
}
}
pub(crate) fn config_schema() -> Result<Value> {
let mut schema = schema_value(schemars::schema_for!(crate::config::RunnerConfig))?;
set_object_field(
&mut schema,
"$id",
json!(crate::schema::config_schema_url()),
);
patch_tasks_label_vocab(&mut schema);
Ok(schema)
}
fn patch_tasks_label_vocab(schema: &mut Value) {
let Some(defs) = schema.get_mut("$defs").and_then(Value::as_object_mut) else {
return;
};
let Some(tasks) = defs
.get_mut("TasksSection")
.and_then(|def| def.get_mut("properties"))
.and_then(Value::as_object_mut)
else {
return;
};
let labels = json!(crate::types::task_source_labels());
if let Some(prefer_items) = tasks
.get_mut("prefer")
.and_then(|f| f.get_mut("items"))
.and_then(Value::as_object_mut)
{
prefer_items.insert("enum".to_string(), labels.clone());
}
if let Some(overrides_values) = tasks
.get_mut("overrides")
.and_then(|f| f.get_mut("additionalProperties"))
.and_then(Value::as_object_mut)
{
overrides_values.insert("enum".to_string(), labels);
}
}
fn write_all_schemas(dir: &Path) -> Result<()> {
if dir.exists() && !dir.is_dir() {
bail!("--all output must be a directory: {}", dir.display());
}
std::fs::create_dir_all(dir).with_context(|| format!("failed to create {}", dir.display()))?;
for document in schema_documents()? {
write_json(Some(&dir.join(document.filename)), &document.value)?;
}
let init_template_path = dir.join("runner.init.toml");
std::fs::write(&init_template_path, checked_init_template()?)
.with_context(|| format!("failed to write {}", init_template_path.display()))?;
Ok(())
}
fn checked_init_template() -> Result<String> {
let previous_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let result = std::panic::catch_unwind(render_init_template);
std::panic::set_hook(previous_hook);
result.map_err(|payload| {
let message = panic_message(&*payload);
anyhow::anyhow!(
"internal error generating the runner.toml scaffold (FIELD_TEMPLATE has drifted from \
RunnerConfig): {message}"
)
})
}
fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
payload
.downcast_ref::<&str>()
.map(|s| (*s).to_string())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "render_init_template panicked with a non-string payload".to_string())
}
#[derive(Clone, Copy)]
enum FieldHint {
Static(&'static str),
ClosedSet { suffix: Option<&'static str> },
Annotated(&'static [(&'static str, &'static str)]),
}
const FIELD_TEMPLATE: &[(&str, &str, &str, FieldHint)] = &[
(
"pm",
"node",
r#""pnpm""#,
FieldHint::ClosedSet { suffix: None },
),
(
"pm",
"python",
r#""uv""#,
FieldHint::ClosedSet { suffix: None },
),
(
"tasks",
"prefer",
r#"["turbo", "bun"]"#,
FieldHint::Static("global order: turbo, then package.json (bun)"),
),
(
"tasks",
"overrides",
r#"{ dev = "bun", build = "turbo" }"#,
FieldHint::Static("per-task pins beat the order"),
),
(
"task_runner",
"prefer",
r#"["just", "turbo"]"#,
FieldHint::ClosedSet { suffix: None },
),
(
"install",
"pms",
r#"["bun"]"#,
FieldHint::Static("only install with these; each must be detected"),
),
(
"install",
"scripts",
r#""deny""#,
FieldHint::ClosedSet {
suffix: Some("(absent = each PM's own default)"),
},
),
(
"resolution",
"fallback",
r#""probe""#,
FieldHint::Annotated(&[("probe", "PATH probe"), ("npm", "legacy"), ("error", "")]),
),
(
"resolution",
"on_mismatch",
r#""warn""#,
FieldHint::Annotated(&[("warn", ""), ("ignore", ""), ("error", "exit 2")]),
),
(
"chain",
"keep_going",
"false",
FieldHint::Static("run every task despite failures (same as -k)"),
),
(
"chain",
"kill_on_fail",
"false",
FieldHint::Static("parallel: kill siblings on first failure (same as -K)"),
),
(
"github",
"group_output",
"true",
FieldHint::Static("wrap each task's output in a collapsible ::group::"),
),
(
"github",
"group_parallel",
"true",
FieldHint::Static("buffer parallel tasks, print each as one block"),
),
(
"parallel",
"grouped",
"false",
FieldHint::Static("buffer + print each task as one block on completion"),
),
];
fn render_hint(section: &str, field: &str, hint: &FieldHint) -> String {
match hint {
FieldHint::Static(text) => (*text).to_string(),
FieldHint::ClosedSet { suffix } => {
let labels = accepted_labels(section, field).unwrap_or_else(|| {
panic!("{section}.{field}: ClosedSet needs an accepted_labels entry")
});
let joined = labels.join(" | ");
suffix.map_or_else(|| joined.clone(), |suffix| format!("{joined} {suffix}"))
}
FieldHint::Annotated(notes) => {
let labels = accepted_labels(section, field).unwrap_or_else(|| {
panic!("{section}.{field}: Annotated needs an accepted_labels entry")
});
let annotated: Vec<&str> = notes.iter().map(|(label, _)| *label).collect();
assert!(
annotated == labels,
"{section}.{field}: Annotated labels {annotated:?} don't match the real accepted \
set {labels:?} exactly (wrong order, or a variant was added/removed without \
updating the annotation table)"
);
notes
.iter()
.map(|(label, note)| {
if note.is_empty() {
(*label).to_string()
} else {
format!("{label} ({note})")
}
})
.collect::<Vec<_>>()
.join(" | ")
}
}
}
fn accepted_labels(section: &str, field: &str) -> Option<Vec<&'static str>> {
use crate::resolver::{FallbackPolicy, MismatchPolicy, ScriptPolicy};
use crate::types::{Ecosystem, PackageManager, TaskRunner};
match (section, field) {
("pm", "node") => Some(
PackageManager::all()
.iter()
.filter(|pm| matches!(pm.ecosystem(), Ecosystem::Node | Ecosystem::Deno))
.map(|pm| pm.label())
.collect(),
),
("pm", "python") => Some(
PackageManager::all()
.iter()
.filter(|pm| pm.ecosystem() == Ecosystem::Python)
.map(|pm| pm.label())
.collect(),
),
("task_runner", "prefer") => Some(TaskRunner::all().iter().map(|r| r.label()).collect()),
("install", "scripts") => Some(
ScriptPolicy::SETTABLE
.iter()
.filter_map(|p| p.label())
.collect(),
),
("resolution", "fallback") => Some(FallbackPolicy::ALL.iter().map(|p| p.label()).collect()),
("resolution", "on_mismatch") => {
Some(MismatchPolicy::ALL.iter().map(|p| p.label()).collect())
}
_ => None,
}
}
fn broader_vocab(section: &str, field: &str) -> Option<Vec<&'static str>> {
match (section, field) {
("install", "pms") => Some(
crate::types::PackageManager::all()
.iter()
.map(|pm| pm.label())
.collect(),
),
("tasks", "prefer" | "overrides") => Some(crate::types::task_source_labels()),
_ => None,
}
}
fn assert_value_uses_real_labels(section: &str, field: &str, value: &str) {
let Some(vocab) = accepted_labels(section, field).or_else(|| broader_vocab(section, field))
else {
return;
};
let parsed: toml::Value = value.parse().unwrap_or_else(|err| {
panic!("{section}.{field}: value {value:?} is not valid TOML: {err}")
});
let mut leaves = Vec::new();
collect_string_leaves(&parsed, &mut leaves);
for leaf in leaves {
assert!(
vocab.contains(&leaf.as_str()),
"{section}.{field}: example value {value:?} uses {leaf:?}, which isn't in the real \
accepted set {vocab:?}"
);
}
}
fn collect_string_leaves(value: &toml::Value, out: &mut Vec<String>) {
match value {
toml::Value::String(s) => out.push(s.clone()),
toml::Value::Array(items) => items.iter().for_each(|v| collect_string_leaves(v, out)),
toml::Value::Table(map) => map.values().for_each(|v| collect_string_leaves(v, out)),
_ => {}
}
}
const INIT_TEMPLATE_HEADER: &str = r"#:schema ./runner.toml.schema.json
# runner.toml — project task-runner configuration.
# Docs: https://runner.kjanat.dev
#
# Every key below is commented out, showing either its built-in default or an
# illustrative example value. Uncomment and edit the ones you want to pin.
# Precedence, highest first:
# CLI flags > RUNNER_* env vars > this file > manifest declarations.
";
pub(crate) fn render_init_template() -> String {
let schema = serde_json::to_value(schemars::schema_for!(crate::config::RunnerConfig))
.expect("RunnerConfig schema should serialize");
let top_properties = schema["properties"]
.as_object()
.expect("RunnerConfig schema must have top-level properties");
let defs = schema["$defs"]
.as_object()
.expect("RunnerConfig schema must have $defs");
let mut out = INIT_TEMPLATE_HEADER.to_string();
let mut used = std::collections::HashSet::with_capacity(FIELD_TEMPLATE.len());
for (section, section_schema) in top_properties {
let def_name = section_schema["$ref"]
.as_str()
.and_then(|r| r.strip_prefix("#/$defs/"))
.unwrap_or_else(|| panic!("{section}: expected a $defs $ref in the schema"));
let def = &defs[def_name];
let properties = def["properties"]
.as_object()
.unwrap_or_else(|| panic!("{def_name}: expected a properties object"));
if def["deprecated"].as_bool().unwrap_or(false) {
for field in properties.keys() {
let &(entry_section, entry_field, value, hint) = FIELD_TEMPLATE
.iter()
.find(|(s, f, ..)| s == section && f == field)
.unwrap_or_else(|| panic!("{section}.{field}: missing FIELD_TEMPLATE entry"));
used.insert((entry_section, entry_field));
assert_value_uses_real_labels(section, field, value);
let _ = render_hint(section, field, &hint);
}
continue;
}
let description = def["description"].as_str().unwrap_or_default();
out.push('\n');
for line in description.lines() {
if line.is_empty() {
out.push_str("#\n");
} else {
out.push_str("# ");
out.push_str(&strip_intra_doc_links(line));
out.push('\n');
}
}
let _ = writeln!(out, "[{section}]");
for field in properties.keys() {
let &(entry_section, entry_field, value, hint) = FIELD_TEMPLATE
.iter()
.find(|(s, f, ..)| s == section && f == field)
.unwrap_or_else(|| panic!("{section}.{field}: missing FIELD_TEMPLATE entry"));
used.insert((entry_section, entry_field));
assert_value_uses_real_labels(section, field, value);
let hint = render_hint(section, field, &hint);
let _ = writeln!(out, "# {field} = {value} # {hint}");
}
}
let orphaned: Vec<String> = FIELD_TEMPLATE
.iter()
.filter(|&&(s, f, ..)| !used.contains(&(s, f)))
.map(|(s, f, ..)| format!("{s}.{f}"))
.collect();
assert!(
orphaned.is_empty(),
"FIELD_TEMPLATE has entries for fields RunnerConfig no longer declares: {orphaned:?} — \
remove them"
);
out
}
fn strip_intra_doc_links(line: &str) -> String {
let mut out = String::with_capacity(line.len());
let mut rest = line;
while let Some(start) = rest.find("[`") {
out.push_str(&rest[..start]);
let after_bracket = &rest[start + 1..];
let Some(end) = after_bracket.find("`]") else {
out.push_str(&rest[start..]);
return out;
};
out.push_str(&after_bracket[..=end]);
rest = &after_bracket[end + 2..];
}
out.push_str(rest);
out
}
fn schema_documents() -> Result<Vec<SchemaDocument>> {
Ok(vec![
SchemaDocument {
filename: "runner.toml.schema.json",
value: config_schema()?,
},
SchemaDocument {
filename: "doctor.schema.json",
value: output_schema::<crate::schema::doctor::DoctorReport<'static>>("doctor")?,
},
SchemaDocument {
filename: "list.schema.json",
value: output_schema::<TaskListView<'static>>("list")?,
},
SchemaDocument {
filename: "why.schema.json",
value: output_schema::<super::why::WhyReport<'static>>("why")?,
},
])
}
fn output_schema<T: JsonSchema>(command: &'static str) -> Result<Value> {
let mut schema = serialize_schema_value::<T>()?;
set_object_field(&mut schema, "$id", json!(schema_id(command)));
set_object_field(&mut schema, "title", json!(title(command)));
set_object_field(&mut schema, "description", json!(description(command)));
patch_schema_version_const(&mut schema);
patch_source_schema(&mut schema, command);
Ok(schema)
}
fn serialize_schema_value<T: JsonSchema>() -> Result<Value> {
let generator = schemars::generate::SchemaSettings::default()
.for_serialize()
.into_generator();
schema_value(generator.into_root_schema_for::<T>())
}
fn schema_value(schema: Schema) -> Result<Value> {
serde_json::to_value(schema).context("failed to serialize schema")
}
fn write_json(output: Option<&Path>, value: &Value) -> Result<()> {
let mut sorted = value.clone();
json_schema_sort::sort_schema(&mut sorted);
let json = serde_json::to_string_pretty(&sorted).context("failed to serialize schema")?;
output.map_or_else(
|| writeln!(std::io::stdout(), "{json}").context("failed to write schema to stdout"),
|path| {
std::fs::write(path, format!("{json}\n"))
.with_context(|| format!("failed to write {}", path.display()))
},
)
}
fn set_object_field(schema: &mut Value, key: &'static str, value: Value) {
if let Some(object) = schema.as_object_mut() {
object.insert(key.to_string(), value);
}
}
fn patch_schema_version_const(schema: &mut Value) {
let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) else {
return;
};
let Some(version_schema) = properties
.get_mut("schema_version")
.and_then(Value::as_object_mut)
else {
return;
};
version_schema.insert("const".to_string(), json!(crate::schema::SCHEMA_VERSION));
}
fn patch_source_schema(schema: &mut Value, command: &str) {
let Some(defs) = schema.get_mut("$defs").and_then(Value::as_object_mut) else {
return;
};
defs.insert(
"TaskSourceLabel".to_string(),
task_source_label_schema(command),
);
patch_task_info_source(defs);
patch_why_task(defs);
patch_def_field(defs, "SourceEntry", "kind", "TaskSourceLabel");
patch_overrides_source_labels(defs);
}
fn patch_overrides_source_labels(defs: &mut Map<String, Value>) {
if !defs.contains_key("Overrides") {
return;
}
patch_def_array_items(defs, "Overrides", "prefer_sources", "TaskSourceLabel");
patch_def_map_array_items(defs, "Overrides", "task_source_pins", "TaskSourceLabel");
}
fn patch_task_info_source(defs: &mut Map<String, Value>) {
patch_def_field(defs, "TaskInfo", "source", "TaskSourceLabel");
}
fn patch_why_task(defs: &mut Map<String, Value>) {
if !defs.contains_key("WhyTask") {
return;
}
defs.insert(
"ProviderLabel".to_string(),
json!({ "type": "string", "enum": provider_labels() }),
);
patch_def_field(defs, "WhyTask", "kind", "TaskSourceLabel");
patch_def_field(defs, "WhyTask", "provider", "ProviderLabel");
}
fn field_schema_mut<'a>(
defs: &'a mut Map<String, Value>,
def_name: &str,
field: &str,
) -> Option<&'a mut Value> {
defs.get_mut(def_name)
.and_then(|definition| definition.get_mut("properties"))
.and_then(Value::as_object_mut)
.and_then(|properties| properties.get_mut(field))
}
fn def_ref(target_def: &str) -> Value {
json!({ "$ref": format!("#/$defs/{target_def}") })
}
fn patch_def_field(
defs: &mut Map<String, Value>,
def_name: &'static str,
field: &'static str,
target_def: &'static str,
) {
if let Some(field_schema) = field_schema_mut(defs, def_name, field) {
*field_schema = def_ref(target_def);
}
}
fn patch_def_array_items(
defs: &mut Map<String, Value>,
def_name: &'static str,
field: &'static str,
target_def: &'static str,
) {
if let Some(items) = field_schema_mut(defs, def_name, field)
.and_then(|field_schema| field_schema.get_mut("items"))
{
*items = def_ref(target_def);
}
}
fn patch_def_map_array_items(
defs: &mut Map<String, Value>,
def_name: &'static str,
field: &'static str,
target_def: &'static str,
) {
if let Some(items) = field_schema_mut(defs, def_name, field)
.and_then(|field_schema| field_schema.get_mut("additionalProperties"))
.and_then(|additional| additional.get_mut("items"))
{
*items = def_ref(target_def);
}
}
fn task_source_label_schema(command: &str) -> Value {
json!({ "type": "string", "enum": source_labels(command) })
}
fn provider_labels() -> Vec<&'static str> {
crate::types::TaskSource::all()
.iter()
.map(|&source| super::why::provider_label(source))
.collect()
}
fn source_labels(command: &str) -> Vec<&'static str> {
use crate::schema::labels::{flat_source_label, structured_source_label};
use crate::types::TaskSource;
TaskSource::all()
.iter()
.map(|&source| {
if command == "list" {
flat_source_label(source)
} else {
structured_source_label(source)
}
})
.collect()
}
fn schema_id(command: &str) -> String {
crate::schema::schema_url(command)
}
fn title(command: &str) -> String {
match command {
"why" => "runner why <task> --json".to_string(),
_ => format!("runner {command} --json"),
}
}
fn description(command: &str) -> String {
match command {
"doctor" => "JSON schema for `runner doctor --json`: structured diagnostic inventory with \
invocation/environment provenance, per-ecosystem decisions, sources, \
fqn-keyed tasks, tools, conflicts, and diagnostics."
.to_string(),
"why" => "JSON schema for `runner why <task> --json`: candidate `{task, match}` pairs \
plus the selection decision."
.to_string(),
_ => format!("JSON schema for `{}`.", title(command)),
}
}
#[cfg(test)]
mod tests {
use serde_json::Value;
#[test]
fn committed_doctor_example_includes_quiet_override() {
let raw = std::fs::read_to_string("schemas/doctor.example.json")
.expect("committed doctor example should be readable");
let example: Value = serde_json::from_str(&raw).expect("example should parse as JSON");
assert_eq!(example["overrides"]["quiet"], serde_json::json!(false));
}
const COMMITTED_SCHEMAS_WITH_TASK_SOURCE_LABEL: &[(&str, &str)] = &[
("schemas/doctor.schema.json", "doctor"),
("schemas/list.schema.json", "list"),
("schemas/why.schema.json", "why"),
];
fn runtime_labels(command: &str) -> Vec<&'static str> {
use crate::schema::labels::{flat_source_label, structured_source_label};
use crate::types::TaskSource;
TaskSource::all()
.iter()
.map(|&source| {
if command == "list" {
flat_source_label(source)
} else {
structured_source_label(source)
}
})
.collect()
}
#[test]
fn task_source_label_schema_matches_runtime_labels_per_command() {
for command in ["list", "doctor", "why"] {
let schema = super::task_source_label_schema(command);
let enum_values: Vec<&str> = schema["enum"]
.as_array()
.unwrap_or_else(|| panic!("{command}: expected enum array"))
.iter()
.map(|v| v.as_str().expect("enum values should be strings"))
.collect();
assert_eq!(
enum_values,
runtime_labels(command),
"{command}: schema TaskSourceLabel enum must match the runtime label function"
);
}
}
#[test]
fn committed_schemas_task_source_label_matches_runtime_labels() {
for &(path, command) in COMMITTED_SCHEMAS_WITH_TASK_SOURCE_LABEL {
let raw = std::fs::read_to_string(path)
.unwrap_or_else(|err| panic!("{path}: should be readable: {err}"));
let schema: Value = serde_json::from_str(&raw)
.unwrap_or_else(|err| panic!("{path}: should parse as JSON: {err}"));
let enum_values: Vec<&str> = schema["$defs"]["TaskSourceLabel"]["enum"]
.as_array()
.unwrap_or_else(|| panic!("{path}: expected $defs.TaskSourceLabel.enum array"))
.iter()
.map(|v| v.as_str().expect("enum values should be strings"))
.collect();
assert_eq!(
enum_values,
runtime_labels(command),
"{path}: committed TaskSourceLabel enum has drifted from the runtime label \
function — run `just gen-schema` and commit the result"
);
}
}
#[test]
fn provider_label_schema_matches_runtime_labels() {
let enum_values = super::provider_labels();
let runtime_values: Vec<&str> = crate::types::TaskSource::all()
.iter()
.map(|&source| super::super::why::provider_label(source))
.collect();
assert_eq!(
enum_values, runtime_values,
"ProviderLabel enum must match cmd::why::provider_label exactly"
);
}
#[test]
fn committed_why_schema_provider_label_matches_runtime_labels() {
let raw = std::fs::read_to_string("schemas/why.schema.json")
.expect("committed why schema should be readable");
let schema: Value = serde_json::from_str(&raw).expect("schema should parse as JSON");
let enum_values: Vec<&str> = schema["$defs"]["ProviderLabel"]["enum"]
.as_array()
.expect("expected $defs.ProviderLabel.enum array")
.iter()
.map(|v| v.as_str().expect("enum values should be strings"))
.collect();
assert_eq!(
enum_values,
super::provider_labels(),
"schemas/why.schema.json: committed ProviderLabel enum has drifted from \
cmd::why::provider_label — run `just gen-schema` and commit the result"
);
}
#[test]
fn committed_init_template_matches_generator() {
let generated = super::render_init_template();
let committed = std::fs::read_to_string("schemas/runner.init.toml")
.expect("committed init template should be readable");
assert_eq!(
generated, committed,
"schemas/runner.init.toml has drifted from render_init_template() — run `just \
gen-schema` and commit the result"
);
}
#[test]
fn panic_message_extracts_str_payload() {
let payload: Box<dyn std::any::Any + Send> = Box::new("boom");
assert_eq!(super::panic_message(&*payload), "boom");
}
#[test]
fn panic_message_extracts_string_payload() {
let payload: Box<dyn std::any::Any + Send> = Box::new(String::from("boom"));
assert_eq!(super::panic_message(&*payload), "boom");
}
#[test]
fn panic_message_falls_back_for_non_string_payload() {
let payload: Box<dyn std::any::Any + Send> = Box::new(42_i32);
assert_eq!(
super::panic_message(&*payload),
"render_init_template panicked with a non-string payload"
);
}
}