package OLLM::Config; use v5.30; use strict; use warnings; use Cwd qw(abs_path getcwd); use Digest::SHA qw(sha256_hex); use File::Basename qw(dirname); use File::Find qw(find); use File::Spec; use JSON::PP; use OLLM::Version qw($VERSION); our $MANIFEST_SCHEMA = 2; our $DEFINITION_SCHEMA = 1; our $LOCAL_SCHEMA = 1; our $USER_SCHEMA = 1; our $PARSER_NAME = 'TOML::Tiny::Parser'; our $PARSER_ERROR; our $PARSER_LOADED; # Boolean keys of deployment permissions tables; `print` is the only # non-boolean key. OLLM::Deployment maps them onto qpdf options. our @PERMISSION_FLAGS = qw(extract modify annotate form assemble); sub resolve_request { my ($class, %arg) = @_; my $plan = $arg{plan} // die "missing CLI plan"; my $start = _absolute_directory( $arg{start_dir} // getcwd(), 'request start directory', ); my $standalone = $plan->{standalone} ? 1 : 0; my $located = $standalone && !defined $plan->{config} && !defined $plan->{project_root} ? { kind => 'none' } : $class->find_manifest( start_dir => $start, config => $plan->{config}, project_root => $plan->{project_root}, ); my $request = _base_request($plan, $standalone, $located); die "only a Perl configuration was found at $located->{path}; run " . "'ollm convertproject' to create ollmconfig.toml\n" if $located->{kind} eq 'unconverted'; return $class->_resolve_none_request($request, $standalone, $start) if $located->{kind} eq 'none'; return $class->_resolve_toml_request($request, $located, $start, %arg); } sub _base_request { my ($plan, $standalone, $located) = @_; my %request = ( action => $plan->{action}, all => $plan->{all} ? 1 : 0, context => $standalone ? 'standalone' : $located->{kind} eq 'toml' ? 'series' : 'none', dry_run => $plan->{dry_run} ? 1 : 0, latexmk_args => [@{ $plan->{latexmk_args} }], non_interactive => $plan->{non_interactive} ? 1 : 0, rebuild => $plan->{rebuild} ? 1 : 0, resolve => $plan->{resolve} ? 1 : 0, scope => $plan->{scope} // 'current', source => $plan->{source} // 'main.tex', target => $plan->{target}, target_explicit => $plan->{target_explicit} ? 1 : 0, language_explicit => $plan->{language_explicit} ? 1 : 0, source_explicit => $plan->{source_explicit} ? 1 : 0, ); $request{language} = $plan->{language} if defined $plan->{language}; $request{debug} = $plan->{debug} if defined $plan->{debug}; return \%request; } sub _resolve_none_request { my ($class, $request, $standalone, $start) = @_; if (!$standalone) { return { request => $request, configuration => { kind => 'none', parser => $class->parser_info, }, }; } die "standalone builds take document type and language from " . "osglecture class options; remove the explicit OLLM target/language" if $request->{target_explicit} || $request->{language_explicit}; die "--all requires a series manifest and is not available in standalone mode" if $request->{all}; my $source_candidate = File::Spec->rel2abs($request->{source}, $start); my $source = abs_path($source_candidate) // die "source file not found: $source_candidate"; die "source is not a regular file: $source" if !-f $source; my ($volume, $directories, $filename) = File::Spec->splitpath($source); (my $job_id = $filename) =~ s/\.[^.]+\z//; die "cannot derive a job name from standalone source '$source'" if $job_id eq ''; $request->{source} = $source; return { request => $request, configuration => { kind => 'none', parser => $class->parser_info, }, standalone_spec => { context => 'standalone', source => $source, source_directory => dirname($source), job_id => $job_id, shell_escape => 'restricted', }, }; } sub _resolve_toml_request { my ($class, $request, $located, $start, %arg) = @_; my $manifest = $class->load_manifest($located->{path}); my $user_defaults = $class->load_user_defaults; my $root = dirname($located->{path}); my $structure = $class->structure_snapshot(project_root => $root); my $definitions = $class->resolve_definitions( manifest => $manifest, manifest_path => $located->{path}, project_root => $root, bundle_path => $arg{definitions_dir}, ); $request->{project_root} = $root; $request->{series_id} = $manifest->{project}{id}; my @builds = _select_builds( request => $request, manifest => $manifest, definitions => $definitions, structure => $structure, project_root => $root, start => $start, manifest_path => $located->{path}, ); $request->{builds} = [map { +{ target => $_->{target}, language => $_->{language}, source => $request->{source}, physical_unit => $_->{physical_unit}, } } @builds]; if (@builds == 1) { $request->{target} = $builds[0]{target}; $request->{language} = $builds[0]{language}; } else { delete $request->{target}; delete $request->{language}; } my $resolved = { manifest => $manifest, request => $request, configuration => { kind => 'toml', path => $located->{path}, bundle_preset => $manifest->{bundle_preset}, definitions => $definitions, structure => $structure, user_defaults => $user_defaults, schema => $manifest->{schema}, parser => $class->parser_info, resolve_max_rounds => exists($manifest->{build}) && exists($manifest->{build}{resolve}) && exists($manifest->{build}{resolve}{max_rounds}) ? $manifest->{build}{resolve}{max_rounds} : 8, }, }; _attach_build_specs($resolved, $manifest, $root, @builds); return $resolved; } sub _attach_build_specs { my ($resolved, $manifest, $root, @builds) = @_; require OLLM::BuildFile; my @specs = map { OLLM::BuildFile->build_spec( resolved => $resolved, manifest => $manifest, unit_directory => File::Spec->catdir($root, $_->{physical_unit}), target => $_->{target}, language => $_->{language}, ) } @builds; my (%job, %directory); for my $spec (@specs) { my $job_key = lc $spec->{job_id}; die "build matrix produces job ids that collide on " . "case-insensitive filesystems: '$job{$job_key}' and " . "'$spec->{job_id}'" if exists $job{$job_key}; $job{$job_key} = $spec->{job_id}; my $directory_key = lc $spec->{build_directory}; die "build matrix assigns more than one build to directory " . "'$spec->{build_directory}'" if $directory{$directory_key}++; } if (@specs == 1) { $resolved->{build_spec} = $specs[0]; } else { $resolved->{build_specs} = \@specs; } return; } sub _select_builds { my (%arg) = @_; my $request = $arg{request}; my $manifest = $arg{manifest}; my $definitions = $arg{definitions}; my $scope = $request->{scope}; my @units = @{ $arg{structure}{units} }; my $current_name = File::Basename::basename($arg{start}); my ($current) = grep { $_->{physical_unit} eq $current_name } @units; die "invalid build scope '$scope'" if $scope !~ /\A(?:current|unit|series|collection)\z/; die "build --scope=$scope must be run inside a series unit" if ($scope eq 'current' || $scope eq 'unit') && !$current; my $targets = $manifest->{targets}; my @target_names = ($scope eq 'unit' || $request->{all}) ? ($request->{target_explicit} ? ($request->{target}) : sort keys %$targets) : ($request->{target}); for my $target (@target_names) { die "target '$target' is not configured in $arg{manifest_path}" if !defined($target) || !exists $targets->{$target}; } my @selected_units; if ($scope eq 'current' || $scope eq 'unit') { @selected_units = ($current); } elsif ($scope eq 'series') { @selected_units = grep { ($_->{unit_role} // 'content') ne 'i' } @units; } else { my %doctypes = map { $definitions->{targets}{$_}{doctype} => 1 } @target_names; for my $doctype (sort keys %doctypes) { my @doctype_targets = grep { $definitions->{targets}{$_}{doctype} eq $doctype } @target_names; my @candidates; UNIT: for my $unit (@units) { next if ($unit->{unit_role} // '') ne 'i'; for my $target (@doctype_targets) { if (_unit_allows_definition( $unit, $definitions->{targets}{$target}{unit_scopes})) { push @candidates, $unit; next UNIT; } } } die "no integration unit applies to document type '$doctype'" if !@candidates; die "more than one integration unit applies to document type '$doctype': " . join(', ', map { $_->{physical_unit} } @candidates) if @candidates > 1; push @selected_units, $candidates[0]; } } my @builds; for my $unit (@selected_units) { for my $target (@target_names) { my $definition = $definitions->{targets}{$target}; next if !_unit_allows_definition($unit, $definition->{unit_scopes}); my @configured = @{ $targets->{$target}{languages} }; my %configured = map { $_ => 1 } @configured; my @languages; if ($request->{language_explicit}) { if (!$configured{$request->{language}}) { die "language '$request->{language}' is not configured for target '$target'" if $request->{target_explicit}; next; } @languages = ($request->{language}); } elsif ($scope eq 'unit' || $request->{all}) { @languages = @configured; } else { my $default = $targets->{$target}{default_language} // $manifest->{languages}{default}; @languages = ($configured{$default} ? $default : $configured[0]); } push @builds, map { +{ physical_unit => $unit->{physical_unit}, target => $target, language => $_, } } @languages; } } die "no configured build matches --scope=$scope" if !@builds; return @builds; } sub _unit_allows_definition { my ($unit, $unit_scopes) = @_; return 1 if ($unit->{unit_scope} // '') eq ''; return scalar grep { $_ eq ($unit->{unit_scope} // '') } @{ $unit_scopes // [] }; } # Directory-grammar parsing and discovery live in the shared Lua module # (osglecture-series-index.lua, called via OLLM::LuaManifest), not here: # see osglecture/ARCHITECTURE.md section 12 and HISTORY.md. This function # keeps only what is genuinely OLLM's own concern -- the structure # signature is OLLM's build-state invalidation value, not something the # class needs to compute. sub structure_snapshot { my ($class, %arg) = @_; my $root = _absolute_directory( $arg{project_root} // die("missing project root"), 'project root', ); require OLLM::LuaManifest; my $units = eval { OLLM::LuaManifest::discover_units($root) }; if (!$units) { my $error = $@ || 'unknown error'; chomp $error; die "cannot discover series units in '$root': $error\n"; } my $canonical = JSON::PP->new->canonical->encode({ schema => 1, units => $units, }); return { schema => 1, units => $units, signature => sha256_hex($canonical), }; } sub find_manifest { my ($class, %arg) = @_; my $start = _absolute_directory($arg{start_dir} // getcwd(), 'start directory'); if (defined $arg{config}) { my $path = File::Spec->rel2abs($arg{config}, $start); die "configuration file not found: $path" if !-f $path; die "--config accepts only a TOML manifest, not '$path'" if $path !~ /\.toml\z/i; return { kind => 'toml', path => _canonical_path($path) }; } if (defined $arg{project_root}) { my $root = _absolute_directory( File::Spec->rel2abs($arg{project_root}, $start), 'project root', ); return _manifest_in($root, 1); } my $directory = $start; while (1) { my $found = _manifest_in($directory, 0); return $found if $found->{kind} ne 'none'; my $parent = dirname($directory); last if $parent eq $directory; $directory = $parent; } return { kind => 'none' }; } sub load_manifest { my ($class, $path) = @_; $class->_load_parser or die "TOML parser unavailable: $PARSER_ERROR; reinstall OLLM or install " . "TOML::Tiny with 'cpan TOML::Tiny'"; open my $handle, '<:raw', $path or die "cannot read configuration '$path': $!"; local $/; my $source = <$handle>; close $handle or die "cannot close configuration '$path': $!"; my $manifest = eval { TOML::Tiny::Parser->new(strict => 1)->parse($source); }; if (!$manifest) { my $error = $@ || 'unknown TOML parser error'; chomp $error; die "cannot parse configuration '$path': $error"; } $class->validate_manifest($manifest, $path, _source_map($source)); return $manifest; } sub validate_manifest { my ($class, $manifest, $path, $lines) = @_; $path //= ''; $lines //= {}; die "$path: manifest root must be a table" if ref $manifest ne 'HASH'; _known_keys($manifest, [qw(schema bundle_preset project targets security deployment build)], $path, $lines, ''); _validate_schema($manifest, $path, $lines); _require_string($manifest, 'bundle_preset', $path) if exists $manifest->{bundle_preset}; _validate_project_section($manifest, $path, $lines); _validate_targets_section($manifest, $path, $lines); _validate_security_section($manifest, $path, $lines) if exists $manifest->{security}; _validate_deployment($manifest, $path, $lines) if exists $manifest->{deployment}; _validate_build_section($manifest, $path, $lines) if exists $manifest->{build}; _normalize_manifest($manifest); return 1; } sub _validate_schema { my ($manifest, $path, $lines) = @_; return if defined($manifest->{schema}) && !ref($manifest->{schema}) && $manifest->{schema} == $MANIFEST_SCHEMA; my $actual = defined($manifest->{schema}) && !ref($manifest->{schema}) ? $manifest->{schema} : ''; _fail_at($path, $lines, 'schema', "unsupported project-manifest schema $actual; " . "this OLLM supports project-manifest schema $MANIFEST_SCHEMA"); } sub _validate_project_section { my ($manifest, $path, $lines) = @_; _require_table($manifest, 'project', $path); _known_keys($manifest->{project}, [qw(id tex_directory tex_config)], $path, $lines, 'project'); _require_string($manifest->{project}, 'id', "$path: project"); my $project = $manifest->{project}; if (exists $project->{tex_directory}) { my $directory = _require_string($project, 'tex_directory', "$path: project"); _fail_at($path, $lines, 'project.tex_directory', 'project.tex_directory must be a non-empty project-root-relative path') if $directory eq '' || File::Spec->file_name_is_absolute($directory); } if (exists $project->{tex_config}) { my $config = _require_string($project, 'tex_config', "$path: project"); _fail_at($path, $lines, 'project.tex_config', 'project.tex_config must be a filename without directory components') if $config eq '' || File::Spec->file_name_is_absolute($config) || $config =~ m{[\\/]}; } } # Values a target may draw from [targets.defaults] when it does not set its # own. document_metadata acts only where the resolved profile leaves the # metadata contract open ('supported'); resolve_definitions rejects it against # a profile that forces the contract. sub _validate_document_metadata_key { my ($table, $where, $path, $lines) = @_; return if !exists $table->{document_metadata}; my $value = _require_string($table, 'document_metadata', "$path: $where"); _fail_at($path, $lines, "$where.document_metadata", "invalid $where.document_metadata '$value'; expected 'enabled' or 'disabled'") if $value !~ /\A(?:enabled|disabled)\z/; } sub _validate_targets_section { my ($manifest, $path, $lines) = @_; _require_table($manifest, 'targets', $path); my $targets = $manifest->{targets}; my $defaults = $targets->{defaults}; _fail_at($path, $lines, 'targets.defaults', 'targets.defaults is required and must declare languages and ' . 'default_language') if ref $defaults ne 'HASH'; _known_keys($defaults, [qw(languages default_language presentation_profile longform_profile document_metadata)], $path, $lines, 'targets.defaults'); my $available = _require_string_array($defaults, 'languages', "$path: targets.defaults"); _fail_at($path, $lines, 'targets.defaults.languages', 'targets.defaults.languages must not be empty') if !@$available; my %available = map { $_ => 1 } @$available; _fail_at($path, $lines, 'targets.defaults.languages', 'targets.defaults.languages contains duplicates') if keys(%available) != @$available; _fail_at($path, $lines, 'targets.defaults.languages', 'targets.defaults.languages contains values that collide on ' . 'case-insensitive filesystems') if (grep { 1 } keys %{{ map { lc($_) => 1 } @$available }}) != @$available; my $default_language = _require_string($defaults, 'default_language', "$path: targets.defaults"); _fail_at($path, $lines, 'targets.defaults.default_language', "default_language '$default_language' is not listed in " . 'targets.defaults.languages') if !$available{$default_language}; _require_string($defaults, $_, "$path: targets.defaults") for grep { exists $defaults->{$_} } qw(presentation_profile longform_profile); _validate_document_metadata_key($defaults, 'targets.defaults', $path, $lines); my @names = grep { $_ ne 'defaults' } keys %$targets; _fail_at($path, $lines, 'targets', 'no build targets are configured (only targets.defaults)') if !@names; my %folded; for my $name (sort @names) { my $lc = lc $name; _fail_at($path, $lines, "targets.$name", "target names '$folded{$lc}' and '$name' collide on " . 'case-insensitive filesystems') if exists $folded{$lc}; $folded{$lc} = $name; } for my $name (sort @names) { my $target = $targets->{$name}; _fail_at($path, $lines, "targets.$name", "targets.$name must be a table") if ref $target ne 'HASH'; _known_keys($target, [qw(languages default_language profile document_metadata)], $path, $lines, "targets.$name"); if (exists $target->{languages}) { my $langs = _require_string_array($target, 'languages', "$path: targets.$name"); _fail_at($path, $lines, "targets.$name.languages", "targets.$name.languages must not be empty") if !@$langs; my %seen; for my $lang (@$langs) { _fail_at($path, $lines, "targets.$name.languages", "targets.$name.languages contains duplicates") if $seen{$lang}++; _fail_at($path, $lines, "targets.$name.languages", "target '$name' uses language '$lang' not listed in " . 'targets.defaults.languages') if !$available{$lang}; } } if (exists $target->{default_language}) { my $dl = _require_string($target, 'default_language', "$path: targets.$name"); my %effective = map { $_ => 1 } @{ $target->{languages} // $available }; _fail_at($path, $lines, "targets.$name.default_language", "target '$name' default_language '$dl' is not one of its languages") if !$effective{$dl}; } _require_string($target, 'profile', "$path: targets.$name") if exists $target->{profile}; _validate_document_metadata_key($target, "targets.$name", $path, $lines); } } # Schema 2 keeps language and profile declarations in one place -- in # [targets.defaults] and the individual [targets.] tables. The rest of # OLLM (build planning, deployment, the build-file writer) expects each target # to carry its own effective language set and a project-wide [languages] # block, so both are filled in once here; targets.defaults is lifted out of # the iterable target set into a private key. sub _normalize_manifest { my ($manifest) = @_; return if exists $manifest->{target_defaults}; my $defaults = delete $manifest->{targets}{defaults}; $manifest->{target_defaults} = $defaults; $manifest->{languages} = { available => [ @{ $defaults->{languages} } ], default => $defaults->{default_language}, }; for my $name (keys %{ $manifest->{targets} }) { my $target = $manifest->{targets}{$name}; $target->{languages} //= [ @{ $defaults->{languages} } ]; $target->{default_language} //= $defaults->{default_language}; } my $deployment = $manifest->{deployment}; if (ref $deployment eq 'HASH' && ref $deployment->{types} eq 'HASH') { for my $rule (values %{ $deployment->{types} }) { next if ref $rule ne 'HASH'; $rule->{paths} //= [ @{ $deployment->{paths} } ] if ref $deployment->{paths} eq 'ARRAY'; $rule->{filename} //= $deployment->{filename} if defined $deployment->{filename}; $rule->{collection_filename} //= $deployment->{collection_filename} if defined $deployment->{collection_filename}; $rule->{permissions} //= { %{ $deployment->{permissions} } } if ref $deployment->{permissions} eq 'HASH'; } } } sub _validate_security_section { my ($manifest, $path, $lines) = @_; die "$path: security must be a table" if ref $manifest->{security} ne 'HASH'; _known_keys($manifest->{security}, [qw(shell_escape overwrite)], $path, $lines, 'security'); if (exists $manifest->{security}{shell_escape}) { my $policy = _require_string( $manifest->{security}, 'shell_escape', "$path: security", ); die "$path: invalid security.shell_escape '$policy'" if $policy !~ /\A(?:off|restricted|full)\z/; } if (exists $manifest->{security}{overwrite}) { my $overwrite = _require_string( $manifest->{security}, 'overwrite', "$path: security", ); die "$path: invalid security.overwrite '$overwrite'" if $overwrite !~ /\A(?:explicit|automatic)\z/; } } sub _validate_build_section { my ($manifest, $path, $lines) = @_; die "$path: build must be a table" if ref $manifest->{build} ne 'HASH'; _known_keys($manifest->{build}, [qw(resolve)], $path, $lines, 'build'); return if !exists $manifest->{build}{resolve}; my $resolve = $manifest->{build}{resolve}; die "$path: build.resolve must be a table" if ref $resolve ne 'HASH'; _known_keys($resolve, [qw(max_rounds)], $path, $lines, 'build.resolve'); return if !exists $resolve->{max_rounds}; die "$path: build.resolve.max_rounds must be a positive integer" if ref($resolve->{max_rounds}) || $resolve->{max_rounds} !~ /\A[1-9][0-9]*\z/; } sub _validate_deployment { my ($manifest, $path, $lines) = @_; my $deployment = $manifest->{deployment}; die "$path: deployment must be a table" if ref $deployment ne 'HASH'; _known_keys($deployment, [qw(series roles types paths filename collection_filename permissions)], $path, $lines, 'deployment'); if (exists $deployment->{series}) { my $series = _require_string($deployment, 'series', "$path: deployment"); die "$path: invalid deployment.series '$series'" if $series !~ /\A(?:units|collection|both)\z/; } if (exists $deployment->{roles}) { my $roles = $deployment->{roles}; die "$path: deployment.roles must be a table" if ref $roles ne 'HASH'; for my $role (keys %$roles) { die "$path: deployment.roles.$role must be a string" if !defined($roles->{$role}) || ref($roles->{$role}); } } # Default routing every type inherits when it does not set its own; a type # override with no paths/filename of its own is only valid with these. my $default_paths; if (exists $deployment->{paths}) { $default_paths = _require_string_array($deployment, 'paths', "$path: deployment"); die "$path: deployment.paths must not be empty" if !@$default_paths; } _require_string($deployment, 'filename', "$path: deployment") if exists $deployment->{filename}; _require_string($deployment, 'collection_filename', "$path: deployment") if exists $deployment->{collection_filename}; _validate_permissions($deployment, $path, $lines, 'deployment') if exists $deployment->{permissions}; my $types = _require_table($deployment, 'types', "$path: deployment"); for my $doctype (sort keys %$types) { die "$path: invalid deployment document type '$doctype'" if $doctype !~ /\A[A-Za-z0-9][A-Za-z0-9._-]*\z/; my $rule = $types->{$doctype}; die "$path: deployment.types.$doctype must be a table" if ref $rule ne 'HASH'; _known_keys($rule, [qw(paths filename collection_filename series units permissions)], $path, $lines, "deployment.types.$doctype"); _validate_permissions($rule, $path, $lines, "deployment.types.$doctype") if exists $rule->{permissions}; if (exists $rule->{paths}) { my $paths = _require_string_array( $rule, 'paths', "$path: deployment.types.$doctype", ); die "$path: deployment.types.$doctype.paths must not be empty" if !@$paths; } elsif (!$default_paths) { die "$path: deployment.types.$doctype has no paths and deployment.paths " . "is not set"; } _require_string($rule, 'filename', "$path: deployment.types.$doctype") if exists $rule->{filename}; die "$path: deployment.types.$doctype has no filename and " . "deployment.filename is not set" if !exists $rule->{filename} && !exists $deployment->{filename}; _require_string($rule, 'collection_filename', "$path: deployment.types.$doctype") if exists $rule->{collection_filename}; if (exists $rule->{series}) { my $series = _require_string( $rule, 'series', "$path: deployment.types.$doctype", ); die "$path: invalid deployment.types.$doctype.series '$series'" if $series !~ /\A(?:units|collection|both)\z/; } next if !exists $rule->{units}; my $units = $rule->{units}; die "$path: deployment.types.$doctype.units must be a table" if ref $units ne 'HASH'; for my $unit (sort keys %$units) { die "$path: invalid deployment unit-id '$unit'" if $unit !~ /\A[^\s,=]+\z/ || index($unit, '...') >= 0; my $override = $units->{$unit}; die "$path: deployment.types.$doctype.units.$unit must be a table" if ref $override ne 'HASH'; _known_keys($override, [qw(filename)], $path, $lines, "deployment.types.$doctype.units.$unit"); _require_string($override, 'filename', "$path: deployment.types.$doctype.units.$unit"); } } } # PDF permissions applied to deployed copies. Absent keys grant the # permission; the values are normalized in place to 'full'|'low'|'none' and # 1|0 so that OLLM::Deployment never sees TOML boolean objects. sub _validate_permissions { my ($table, $path, $lines, $prefix) = @_; my $permissions = $table->{permissions}; die "$path: $prefix.permissions must be a table" if ref $permissions ne 'HASH'; _known_keys($permissions, [@PERMISSION_FLAGS, 'print'], $path, $lines, "$prefix.permissions"); if (exists $permissions->{print}) { my $print = _require_string($permissions, 'print', "$path: $prefix.permissions"); die "$path: invalid $prefix.permissions.print '$print'; " . "expected full, low, or none" if $print !~ /\A(?:full|low|none)\z/; } for my $flag (grep { exists $permissions->{$_} } @PERMISSION_FLAGS) { my $value = $permissions->{$flag}; my $boolean = ref $value ? eval { $value->isa('JSON::PP::Boolean') || $value->isa('Types::Serialiser::Boolean') } : defined($value) && $value =~ /\A[01]\z/; _fail_at($path, $lines, "$prefix.permissions.$flag", "$prefix.permissions.$flag must be true or false") if !$boolean; $permissions->{$flag} = $value ? 1 : 0; } } sub load_user_defaults { my ($class) = @_; my $path; if (defined $ENV{OLLM_USER_CONFIG} && $ENV{OLLM_USER_CONFIG} ne '') { $path = File::Spec->rel2abs($ENV{OLLM_USER_CONFIG}); die "OLLM user configuration not found: $path" if !-f $path; } elsif ($^O eq 'MSWin32' && defined $ENV{APPDATA}) { $path = File::Spec->catfile($ENV{APPDATA}, 'ollm', 'config.toml'); } elsif (defined $ENV{XDG_CONFIG_HOME} && $ENV{XDG_CONFIG_HOME} ne '') { $path = File::Spec->catfile($ENV{XDG_CONFIG_HOME}, 'ollm', 'config.toml'); } elsif (defined $ENV{HOME} && $ENV{HOME} ne '') { $path = File::Spec->catfile($ENV{HOME}, '.config', 'ollm', 'config.toml'); } return {} if !defined $path || !-f $path; my ($data, $lines) = $class->_load_toml($path); _known_keys($data, [qw(schema bundle_preset)], $path, $lines, ''); if (!defined $data->{schema} || ref $data->{schema} || $data->{schema} != $USER_SCHEMA) { my $actual = defined($data->{schema}) && !ref($data->{schema}) ? $data->{schema} : ''; _fail_at($path, $lines, 'schema', "unsupported user-configuration schema $actual; " . "this OLLM supports user-configuration schema $USER_SCHEMA"); } _require_string($data, 'bundle_preset', $path) if exists $data->{bundle_preset}; $data->{path} = $path; return $data; } sub resolve_definitions { my ($class, %arg) = @_; my $manifest = $arg{manifest} // die "missing manifest"; my $manifest_path = $arg{manifest_path} // ''; my $root = $arg{project_root} // die "missing project root"; my $manifest_lines = -f $manifest_path ? _source_map_from_file($manifest_path) : {}; my @paths; my $local_path = File::Spec->catfile($root, '.ollmconfig.local.toml'); if (-f $local_path) { my $local = $class->_load_local_config($local_path); for my $path (@{ $local->{definitions}{paths} // [] }) { push @paths, File::Spec->file_name_is_absolute($path) ? $path : File::Spec->catdir($root, $path); } } push @paths, $arg{bundle_path} if defined $arg{bundle_path}; my (%preset, %target, %profile); my %index_for = ( 'bundle-preset' => \%preset, 'target' => \%target, 'profile' => \%profile, ); for my $path (@paths) { die "definition search path not found: $path" if !-d $path; my @files; find( sub { push @files, $File::Find::name if -f $_ && /\.toml\z/i }, $path, ); for my $file (sort @files) { my $definition = $class->_load_definition($file); my $index = $index_for{ $definition->{kind} }; my $reference = $definition->{kind} eq 'bundle-preset' ? "$definition->{name}/$definition->{version}" : $definition->{name}; if (exists $index->{$reference}) { die "duplicate $definition->{kind} definition '$reference': " . "$index->{$reference}{path} and $file"; } $index->{$reference} = { path => abs_path($file), data => $definition, }; } } # A manifest without bundle_preset uses the personal default, then the # built-in one. Every caller (build, report/check, doctor) resolves through # here, and later build steps read the filled-in value from the manifest. $manifest->{bundle_preset} //= $class->load_user_defaults->{bundle_preset} // 'OSG lecture/1'; my $preset_name = $manifest->{bundle_preset}; if (!exists $preset{$preset_name}) { my $available = keys(%preset) ? join(', ', map { "'$_'" } sort keys %preset) : ''; _fail_at($manifest_path, $manifest_lines, 'bundle_preset', "requested bundle preset '$preset_name' was not found; " . "available bundle presets: $available; searched: " . join(', ', @paths)); } my %profiles; for my $name (sort keys %profile) { $profiles{$name} = { document_metadata => $profile{$name}{data}{document_metadata}, doctypes => $profile{$name}{data}{doctypes}, profile_class => $profile{$name}{data}{profile_class}, path => $profile{$name}{path}, version => $profile{$name}{data}{version}, signature => sha256_hex( JSON::PP->new->canonical->encode($profile{$name}{data}), ), }; } my $target_defaults = $manifest->{target_defaults} // {}; my %selected_targets; my %selected_target_data; for my $name (sort keys %{ $manifest->{targets} }) { _fail_at($manifest_path, $manifest_lines, "targets.$name", "target '$name' was not found; searched: " . join(', ', @paths)) if !exists $target{$name}; my $definition = $target{$name}{data}; my $profile_class = $definition->{profile_class}; my $profile_name = $manifest->{targets}{$name}{profile} // $target_defaults->{"${profile_class}_profile"} // $preset{$preset_name}{data}{"${profile_class}_profile"}; _fail_at($manifest_path, $manifest_lines, "targets.$name", "target '$name' has no ${profile_class} profile; set profile in " . "targets.$name or ${profile_class}_profile in targets.defaults") if !defined $profile_name; _fail_at($manifest_path, $manifest_lines, "targets.$name", "target '$name' selects profile '$profile_name', which was not found; " . 'searched: ' . join(', ', @paths)) if !exists $profile{$profile_name}; my $profile_data = $profile{$profile_name}{data}; _fail_at($manifest_path, $manifest_lines, "targets.$name", "target '$name' is a $profile_class target but profile '$profile_name' " . "is a $profile_data->{profile_class} profile") if $profile_data->{profile_class} ne $profile_class; _fail_at($manifest_path, $manifest_lines, "targets.$name", "profile '$profile_name' does not support document type " . "'$definition->{doctype}'") if !grep { $_ eq $definition->{doctype} } @{ $profile_data->{doctypes} }; my $metadata_choice = $manifest->{targets}{$name}{document_metadata} // $target_defaults->{document_metadata}; my $metadata_policy = _resolve_metadata_policy( $profile_data->{document_metadata}, $metadata_choice, $manifest_path, $manifest_lines, "targets.$name", $profile_name, ); $selected_targets{$name} = { doctype => $definition->{doctype}, profile_class => $profile_class, profile => $profile_name, profile_signature => $profiles{$profile_name}{signature}, document_metadata => $metadata_policy, path => $target{$name}{path}, signature => sha256_hex( JSON::PP->new->canonical->encode($definition), ), version => $definition->{version}, unit_scopes => $definition->{unit_scopes} // [], }; $selected_target_data{$name} = $definition; } my $signature = sha256_hex( JSON::PP->new->canonical->encode({ manifest => $manifest, bundle_preset => $preset{$preset_name}{data}, targets => \%selected_target_data, }), ); return { definition_signature => $signature, local_config => -f $local_path ? abs_path($local_path) : undef, bundle_preset => { name => $preset{$preset_name}{data}{name}, path => $preset{$preset_name}{path}, reference => $preset_name, version => $preset{$preset_name}{data}{version}, signature => sha256_hex( JSON::PP->new->canonical->encode($preset{$preset_name}{data}), ), }, search_paths => [map { abs_path($_) // $_ } @paths], targets => \%selected_targets, profiles => \%profiles, }; } # Turns the profile's metadata capability plus the project's optional # document_metadata choice into the single build-file policy the class reads. # 'required'/'forbidden' profiles fix the policy and reject a contradicting # choice; only a 'supported' profile lets the project decide, defaulting off. sub _resolve_metadata_policy { my ($capability, $choice, $manifest_path, $lines, $where, $profile_name) = @_; if ($capability eq 'required') { _fail_at($manifest_path, $lines, "$where.document_metadata", "$where sets document_metadata = 'disabled', but profile " . "'$profile_name' requires document metadata") if defined $choice && $choice eq 'disabled'; return 'enabled'; } if ($capability eq 'forbidden') { _fail_at($manifest_path, $lines, "$where.document_metadata", "$where sets document_metadata = 'enabled', but profile " . "'$profile_name' forbids document metadata") if defined $choice && $choice eq 'enabled'; return 'disabled'; } return defined $choice ? $choice : 'disabled'; } sub _load_local_config { my ($class, $path) = @_; my ($data, $lines) = $class->_load_toml($path); _known_keys($data, [qw(schema definitions)], $path, $lines, ''); if (!defined $data->{schema} || ref $data->{schema} || $data->{schema} != $LOCAL_SCHEMA) { my $actual = defined($data->{schema}) && !ref($data->{schema}) ? $data->{schema} : ''; _fail_at($path, $lines, 'schema', "unsupported local-configuration schema $actual; " . "this OLLM supports local-configuration schema $LOCAL_SCHEMA"); } _require_table($data, 'definitions', $path); _known_keys($data->{definitions}, [qw(paths)], $path, $lines, 'definitions'); _require_string_array($data->{definitions}, 'paths', "$path: definitions"); return $data; } sub _load_definition { my ($class, $path) = @_; my ($data, $lines) = $class->_load_toml($path); _known_keys($data, [qw(schema kind name version doctype doctypes profile_class unit_scopes document_metadata presentation_profile longform_profile)], $path, $lines, ''); if (!defined $data->{schema} || ref $data->{schema} || $data->{schema} != $DEFINITION_SCHEMA) { my $actual = defined($data->{schema}) && !ref($data->{schema}) ? $data->{schema} : ''; _fail_at($path, $lines, 'schema', "unsupported definition schema $actual; " . "this OLLM supports definition schema $DEFINITION_SCHEMA"); } my $kind = _require_string($data, 'kind', $path); _fail_at($path, $lines, 'kind', "unknown definition kind '$kind'") if $kind !~ /\A(?:bundle-preset|target|profile)\z/; _require_string($data, 'name', $path); _require_string($data, 'version', $path); if ($kind eq 'bundle-preset') { for my $reject (qw(doctype doctypes profile_class unit_scopes document_metadata)) { _fail_at($path, $lines, $reject, "bundle preset must not define '$reject'") if exists $data->{$reject}; } _require_string($data, $_, $path) for grep { exists $data->{$_} } qw(presentation_profile longform_profile); } elsif ($kind eq 'profile') { # The profile capability projection mirrors, for OLLM's pre-class use, # exactly the \DeclareOsgLectureProfile fields that decide the build # (metadata contract, supported doctypes, profile class). Anything about # rendering -- backend, adapter, class options -- stays TeX-only. for my $reject (qw(doctype unit_scopes presentation_profile longform_profile)) { _fail_at($path, $lines, $reject, "profile definition must not define '$reject'") if exists $data->{$reject}; } my $metadata = _require_string($data, 'document_metadata', $path); _fail_at($path, $lines, 'document_metadata', "invalid profile document_metadata '$metadata'; " . "expected 'required', 'supported' or 'forbidden'") if $metadata !~ /\A(?:required|supported|forbidden)\z/; my $profile_class = _require_string($data, 'profile_class', $path); _fail_at($path, $lines, 'profile_class', "invalid profile profile_class '$profile_class'; " . "expected 'presentation' or 'longform'") if $profile_class !~ /\A(?:presentation|longform)\z/; my $doctypes = _require_string_array($data, 'doctypes', $path); _fail_at($path, $lines, 'doctypes', 'profile doctypes must not be empty') if !@$doctypes; } else { for my $reject (qw(doctypes document_metadata presentation_profile longform_profile)) { _fail_at($path, $lines, $reject, "target definition must not define '$reject'") if exists $data->{$reject}; } _require_string($data, 'doctype', $path); my $profile_class = _require_string($data, 'profile_class', $path); _fail_at($path, $lines, 'profile_class', "invalid target profile_class '$profile_class'; " . "expected 'presentation' or 'longform'") if $profile_class !~ /\A(?:presentation|longform)\z/; _fail_at($path, $lines, 'doctype', "target name '$data->{name}' and doctype '$data->{doctype}' differ; " . "schema 1 has no versioned target/doctype adapter contract") if $data->{name} ne $data->{doctype}; if (exists $data->{unit_scopes}) { my $unit_scopes = _require_string_array( $data, 'unit_scopes', $path, ); for my $unit_scope (@$unit_scopes) { _fail_at($path, $lines, 'unit_scopes', "invalid target unit scope '$unit_scope'") if $unit_scope !~ /\A[a-z]{1,2}\z/; } } } return $data; } sub _load_toml { my ($class, $path) = @_; $class->_load_parser or die "TOML parser unavailable: $PARSER_ERROR"; open my $handle, '<:raw', $path or die "cannot read configuration '$path': $!"; local $/; my $source = <$handle>; close $handle; my $data = eval { TOML::Tiny::Parser->new(strict => 1)->parse($source) }; if (!$data) { my $error = $@ || 'unknown TOML parser error'; chomp $error; die "cannot parse configuration '$path': $error"; } return ($data, _source_map($source)); } sub parser_info { my ($class) = @_; my $available = $class->_load_parser; my $loaded_from = $INC{'TOML/Tiny/Parser.pm'}; my $source = $available && defined $loaded_from && $loaded_from =~ m{(?:^|/)vendor/TOML-Tiny-0[.]22/} ? 'bundled' : $available ? 'system' : 'missing'; return { available => $available ? 1 : 0, error => $PARSER_ERROR, name => $PARSER_NAME, version => $available ? $TOML::Tiny::Parser::VERSION : undef, source => $source, standard => 'TOML 1.0', }; } sub _load_parser { return 1 if $PARSER_LOADED; my $loaded = eval { require TOML::Tiny::Parser; 1; }; if (!$loaded) { $PARSER_ERROR = $@ || 'unknown loader error'; chomp $PARSER_ERROR; return 0; } $PARSER_LOADED = 1; $PARSER_ERROR = undef; return 1; } sub _manifest_in { my ($directory, $required) = @_; my $toml = File::Spec->catfile($directory, 'ollmconfig.toml'); my $perl = File::Spec->catfile($directory, 'ollmconfig.pl'); return { kind => 'toml', path => _canonical_path($toml) } if -f $toml; die "no ollmconfig.toml found in project root $directory" if $required; return { kind => 'unconverted', path => _canonical_path($perl) } if -f $perl; return { kind => 'none' }; } sub _canonical_path { my ($path) = @_; my $absolute = abs_path($path); return defined $absolute ? File::Spec->canonpath($absolute) : undef; } sub _absolute_directory { my ($path, $label) = @_; my $absolute = abs_path($path); die "$label not found: $path" if !defined $absolute; die "$label is not a directory: $absolute" if !-d $absolute; return $absolute; } sub _source_map { my ($source) = @_; my %lines; my $table = ''; my @source_lines = split /\n/, $source, -1; for my $index (0 .. $#source_lines) { my $line = $source_lines[$index]; if ($line =~ /^\s*\[\s*([A-Za-z0-9_.-]+)\s*\]\s*(?:#.*)?$/) { $table = $1; $lines{$table} //= $index + 1; next; } next if $line !~ /^\s*([A-Za-z0-9_-]+)\s*=/; my $key = $1; my $path = length($table) ? "$table.$key" : $key; $lines{$path} //= $index + 1; } return \%lines; } sub _source_map_from_file { my ($path) = @_; open my $handle, '<:raw', $path or die "cannot read configuration '$path': $!"; local $/; my $source = <$handle>; close $handle; return _source_map($source); } sub _known_keys { my ($table, $allowed, $file, $lines, $prefix) = @_; my %allowed = map { $_ => 1 } @$allowed; for my $key (sort keys %$table) { next if $allowed{$key}; my $path = length($prefix) ? "$prefix.$key" : $key; _fail_at($file, $lines, $path, "unknown key '$path'"); } } sub _fail_at { my ($file, $lines, $path, $message) = @_; my $line = $lines->{$path}; die defined($line) ? "$file:$line: $message" : "$file: $message"; } sub _require_table { my ($table, $key, $where) = @_; die "$where: missing table '$key'" if !exists $table->{$key}; die "$where: '$key' must be a table" if ref $table->{$key} ne 'HASH'; return $table->{$key}; } sub _require_string { my ($table, $key, $where) = @_; die "$where: missing '$key'" if !exists $table->{$key}; my $value = $table->{$key}; die "$where: '$key' must be a non-empty string" if ref $value || !defined $value || $value eq ''; return $value; } sub _require_string_array { my ($table, $key, $where) = @_; die "$where: missing '$key'" if !exists $table->{$key}; my $values = $table->{$key}; die "$where: '$key' must be an array" if ref $values ne 'ARRAY'; for my $value (@$values) { die "$where: '$key' entries must be non-empty strings" if ref $value || !defined $value || $value eq ''; } return $values; } 1;