1use blake3::Hasher;
2use camino::Utf8PathBuf;
3use numi_config::{BundleConfig, DefaultsConfig, HookConfig, JobConfig, TemplateVariables};
4use numi_diagnostics::Diagnostic;
5use numi_ir::{
6 GraphMetadata, Metadata, ModuleKind, ResourceGraph, ResourceModule,
7 normalize_flat_entries_preserve_order, normalize_scope, swift_identifier,
8};
9use serde::Serialize;
10use serde_json::Value;
11use std::{
12 cmp::Ordering,
13 collections::BTreeMap,
14 fs,
15 path::{Path, PathBuf},
16 process::Command,
17 time::{SystemTime, UNIX_EPOCH},
18};
19
20use crate::{
21 context::{AssetTemplateContext, ContextError},
22 generation_cache,
23 input_filters::should_ignore_directory_entry,
24 output::{OutputError, WriteOutcome, output_is_stale, write_if_changed_atomic},
25 parse_cache::{self, CacheKind, CachedParseData},
26 parse_files::{ParseFilesError, parse_files},
27 parse_fonts::{ParseFontsError, parse_font_entries},
28 parse_l10n::{LocalizationTable, ParseL10nError, parse_strings, parse_xcstrings},
29 parse_xcassets::{ParseXcassetsError, parse_catalog},
30 render::{
31 RenderError, builtin_template_source, collect_custom_template_dependencies,
32 discover_job_template_path, render_builtin, render_path, resolve_template_entry_path,
33 },
34};
35
36const GENERATION_FINGERPRINT_SCHEMA_VERSION: u32 = 2;
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct GenerateReport {
40 pub jobs: Vec<JobReport>,
41 pub warnings: Vec<Diagnostic>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum GenerateProgress {
46 JobStarted { job_name: String },
47}
48
49#[derive(Debug, Clone, Default, PartialEq, Eq)]
50pub struct GenerateOptions {
51 pub incremental: Option<bool>,
52 pub parse_cache: Option<bool>,
53 pub force_regenerate: bool,
54 pub workspace_manifest_path: Option<PathBuf>,
55}
56
57#[derive(Debug, Clone, Default, PartialEq, Eq)]
58pub struct CheckOptions {
59 pub workspace_manifest_path: Option<PathBuf>,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct JobReport {
64 pub job_name: String,
65 pub output_path: Utf8PathBuf,
66 pub outcome: WriteOutcome,
67 pub hook_reports: Vec<HookReport>,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct HookReport {
72 pub phase: HookPhase,
73 pub command: String,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum HookPhase {
78 PreGenerate,
79 PostGenerate,
80}
81
82impl HookPhase {
83 pub fn as_str(self) -> &'static str {
84 match self {
85 Self::PreGenerate => "pre_generate",
86 Self::PostGenerate => "post_generate",
87 }
88 }
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct CheckReport {
93 pub stale_paths: Vec<Utf8PathBuf>,
94 pub warnings: Vec<Diagnostic>,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct DumpContextReport {
99 pub json: String,
100 pub warnings: Vec<Diagnostic>,
101}
102
103#[derive(Debug)]
104pub enum GenerateError {
105 LoadConfig(numi_config::ConfigError),
106 Diagnostics(Vec<Diagnostic>),
107 UnsupportedJob {
108 job: String,
109 detail: String,
110 },
111 ParseXcassets {
112 job: String,
113 source: ParseXcassetsError,
114 },
115 ParseStrings {
116 job: String,
117 source: ParseL10nError,
118 },
119 ParseXcstrings {
120 job: String,
121 source: ParseL10nError,
122 },
123 ParseFiles {
124 job: String,
125 source: ParseFilesError,
126 },
127 ParseFonts {
128 job: String,
129 source: ParseFontsError,
130 },
131 BuildContext {
132 job: String,
133 source: ContextError,
134 },
135 Render {
136 job: String,
137 source: RenderError,
138 },
139 SerializeContext(serde_json::Error),
140 WriteOutput {
141 job: String,
142 source: OutputError,
143 },
144 InspectOutput {
145 job: String,
146 source: OutputError,
147 },
148 HookSpawn {
149 job: String,
150 phase: HookPhase,
151 command: String,
152 source: std::io::Error,
153 },
154 HookExit {
155 job: String,
156 phase: HookPhase,
157 command: String,
158 status: std::process::ExitStatus,
159 stdout: String,
160 stderr: String,
161 },
162 InvalidOutputPath {
163 path: PathBuf,
164 },
165}
166
167impl std::fmt::Display for GenerateError {
168 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169 match self {
170 Self::LoadConfig(error) => write!(f, "{error}"),
171 Self::Diagnostics(diagnostics) => {
172 for (index, diagnostic) in diagnostics.iter().enumerate() {
173 if index > 0 {
174 writeln!(f)?;
175 }
176 write!(f, "{diagnostic}")?;
177 }
178 Ok(())
179 }
180 Self::UnsupportedJob { job, detail } => {
181 write!(f, "job `{job}` is not supported yet: {detail}")
182 }
183 Self::ParseXcassets { job, source } => {
184 write!(
185 f,
186 "failed to parse xcassets input for job `{job}`: {source}"
187 )
188 }
189 Self::ParseStrings { job, source } => {
190 write!(f, "failed to parse strings input for job `{job}`: {source}")
191 }
192 Self::ParseXcstrings { job, source } => {
193 write!(
194 f,
195 "failed to parse xcstrings input for job `{job}`: {source}"
196 )
197 }
198 Self::ParseFiles { job, source } => {
199 write!(f, "failed to parse files input for job `{job}`: {source}")
200 }
201 Self::ParseFonts { job, source } => {
202 write!(f, "failed to parse fonts input for job `{job}`: {source}")
203 }
204 Self::BuildContext { job, source } => {
205 write!(
206 f,
207 "failed to build render context for job `{job}`: {source}"
208 )
209 }
210 Self::Render { job, source } => {
211 write!(f, "failed to render output for job `{job}`: {source}")
212 }
213 Self::SerializeContext(source) => {
214 write!(f, "failed to serialize context as JSON: {source}")
215 }
216 Self::WriteOutput { job, source } => {
217 write!(f, "failed to write output for job `{job}`: {source}")
218 }
219 Self::InspectOutput { job, source } => {
220 write!(f, "failed to inspect output for job `{job}`: {source}")
221 }
222 Self::HookSpawn {
223 job,
224 phase,
225 command,
226 source,
227 } => write!(
228 f,
229 "failed to run {} hook for job `{job}` ({}): {source}",
230 phase.as_str(),
231 command
232 ),
233 Self::HookExit {
234 job,
235 phase,
236 command,
237 status,
238 stdout,
239 stderr,
240 } => {
241 write!(
242 f,
243 "{} hook for job `{job}` failed ({}) with status {}",
244 phase.as_str(),
245 command,
246 status
247 )?;
248 if !stderr.trim().is_empty() {
249 write!(f, "\nstderr:\n{}", stderr.trim_end())?;
250 }
251 if !stdout.trim().is_empty() {
252 write!(f, "\nstdout:\n{}", stdout.trim_end())?;
253 }
254 Ok(())
255 }
256 Self::InvalidOutputPath { path } => write!(
257 f,
258 "generated output path {} is not valid UTF-8 and cannot be recorded",
259 path.display()
260 ),
261 }
262 }
263}
264
265impl std::error::Error for GenerateError {}
266
267pub fn generate(
268 config_path: &Path,
269 selected_jobs: Option<&[String]>,
270) -> Result<GenerateReport, GenerateError> {
271 generate_with_options(config_path, selected_jobs, GenerateOptions::default())
272}
273
274pub fn generate_with_options(
275 config_path: &Path,
276 selected_jobs: Option<&[String]>,
277 options: GenerateOptions,
278) -> Result<GenerateReport, GenerateError> {
279 generate_with_options_and_progress(config_path, selected_jobs, options, |_| {})
280}
281
282pub fn generate_with_options_and_progress<F>(
283 config_path: &Path,
284 selected_jobs: Option<&[String]>,
285 options: GenerateOptions,
286 progress: F,
287) -> Result<GenerateReport, GenerateError>
288where
289 F: FnMut(&GenerateProgress),
290{
291 let loaded = numi_config::load_from_path(config_path).map_err(GenerateError::LoadConfig)?;
292 generate_loaded_config_with_progress(
293 &loaded.path,
294 &loaded.config,
295 selected_jobs,
296 options,
297 progress,
298 )
299}
300
301pub fn generate_loaded_config(
302 config_path: &Path,
303 config: &numi_config::Config,
304 selected_jobs: Option<&[String]>,
305 options: GenerateOptions,
306) -> Result<GenerateReport, GenerateError> {
307 generate_loaded_config_with_progress(config_path, config, selected_jobs, options, |_| {})
308}
309
310pub fn generate_loaded_config_with_progress<F>(
311 config_path: &Path,
312 config: &numi_config::Config,
313 selected_jobs: Option<&[String]>,
314 options: GenerateOptions,
315 mut progress: F,
316) -> Result<GenerateReport, GenerateError>
317where
318 F: FnMut(&GenerateProgress),
319{
320 let config_dir = config_dir(config_path);
321 let template_lookup_root = options
322 .workspace_manifest_path
323 .as_deref()
324 .map(self::config_dir)
325 .unwrap_or(config_dir);
326 let jobs = numi_config::resolve_selected_jobs(config, selected_jobs)
327 .map_err(GenerateError::Diagnostics)?;
328
329 let mut reports = Vec::with_capacity(jobs.len());
330 let mut warnings = Vec::new();
331
332 for job in jobs {
333 progress(&GenerateProgress::JobStarted {
334 job_name: job.name.clone(),
335 });
336 let job_report = generate_job(
337 config_path,
338 config_dir,
339 template_lookup_root,
340 &config.defaults,
341 job,
342 &options,
343 )?;
344 warnings.extend(job_report.warnings);
345 reports.push(JobReport {
346 job_name: job_report.job_name,
347 output_path: job_report.output_path,
348 outcome: job_report.outcome,
349 hook_reports: job_report.hook_reports,
350 });
351 }
352
353 Ok(GenerateReport {
354 jobs: reports,
355 warnings,
356 })
357}
358
359pub fn dump_context(
360 config_path: &Path,
361 job_name: &str,
362) -> Result<DumpContextReport, GenerateError> {
363 let loaded = numi_config::load_from_path(config_path).map_err(GenerateError::LoadConfig)?;
364 let config_dir = loaded
365 .path
366 .parent()
367 .filter(|path| !path.as_os_str().is_empty())
368 .unwrap_or_else(|| Path::new("."));
369 let selected_jobs = vec![job_name.to_owned()];
370 let jobs = numi_config::resolve_selected_jobs(&loaded.config, Some(&selected_jobs))
371 .map_err(GenerateError::Diagnostics)?;
372 let job = jobs
373 .into_iter()
374 .next()
375 .expect("selected one job should resolve to one job");
376
377 let (context, warnings) =
378 build_context(&loaded.path, config_dir, &loaded.config.defaults, job, None)?;
379 let json = serde_json::to_string_pretty(&context).map_err(GenerateError::SerializeContext)?;
380 Ok(DumpContextReport { json, warnings })
381}
382
383pub fn check(
384 config_path: &Path,
385 selected_jobs: Option<&[String]>,
386) -> Result<CheckReport, GenerateError> {
387 let loaded = numi_config::load_from_path(config_path).map_err(GenerateError::LoadConfig)?;
388 check_loaded_config_with_options(
389 &loaded.path,
390 &loaded.config,
391 selected_jobs,
392 CheckOptions::default(),
393 )
394}
395
396pub fn check_loaded_config(
397 config_path: &Path,
398 config: &numi_config::Config,
399 selected_jobs: Option<&[String]>,
400) -> Result<CheckReport, GenerateError> {
401 check_loaded_config_with_options(config_path, config, selected_jobs, CheckOptions::default())
402}
403
404pub fn check_loaded_config_with_options(
405 config_path: &Path,
406 config: &numi_config::Config,
407 selected_jobs: Option<&[String]>,
408 options: CheckOptions,
409) -> Result<CheckReport, GenerateError> {
410 let config_dir = config_dir(config_path);
411 let template_lookup_root = options
412 .workspace_manifest_path
413 .as_deref()
414 .map(self::config_dir)
415 .unwrap_or(config_dir);
416 let jobs = numi_config::resolve_selected_jobs(config, selected_jobs)
417 .map_err(GenerateError::Diagnostics)?;
418 let mut warnings = Vec::new();
419 let mut stale_paths = Vec::new();
420
421 for job in jobs {
422 let job_report = check_job(
423 config_path,
424 config_dir,
425 template_lookup_root,
426 &config.defaults,
427 job,
428 options.workspace_manifest_path.as_deref(),
429 )?;
430 warnings.extend(job_report.warnings);
431 if let Some(output_path) = job_report.stale_path {
432 stale_paths.push(output_path);
433 }
434 }
435
436 Ok(CheckReport {
437 stale_paths,
438 warnings,
439 })
440}
441
442fn config_dir(config_path: &Path) -> &Path {
443 config_path
444 .parent()
445 .filter(|path| !path.as_os_str().is_empty())
446 .unwrap_or_else(|| Path::new("."))
447}
448
449fn generate_job(
450 config_path: &Path,
451 config_dir: &Path,
452 template_lookup_root: &Path,
453 defaults: &DefaultsConfig,
454 job: &JobConfig,
455 options: &GenerateOptions,
456) -> Result<JobExecution, GenerateError> {
457 let output_path = config_dir.join(&job.output);
458 let mut hook_reports = Vec::new();
459 let incremental = resolve_incremental(defaults, job, options);
460 let parse_cache = resolve_parse_cache(options);
461 let should_check_generation_cache = incremental
462 && !options.force_regenerate
463 && generation_cache::cache_record_exists(config_path, &job.name)
464 .ok()
465 .unwrap_or(false);
466 let mut generation_plan = None;
467
468 if should_check_generation_cache || parse_cache {
469 generation_plan =
470 compute_generation_fingerprint(config_dir, template_lookup_root, defaults, job);
471 }
472
473 if incremental
474 && !options.force_regenerate
475 && let Some(plan) = generation_plan.as_ref()
476 && generation_cache::is_fresh(config_path, &job.name, &plan.fingerprint, &output_path)
477 .ok()
478 .unwrap_or(false)
479 {
480 return Ok(JobExecution {
481 job_name: job.name.clone(),
482 output_path: to_utf8_path(&output_path)?,
483 outcome: WriteOutcome::Skipped,
484 hook_reports,
485 warnings: Vec::new(),
486 });
487 }
488
489 if generation_plan.is_none() && incremental {
490 generation_plan =
491 compute_generation_fingerprint(config_dir, template_lookup_root, defaults, job);
492 }
493
494 let hook_env = HookEnvironment::new(
495 config_path,
496 options.workspace_manifest_path.as_deref(),
497 &job.name,
498 &output_path,
499 )?;
500 if let Some(hook) = job.hooks.pre_generate.as_ref() {
501 hook_reports.push(run_hook(
502 config_dir,
503 hook,
504 HookPhase::PreGenerate,
505 &job.name,
506 &hook_env,
507 None,
508 )?);
509 }
510
511 let (context, warnings) = build_context(
512 config_path,
513 config_dir,
514 defaults,
515 job,
516 parse_cache.then_some(()).and(generation_plan.as_ref()),
517 )?;
518 let rendered = render_job(config_dir, template_lookup_root, job, &context)?;
519 let outcome = write_if_changed_atomic(&output_path, &rendered).map_err(|source| {
520 GenerateError::WriteOutput {
521 job: job.name.clone(),
522 source,
523 }
524 })?;
525
526 let should_run_post_hook = matches!(outcome, WriteOutcome::Created | WriteOutcome::Updated)
527 || (options.force_regenerate && matches!(outcome, WriteOutcome::Unchanged));
528
529 if should_run_post_hook && let Some(hook) = job.hooks.post_generate.as_ref() {
530 hook_reports.push(run_hook(
531 config_dir,
532 hook,
533 HookPhase::PostGenerate,
534 &job.name,
535 &hook_env,
536 Some(outcome),
537 )?);
538 }
539
540 if let Some(plan) = generation_plan.as_ref() {
541 let _ = generation_cache::store(config_path, &job.name, &plan.fingerprint, &output_path);
542 }
543
544 Ok(JobExecution {
545 job_name: job.name.clone(),
546 output_path: to_utf8_path(&output_path)?,
547 outcome,
548 hook_reports,
549 warnings,
550 })
551}
552
553fn check_job(
554 config_path: &Path,
555 config_dir: &Path,
556 template_lookup_root: &Path,
557 defaults: &DefaultsConfig,
558 job: &JobConfig,
559 workspace_manifest_path: Option<&Path>,
560) -> Result<CheckJobExecution, GenerateError> {
561 let (context, warnings) = build_context(config_path, config_dir, defaults, job, None)?;
562 let rendered = render_job(config_dir, template_lookup_root, job, &context)?;
563 let output_path = config_dir.join(&job.output);
564 let stale_without_hook = output_is_stale(&output_path, &rendered).map_err(|source| {
565 GenerateError::InspectOutput {
566 job: job.name.clone(),
567 source,
568 }
569 })?;
570 let stale = if !stale_without_hook {
571 false
572 } else if let Some(hook) = job.hooks.post_generate.as_ref() {
573 output_is_stale_after_post_hook(
574 config_path,
575 config_dir,
576 workspace_manifest_path,
577 job,
578 hook,
579 &output_path,
580 &rendered,
581 )?
582 } else {
583 true
584 };
585
586 Ok(CheckJobExecution {
587 stale_path: if stale {
588 Some(to_utf8_path(&output_path)?)
589 } else {
590 None
591 },
592 warnings,
593 })
594}
595
596fn output_is_stale_after_post_hook(
597 config_path: &Path,
598 config_dir: &Path,
599 workspace_manifest_path: Option<&Path>,
600 job: &JobConfig,
601 hook: &HookConfig,
602 output_path: &Path,
603 rendered: &str,
604) -> Result<bool, GenerateError> {
605 let temp_output_path = make_check_temp_output_path(job, output_path);
606 let temp_output_dir = temp_output_path
607 .parent()
608 .filter(|path| !path.as_os_str().is_empty())
609 .unwrap_or_else(|| Path::new("."));
610 fs::create_dir_all(temp_output_dir).map_err(|source| GenerateError::InspectOutput {
611 job: job.name.clone(),
612 source: OutputError::CreateDirectory {
613 path: temp_output_dir.to_path_buf(),
614 source,
615 },
616 })?;
617 fs::write(&temp_output_path, rendered).map_err(|source| GenerateError::InspectOutput {
618 job: job.name.clone(),
619 source: OutputError::WriteTemp {
620 path: temp_output_path.clone(),
621 source,
622 },
623 })?;
624
625 let hook_env = HookEnvironment::new(
626 config_path,
627 workspace_manifest_path,
628 &job.name,
629 &temp_output_path,
630 )?;
631 let hook_result = run_hook(
632 config_dir,
633 hook,
634 HookPhase::PostGenerate,
635 &job.name,
636 &hook_env,
637 Some(WriteOutcome::Updated),
638 );
639 let transformed =
640 fs::read_to_string(&temp_output_path).map_err(|source| GenerateError::InspectOutput {
641 job: job.name.clone(),
642 source: OutputError::ReadExisting {
643 path: temp_output_path.clone(),
644 source,
645 },
646 });
647 let cleanup_result = fs::remove_dir_all(
648 temp_output_path
649 .parent()
650 .expect("temp output path should always have a parent"),
651 );
652
653 hook_result?;
654 let transformed = transformed?;
655 if let Err(source) = cleanup_result {
656 return Err(GenerateError::InspectOutput {
657 job: job.name.clone(),
658 source: OutputError::Cleanup {
659 path: temp_output_path
660 .parent()
661 .expect("temp output path should always have a parent")
662 .to_path_buf(),
663 source,
664 },
665 });
666 }
667
668 output_is_stale(output_path, &transformed).map_err(|source| GenerateError::InspectOutput {
669 job: job.name.clone(),
670 source,
671 })
672}
673
674fn make_check_temp_output_path(job: &JobConfig, output_path: &Path) -> PathBuf {
675 let timestamp = SystemTime::now()
676 .duration_since(UNIX_EPOCH)
677 .expect("clock should be after epoch")
678 .as_nanos();
679 let file_name = output_path
680 .file_name()
681 .map(|name| name.to_string_lossy().into_owned())
682 .unwrap_or_else(|| "output".to_string());
683 std::env::temp_dir()
684 .join(format!(
685 "numi-check-{}-{}-{}",
686 sanitize_job_name(&job.name),
687 std::process::id(),
688 timestamp
689 ))
690 .join(file_name)
691}
692
693fn sanitize_job_name(job_name: &str) -> String {
694 job_name
695 .chars()
696 .map(|ch| {
697 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
698 ch
699 } else {
700 '-'
701 }
702 })
703 .collect()
704}
705
706fn build_context(
707 config_path: &Path,
708 config_dir: &Path,
709 defaults: &DefaultsConfig,
710 job: &JobConfig,
711 generation_plan: Option<&GenerationFingerprintPlan>,
712) -> Result<(AssetTemplateContext, Vec<Diagnostic>), GenerateError> {
713 let BuildModulesResult { modules, warnings } = build_modules(config_dir, job, generation_plan)?;
714 let _graph = ResourceGraph {
715 modules: modules.clone(),
716 diagnostics: warnings.clone(),
717 metadata: GraphMetadata {
718 config_path: Some(to_utf8_path(config_path)?),
719 },
720 };
721
722 let access_level = resolve_access_level(defaults, job);
723 let bundle = merged_bundle(defaults, job);
724 let bundle_mode = bundle.mode.as_deref().unwrap_or("module");
725 validate_bundle_mode(&job.name, bundle_mode, bundle.identifier.as_deref())?;
726 let context = AssetTemplateContext::new(
727 &job.name,
728 &job.output,
729 access_level,
730 bundle_mode,
731 bundle.identifier.as_deref(),
732 &modules,
733 job.variables.clone(),
734 )
735 .map_err(|source| GenerateError::BuildContext {
736 job: job.name.clone(),
737 source,
738 })?;
739
740 Ok((context, warnings))
741}
742
743struct BuildModulesResult {
744 modules: Vec<ResourceModule>,
745 warnings: Vec<Diagnostic>,
746}
747
748struct JobExecution {
749 job_name: String,
750 output_path: Utf8PathBuf,
751 outcome: WriteOutcome,
752 hook_reports: Vec<HookReport>,
753 warnings: Vec<Diagnostic>,
754}
755
756struct CheckJobExecution {
757 stale_path: Option<Utf8PathBuf>,
758 warnings: Vec<Diagnostic>,
759}
760
761struct GenerationFingerprintPlan {
762 fingerprint: String,
763 cache_input_fingerprints: BTreeMap<PathBuf, ParseCacheInputPlan>,
764}
765
766struct ParseCacheInputPlan {
767 fingerprint: String,
768 snapshot: parse_cache::InputSnapshot,
769}
770
771#[derive(Debug, Serialize)]
772struct GenerationFingerprintRecord {
773 schema_version: u32,
774 job_name: String,
775 output: String,
776 access_level: String,
777 bundle_mode: String,
778 bundle_identifier: Option<String>,
779 variables: TemplateVariables,
780 inputs: Vec<GenerationInputFingerprintRecord>,
781 template: GenerationTemplateFingerprintRecord,
782}
783
784#[derive(Debug, Serialize)]
785struct GenerationInputFingerprintRecord {
786 kind: String,
787 path: String,
788 fingerprint: String,
789}
790
791#[derive(Debug, Serialize)]
792#[serde(tag = "kind")]
793enum GenerationTemplateFingerprintRecord {
794 Builtin {
795 language: String,
796 name: String,
797 fingerprint: String,
798 },
799 Custom {
800 path: String,
801 dependencies: Vec<GenerationDependencyFingerprintRecord>,
802 },
803}
804
805#[derive(Debug, Serialize)]
806struct GenerationDependencyFingerprintRecord {
807 path: String,
808 fingerprint: String,
809}
810
811fn build_modules(
812 config_dir: &Path,
813 job: &JobConfig,
814 generation_plan: Option<&GenerationFingerprintPlan>,
815) -> Result<BuildModulesResult, GenerateError> {
816 let mut modules = Vec::new();
817 let mut asset_entries = Vec::new();
818 let mut duplicate_table_sources = BTreeMap::<String, Utf8PathBuf>::new();
819 let mut duplicate_files_module_sources = BTreeMap::<String, (String, PathBuf)>::new();
820 let mut duplicate_fonts_module_sources = BTreeMap::<String, (String, PathBuf)>::new();
821 let mut diagnostics = Vec::new();
822 let mut warnings = Vec::new();
823
824 for input in &job.inputs {
825 let input_path = config_dir.join(&input.path);
826 let known_cache_input =
827 generation_plan.and_then(|plan| plan.cache_input_fingerprints.get(&input_path));
828 let known_cache_fingerprint = known_cache_input.map(|plan| plan.fingerprint.as_str());
829 let known_cache_snapshot = known_cache_input.map(|plan| &plan.snapshot);
830
831 match input.kind.as_str() {
832 "xcassets" => {
833 let report = load_or_parse_xcassets(
834 &input_path,
835 &job.name,
836 known_cache_fingerprint,
837 known_cache_snapshot,
838 )?;
839 warnings.extend(
840 report
841 .warnings
842 .into_iter()
843 .map(|warning| warning.with_job(job.name.clone())),
844 );
845 asset_entries.extend(report.entries);
846 }
847 "strings" => {
848 let tables = load_or_parse_strings(
849 &input_path,
850 &job.name,
851 known_cache_fingerprint,
852 known_cache_snapshot,
853 )?;
854
855 for table in tables {
856 let table_name = table.table_name.clone();
857 warnings.extend(
858 table
859 .warnings
860 .into_iter()
861 .map(|warning| warning.with_job(job.name.clone())),
862 );
863 if let Some(first_source) = duplicate_table_sources
864 .insert(table_name.clone(), table.source_path.clone())
865 {
866 diagnostics.push(
867 Diagnostic::error(format!(
868 "duplicate localization table `{table_name}` from directory-based `.strings` input"
869 ))
870 .with_job(job.name.clone())
871 .with_path(table.source_path.as_std_path())
872 .with_hint(format!(
873 "found `{}` and `{}`; merge these inputs before generation or select a single localized source",
874 first_source,
875 table.source_path
876 )),
877 );
878 continue;
879 }
880 let entries = normalize_flat_entries_preserve_order(&job.name, table.entries)
881 .map_err(GenerateError::Diagnostics)?;
882 modules.push(ResourceModule {
883 id: table_name.clone(),
884 kind: table.module_kind,
885 name: swift_identifier(&table_name),
886 entries,
887 metadata: Metadata::from([(
888 "tableName".to_string(),
889 Value::String(table_name),
890 )]),
891 });
892 }
893 }
894 "xcstrings" => {
895 let tables = load_or_parse_xcstrings(
896 &input_path,
897 &job.name,
898 known_cache_fingerprint,
899 known_cache_snapshot,
900 )?;
901
902 for table in tables {
903 let table_name = table.table_name.clone();
904 warnings.extend(
905 table
906 .warnings
907 .into_iter()
908 .map(|warning| warning.with_job(job.name.clone())),
909 );
910 if let Some(first_source) = duplicate_table_sources
911 .insert(table_name.clone(), table.source_path.clone())
912 {
913 diagnostics.push(
914 Diagnostic::error(format!(
915 "duplicate localization table `{table_name}` from directory-based `.xcstrings` input"
916 ))
917 .with_job(job.name.clone())
918 .with_path(table.source_path.as_std_path())
919 .with_hint(format!(
920 "found `{}` and `{}`; merge these inputs before generation or select a single localized source",
921 first_source,
922 table.source_path
923 )),
924 );
925 continue;
926 }
927 let entries = normalize_scope(&job.name, table.entries)
928 .map_err(GenerateError::Diagnostics)?;
929 modules.push(ResourceModule {
930 id: table_name.clone(),
931 kind: table.module_kind,
932 name: swift_identifier(&table_name),
933 entries,
934 metadata: Metadata::from([(
935 "tableName".to_string(),
936 Value::String(table_name),
937 )]),
938 });
939 }
940 }
941 "files" => {
942 let raw_entries = load_or_parse_files(
943 &input_path,
944 &job.name,
945 known_cache_fingerprint,
946 known_cache_snapshot,
947 )?;
948 let mut entries =
949 normalize_scope(&job.name, raw_entries).map_err(GenerateError::Diagnostics)?;
950 annotate_swiftgen_file_sort_keys(&mut entries);
951 if entries.is_empty() {
952 continue;
953 }
954 let module_id = input_path
955 .file_stem()
956 .or_else(|| input_path.file_name())
957 .and_then(|name| name.to_str())
958 .unwrap_or("Files")
959 .to_string();
960 let module_name = swift_identifier(&module_id);
961 if let Some(diagnostic) = duplicate_input_module_diagnostic(
962 &mut duplicate_files_module_sources,
963 "files",
964 &job.name,
965 &module_id,
966 &module_name,
967 &input_path,
968 ) {
969 diagnostics.push(diagnostic);
970 continue;
971 }
972 modules.push(ResourceModule {
973 id: module_id.clone(),
974 kind: ModuleKind::Files,
975 name: module_name,
976 entries,
977 metadata: Metadata::new(),
978 });
979 }
980 "fonts" => {
981 let parsed_fonts = parse_font_entries(&input_path).map_err(|source| {
982 GenerateError::ParseFonts {
983 job: job.name.clone(),
984 source,
985 }
986 })?;
987 let raw_entries = parsed_fonts
988 .iter()
989 .cloned()
990 .map(crate::parse_fonts::ParsedFontEntry::into_raw_entry)
991 .collect::<Vec<_>>();
992 let entries =
993 normalize_scope(&job.name, raw_entries).map_err(GenerateError::Diagnostics)?;
994 if entries.is_empty() {
995 continue;
996 }
997 let module_id = input_path
998 .file_stem()
999 .or_else(|| input_path.file_name())
1000 .and_then(|name| name.to_str())
1001 .unwrap_or("Fonts")
1002 .to_string();
1003 let module_name = swift_identifier(&module_id);
1004 if let Some(diagnostic) = duplicate_input_module_diagnostic(
1005 &mut duplicate_fonts_module_sources,
1006 "fonts",
1007 &job.name,
1008 &module_id,
1009 &module_name,
1010 &input_path,
1011 ) {
1012 diagnostics.push(diagnostic);
1013 continue;
1014 }
1015 modules.push(ResourceModule {
1016 id: module_id.clone(),
1017 kind: ModuleKind::Fonts,
1018 name: module_name,
1019 entries,
1020 metadata: build_font_module_metadata(&parsed_fonts),
1021 });
1022 }
1023 other => {
1024 return Err(GenerateError::UnsupportedJob {
1025 job: job.name.clone(),
1026 detail: format!("input kind `{other}`"),
1027 });
1028 }
1029 }
1030 }
1031
1032 if !asset_entries.is_empty() {
1033 let mut entries =
1034 normalize_scope(&job.name, asset_entries).map_err(GenerateError::Diagnostics)?;
1035 sort_entries_for_assets(&mut entries);
1036 annotate_swiftgen_sort_keys(&mut entries);
1037 modules.insert(
1038 0,
1039 ResourceModule {
1040 id: job.name.clone(),
1041 kind: ModuleKind::Xcassets,
1042 name: swift_identifier(&job.name),
1043 entries,
1044 metadata: Metadata::new(),
1045 },
1046 );
1047 }
1048
1049 if !diagnostics.is_empty() {
1050 return Err(GenerateError::Diagnostics(diagnostics));
1051 }
1052
1053 Ok(BuildModulesResult { modules, warnings })
1054}
1055
1056fn duplicate_input_module_diagnostic(
1057 seen_modules: &mut BTreeMap<String, (String, PathBuf)>,
1058 input_kind: &str,
1059 job_name: &str,
1060 module_id: &str,
1061 module_name: &str,
1062 input_path: &Path,
1063) -> Option<Diagnostic> {
1064 if let Some((first_module_id, first_source)) = seen_modules.insert(
1065 module_name.to_string(),
1066 (module_id.to_string(), input_path.to_path_buf()),
1067 ) {
1068 let detail = if first_module_id == module_id {
1069 format!("both inputs normalize to module `{module_name}`")
1070 } else {
1071 format!(
1072 "module names `{first_module_id}` and `{module_id}` both normalize to `{module_name}`"
1073 )
1074 };
1075 return Some(
1076 Diagnostic::error(format!("duplicate {input_kind} module `{module_name}`"))
1077 .with_job(job_name)
1078 .with_path(input_path)
1079 .with_hint(format!(
1080 "found `{}` and `{}`; {detail}",
1081 first_source.display(),
1082 input_path.display()
1083 )),
1084 );
1085 }
1086
1087 None
1088}
1089
1090fn annotate_swiftgen_sort_keys(entries: &mut [numi_ir::ResourceEntry]) {
1091 for entry in entries {
1092 entry.metadata.insert(
1093 "sortKey".to_string(),
1094 Value::String(swiftgen_sort_key(&entry.swift_identifier)),
1095 );
1096 annotate_swiftgen_sort_keys(&mut entry.children);
1097 }
1098}
1099
1100fn annotate_swiftgen_file_sort_keys(entries: &mut [numi_ir::ResourceEntry]) {
1101 let sibling_names = entries
1102 .iter()
1103 .map(|entry| entry.name.to_ascii_lowercase())
1104 .collect::<Vec<_>>();
1105
1106 for entry in entries {
1107 entry.metadata.insert(
1108 "sortKey".to_string(),
1109 Value::String(swiftgen_file_sort_key(&entry.name, &sibling_names)),
1110 );
1111 annotate_swiftgen_file_sort_keys(&mut entry.children);
1112 }
1113}
1114
1115fn swiftgen_file_sort_key(name: &str, sibling_names: &[String]) -> String {
1116 let _ = sibling_names;
1117 name.to_ascii_lowercase()
1118}
1119
1120fn sort_entries_for_assets(entries: &mut [numi_ir::ResourceEntry]) {
1121 for entry in entries.iter_mut() {
1122 sort_entries_for_assets(&mut entry.children);
1123 }
1124 entries.sort_by(compare_asset_entries);
1125}
1126
1127fn compare_asset_entries(
1128 left: &numi_ir::ResourceEntry,
1129 right: &numi_ir::ResourceEntry,
1130) -> Ordering {
1131 compare_asset_names(&left.name, &right.name).then_with(|| left.id.cmp(&right.id))
1132}
1133
1134fn compare_asset_names(left: &str, right: &str) -> Ordering {
1135 match (left.strip_suffix(".9"), right.strip_suffix(".9")) {
1136 (Some(base), None) if base == right => Ordering::Less,
1137 (None, Some(base)) if left == base => Ordering::Greater,
1138 _ => left.cmp(right),
1139 }
1140}
1141
1142fn swiftgen_sort_key(identifier: &str) -> String {
1143 let identifier = identifier
1144 .strip_prefix('`')
1145 .and_then(|trimmed| trimmed.strip_suffix('`'))
1146 .unwrap_or(identifier);
1147 let chars = identifier.chars().collect::<Vec<_>>();
1148 if chars.is_empty() || !chars[0].is_ascii_uppercase() {
1149 return identifier.to_ascii_lowercase();
1150 }
1151
1152 let mut prefix_len = 1;
1153 while prefix_len < chars.len() && chars[prefix_len].is_ascii_uppercase() {
1154 prefix_len += 1;
1155 }
1156
1157 let lower_count = if prefix_len == chars.len() {
1158 prefix_len
1159 } else if prefix_len == 1 {
1160 1
1161 } else {
1162 prefix_len - 1
1163 };
1164
1165 let mut lowered = String::with_capacity(identifier.len());
1166 for ch in &chars[..lower_count] {
1167 lowered.push(ch.to_ascii_lowercase());
1168 }
1169 for ch in &chars[lower_count..] {
1170 lowered.push(*ch);
1171 }
1172 lowered.to_ascii_lowercase()
1173}
1174
1175fn build_font_module_metadata(parsed_fonts: &[crate::parse_fonts::ParsedFontEntry]) -> Metadata {
1176 #[derive(Clone)]
1177 struct CanonicalFont {
1178 file_name: String,
1179 relative_path: String,
1180 family_name: String,
1181 style_name: String,
1182 full_name: String,
1183 post_script_name: String,
1184 }
1185
1186 let mut unique_fonts = BTreeMap::<String, CanonicalFont>::new();
1187 for font in parsed_fonts {
1188 let (family_name, style_name) = canonicalize_font_family_and_style(
1189 &font.family_name,
1190 &font.style_name,
1191 &font.post_script_name,
1192 );
1193 unique_fonts.insert(
1194 font.post_script_name.clone(),
1195 CanonicalFont {
1196 file_name: font.file_name.clone(),
1197 relative_path: font.relative_path.clone(),
1198 family_name,
1199 style_name,
1200 full_name: font.full_name.clone(),
1201 post_script_name: font.post_script_name.clone(),
1202 },
1203 );
1204 }
1205
1206 let mut families = BTreeMap::<String, Vec<CanonicalFont>>::new();
1207 for font in unique_fonts.into_values() {
1208 families
1209 .entry(font.family_name.clone())
1210 .or_default()
1211 .push(font);
1212 }
1213
1214 let mut family_items = Vec::new();
1215 for (family_name, mut fonts) in families {
1216 fonts.sort_by(|left, right| left.post_script_name.cmp(&right.post_script_name));
1217 let display_name = if fonts.len() == 1
1218 && fonts[0].style_name != "Regular"
1219 && fonts[0].full_name.ends_with(&fonts[0].style_name)
1220 && fonts[0].full_name != family_name
1221 {
1222 fonts[0].full_name.clone()
1223 } else {
1224 family_name.clone()
1225 };
1226 family_items.push(Value::Object(
1227 [
1228 ("name".to_string(), Value::String(display_name.clone())),
1229 (
1230 "swiftIdentifier".to_string(),
1231 Value::String(swift_identifier(&display_name)),
1232 ),
1233 (
1234 "fonts".to_string(),
1235 Value::Array(
1236 fonts
1237 .into_iter()
1238 .map(|font| {
1239 Value::Object(
1240 [
1241 (
1242 "postScriptName".to_string(),
1243 Value::String(font.post_script_name.clone()),
1244 ),
1245 (
1246 "styleName".to_string(),
1247 Value::String(font.style_name.clone()),
1248 ),
1249 (
1250 "familyName".to_string(),
1251 Value::String(display_name.clone()),
1252 ),
1253 (
1254 "fileName".to_string(),
1255 Value::String(font.file_name.clone()),
1256 ),
1257 (
1258 "relativePath".to_string(),
1259 Value::String(font.relative_path.clone()),
1260 ),
1261 ]
1262 .into_iter()
1263 .collect(),
1264 )
1265 })
1266 .collect(),
1267 ),
1268 ),
1269 ]
1270 .into_iter()
1271 .collect(),
1272 ));
1273 }
1274
1275 Metadata::from([("families".to_string(), Value::Array(family_items))])
1276}
1277
1278fn canonicalize_font_family_and_style(
1279 family_name: &str,
1280 style_name: &str,
1281 post_script_name: &str,
1282) -> (String, String) {
1283 if style_name != "Regular" {
1284 return (family_name.to_string(), style_name.to_string());
1285 }
1286
1287 let Some((_, post_script_style)) = post_script_name.rsplit_once('-') else {
1288 return (family_name.to_string(), style_name.to_string());
1289 };
1290
1291 if post_script_style == "Regular" {
1292 return (family_name.to_string(), style_name.to_string());
1293 }
1294
1295 if let Some(prefix) = family_name.strip_suffix(&format!(" {post_script_style}")) {
1296 return (prefix.to_string(), post_script_style.to_string());
1297 }
1298
1299 (family_name.to_string(), style_name.to_string())
1300}
1301
1302fn load_or_parse_xcassets(
1303 input_path: &Path,
1304 job_name: &str,
1305 known_fingerprint: Option<&str>,
1306 known_snapshot: Option<&parse_cache::InputSnapshot>,
1307) -> Result<crate::parse_xcassets::XcassetsReport, GenerateError> {
1308 load_or_parse_cached(
1309 CacheKind::Xcassets,
1310 input_path,
1311 known_fingerprint,
1312 known_snapshot,
1313 || {
1314 parse_catalog(input_path).map_err(|source| GenerateError::ParseXcassets {
1315 job: job_name.to_owned(),
1316 source,
1317 })
1318 },
1319 CachedParseData::Xcassets,
1320 |cached| match cached {
1321 CachedParseData::Xcassets(report) => Some(report),
1322 _ => None,
1323 },
1324 )
1325}
1326
1327fn load_or_parse_strings(
1328 input_path: &Path,
1329 job_name: &str,
1330 known_fingerprint: Option<&str>,
1331 known_snapshot: Option<&parse_cache::InputSnapshot>,
1332) -> Result<Vec<LocalizationTable>, GenerateError> {
1333 load_or_parse_cached(
1334 CacheKind::Strings,
1335 input_path,
1336 known_fingerprint,
1337 known_snapshot,
1338 || {
1339 parse_strings(input_path).map_err(|source| GenerateError::ParseStrings {
1340 job: job_name.to_owned(),
1341 source,
1342 })
1343 },
1344 CachedParseData::Strings,
1345 |cached| match cached {
1346 CachedParseData::Strings(tables) => Some(tables),
1347 _ => None,
1348 },
1349 )
1350}
1351
1352fn load_or_parse_xcstrings(
1353 input_path: &Path,
1354 job_name: &str,
1355 known_fingerprint: Option<&str>,
1356 known_snapshot: Option<&parse_cache::InputSnapshot>,
1357) -> Result<Vec<LocalizationTable>, GenerateError> {
1358 load_or_parse_cached(
1359 CacheKind::Xcstrings,
1360 input_path,
1361 known_fingerprint,
1362 known_snapshot,
1363 || {
1364 parse_xcstrings(input_path).map_err(|source| GenerateError::ParseXcstrings {
1365 job: job_name.to_owned(),
1366 source,
1367 })
1368 },
1369 CachedParseData::Xcstrings,
1370 |cached| match cached {
1371 CachedParseData::Xcstrings(tables) => Some(tables),
1372 _ => None,
1373 },
1374 )
1375}
1376
1377fn load_or_parse_files(
1378 input_path: &Path,
1379 job_name: &str,
1380 known_fingerprint: Option<&str>,
1381 known_snapshot: Option<&parse_cache::InputSnapshot>,
1382) -> Result<Vec<numi_ir::RawEntry>, GenerateError> {
1383 load_or_parse_cached(
1384 CacheKind::Files,
1385 input_path,
1386 known_fingerprint,
1387 known_snapshot,
1388 || {
1389 parse_files(input_path).map_err(|source| GenerateError::ParseFiles {
1390 job: job_name.to_owned(),
1391 source,
1392 })
1393 },
1394 CachedParseData::Files,
1395 |cached| match cached {
1396 CachedParseData::Files(entries) => Some(entries),
1397 _ => None,
1398 },
1399 )
1400}
1401
1402fn load_or_parse_cached<T, ParseFn, WrapFn, ExtractFn>(
1403 kind: CacheKind,
1404 input_path: &Path,
1405 known_fingerprint: Option<&str>,
1406 known_snapshot: Option<&parse_cache::InputSnapshot>,
1407 parse: ParseFn,
1408 wrap: WrapFn,
1409 extract: ExtractFn,
1410) -> Result<T, GenerateError>
1411where
1412 T: Clone,
1413 ParseFn: FnOnce() -> Result<T, GenerateError>,
1414 WrapFn: Fn(T) -> CachedParseData,
1415 ExtractFn: Fn(CachedParseData) -> Option<T>,
1416{
1417 if let Some(parsed) = load_cached_parse(kind, input_path, known_fingerprint, extract) {
1418 return Ok(parsed);
1419 }
1420
1421 let mut snapshot_before_parse = known_snapshot.cloned();
1422 let fingerprint_before_parse = if let Some(fingerprint) = known_fingerprint {
1423 Some(fingerprint.to_owned())
1424 } else {
1425 let fingerprinted = parse_cache::fingerprint_input_with_snapshot(kind, input_path).ok();
1426 if let Some(fingerprinted) = fingerprinted {
1427 snapshot_before_parse = Some(fingerprinted.snapshot.clone());
1428 Some(fingerprinted.fingerprint)
1429 } else {
1430 None
1431 }
1432 };
1433 let parsed = parse()?;
1434 store_cached_parse(
1435 kind,
1436 input_path,
1437 fingerprint_before_parse.as_deref(),
1438 snapshot_before_parse.as_ref(),
1439 wrap(parsed.clone()),
1440 );
1441 Ok(parsed)
1442}
1443
1444fn load_cached_parse<T, ExtractFn>(
1445 kind: CacheKind,
1446 input_path: &Path,
1447 known_fingerprint: Option<&str>,
1448 extract: ExtractFn,
1449) -> Option<T>
1450where
1451 ExtractFn: Fn(CachedParseData) -> Option<T>,
1452{
1453 let loaded = match known_fingerprint {
1454 Some(fingerprint) => parse_cache::load_with_fingerprint(kind, input_path, fingerprint),
1455 None => parse_cache::load(kind, input_path),
1456 };
1457 loaded.ok().flatten().and_then(extract)
1458}
1459
1460fn store_cached_parse(
1461 kind: CacheKind,
1462 input_path: &Path,
1463 fingerprint_before_parse: Option<&str>,
1464 snapshot_before_parse: Option<&parse_cache::InputSnapshot>,
1465 data: CachedParseData,
1466) {
1467 let Some(fingerprint_before_parse) = fingerprint_before_parse else {
1468 return;
1469 };
1470 if let Some(snapshot_before_parse) = snapshot_before_parse {
1471 let Ok(snapshot_matches) =
1472 parse_cache::input_matches_snapshot(kind, input_path, snapshot_before_parse)
1473 else {
1474 return;
1475 };
1476 if !snapshot_matches {
1477 return;
1478 }
1479 } else {
1480 let Ok(fingerprint_after_parse) = parse_cache::fingerprint_input(kind, input_path) else {
1481 return;
1482 };
1483 if fingerprint_before_parse != fingerprint_after_parse {
1484 return;
1485 }
1486 }
1487
1488 let _ = parse_cache::store(kind, input_path, fingerprint_before_parse, &data);
1489}
1490
1491fn render_job(
1492 config_dir: &Path,
1493 template_lookup_root: &Path,
1494 job: &JobConfig,
1495 context: &AssetTemplateContext,
1496) -> Result<String, GenerateError> {
1497 match resolve_job_template(config_dir, template_lookup_root, job).map_err(|source| {
1498 GenerateError::Render {
1499 job: job.name.clone(),
1500 source,
1501 }
1502 })? {
1503 ResolvedJobTemplate::Builtin { language, name } => {
1504 render_builtin((language, name), context).map_err(|source| GenerateError::Render {
1505 job: job.name.clone(),
1506 source,
1507 })
1508 }
1509 ResolvedJobTemplate::Custom {
1510 resolved_path,
1511 template_root,
1512 ..
1513 } => render_path(&resolved_path, &template_root, context).map_err(|source| {
1514 GenerateError::Render {
1515 job: job.name.clone(),
1516 source,
1517 }
1518 }),
1519 ResolvedJobTemplate::Missing => Err(GenerateError::UnsupportedJob {
1520 job: job.name.clone(),
1521 detail: missing_template_detail(job),
1522 }),
1523 }
1524}
1525
1526fn resolve_incremental(
1527 defaults: &DefaultsConfig,
1528 job: &JobConfig,
1529 options: &GenerateOptions,
1530) -> bool {
1531 options
1532 .incremental
1533 .or(job.incremental)
1534 .or(defaults.incremental)
1535 .unwrap_or(true)
1536}
1537
1538fn resolve_parse_cache(options: &GenerateOptions) -> bool {
1539 options.parse_cache.unwrap_or(true)
1540}
1541
1542fn compute_generation_fingerprint(
1543 config_dir: &Path,
1544 template_lookup_root: &Path,
1545 defaults: &DefaultsConfig,
1546 job: &JobConfig,
1547) -> Option<GenerationFingerprintPlan> {
1548 let mut cache_input_fingerprints = BTreeMap::new();
1549 let inputs = job
1550 .inputs
1551 .iter()
1552 .map(|input| {
1553 let resolved_path = config_dir.join(&input.path);
1554 let fingerprint = if let Some(kind) = cache_kind_for_input(&input.kind) {
1555 let fingerprinted =
1556 parse_cache::fingerprint_input_with_snapshot(kind, &resolved_path).ok()?;
1557 let fingerprint = fingerprinted.fingerprint;
1558 cache_input_fingerprints.insert(
1559 resolved_path.clone(),
1560 ParseCacheInputPlan {
1561 fingerprint: fingerprint.clone(),
1562 snapshot: fingerprinted.snapshot,
1563 },
1564 );
1565 fingerprint
1566 } else {
1567 fingerprint_path_contents(&resolved_path).ok()?
1568 };
1569 Some(GenerationInputFingerprintRecord {
1570 kind: input.kind.clone(),
1571 path: input.path.clone(),
1572 fingerprint,
1573 })
1574 })
1575 .collect::<Option<Vec<_>>>()?;
1576
1577 let template = match resolve_job_template(config_dir, template_lookup_root, job).ok()? {
1578 ResolvedJobTemplate::Builtin { language, name } => {
1579 let source = builtin_template_source((language, name)).ok()?;
1580 GenerationTemplateFingerprintRecord::Builtin {
1581 language: language.to_owned(),
1582 name: name.to_owned(),
1583 fingerprint: generation_cache::blake3_hex([source.as_bytes()]),
1584 }
1585 }
1586 ResolvedJobTemplate::Custom {
1587 configured_path,
1588 resolved_path,
1589 template_root,
1590 } => {
1591 let dependencies = collect_custom_template_dependencies(&resolved_path, &template_root)
1592 .ok()??
1593 .into_iter()
1594 .map(|dependency_path| {
1595 Some(GenerationDependencyFingerprintRecord {
1596 path: display_relative_path(&dependency_path, &template_root),
1597 fingerprint: fingerprint_path_contents(&dependency_path).ok()?,
1598 })
1599 })
1600 .collect::<Option<Vec<_>>>()?;
1601 GenerationTemplateFingerprintRecord::Custom {
1602 path: configured_path,
1603 dependencies,
1604 }
1605 }
1606 ResolvedJobTemplate::Missing => return None,
1607 };
1608
1609 let bundle = merged_bundle(defaults, job);
1610 let record = GenerationFingerprintRecord {
1611 schema_version: GENERATION_FINGERPRINT_SCHEMA_VERSION,
1612 job_name: job.name.clone(),
1613 output: job.output.clone(),
1614 access_level: resolve_access_level(defaults, job).to_owned(),
1615 bundle_mode: bundle.mode.clone().unwrap_or_else(|| "module".to_string()),
1616 bundle_identifier: bundle.identifier.clone(),
1617 variables: job.variables.clone(),
1618 inputs,
1619 template,
1620 };
1621 let payload = serde_json::to_vec(&record).ok()?;
1622 Some(GenerationFingerprintPlan {
1623 fingerprint: generation_cache::blake3_hex([payload.as_slice()]),
1624 cache_input_fingerprints,
1625 })
1626}
1627
1628enum ResolvedJobTemplate<'a> {
1629 Builtin {
1630 language: &'a str,
1631 name: &'a str,
1632 },
1633 Custom {
1634 configured_path: String,
1635 resolved_path: PathBuf,
1636 template_root: PathBuf,
1637 },
1638 Missing,
1639}
1640
1641fn resolve_job_template<'a>(
1642 config_dir: &Path,
1643 template_lookup_root: &Path,
1644 job: &'a JobConfig,
1645) -> Result<ResolvedJobTemplate<'a>, RenderError> {
1646 let builtin = job.template.builtin.as_ref();
1647 let builtin_language = builtin.and_then(|builtin| builtin.language.as_deref());
1648 let builtin_name = builtin.and_then(|builtin| builtin.name.as_deref());
1649
1650 if let (Some(language), Some(name)) = (builtin_language, builtin_name) {
1651 return Ok(ResolvedJobTemplate::Builtin { language, name });
1652 }
1653
1654 if let Some(template_path) = job.template.path.as_deref() {
1655 let resolved_path = resolve_template_entry_path(config_dir, template_path)?;
1656 return Ok(ResolvedJobTemplate::Custom {
1657 configured_path: template_path.to_owned(),
1658 resolved_path,
1659 template_root: config_dir.to_path_buf(),
1660 });
1661 }
1662
1663 if job.template.auto_lookup != Some(false)
1664 && job.template.builtin.is_none()
1665 && let Some(resolved_path) = discover_job_template_path(template_lookup_root, &job.name)?
1666 {
1667 return Ok(ResolvedJobTemplate::Custom {
1668 configured_path: display_relative_path(&resolved_path, template_lookup_root),
1669 resolved_path,
1670 template_root: template_lookup_root.to_path_buf(),
1671 });
1672 }
1673
1674 Ok(ResolvedJobTemplate::Missing)
1675}
1676
1677fn missing_template_detail(job: &JobConfig) -> String {
1678 if job.template.auto_lookup == Some(false) {
1679 return "job template must set a built-in or custom template path; implicit template lookup is disabled".to_string();
1680 }
1681
1682 format!(
1683 "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",
1684 job.name
1685 )
1686}
1687
1688fn cache_kind_for_input(input_kind: &str) -> Option<CacheKind> {
1689 match input_kind {
1690 "xcassets" => Some(CacheKind::Xcassets),
1691 "strings" => Some(CacheKind::Strings),
1692 "xcstrings" => Some(CacheKind::Xcstrings),
1693 "files" => Some(CacheKind::Files),
1694 _ => None,
1695 }
1696}
1697
1698fn fingerprint_path_contents(path: &Path) -> std::io::Result<String> {
1699 let mut files = Vec::new();
1700
1701 if path.is_file() {
1702 files.push(path.to_path_buf());
1703 } else if path.is_dir() {
1704 collect_fingerprint_files(path, &mut files)?;
1705 } else {
1706 return Err(std::io::Error::new(
1707 std::io::ErrorKind::NotFound,
1708 format!("path {} does not exist", path.display()),
1709 ));
1710 }
1711
1712 files.sort();
1713
1714 let mut hasher = Hasher::new();
1715 hasher.update(if path.is_file() { b"file" } else { b"dir" });
1716 hasher.update(b"\0");
1717
1718 for file in files {
1719 let relative = if path.is_file() {
1720 file.file_name().unwrap_or_default().as_encoded_bytes()
1721 } else {
1722 file.strip_prefix(path)
1723 .unwrap_or(&file)
1724 .as_os_str()
1725 .as_encoded_bytes()
1726 };
1727 let contents = fs::read(&file)?;
1728
1729 hasher.update(relative);
1730 hasher.update(b"\0");
1731 hasher.update(&contents);
1732 hasher.update(b"\0");
1733 }
1734
1735 Ok(hasher.finalize().to_hex().to_string())
1736}
1737
1738fn collect_fingerprint_files(root: &Path, files: &mut Vec<PathBuf>) -> std::io::Result<()> {
1739 for entry in fs::read_dir(root)? {
1740 let entry = entry?;
1741 let path = entry.path();
1742 if should_ignore_directory_entry(&path) {
1743 continue;
1744 }
1745 let file_type = entry.file_type()?;
1746
1747 if file_type.is_dir() {
1748 collect_fingerprint_files(&path, files)?;
1749 } else if file_type.is_file() {
1750 files.push(path);
1751 }
1752 }
1753
1754 Ok(())
1755}
1756
1757fn display_relative_path(path: &Path, root: &Path) -> String {
1758 path.strip_prefix(root)
1759 .unwrap_or(path)
1760 .display()
1761 .to_string()
1762}
1763
1764fn resolve_access_level<'a>(defaults: &'a DefaultsConfig, job: &'a JobConfig) -> &'a str {
1765 job.access_level
1766 .as_deref()
1767 .or(defaults.access_level.as_deref())
1768 .unwrap_or("internal")
1769}
1770
1771fn validate_bundle_mode(
1772 job_name: &str,
1773 mode: &str,
1774 identifier: Option<&str>,
1775) -> Result<(), GenerateError> {
1776 match mode {
1777 "module" | "main" => Ok(()),
1778 "custom" => {
1779 let _identifier = identifier.ok_or_else(|| GenerateError::UnsupportedJob {
1780 job: job_name.to_owned(),
1781 detail: "bundle mode `custom` requires an identifier".to_string(),
1782 })?;
1783 Ok(())
1784 }
1785 other => Err(GenerateError::UnsupportedJob {
1786 job: job_name.to_owned(),
1787 detail: format!("bundle mode `{other}`"),
1788 }),
1789 }
1790}
1791
1792fn merged_bundle(defaults: &DefaultsConfig, job: &JobConfig) -> BundleConfig {
1793 BundleConfig {
1794 mode: job
1795 .bundle
1796 .mode
1797 .clone()
1798 .or_else(|| defaults.bundle.mode.clone()),
1799 identifier: job
1800 .bundle
1801 .identifier
1802 .clone()
1803 .or_else(|| defaults.bundle.identifier.clone()),
1804 }
1805}
1806
1807#[derive(Debug, Clone)]
1808struct HookEnvironment {
1809 config_path: PathBuf,
1810 workspace_manifest_path: Option<PathBuf>,
1811 output_path: PathBuf,
1812 output_dir: PathBuf,
1813 job_name: String,
1814}
1815
1816impl HookEnvironment {
1817 fn new(
1818 config_path: &Path,
1819 workspace_manifest_path: Option<&Path>,
1820 job_name: &str,
1821 output_path: &Path,
1822 ) -> Result<Self, GenerateError> {
1823 Ok(Self {
1824 config_path: absolute_path(config_path)?,
1825 workspace_manifest_path: workspace_manifest_path.map(absolute_path).transpose()?,
1826 output_path: absolute_path(output_path)?,
1827 output_dir: absolute_path(output_path.parent().unwrap_or_else(|| Path::new(".")))?,
1828 job_name: job_name.to_owned(),
1829 })
1830 }
1831}
1832
1833fn run_hook(
1834 config_dir: &Path,
1835 hook: &HookConfig,
1836 phase: HookPhase,
1837 job_name: &str,
1838 env: &HookEnvironment,
1839 outcome: Option<WriteOutcome>,
1840) -> Result<HookReport, GenerateError> {
1841 let invocation = resolve_hook_command(config_dir, hook);
1842 let output = Command::new(&invocation.program)
1843 .args(&invocation.args)
1844 .current_dir(config_dir)
1845 .env("NUMI_HOOK_PHASE", phase.as_str())
1846 .env("NUMI_HOOK_JOB_NAME", &env.job_name)
1847 .env("NUMI_JOB_NAME", &env.job_name)
1848 .env("NUMI_HOOK_CONFIG_PATH", &env.config_path)
1849 .env("NUMI_CONFIG_PATH", &env.config_path)
1850 .env("NUMI_HOOK_OUTPUT_PATH", &env.output_path)
1851 .env("NUMI_OUTPUT_PATH", &env.output_path)
1852 .env("NUMI_HOOK_OUTPUT_DIR", &env.output_dir)
1853 .env("NUMI_OUTPUT_DIR", &env.output_dir)
1854 .env_remove("NUMI_HOOK_WRITE_OUTCOME")
1855 .env_remove("NUMI_WRITE_OUTCOME")
1856 .env_remove("NUMI_HOOK_WORKSPACE_CONFIG_PATH")
1857 .env_remove("NUMI_WORKSPACE_MANIFEST_PATH")
1858 .envs(
1859 outcome
1860 .map(|value| {
1861 let outcome = write_outcome_name(value).to_string();
1862 [
1863 ("NUMI_HOOK_WRITE_OUTCOME", outcome.clone()),
1864 ("NUMI_WRITE_OUTCOME", outcome),
1865 ]
1866 })
1867 .into_iter()
1868 .flatten(),
1869 )
1870 .envs(
1871 env.workspace_manifest_path
1872 .as_ref()
1873 .map(|path| {
1874 let path = path.display().to_string();
1875 [
1876 ("NUMI_HOOK_WORKSPACE_CONFIG_PATH", path.clone()),
1877 ("NUMI_WORKSPACE_MANIFEST_PATH", path),
1878 ]
1879 })
1880 .into_iter()
1881 .flatten(),
1882 )
1883 .output()
1884 .map_err(|source| GenerateError::HookSpawn {
1885 job: job_name.to_owned(),
1886 phase,
1887 command: invocation.display.clone(),
1888 source,
1889 })?;
1890
1891 if !output.status.success() {
1892 return Err(GenerateError::HookExit {
1893 job: job_name.to_owned(),
1894 phase,
1895 command: invocation.display.clone(),
1896 status: output.status,
1897 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
1898 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
1899 });
1900 }
1901
1902 Ok(HookReport {
1903 phase,
1904 command: invocation.display,
1905 })
1906}
1907
1908struct ResolvedHookCommand {
1909 program: PathBuf,
1910 args: Vec<String>,
1911 display: String,
1912}
1913
1914fn resolve_hook_command(config_dir: &Path, hook: &HookConfig) -> ResolvedHookCommand {
1915 if let Some(shell) = hook.shell.as_deref() {
1916 return ResolvedHookCommand {
1917 program: platform_shell_program(),
1918 args: platform_shell_args(shell),
1919 display: shell.to_string(),
1920 };
1921 }
1922
1923 let program = hook
1924 .command
1925 .first()
1926 .map(|value| {
1927 if command_looks_like_path(value) {
1928 config_dir.join(value)
1929 } else {
1930 PathBuf::from(value)
1931 }
1932 })
1933 .unwrap_or_default();
1934
1935 ResolvedHookCommand {
1936 program,
1937 args: hook.command[1..].to_vec(),
1938 display: render_hook_command(&hook.command),
1939 }
1940}
1941
1942fn command_looks_like_path(command: &str) -> bool {
1943 let path = Path::new(command);
1944 path.is_absolute()
1945 || command.starts_with('.')
1946 || path
1947 .components()
1948 .any(|component| matches!(component, std::path::Component::ParentDir))
1949 || path.components().count() > 1
1950}
1951
1952fn absolute_path(path: &Path) -> Result<PathBuf, GenerateError> {
1953 if path.is_absolute() {
1954 return Ok(path.to_path_buf());
1955 }
1956
1957 let cwd = std::env::current_dir().map_err(|error| GenerateError::UnsupportedJob {
1958 job: "hooks".to_string(),
1959 detail: format!("failed to read cwd while resolving hook paths: {error}"),
1960 })?;
1961 Ok(cwd.join(path))
1962}
1963
1964fn write_outcome_name(outcome: WriteOutcome) -> &'static str {
1965 match outcome {
1966 WriteOutcome::Created => "created",
1967 WriteOutcome::Updated => "updated",
1968 WriteOutcome::Unchanged => "unchanged",
1969 WriteOutcome::Skipped => "skipped",
1970 }
1971}
1972
1973fn render_hook_command(command: &[String]) -> String {
1974 command.join(" ")
1975}
1976
1977#[cfg(windows)]
1978fn platform_shell_program() -> PathBuf {
1979 PathBuf::from("cmd")
1980}
1981
1982#[cfg(not(windows))]
1983fn platform_shell_program() -> PathBuf {
1984 PathBuf::from("/bin/sh")
1985}
1986
1987#[cfg(windows)]
1988fn platform_shell_args(command: &str) -> Vec<String> {
1989 vec!["/C".to_string(), command.to_string()]
1990}
1991
1992#[cfg(not(windows))]
1993fn platform_shell_args(command: &str) -> Vec<String> {
1994 vec!["-c".to_string(), command.to_string()]
1995}
1996
1997fn to_utf8_path(path: &Path) -> Result<Utf8PathBuf, GenerateError> {
1998 Utf8PathBuf::from_path_buf(path.to_path_buf())
1999 .map_err(|path| GenerateError::InvalidOutputPath { path })
2000}
2001
2002#[cfg(test)]
2003mod tests;