use blake3::Hasher;
use camino::Utf8PathBuf;
use numi_config::{BundleConfig, DefaultsConfig, HookConfig, JobConfig, TemplateVariables};
use numi_diagnostics::Diagnostic;
use numi_ir::{
GraphMetadata, Metadata, ModuleKind, ResourceGraph, ResourceModule,
normalize_flat_entries_preserve_order, normalize_scope, swift_identifier,
};
use serde::Serialize;
use serde_json::Value;
use std::{
cmp::Ordering,
collections::BTreeMap,
fs,
path::{Path, PathBuf},
process::Command,
time::{SystemTime, UNIX_EPOCH},
};
use crate::{
context::{AssetTemplateContext, ContextError},
generation_cache,
input_filters::should_ignore_directory_entry,
output::{OutputError, WriteOutcome, output_is_stale, write_if_changed_atomic},
parse_cache::{self, CacheKind, CachedParseData},
parse_files::{ParseFilesError, parse_files},
parse_fonts::{ParseFontsError, parse_font_entries},
parse_l10n::{LocalizationTable, ParseL10nError, parse_strings, parse_xcstrings},
parse_xcassets::{ParseXcassetsError, parse_catalog},
render::{
RenderError, builtin_template_source, collect_custom_template_dependencies,
discover_job_template_path, render_builtin, render_path, resolve_template_entry_path,
},
};
const GENERATION_FINGERPRINT_SCHEMA_VERSION: u32 = 2;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GenerateReport {
pub jobs: Vec<JobReport>,
pub warnings: Vec<Diagnostic>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GenerateProgress {
JobStarted { job_name: String },
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct GenerateOptions {
pub incremental: Option<bool>,
pub parse_cache: Option<bool>,
pub force_regenerate: bool,
pub workspace_manifest_path: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CheckOptions {
pub workspace_manifest_path: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JobReport {
pub job_name: String,
pub output_path: Utf8PathBuf,
pub outcome: WriteOutcome,
pub hook_reports: Vec<HookReport>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HookReport {
pub phase: HookPhase,
pub command: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookPhase {
PreGenerate,
PostGenerate,
}
impl HookPhase {
pub fn as_str(self) -> &'static str {
match self {
Self::PreGenerate => "pre_generate",
Self::PostGenerate => "post_generate",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckReport {
pub stale_paths: Vec<Utf8PathBuf>,
pub warnings: Vec<Diagnostic>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DumpContextReport {
pub json: String,
pub warnings: Vec<Diagnostic>,
}
#[derive(Debug)]
pub enum GenerateError {
LoadConfig(numi_config::ConfigError),
Diagnostics(Vec<Diagnostic>),
UnsupportedJob {
job: String,
detail: String,
},
ParseXcassets {
job: String,
source: ParseXcassetsError,
},
ParseStrings {
job: String,
source: ParseL10nError,
},
ParseXcstrings {
job: String,
source: ParseL10nError,
},
ParseFiles {
job: String,
source: ParseFilesError,
},
ParseFonts {
job: String,
source: ParseFontsError,
},
BuildContext {
job: String,
source: ContextError,
},
Render {
job: String,
source: RenderError,
},
SerializeContext(serde_json::Error),
WriteOutput {
job: String,
source: OutputError,
},
InspectOutput {
job: String,
source: OutputError,
},
HookSpawn {
job: String,
phase: HookPhase,
command: String,
source: std::io::Error,
},
HookExit {
job: String,
phase: HookPhase,
command: String,
status: std::process::ExitStatus,
stdout: String,
stderr: String,
},
InvalidOutputPath {
path: PathBuf,
},
}
impl std::fmt::Display for GenerateError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::LoadConfig(error) => write!(f, "{error}"),
Self::Diagnostics(diagnostics) => {
for (index, diagnostic) in diagnostics.iter().enumerate() {
if index > 0 {
writeln!(f)?;
}
write!(f, "{diagnostic}")?;
}
Ok(())
}
Self::UnsupportedJob { job, detail } => {
write!(f, "job `{job}` is not supported yet: {detail}")
}
Self::ParseXcassets { job, source } => {
write!(
f,
"failed to parse xcassets input for job `{job}`: {source}"
)
}
Self::ParseStrings { job, source } => {
write!(f, "failed to parse strings input for job `{job}`: {source}")
}
Self::ParseXcstrings { job, source } => {
write!(
f,
"failed to parse xcstrings input for job `{job}`: {source}"
)
}
Self::ParseFiles { job, source } => {
write!(f, "failed to parse files input for job `{job}`: {source}")
}
Self::ParseFonts { job, source } => {
write!(f, "failed to parse fonts input for job `{job}`: {source}")
}
Self::BuildContext { job, source } => {
write!(
f,
"failed to build render context for job `{job}`: {source}"
)
}
Self::Render { job, source } => {
write!(f, "failed to render output for job `{job}`: {source}")
}
Self::SerializeContext(source) => {
write!(f, "failed to serialize context as JSON: {source}")
}
Self::WriteOutput { job, source } => {
write!(f, "failed to write output for job `{job}`: {source}")
}
Self::InspectOutput { job, source } => {
write!(f, "failed to inspect output for job `{job}`: {source}")
}
Self::HookSpawn {
job,
phase,
command,
source,
} => write!(
f,
"failed to run {} hook for job `{job}` ({}): {source}",
phase.as_str(),
command
),
Self::HookExit {
job,
phase,
command,
status,
stdout,
stderr,
} => {
write!(
f,
"{} hook for job `{job}` failed ({}) with status {}",
phase.as_str(),
command,
status
)?;
if !stderr.trim().is_empty() {
write!(f, "\nstderr:\n{}", stderr.trim_end())?;
}
if !stdout.trim().is_empty() {
write!(f, "\nstdout:\n{}", stdout.trim_end())?;
}
Ok(())
}
Self::InvalidOutputPath { path } => write!(
f,
"generated output path {} is not valid UTF-8 and cannot be recorded",
path.display()
),
}
}
}
impl std::error::Error for GenerateError {}
pub fn generate(
config_path: &Path,
selected_jobs: Option<&[String]>,
) -> Result<GenerateReport, GenerateError> {
generate_with_options(config_path, selected_jobs, GenerateOptions::default())
}
pub fn generate_with_options(
config_path: &Path,
selected_jobs: Option<&[String]>,
options: GenerateOptions,
) -> Result<GenerateReport, GenerateError> {
generate_with_options_and_progress(config_path, selected_jobs, options, |_| {})
}
pub fn generate_with_options_and_progress<F>(
config_path: &Path,
selected_jobs: Option<&[String]>,
options: GenerateOptions,
progress: F,
) -> Result<GenerateReport, GenerateError>
where
F: FnMut(&GenerateProgress),
{
let loaded = numi_config::load_from_path(config_path).map_err(GenerateError::LoadConfig)?;
generate_loaded_config_with_progress(
&loaded.path,
&loaded.config,
selected_jobs,
options,
progress,
)
}
pub fn generate_loaded_config(
config_path: &Path,
config: &numi_config::Config,
selected_jobs: Option<&[String]>,
options: GenerateOptions,
) -> Result<GenerateReport, GenerateError> {
generate_loaded_config_with_progress(config_path, config, selected_jobs, options, |_| {})
}
pub fn generate_loaded_config_with_progress<F>(
config_path: &Path,
config: &numi_config::Config,
selected_jobs: Option<&[String]>,
options: GenerateOptions,
mut progress: F,
) -> Result<GenerateReport, GenerateError>
where
F: FnMut(&GenerateProgress),
{
let config_dir = config_dir(config_path);
let template_lookup_root = options
.workspace_manifest_path
.as_deref()
.map(self::config_dir)
.unwrap_or(config_dir);
let jobs = numi_config::resolve_selected_jobs(config, selected_jobs)
.map_err(GenerateError::Diagnostics)?;
let mut reports = Vec::with_capacity(jobs.len());
let mut warnings = Vec::new();
for job in jobs {
progress(&GenerateProgress::JobStarted {
job_name: job.name.clone(),
});
let job_report = generate_job(
config_path,
config_dir,
template_lookup_root,
&config.defaults,
job,
&options,
)?;
warnings.extend(job_report.warnings);
reports.push(JobReport {
job_name: job_report.job_name,
output_path: job_report.output_path,
outcome: job_report.outcome,
hook_reports: job_report.hook_reports,
});
}
Ok(GenerateReport {
jobs: reports,
warnings,
})
}
pub fn dump_context(
config_path: &Path,
job_name: &str,
) -> Result<DumpContextReport, GenerateError> {
let loaded = numi_config::load_from_path(config_path).map_err(GenerateError::LoadConfig)?;
let config_dir = loaded
.path
.parent()
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
let selected_jobs = vec![job_name.to_owned()];
let jobs = numi_config::resolve_selected_jobs(&loaded.config, Some(&selected_jobs))
.map_err(GenerateError::Diagnostics)?;
let job = jobs
.into_iter()
.next()
.expect("selected one job should resolve to one job");
let (context, warnings) =
build_context(&loaded.path, config_dir, &loaded.config.defaults, job, None)?;
let json = serde_json::to_string_pretty(&context).map_err(GenerateError::SerializeContext)?;
Ok(DumpContextReport { json, warnings })
}
pub fn check(
config_path: &Path,
selected_jobs: Option<&[String]>,
) -> Result<CheckReport, GenerateError> {
let loaded = numi_config::load_from_path(config_path).map_err(GenerateError::LoadConfig)?;
check_loaded_config_with_options(
&loaded.path,
&loaded.config,
selected_jobs,
CheckOptions::default(),
)
}
pub fn check_loaded_config(
config_path: &Path,
config: &numi_config::Config,
selected_jobs: Option<&[String]>,
) -> Result<CheckReport, GenerateError> {
check_loaded_config_with_options(config_path, config, selected_jobs, CheckOptions::default())
}
pub fn check_loaded_config_with_options(
config_path: &Path,
config: &numi_config::Config,
selected_jobs: Option<&[String]>,
options: CheckOptions,
) -> Result<CheckReport, GenerateError> {
let config_dir = config_dir(config_path);
let template_lookup_root = options
.workspace_manifest_path
.as_deref()
.map(self::config_dir)
.unwrap_or(config_dir);
let jobs = numi_config::resolve_selected_jobs(config, selected_jobs)
.map_err(GenerateError::Diagnostics)?;
let mut warnings = Vec::new();
let mut stale_paths = Vec::new();
for job in jobs {
let job_report = check_job(
config_path,
config_dir,
template_lookup_root,
&config.defaults,
job,
options.workspace_manifest_path.as_deref(),
)?;
warnings.extend(job_report.warnings);
if let Some(output_path) = job_report.stale_path {
stale_paths.push(output_path);
}
}
Ok(CheckReport {
stale_paths,
warnings,
})
}
fn config_dir(config_path: &Path) -> &Path {
config_path
.parent()
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
}
fn generate_job(
config_path: &Path,
config_dir: &Path,
template_lookup_root: &Path,
defaults: &DefaultsConfig,
job: &JobConfig,
options: &GenerateOptions,
) -> Result<JobExecution, GenerateError> {
let output_path = config_dir.join(&job.output);
let mut hook_reports = Vec::new();
let incremental = resolve_incremental(defaults, job, options);
let parse_cache = resolve_parse_cache(options);
let should_check_generation_cache = incremental
&& !options.force_regenerate
&& generation_cache::cache_record_exists(config_path, &job.name)
.ok()
.unwrap_or(false);
let mut generation_plan = None;
if should_check_generation_cache || parse_cache {
generation_plan =
compute_generation_fingerprint(config_dir, template_lookup_root, defaults, job);
}
if incremental
&& !options.force_regenerate
&& let Some(plan) = generation_plan.as_ref()
&& generation_cache::is_fresh(config_path, &job.name, &plan.fingerprint, &output_path)
.ok()
.unwrap_or(false)
{
return Ok(JobExecution {
job_name: job.name.clone(),
output_path: to_utf8_path(&output_path)?,
outcome: WriteOutcome::Skipped,
hook_reports,
warnings: Vec::new(),
});
}
if generation_plan.is_none() && incremental {
generation_plan =
compute_generation_fingerprint(config_dir, template_lookup_root, defaults, job);
}
let hook_env = HookEnvironment::new(
config_path,
options.workspace_manifest_path.as_deref(),
&job.name,
&output_path,
)?;
if let Some(hook) = job.hooks.pre_generate.as_ref() {
hook_reports.push(run_hook(
config_dir,
hook,
HookPhase::PreGenerate,
&job.name,
&hook_env,
None,
)?);
}
let (context, warnings) = build_context(
config_path,
config_dir,
defaults,
job,
parse_cache.then_some(()).and(generation_plan.as_ref()),
)?;
let rendered = render_job(config_dir, template_lookup_root, job, &context)?;
let outcome = write_if_changed_atomic(&output_path, &rendered).map_err(|source| {
GenerateError::WriteOutput {
job: job.name.clone(),
source,
}
})?;
let should_run_post_hook = matches!(outcome, WriteOutcome::Created | WriteOutcome::Updated)
|| (options.force_regenerate && matches!(outcome, WriteOutcome::Unchanged));
if should_run_post_hook && let Some(hook) = job.hooks.post_generate.as_ref() {
hook_reports.push(run_hook(
config_dir,
hook,
HookPhase::PostGenerate,
&job.name,
&hook_env,
Some(outcome),
)?);
}
if let Some(plan) = generation_plan.as_ref() {
let _ = generation_cache::store(config_path, &job.name, &plan.fingerprint, &output_path);
}
Ok(JobExecution {
job_name: job.name.clone(),
output_path: to_utf8_path(&output_path)?,
outcome,
hook_reports,
warnings,
})
}
fn check_job(
config_path: &Path,
config_dir: &Path,
template_lookup_root: &Path,
defaults: &DefaultsConfig,
job: &JobConfig,
workspace_manifest_path: Option<&Path>,
) -> Result<CheckJobExecution, GenerateError> {
let (context, warnings) = build_context(config_path, config_dir, defaults, job, None)?;
let rendered = render_job(config_dir, template_lookup_root, job, &context)?;
let output_path = config_dir.join(&job.output);
let stale_without_hook = output_is_stale(&output_path, &rendered).map_err(|source| {
GenerateError::InspectOutput {
job: job.name.clone(),
source,
}
})?;
let stale = if !stale_without_hook {
false
} else if let Some(hook) = job.hooks.post_generate.as_ref() {
output_is_stale_after_post_hook(
config_path,
config_dir,
workspace_manifest_path,
job,
hook,
&output_path,
&rendered,
)?
} else {
true
};
Ok(CheckJobExecution {
stale_path: if stale {
Some(to_utf8_path(&output_path)?)
} else {
None
},
warnings,
})
}
fn output_is_stale_after_post_hook(
config_path: &Path,
config_dir: &Path,
workspace_manifest_path: Option<&Path>,
job: &JobConfig,
hook: &HookConfig,
output_path: &Path,
rendered: &str,
) -> Result<bool, GenerateError> {
let temp_output_path = make_check_temp_output_path(job, output_path);
let temp_output_dir = temp_output_path
.parent()
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
fs::create_dir_all(temp_output_dir).map_err(|source| GenerateError::InspectOutput {
job: job.name.clone(),
source: OutputError::CreateDirectory {
path: temp_output_dir.to_path_buf(),
source,
},
})?;
fs::write(&temp_output_path, rendered).map_err(|source| GenerateError::InspectOutput {
job: job.name.clone(),
source: OutputError::WriteTemp {
path: temp_output_path.clone(),
source,
},
})?;
let hook_env = HookEnvironment::new(
config_path,
workspace_manifest_path,
&job.name,
&temp_output_path,
)?;
let hook_result = run_hook(
config_dir,
hook,
HookPhase::PostGenerate,
&job.name,
&hook_env,
Some(WriteOutcome::Updated),
);
let transformed =
fs::read_to_string(&temp_output_path).map_err(|source| GenerateError::InspectOutput {
job: job.name.clone(),
source: OutputError::ReadExisting {
path: temp_output_path.clone(),
source,
},
});
let cleanup_result = fs::remove_dir_all(
temp_output_path
.parent()
.expect("temp output path should always have a parent"),
);
hook_result?;
let transformed = transformed?;
if let Err(source) = cleanup_result {
return Err(GenerateError::InspectOutput {
job: job.name.clone(),
source: OutputError::Cleanup {
path: temp_output_path
.parent()
.expect("temp output path should always have a parent")
.to_path_buf(),
source,
},
});
}
output_is_stale(output_path, &transformed).map_err(|source| GenerateError::InspectOutput {
job: job.name.clone(),
source,
})
}
fn make_check_temp_output_path(job: &JobConfig, output_path: &Path) -> PathBuf {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should be after epoch")
.as_nanos();
let file_name = output_path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| "output".to_string());
std::env::temp_dir()
.join(format!(
"numi-check-{}-{}-{}",
sanitize_job_name(&job.name),
std::process::id(),
timestamp
))
.join(file_name)
}
fn sanitize_job_name(job_name: &str) -> String {
job_name
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
ch
} else {
'-'
}
})
.collect()
}
fn build_context(
config_path: &Path,
config_dir: &Path,
defaults: &DefaultsConfig,
job: &JobConfig,
generation_plan: Option<&GenerationFingerprintPlan>,
) -> Result<(AssetTemplateContext, Vec<Diagnostic>), GenerateError> {
let BuildModulesResult { modules, warnings } = build_modules(config_dir, job, generation_plan)?;
let _graph = ResourceGraph {
modules: modules.clone(),
diagnostics: warnings.clone(),
metadata: GraphMetadata {
config_path: Some(to_utf8_path(config_path)?),
},
};
let access_level = resolve_access_level(defaults, job);
let bundle = merged_bundle(defaults, job);
let bundle_mode = bundle.mode.as_deref().unwrap_or("module");
validate_bundle_mode(&job.name, bundle_mode, bundle.identifier.as_deref())?;
let context = AssetTemplateContext::new(
&job.name,
&job.output,
access_level,
bundle_mode,
bundle.identifier.as_deref(),
&modules,
job.variables.clone(),
)
.map_err(|source| GenerateError::BuildContext {
job: job.name.clone(),
source,
})?;
Ok((context, warnings))
}
struct BuildModulesResult {
modules: Vec<ResourceModule>,
warnings: Vec<Diagnostic>,
}
struct JobExecution {
job_name: String,
output_path: Utf8PathBuf,
outcome: WriteOutcome,
hook_reports: Vec<HookReport>,
warnings: Vec<Diagnostic>,
}
struct CheckJobExecution {
stale_path: Option<Utf8PathBuf>,
warnings: Vec<Diagnostic>,
}
struct GenerationFingerprintPlan {
fingerprint: String,
cache_input_fingerprints: BTreeMap<PathBuf, ParseCacheInputPlan>,
}
struct ParseCacheInputPlan {
fingerprint: String,
snapshot: parse_cache::InputSnapshot,
}
#[derive(Debug, Serialize)]
struct GenerationFingerprintRecord {
schema_version: u32,
job_name: String,
output: String,
access_level: String,
bundle_mode: String,
bundle_identifier: Option<String>,
variables: TemplateVariables,
inputs: Vec<GenerationInputFingerprintRecord>,
template: GenerationTemplateFingerprintRecord,
}
#[derive(Debug, Serialize)]
struct GenerationInputFingerprintRecord {
kind: String,
path: String,
fingerprint: String,
}
#[derive(Debug, Serialize)]
#[serde(tag = "kind")]
enum GenerationTemplateFingerprintRecord {
Builtin {
language: String,
name: String,
fingerprint: String,
},
Custom {
path: String,
dependencies: Vec<GenerationDependencyFingerprintRecord>,
},
}
#[derive(Debug, Serialize)]
struct GenerationDependencyFingerprintRecord {
path: String,
fingerprint: String,
}
fn build_modules(
config_dir: &Path,
job: &JobConfig,
generation_plan: Option<&GenerationFingerprintPlan>,
) -> Result<BuildModulesResult, GenerateError> {
let mut modules = Vec::new();
let mut asset_entries = Vec::new();
let mut duplicate_table_sources = BTreeMap::<String, Utf8PathBuf>::new();
let mut duplicate_files_module_sources = BTreeMap::<String, (String, PathBuf)>::new();
let mut duplicate_fonts_module_sources = BTreeMap::<String, (String, PathBuf)>::new();
let mut diagnostics = Vec::new();
let mut warnings = Vec::new();
for input in &job.inputs {
let input_path = config_dir.join(&input.path);
let known_cache_input =
generation_plan.and_then(|plan| plan.cache_input_fingerprints.get(&input_path));
let known_cache_fingerprint = known_cache_input.map(|plan| plan.fingerprint.as_str());
let known_cache_snapshot = known_cache_input.map(|plan| &plan.snapshot);
match input.kind.as_str() {
"xcassets" => {
let report = load_or_parse_xcassets(
&input_path,
&job.name,
known_cache_fingerprint,
known_cache_snapshot,
)?;
warnings.extend(
report
.warnings
.into_iter()
.map(|warning| warning.with_job(job.name.clone())),
);
asset_entries.extend(report.entries);
}
"strings" => {
let tables = load_or_parse_strings(
&input_path,
&job.name,
known_cache_fingerprint,
known_cache_snapshot,
)?;
for table in tables {
let table_name = table.table_name.clone();
warnings.extend(
table
.warnings
.into_iter()
.map(|warning| warning.with_job(job.name.clone())),
);
if let Some(first_source) = duplicate_table_sources
.insert(table_name.clone(), table.source_path.clone())
{
diagnostics.push(
Diagnostic::error(format!(
"duplicate localization table `{table_name}` from directory-based `.strings` input"
))
.with_job(job.name.clone())
.with_path(table.source_path.as_std_path())
.with_hint(format!(
"found `{}` and `{}`; merge these inputs before generation or select a single localized source",
first_source,
table.source_path
)),
);
continue;
}
let entries = normalize_flat_entries_preserve_order(&job.name, table.entries)
.map_err(GenerateError::Diagnostics)?;
modules.push(ResourceModule {
id: table_name.clone(),
kind: table.module_kind,
name: swift_identifier(&table_name),
entries,
metadata: Metadata::from([(
"tableName".to_string(),
Value::String(table_name),
)]),
});
}
}
"xcstrings" => {
let tables = load_or_parse_xcstrings(
&input_path,
&job.name,
known_cache_fingerprint,
known_cache_snapshot,
)?;
for table in tables {
let table_name = table.table_name.clone();
warnings.extend(
table
.warnings
.into_iter()
.map(|warning| warning.with_job(job.name.clone())),
);
if let Some(first_source) = duplicate_table_sources
.insert(table_name.clone(), table.source_path.clone())
{
diagnostics.push(
Diagnostic::error(format!(
"duplicate localization table `{table_name}` from directory-based `.xcstrings` input"
))
.with_job(job.name.clone())
.with_path(table.source_path.as_std_path())
.with_hint(format!(
"found `{}` and `{}`; merge these inputs before generation or select a single localized source",
first_source,
table.source_path
)),
);
continue;
}
let entries = normalize_scope(&job.name, table.entries)
.map_err(GenerateError::Diagnostics)?;
modules.push(ResourceModule {
id: table_name.clone(),
kind: table.module_kind,
name: swift_identifier(&table_name),
entries,
metadata: Metadata::from([(
"tableName".to_string(),
Value::String(table_name),
)]),
});
}
}
"files" => {
let raw_entries = load_or_parse_files(
&input_path,
&job.name,
known_cache_fingerprint,
known_cache_snapshot,
)?;
let mut entries =
normalize_scope(&job.name, raw_entries).map_err(GenerateError::Diagnostics)?;
annotate_swiftgen_file_sort_keys(&mut entries);
if entries.is_empty() {
continue;
}
let module_id = input_path
.file_stem()
.or_else(|| input_path.file_name())
.and_then(|name| name.to_str())
.unwrap_or("Files")
.to_string();
let module_name = swift_identifier(&module_id);
if let Some(diagnostic) = duplicate_input_module_diagnostic(
&mut duplicate_files_module_sources,
"files",
&job.name,
&module_id,
&module_name,
&input_path,
) {
diagnostics.push(diagnostic);
continue;
}
modules.push(ResourceModule {
id: module_id.clone(),
kind: ModuleKind::Files,
name: module_name,
entries,
metadata: Metadata::new(),
});
}
"fonts" => {
let parsed_fonts = parse_font_entries(&input_path).map_err(|source| {
GenerateError::ParseFonts {
job: job.name.clone(),
source,
}
})?;
let raw_entries = parsed_fonts
.iter()
.cloned()
.map(crate::parse_fonts::ParsedFontEntry::into_raw_entry)
.collect::<Vec<_>>();
let entries =
normalize_scope(&job.name, raw_entries).map_err(GenerateError::Diagnostics)?;
if entries.is_empty() {
continue;
}
let module_id = input_path
.file_stem()
.or_else(|| input_path.file_name())
.and_then(|name| name.to_str())
.unwrap_or("Fonts")
.to_string();
let module_name = swift_identifier(&module_id);
if let Some(diagnostic) = duplicate_input_module_diagnostic(
&mut duplicate_fonts_module_sources,
"fonts",
&job.name,
&module_id,
&module_name,
&input_path,
) {
diagnostics.push(diagnostic);
continue;
}
modules.push(ResourceModule {
id: module_id.clone(),
kind: ModuleKind::Fonts,
name: module_name,
entries,
metadata: build_font_module_metadata(&parsed_fonts),
});
}
other => {
return Err(GenerateError::UnsupportedJob {
job: job.name.clone(),
detail: format!("input kind `{other}`"),
});
}
}
}
if !asset_entries.is_empty() {
let mut entries =
normalize_scope(&job.name, asset_entries).map_err(GenerateError::Diagnostics)?;
sort_entries_for_assets(&mut entries);
annotate_swiftgen_sort_keys(&mut entries);
modules.insert(
0,
ResourceModule {
id: job.name.clone(),
kind: ModuleKind::Xcassets,
name: swift_identifier(&job.name),
entries,
metadata: Metadata::new(),
},
);
}
if !diagnostics.is_empty() {
return Err(GenerateError::Diagnostics(diagnostics));
}
Ok(BuildModulesResult { modules, warnings })
}
fn duplicate_input_module_diagnostic(
seen_modules: &mut BTreeMap<String, (String, PathBuf)>,
input_kind: &str,
job_name: &str,
module_id: &str,
module_name: &str,
input_path: &Path,
) -> Option<Diagnostic> {
if let Some((first_module_id, first_source)) = seen_modules.insert(
module_name.to_string(),
(module_id.to_string(), input_path.to_path_buf()),
) {
let detail = if first_module_id == module_id {
format!("both inputs normalize to module `{module_name}`")
} else {
format!(
"module names `{first_module_id}` and `{module_id}` both normalize to `{module_name}`"
)
};
return Some(
Diagnostic::error(format!("duplicate {input_kind} module `{module_name}`"))
.with_job(job_name)
.with_path(input_path)
.with_hint(format!(
"found `{}` and `{}`; {detail}",
first_source.display(),
input_path.display()
)),
);
}
None
}
fn annotate_swiftgen_sort_keys(entries: &mut [numi_ir::ResourceEntry]) {
for entry in entries {
entry.metadata.insert(
"sortKey".to_string(),
Value::String(swiftgen_sort_key(&entry.swift_identifier)),
);
annotate_swiftgen_sort_keys(&mut entry.children);
}
}
fn annotate_swiftgen_file_sort_keys(entries: &mut [numi_ir::ResourceEntry]) {
let sibling_names = entries
.iter()
.map(|entry| entry.name.to_ascii_lowercase())
.collect::<Vec<_>>();
for entry in entries {
entry.metadata.insert(
"sortKey".to_string(),
Value::String(swiftgen_file_sort_key(&entry.name, &sibling_names)),
);
annotate_swiftgen_file_sort_keys(&mut entry.children);
}
}
fn swiftgen_file_sort_key(name: &str, sibling_names: &[String]) -> String {
let _ = sibling_names;
name.to_ascii_lowercase()
}
fn sort_entries_for_assets(entries: &mut [numi_ir::ResourceEntry]) {
for entry in entries.iter_mut() {
sort_entries_for_assets(&mut entry.children);
}
entries.sort_by(compare_asset_entries);
}
fn compare_asset_entries(
left: &numi_ir::ResourceEntry,
right: &numi_ir::ResourceEntry,
) -> Ordering {
compare_asset_names(&left.name, &right.name).then_with(|| left.id.cmp(&right.id))
}
fn compare_asset_names(left: &str, right: &str) -> Ordering {
match (left.strip_suffix(".9"), right.strip_suffix(".9")) {
(Some(base), None) if base == right => Ordering::Less,
(None, Some(base)) if left == base => Ordering::Greater,
_ => left.cmp(right),
}
}
fn swiftgen_sort_key(identifier: &str) -> String {
let identifier = identifier
.strip_prefix('`')
.and_then(|trimmed| trimmed.strip_suffix('`'))
.unwrap_or(identifier);
let chars = identifier.chars().collect::<Vec<_>>();
if chars.is_empty() || !chars[0].is_ascii_uppercase() {
return identifier.to_ascii_lowercase();
}
let mut prefix_len = 1;
while prefix_len < chars.len() && chars[prefix_len].is_ascii_uppercase() {
prefix_len += 1;
}
let lower_count = if prefix_len == chars.len() {
prefix_len
} else if prefix_len == 1 {
1
} else {
prefix_len - 1
};
let mut lowered = String::with_capacity(identifier.len());
for ch in &chars[..lower_count] {
lowered.push(ch.to_ascii_lowercase());
}
for ch in &chars[lower_count..] {
lowered.push(*ch);
}
lowered.to_ascii_lowercase()
}
fn build_font_module_metadata(parsed_fonts: &[crate::parse_fonts::ParsedFontEntry]) -> Metadata {
#[derive(Clone)]
struct CanonicalFont {
file_name: String,
relative_path: String,
family_name: String,
style_name: String,
full_name: String,
post_script_name: String,
}
let mut unique_fonts = BTreeMap::<String, CanonicalFont>::new();
for font in parsed_fonts {
let (family_name, style_name) = canonicalize_font_family_and_style(
&font.family_name,
&font.style_name,
&font.post_script_name,
);
unique_fonts.insert(
font.post_script_name.clone(),
CanonicalFont {
file_name: font.file_name.clone(),
relative_path: font.relative_path.clone(),
family_name,
style_name,
full_name: font.full_name.clone(),
post_script_name: font.post_script_name.clone(),
},
);
}
let mut families = BTreeMap::<String, Vec<CanonicalFont>>::new();
for font in unique_fonts.into_values() {
families
.entry(font.family_name.clone())
.or_default()
.push(font);
}
let mut family_items = Vec::new();
for (family_name, mut fonts) in families {
fonts.sort_by(|left, right| left.post_script_name.cmp(&right.post_script_name));
let display_name = if fonts.len() == 1
&& fonts[0].style_name != "Regular"
&& fonts[0].full_name.ends_with(&fonts[0].style_name)
&& fonts[0].full_name != family_name
{
fonts[0].full_name.clone()
} else {
family_name.clone()
};
family_items.push(Value::Object(
[
("name".to_string(), Value::String(display_name.clone())),
(
"swiftIdentifier".to_string(),
Value::String(swift_identifier(&display_name)),
),
(
"fonts".to_string(),
Value::Array(
fonts
.into_iter()
.map(|font| {
Value::Object(
[
(
"postScriptName".to_string(),
Value::String(font.post_script_name.clone()),
),
(
"styleName".to_string(),
Value::String(font.style_name.clone()),
),
(
"familyName".to_string(),
Value::String(display_name.clone()),
),
(
"fileName".to_string(),
Value::String(font.file_name.clone()),
),
(
"relativePath".to_string(),
Value::String(font.relative_path.clone()),
),
]
.into_iter()
.collect(),
)
})
.collect(),
),
),
]
.into_iter()
.collect(),
));
}
Metadata::from([("families".to_string(), Value::Array(family_items))])
}
fn canonicalize_font_family_and_style(
family_name: &str,
style_name: &str,
post_script_name: &str,
) -> (String, String) {
if style_name != "Regular" {
return (family_name.to_string(), style_name.to_string());
}
let Some((_, post_script_style)) = post_script_name.rsplit_once('-') else {
return (family_name.to_string(), style_name.to_string());
};
if post_script_style == "Regular" {
return (family_name.to_string(), style_name.to_string());
}
if let Some(prefix) = family_name.strip_suffix(&format!(" {post_script_style}")) {
return (prefix.to_string(), post_script_style.to_string());
}
(family_name.to_string(), style_name.to_string())
}
fn load_or_parse_xcassets(
input_path: &Path,
job_name: &str,
known_fingerprint: Option<&str>,
known_snapshot: Option<&parse_cache::InputSnapshot>,
) -> Result<crate::parse_xcassets::XcassetsReport, GenerateError> {
load_or_parse_cached(
CacheKind::Xcassets,
input_path,
known_fingerprint,
known_snapshot,
|| {
parse_catalog(input_path).map_err(|source| GenerateError::ParseXcassets {
job: job_name.to_owned(),
source,
})
},
CachedParseData::Xcassets,
|cached| match cached {
CachedParseData::Xcassets(report) => Some(report),
_ => None,
},
)
}
fn load_or_parse_strings(
input_path: &Path,
job_name: &str,
known_fingerprint: Option<&str>,
known_snapshot: Option<&parse_cache::InputSnapshot>,
) -> Result<Vec<LocalizationTable>, GenerateError> {
load_or_parse_cached(
CacheKind::Strings,
input_path,
known_fingerprint,
known_snapshot,
|| {
parse_strings(input_path).map_err(|source| GenerateError::ParseStrings {
job: job_name.to_owned(),
source,
})
},
CachedParseData::Strings,
|cached| match cached {
CachedParseData::Strings(tables) => Some(tables),
_ => None,
},
)
}
fn load_or_parse_xcstrings(
input_path: &Path,
job_name: &str,
known_fingerprint: Option<&str>,
known_snapshot: Option<&parse_cache::InputSnapshot>,
) -> Result<Vec<LocalizationTable>, GenerateError> {
load_or_parse_cached(
CacheKind::Xcstrings,
input_path,
known_fingerprint,
known_snapshot,
|| {
parse_xcstrings(input_path).map_err(|source| GenerateError::ParseXcstrings {
job: job_name.to_owned(),
source,
})
},
CachedParseData::Xcstrings,
|cached| match cached {
CachedParseData::Xcstrings(tables) => Some(tables),
_ => None,
},
)
}
fn load_or_parse_files(
input_path: &Path,
job_name: &str,
known_fingerprint: Option<&str>,
known_snapshot: Option<&parse_cache::InputSnapshot>,
) -> Result<Vec<numi_ir::RawEntry>, GenerateError> {
load_or_parse_cached(
CacheKind::Files,
input_path,
known_fingerprint,
known_snapshot,
|| {
parse_files(input_path).map_err(|source| GenerateError::ParseFiles {
job: job_name.to_owned(),
source,
})
},
CachedParseData::Files,
|cached| match cached {
CachedParseData::Files(entries) => Some(entries),
_ => None,
},
)
}
fn load_or_parse_cached<T, ParseFn, WrapFn, ExtractFn>(
kind: CacheKind,
input_path: &Path,
known_fingerprint: Option<&str>,
known_snapshot: Option<&parse_cache::InputSnapshot>,
parse: ParseFn,
wrap: WrapFn,
extract: ExtractFn,
) -> Result<T, GenerateError>
where
T: Clone,
ParseFn: FnOnce() -> Result<T, GenerateError>,
WrapFn: Fn(T) -> CachedParseData,
ExtractFn: Fn(CachedParseData) -> Option<T>,
{
if let Some(parsed) = load_cached_parse(kind, input_path, known_fingerprint, extract) {
return Ok(parsed);
}
let mut snapshot_before_parse = known_snapshot.cloned();
let fingerprint_before_parse = if let Some(fingerprint) = known_fingerprint {
Some(fingerprint.to_owned())
} else {
let fingerprinted = parse_cache::fingerprint_input_with_snapshot(kind, input_path).ok();
if let Some(fingerprinted) = fingerprinted {
snapshot_before_parse = Some(fingerprinted.snapshot.clone());
Some(fingerprinted.fingerprint)
} else {
None
}
};
let parsed = parse()?;
store_cached_parse(
kind,
input_path,
fingerprint_before_parse.as_deref(),
snapshot_before_parse.as_ref(),
wrap(parsed.clone()),
);
Ok(parsed)
}
fn load_cached_parse<T, ExtractFn>(
kind: CacheKind,
input_path: &Path,
known_fingerprint: Option<&str>,
extract: ExtractFn,
) -> Option<T>
where
ExtractFn: Fn(CachedParseData) -> Option<T>,
{
let loaded = match known_fingerprint {
Some(fingerprint) => parse_cache::load_with_fingerprint(kind, input_path, fingerprint),
None => parse_cache::load(kind, input_path),
};
loaded.ok().flatten().and_then(extract)
}
fn store_cached_parse(
kind: CacheKind,
input_path: &Path,
fingerprint_before_parse: Option<&str>,
snapshot_before_parse: Option<&parse_cache::InputSnapshot>,
data: CachedParseData,
) {
let Some(fingerprint_before_parse) = fingerprint_before_parse else {
return;
};
if let Some(snapshot_before_parse) = snapshot_before_parse {
let Ok(snapshot_matches) =
parse_cache::input_matches_snapshot(kind, input_path, snapshot_before_parse)
else {
return;
};
if !snapshot_matches {
return;
}
} else {
let Ok(fingerprint_after_parse) = parse_cache::fingerprint_input(kind, input_path) else {
return;
};
if fingerprint_before_parse != fingerprint_after_parse {
return;
}
}
let _ = parse_cache::store(kind, input_path, fingerprint_before_parse, &data);
}
fn render_job(
config_dir: &Path,
template_lookup_root: &Path,
job: &JobConfig,
context: &AssetTemplateContext,
) -> Result<String, GenerateError> {
match resolve_job_template(config_dir, template_lookup_root, job).map_err(|source| {
GenerateError::Render {
job: job.name.clone(),
source,
}
})? {
ResolvedJobTemplate::Builtin { language, name } => {
render_builtin((language, name), context).map_err(|source| GenerateError::Render {
job: job.name.clone(),
source,
})
}
ResolvedJobTemplate::Custom {
resolved_path,
template_root,
..
} => render_path(&resolved_path, &template_root, context).map_err(|source| {
GenerateError::Render {
job: job.name.clone(),
source,
}
}),
ResolvedJobTemplate::Missing => Err(GenerateError::UnsupportedJob {
job: job.name.clone(),
detail: missing_template_detail(job),
}),
}
}
fn resolve_incremental(
defaults: &DefaultsConfig,
job: &JobConfig,
options: &GenerateOptions,
) -> bool {
options
.incremental
.or(job.incremental)
.or(defaults.incremental)
.unwrap_or(true)
}
fn resolve_parse_cache(options: &GenerateOptions) -> bool {
options.parse_cache.unwrap_or(true)
}
fn compute_generation_fingerprint(
config_dir: &Path,
template_lookup_root: &Path,
defaults: &DefaultsConfig,
job: &JobConfig,
) -> Option<GenerationFingerprintPlan> {
let mut cache_input_fingerprints = BTreeMap::new();
let inputs = job
.inputs
.iter()
.map(|input| {
let resolved_path = config_dir.join(&input.path);
let fingerprint = if let Some(kind) = cache_kind_for_input(&input.kind) {
let fingerprinted =
parse_cache::fingerprint_input_with_snapshot(kind, &resolved_path).ok()?;
let fingerprint = fingerprinted.fingerprint;
cache_input_fingerprints.insert(
resolved_path.clone(),
ParseCacheInputPlan {
fingerprint: fingerprint.clone(),
snapshot: fingerprinted.snapshot,
},
);
fingerprint
} else {
fingerprint_path_contents(&resolved_path).ok()?
};
Some(GenerationInputFingerprintRecord {
kind: input.kind.clone(),
path: input.path.clone(),
fingerprint,
})
})
.collect::<Option<Vec<_>>>()?;
let template = match resolve_job_template(config_dir, template_lookup_root, job).ok()? {
ResolvedJobTemplate::Builtin { language, name } => {
let source = builtin_template_source((language, name)).ok()?;
GenerationTemplateFingerprintRecord::Builtin {
language: language.to_owned(),
name: name.to_owned(),
fingerprint: generation_cache::blake3_hex([source.as_bytes()]),
}
}
ResolvedJobTemplate::Custom {
configured_path,
resolved_path,
template_root,
} => {
let dependencies = collect_custom_template_dependencies(&resolved_path, &template_root)
.ok()??
.into_iter()
.map(|dependency_path| {
Some(GenerationDependencyFingerprintRecord {
path: display_relative_path(&dependency_path, &template_root),
fingerprint: fingerprint_path_contents(&dependency_path).ok()?,
})
})
.collect::<Option<Vec<_>>>()?;
GenerationTemplateFingerprintRecord::Custom {
path: configured_path,
dependencies,
}
}
ResolvedJobTemplate::Missing => return None,
};
let bundle = merged_bundle(defaults, job);
let record = GenerationFingerprintRecord {
schema_version: GENERATION_FINGERPRINT_SCHEMA_VERSION,
job_name: job.name.clone(),
output: job.output.clone(),
access_level: resolve_access_level(defaults, job).to_owned(),
bundle_mode: bundle.mode.clone().unwrap_or_else(|| "module".to_string()),
bundle_identifier: bundle.identifier.clone(),
variables: job.variables.clone(),
inputs,
template,
};
let payload = serde_json::to_vec(&record).ok()?;
Some(GenerationFingerprintPlan {
fingerprint: generation_cache::blake3_hex([payload.as_slice()]),
cache_input_fingerprints,
})
}
enum ResolvedJobTemplate<'a> {
Builtin {
language: &'a str,
name: &'a str,
},
Custom {
configured_path: String,
resolved_path: PathBuf,
template_root: PathBuf,
},
Missing,
}
fn resolve_job_template<'a>(
config_dir: &Path,
template_lookup_root: &Path,
job: &'a JobConfig,
) -> Result<ResolvedJobTemplate<'a>, RenderError> {
let builtin = job.template.builtin.as_ref();
let builtin_language = builtin.and_then(|builtin| builtin.language.as_deref());
let builtin_name = builtin.and_then(|builtin| builtin.name.as_deref());
if let (Some(language), Some(name)) = (builtin_language, builtin_name) {
return Ok(ResolvedJobTemplate::Builtin { language, name });
}
if let Some(template_path) = job.template.path.as_deref() {
let resolved_path = resolve_template_entry_path(config_dir, template_path)?;
return Ok(ResolvedJobTemplate::Custom {
configured_path: template_path.to_owned(),
resolved_path,
template_root: config_dir.to_path_buf(),
});
}
if job.template.auto_lookup != Some(false)
&& job.template.builtin.is_none()
&& let Some(resolved_path) = discover_job_template_path(template_lookup_root, &job.name)?
{
return Ok(ResolvedJobTemplate::Custom {
configured_path: display_relative_path(&resolved_path, template_lookup_root),
resolved_path,
template_root: template_lookup_root.to_path_buf(),
});
}
Ok(ResolvedJobTemplate::Missing)
}
fn missing_template_detail(job: &JobConfig) -> String {
if job.template.auto_lookup == Some(false) {
return "job template must set a built-in or custom template path; implicit template lookup is disabled".to_string();
}
format!(
"job template must set a built-in or custom template path; also checked `Templates/{0}.jinja`, `Templates/{0}.template.jinja`, `templates/{0}.jinja`, and `templates/{0}.template.jinja` beside numi.toml",
job.name
)
}
fn cache_kind_for_input(input_kind: &str) -> Option<CacheKind> {
match input_kind {
"xcassets" => Some(CacheKind::Xcassets),
"strings" => Some(CacheKind::Strings),
"xcstrings" => Some(CacheKind::Xcstrings),
"files" => Some(CacheKind::Files),
_ => None,
}
}
fn fingerprint_path_contents(path: &Path) -> std::io::Result<String> {
let mut files = Vec::new();
if path.is_file() {
files.push(path.to_path_buf());
} else if path.is_dir() {
collect_fingerprint_files(path, &mut files)?;
} else {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("path {} does not exist", path.display()),
));
}
files.sort();
let mut hasher = Hasher::new();
hasher.update(if path.is_file() { b"file" } else { b"dir" });
hasher.update(b"\0");
for file in files {
let relative = if path.is_file() {
file.file_name().unwrap_or_default().as_encoded_bytes()
} else {
file.strip_prefix(path)
.unwrap_or(&file)
.as_os_str()
.as_encoded_bytes()
};
let contents = fs::read(&file)?;
hasher.update(relative);
hasher.update(b"\0");
hasher.update(&contents);
hasher.update(b"\0");
}
Ok(hasher.finalize().to_hex().to_string())
}
fn collect_fingerprint_files(root: &Path, files: &mut Vec<PathBuf>) -> std::io::Result<()> {
for entry in fs::read_dir(root)? {
let entry = entry?;
let path = entry.path();
if should_ignore_directory_entry(&path) {
continue;
}
let file_type = entry.file_type()?;
if file_type.is_dir() {
collect_fingerprint_files(&path, files)?;
} else if file_type.is_file() {
files.push(path);
}
}
Ok(())
}
fn display_relative_path(path: &Path, root: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.display()
.to_string()
}
fn resolve_access_level<'a>(defaults: &'a DefaultsConfig, job: &'a JobConfig) -> &'a str {
job.access_level
.as_deref()
.or(defaults.access_level.as_deref())
.unwrap_or("internal")
}
fn validate_bundle_mode(
job_name: &str,
mode: &str,
identifier: Option<&str>,
) -> Result<(), GenerateError> {
match mode {
"module" | "main" => Ok(()),
"custom" => {
let _identifier = identifier.ok_or_else(|| GenerateError::UnsupportedJob {
job: job_name.to_owned(),
detail: "bundle mode `custom` requires an identifier".to_string(),
})?;
Ok(())
}
other => Err(GenerateError::UnsupportedJob {
job: job_name.to_owned(),
detail: format!("bundle mode `{other}`"),
}),
}
}
fn merged_bundle(defaults: &DefaultsConfig, job: &JobConfig) -> BundleConfig {
BundleConfig {
mode: job
.bundle
.mode
.clone()
.or_else(|| defaults.bundle.mode.clone()),
identifier: job
.bundle
.identifier
.clone()
.or_else(|| defaults.bundle.identifier.clone()),
}
}
#[derive(Debug, Clone)]
struct HookEnvironment {
config_path: PathBuf,
workspace_manifest_path: Option<PathBuf>,
output_path: PathBuf,
output_dir: PathBuf,
job_name: String,
}
impl HookEnvironment {
fn new(
config_path: &Path,
workspace_manifest_path: Option<&Path>,
job_name: &str,
output_path: &Path,
) -> Result<Self, GenerateError> {
Ok(Self {
config_path: absolute_path(config_path)?,
workspace_manifest_path: workspace_manifest_path.map(absolute_path).transpose()?,
output_path: absolute_path(output_path)?,
output_dir: absolute_path(output_path.parent().unwrap_or_else(|| Path::new(".")))?,
job_name: job_name.to_owned(),
})
}
}
fn run_hook(
config_dir: &Path,
hook: &HookConfig,
phase: HookPhase,
job_name: &str,
env: &HookEnvironment,
outcome: Option<WriteOutcome>,
) -> Result<HookReport, GenerateError> {
let invocation = resolve_hook_command(config_dir, hook);
let output = Command::new(&invocation.program)
.args(&invocation.args)
.current_dir(config_dir)
.env("NUMI_HOOK_PHASE", phase.as_str())
.env("NUMI_HOOK_JOB_NAME", &env.job_name)
.env("NUMI_JOB_NAME", &env.job_name)
.env("NUMI_HOOK_CONFIG_PATH", &env.config_path)
.env("NUMI_CONFIG_PATH", &env.config_path)
.env("NUMI_HOOK_OUTPUT_PATH", &env.output_path)
.env("NUMI_OUTPUT_PATH", &env.output_path)
.env("NUMI_HOOK_OUTPUT_DIR", &env.output_dir)
.env("NUMI_OUTPUT_DIR", &env.output_dir)
.env_remove("NUMI_HOOK_WRITE_OUTCOME")
.env_remove("NUMI_WRITE_OUTCOME")
.env_remove("NUMI_HOOK_WORKSPACE_CONFIG_PATH")
.env_remove("NUMI_WORKSPACE_MANIFEST_PATH")
.envs(
outcome
.map(|value| {
let outcome = write_outcome_name(value).to_string();
[
("NUMI_HOOK_WRITE_OUTCOME", outcome.clone()),
("NUMI_WRITE_OUTCOME", outcome),
]
})
.into_iter()
.flatten(),
)
.envs(
env.workspace_manifest_path
.as_ref()
.map(|path| {
let path = path.display().to_string();
[
("NUMI_HOOK_WORKSPACE_CONFIG_PATH", path.clone()),
("NUMI_WORKSPACE_MANIFEST_PATH", path),
]
})
.into_iter()
.flatten(),
)
.output()
.map_err(|source| GenerateError::HookSpawn {
job: job_name.to_owned(),
phase,
command: invocation.display.clone(),
source,
})?;
if !output.status.success() {
return Err(GenerateError::HookExit {
job: job_name.to_owned(),
phase,
command: invocation.display.clone(),
status: output.status,
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
});
}
Ok(HookReport {
phase,
command: invocation.display,
})
}
struct ResolvedHookCommand {
program: PathBuf,
args: Vec<String>,
display: String,
}
fn resolve_hook_command(config_dir: &Path, hook: &HookConfig) -> ResolvedHookCommand {
if let Some(shell) = hook.shell.as_deref() {
return ResolvedHookCommand {
program: platform_shell_program(),
args: platform_shell_args(shell),
display: shell.to_string(),
};
}
let program = hook
.command
.first()
.map(|value| {
if command_looks_like_path(value) {
config_dir.join(value)
} else {
PathBuf::from(value)
}
})
.unwrap_or_default();
ResolvedHookCommand {
program,
args: hook.command[1..].to_vec(),
display: render_hook_command(&hook.command),
}
}
fn command_looks_like_path(command: &str) -> bool {
let path = Path::new(command);
path.is_absolute()
|| command.starts_with('.')
|| path
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
|| path.components().count() > 1
}
fn absolute_path(path: &Path) -> Result<PathBuf, GenerateError> {
if path.is_absolute() {
return Ok(path.to_path_buf());
}
let cwd = std::env::current_dir().map_err(|error| GenerateError::UnsupportedJob {
job: "hooks".to_string(),
detail: format!("failed to read cwd while resolving hook paths: {error}"),
})?;
Ok(cwd.join(path))
}
fn write_outcome_name(outcome: WriteOutcome) -> &'static str {
match outcome {
WriteOutcome::Created => "created",
WriteOutcome::Updated => "updated",
WriteOutcome::Unchanged => "unchanged",
WriteOutcome::Skipped => "skipped",
}
}
fn render_hook_command(command: &[String]) -> String {
command.join(" ")
}
#[cfg(windows)]
fn platform_shell_program() -> PathBuf {
PathBuf::from("cmd")
}
#[cfg(not(windows))]
fn platform_shell_program() -> PathBuf {
PathBuf::from("/bin/sh")
}
#[cfg(windows)]
fn platform_shell_args(command: &str) -> Vec<String> {
vec!["/C".to_string(), command.to_string()]
}
#[cfg(not(windows))]
fn platform_shell_args(command: &str) -> Vec<String> {
vec!["-c".to_string(), command.to_string()]
}
fn to_utf8_path(path: &Path) -> Result<Utf8PathBuf, GenerateError> {
Utf8PathBuf::from_path_buf(path.to_path_buf())
.map_err(|path| GenerateError::InvalidOutputPath { path })
}
#[cfg(test)]
mod tests;