Skip to main content

numi_core/pipeline/
mod.rs

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;