SimpleProperties.java

package io.jstach.rainbowgum.simple.props;

import java.io.IOException;
import java.io.InputStream;
import java.io.StringReader;
import java.io.UncheckedIOException;
import java.lang.System.Logger.Level;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.Function;
import java.util.regex.Pattern;

import org.jspecify.annotations.Nullable;

import io.jstach.rainbowgum.LogAlerts;
import io.jstach.rainbowgum.LogProperties;
import io.jstach.rainbowgum.LogProperty;
import io.jstach.rainbowgum.LogEventFactory;
import io.jstach.rainbowgum.annotation.CaseChanging;
import io.jstach.rainbowgum.annotation.EnumAlias;

/**
 * Resolves {@link LogProperties} from the following layers, highest priority first:
 * <ol>
 * <li>The optional watched level file, enabled by {@value #LEVEL_FILE_PROPERTY}. Only
 * {@code logging.level} and {@code logging.level.*} keys are supplied by this
 * source.</li>
 * <li>System properties
 * ({@link LogProperties.StandardProperties#SYSTEM_PROPERTIES}).</li>
 * <li>Environment variables, using {@value Builder#DEFAULT_ENV_PREFIX} (configurable) in
 * place of the {@code "logging."} lead segment of the key, remaining <code>.</code>s
 * replaced with <code>_</code>, and casing left exactly as-is; see
 * {@link Builder#envPrefix(String)}.</li>
 * <li>Profile resources selected by {@value #PROFILES_PROPERTY}, first profile wins.
 * These are loaded only when the base resource exists. An empty selection uses the
 * optional {@code default} profile; all other profile resources must exist.</li>
 * <li>A classpath resource (default {@value Builder#DEFAULT_RESOURCE}, configurable)
 * parsed the same way {@link LogProperties.Builder#fromProperties(String)} parses any
 * other properties text. If the base resource is absent, this module supplies no property
 * sources at all.</li>
 * </ol>
 * Create one with {@link #builder()}, or just rely on {@link SimplePropertiesProvider}
 * picking up the defaults automatically via {@link java.util.ServiceLoader}. The provider
 * reports selected profiles and classpath resource loading through {@link LogAlerts}.
 * <p>
 * <b>GraalVM native image</b>: this module bundles a {@code resource-config.json} (at
 * {@code META-INF/native-image/io.jstach.rainbowgum/rainbowgum-simple-props/}) that
 * includes {@code logging.properties} and {@code logging-<profile>.properties}
 * automatically. Profile names contain only ASCII letters, digits, underscores, and
 * hyphens. Custom {@link Builder#resource(String)} names and their profile variants need
 * corresponding native-image resource metadata.
 */
public final class SimpleProperties {

	/**
	 * How resource keys without the logging prefix are treated. The base resource
	 * overrides {@link Builder#strict(StrictType)}. Accepts {@code short} (default),
	 * {@code off} or {@code false}, {@code alert}, and {@code fail} or {@code true}. It
	 * is always written with its full key, even when {@code short} allows other keys
	 * without the logging prefix.
	 */
	public static final String STRICT_PROPERTY = LogProperties.ROOT_PREFIX + "simpleprops.strict";

	/**
	 * Controls how resource property keys without the logging prefix are treated.
	 */
	@CaseChanging
	public enum StrictType {

		/** Disables prefix validation. */
		@EnumAlias("false")
		OFF,
		/** Records an error alert for each key without the logging prefix. */
		ALERT,
		/**
		 * Records error alerts and fails when supplying properties if any key lacks the
		 * logging prefix.
		 */
		@EnumAlias("true")
		FAIL,
		/**
		 * Keys without the logging prefix get it, in the base resource and profile
		 * resources: <code>level.com.myco=debug</code> is
		 * <code>logging.level.com.myco=debug</code>. When both forms of a key are given,
		 * the later line wins, as with any repeated key. A base resource with keys only
		 * java.util.logging uses, such as <code>handlers</code> or <code>.level</code>,
		 * fails like {@link #FAIL}, since its configuration file is also named
		 * <code>logging.properties</code>.
		 */
		SHORT;

		static StrictType parse(String value) {
			return switch (value.toLowerCase(java.util.Locale.ROOT)) {
				case "true" -> FAIL;
				case "false" -> OFF;
				default -> LogProperty.enumValue(StrictType.class, value, "true", "false");
			};
		}

	}

	/**
	 * Comma-separated profiles, in priority order. Resolved from system properties,
	 * environment variables, or {@link Builder#profiles(List)}, in that priority order,
	 * after the base classpath resource is found. With the default environment prefix,
	 * use {@code RAINBOWGUM_profiles}. For example,
	 * {@code logging.profiles=local-dev,dev} loads {@code logging-local-dev.properties}
	 * before {@code logging-dev.properties}, with {@code logging.properties} as fallback.
	 * A missing base resource disables all property sources from this module, regardless
	 * of profile selection. With no profiles selected, the optional {@code default}
	 * profile is used. It is not included automatically when other profiles are selected.
	 * A missing selected profile resource fails initialization unless its name is
	 * {@code default}. Files cannot activate additional profiles.
	 */
	public static final String PROFILES_PROPERTY = LogProperties.ROOT_PREFIX + "profiles";

	/**
	 * Whether to watch a level file for log levels, see
	 * {@value #LEVEL_FILE_PATH_PROPERTY}. Accepts {@code off} or {@code false} (default)
	 * and {@code watch} or {@code true}. Resolved from system properties, environment
	 * variables, or the base resource, and only when the base resource exists.
	 */
	public static final String LEVEL_FILE_PROPERTY = LogProperties.ROOT_PREFIX + "simpleprops.levelFile";

	/**
	 * Where the watched level file is, default <code>level.properties</code>. A relative
	 * path is resolved against the working directory. Resolved from the same sources as
	 * {@value #LEVEL_FILE_PROPERTY}, never from profile resources or the level file
	 * itself. Where POSIX permissions are available, the file is refused while it or its
	 * directory can be written by group or others, or is owned by someone other than the
	 * user running the application or root. Symbolic links also have their resolved
	 * target's directory checked; a missing symbolic link target is refused.
	 */
	public static final String LEVEL_FILE_PATH_PROPERTY = LogProperties.ROOT_PREFIX + "simpleprops.levelFilePath";

	/**
	 * Whether to watch a level file, see {@value #LEVEL_FILE_PROPERTY}.
	 */
	@CaseChanging
	public enum LevelFileType {

		/** No level file. */
		@EnumAlias("false")
		OFF,
		/**
		 * Polls the level file, see {@value #LEVEL_FILE_PATH_PROPERTY}. Its
		 * <code>logging.level</code> keys take precedence over every other source, and
		 * level changes are allowed unless {@code logging.global.change} or
		 * {@code logging.change} is set otherwise. Keys may leave off the logging prefix:
		 * <code>level.com.myco=debug</code> is <code>logging.level.com.myco=debug</code>.
		 * Other keys in the file are ignored.
		 */
		@EnumAlias("true")
		WATCH;

		static LevelFileType parse(String value) {
			return switch (value.toLowerCase(java.util.Locale.ROOT)) {
				case "true" -> WATCH;
				case "false" -> OFF;
				default -> LogProperty.enumValue(LevelFileType.class, value, "true", "false");
			};
		}

	}

	private static final Pattern PROFILE_NAME = Pattern.compile("[A-Za-z0-9_-]+");

	private final List<LogProperties> properties;

	private final List<String> infoMessages;

	private final List<String> errorMessages;

	private final StrictType strict;

	private final @Nullable LevelFileProperties levelFile;

	private SimpleProperties(List<LogProperties> properties, List<String> infoMessages, List<String> errorMessages,
			StrictType strict, @Nullable LevelFileProperties levelFile) {
		this.properties = properties;
		this.infoMessages = infoMessages;
		this.errorMessages = errorMessages;
		this.strict = strict;
		this.levelFile = levelFile;
	}

	/*
	 * The watched level file, when enabled.
	 */
	@Nullable LevelFileProperties levelFile() {
		return levelFile;
	}

	/**
	 * The layers described in this class's javadoc, highest priority first.
	 * @return properties, highest priority first.
	 * @throws IllegalArgumentException if strict validation is {@link StrictType#FAIL}
	 * and the base resource has unprefixed keys, or is {@link StrictType#SHORT} and the
	 * base resource has java.util.logging keys.
	 */
	public List<LogProperties> properties() {
		if ((strict == StrictType.FAIL || strict == StrictType.SHORT) && !errorMessages.isEmpty()) {
			throw new IllegalArgumentException(
					"Invalid simple-props base resource:\n" + String.join("\n", errorMessages));
		}
		return properties;
	}

	void reportAlerts(LogAlerts alerts) {
		for (var message : infoMessages) {
			alerts.info(SimpleProperties.class, message);
		}
		var eventFactory = LogEventFactory.of(SimpleProperties.class.getName());
		for (var message : errorMessages) {
			alerts.alert(eventFactory.eventNoArg(Level.ERROR, message, null));
		}
	}

	private record ProfileResources(List<String> names, List<LogProperties> properties, List<String> resources) {
	}

	/**
	 * Creates a builder.
	 * @return builder.
	 */
	public static Builder builder() {
		return new Builder();
	}

	/**
	 * Builds a {@link SimpleProperties}.
	 */
	public static final class Builder {

		/**
		 * Default environment variable prefix: {@value #DEFAULT_ENV_PREFIX}.
		 */
		public static final String DEFAULT_ENV_PREFIX = "RAINBOWGUM_";

		/**
		 * Default classpath resource: {@value #DEFAULT_RESOURCE}.
		 */
		public static final String DEFAULT_RESOURCE = "classpath:/logging.properties";

		private String envPrefix = DEFAULT_ENV_PREFIX;

		private String resource = DEFAULT_RESOURCE;

		private List<String> profiles = List.of();

		private StrictType strict = StrictType.SHORT;

		/**
		 * Sets how resource keys without the logging prefix are treated. The base
		 * resource's {@value SimpleProperties#STRICT_PROPERTY} overrides this setting.
		 * {@link StrictType#ALERT} and {@link StrictType#FAIL} record error alerts, and
		 * {@link StrictType#FAIL} also stops initialization when properties are supplied.
		 * @param strict mode, default {@link StrictType#SHORT}.
		 * @return this.
		 */
		public Builder strict(StrictType strict) {
			this.strict = Objects.requireNonNull(strict);
			return this;
		}

		private Function<String, @Nullable String> envLookup = System::getenv;

		private Path levelFilePath = Path.of(LevelFileProperties.FILE_NAME);

		private Duration levelFilePollInterval = LevelFileProperties.POLL_INTERVAL;

		private Builder() {
		}

		/**
		 * Sets the environment variable prefix used in place of the key's
		 * {@code "logging."} lead segment (for example {@code logging.level.com.myapp}
		 * becomes {@code RAINBOWGUM_level_com_myapp} with the default prefix). Casing is
		 * not changed: only the prefix and the <code>.</code> to <code>_</code>
		 * replacement are applied, so a key with mixed-case segments produces a
		 * mixed-case environment variable name.
		 * @param envPrefix prefix, default {@value #DEFAULT_ENV_PREFIX}.
		 * @return this.
		 */
		public Builder envPrefix(String envPrefix) {
			this.envPrefix = Objects.requireNonNull(envPrefix);
			return this;
		}

		/**
		 * Sets the classpath resource to load properties from. A leading
		 * {@code classpath:} scheme (with or without a following <code>/</code>) is
		 * stripped before resolving; the remainder is resolved as a plain classpath
		 * resource name. Profile names are inserted before a trailing {@code .properties}
		 * extension, or appended with a hyphen if there is no such extension. A missing
		 * base resource makes this module supply no property sources, regardless of
		 * profile selection. Missing selected profile resources fail when the base
		 * resource exists, except for the optional {@code default} profile.
		 * @apiNote unlike the default resource name, a custom one here is not covered by
		 * this module's bundled GraalVM {@code resource-config.json} - see this class's
		 * javadoc.
		 * @param resource classpath resource, default {@value #DEFAULT_RESOURCE}.
		 * @return this.
		 */
		public Builder resource(String resource) {
			this.resource = Objects.requireNonNull(resource);
			return this;
		}

		/**
		 * Sets fallback profiles in priority order. A system property or environment
		 * variable for {@value SimpleProperties#PROFILES_PROPERTY} overrides this list,
		 * including when it selects no profiles. Profile names must contain only ASCII
		 * letters, digits, underscores, and hyphens. An empty selection uses the optional
		 * {@code default} profile.
		 * @param profiles fallback profile names, first profile wins.
		 * @return this.
		 */
		public Builder profiles(List<String> profiles) {
			this.profiles = List.copyOf(profiles);
			return this;
		}

		/**
		 * Sets fallback profiles in priority order.
		 * @param profiles fallback profile names, first profile wins.
		 * @return this.
		 * @see #profiles(List)
		 */
		public Builder profiles(String... profiles) {
			return profiles(List.of(profiles));
		}

		/*
		 * Test-only hook so env var mapping can be tested without setting real
		 * environment variables (which the JVM cannot do portably at test time).
		 */
		Builder envLookup(Function<String, @Nullable String> envLookup) {
			this.envLookup = Objects.requireNonNull(envLookup);
			return this;
		}

		/*
		 * Test-only hook so the default level file need not be in the working directory
		 * and tests need not wait seconds for a poll.
		 */
		Builder levelFilePath(Path levelFilePath, Duration pollInterval) {
			this.levelFilePath = Objects.requireNonNull(levelFilePath);
			this.levelFilePollInterval = Objects.requireNonNull(pollInterval);
			return this;
		}

		/**
		 * Builds the {@link SimpleProperties}.
		 * @return simple properties.
		 */
		public SimpleProperties build() {
			var classpathProperties = loadResource(resource);
			if (classpathProperties == LogProperties.StandardProperties.EMPTY) {
				return new SimpleProperties(List.of(), List.of("No properties resource found: " + resource), List.of(),
						strict, null);
			}
			var strictType = classpathProperties.forKey(STRICT_PROPERTY)
				.ofString()
				.map(StrictType::parse)
				.or(strict)
				.validateNow(SimpleProperties.class);
			List<String> errorMessages = List.of();
			if (classpathProperties instanceof SimpleLogProperties simple) {
				errorMessages = switch (strictType) {
					case OFF -> List.of();
					case ALERT, FAIL -> simple.prefixErrors();
					case SHORT -> simple.julErrors();
				};
			}
			boolean shortKeys = strictType == StrictType.SHORT;
			if (shortKeys) {
				classpathProperties = withShortKeys(classpathProperties);
			}
			var systemProperties = LogProperties.StandardProperties.SYSTEM_PROPERTIES;
			var environmentVariables = new EnvVarProperties(envPrefix, envLookup);
			var preProperties = LogProperties.of(List.of(systemProperties, environmentVariables));
			var profileResources = preProperties.forKey(PROFILES_PROPERTY).ofList().or(profiles).map(names -> {
				if (names.isEmpty()) {
					names = List.of("default");
				}
				var layers = new ArrayList<LogProperties>();
				var resources = new ArrayList<String>();
				for (var name : names) {
					if (!PROFILE_NAME.matcher(name).matches()) {
						throw new IllegalArgumentException("Invalid profile name '" + name
								+ "': use only ASCII letters, digits, underscores, and hyphens");
					}
					String profileResource = resource.endsWith(".properties")
							? resource.substring(0, resource.length() - ".properties".length()) + "-" + name
									+ ".properties"
							: resource + "-" + name;
					var profileProperties = loadResource(profileResource);
					if (shortKeys) {
						profileProperties = withShortKeys(profileProperties);
					}
					if (profileProperties == LogProperties.StandardProperties.EMPTY) {
						if (name.equals("default")) {
							continue;
						}
						throw new IllegalArgumentException("Missing classpath resource '" + profileResource
								+ "' for selected profile '" + name + "'");
					}
					layers.add(profileProperties);
					resources.add(profileResource);
				}
				return new ProfileResources(List.copyOf(names), List.copyOf(layers), List.copyOf(resources));
			}).validateNow(SimpleProperties.class);
			var properties = new ArrayList<LogProperties>();
			properties.add(systemProperties);
			properties.add(environmentVariables);
			properties.addAll(profileResources.properties());
			properties.add(classpathProperties);
			var infoMessages = new ArrayList<String>();
			infoMessages.add("Found profiles: " + profileResources.names());
			infoMessages.add("Loaded properties resource: " + resource);
			for (var profileResource : profileResources.resources()) {
				infoMessages.add("Loaded properties resource: " + profileResource);
			}
			var operatorProperties = LogProperties
				.of(List.of(systemProperties, environmentVariables, classpathProperties));
			var levelFileType = operatorProperties.forKey(LEVEL_FILE_PROPERTY)
				.ofString()
				.map(LevelFileType::parse)
				.or(LevelFileType.OFF)
				.validateNow(SimpleProperties.class);
			LevelFileProperties levelFile = null;
			if (levelFileType == LevelFileType.WATCH) {
				var path = operatorProperties.forKey(LEVEL_FILE_PATH_PROPERTY)
					.ofString()
					.map(Path::of)
					.or(levelFilePath)
					.validateNow(SimpleProperties.class);
				levelFile = new LevelFileProperties(path, levelFilePollInterval);
				properties.add(0, levelFile);
				// Lowest precedence so explicit change settings still win.
				properties.add(LogProperties.builder()
					.fromProperties(
							LogProperties.GLOBAL_CHANGE_PROPERTY + "=true\n" + LogProperties.CHANGE_PREFIX + "=level")
					.description("LEVEL_FILE_DEFAULT")
					.order(0)
					.build());
				infoMessages.add("Watching level file: " + levelFile.path());
			}
			return new SimpleProperties(List.copyOf(properties), List.copyOf(infoMessages), errorMessages, strictType,
					levelFile);
		}

		private static LogProperties withShortKeys(LogProperties properties) {
			return properties instanceof SimpleLogProperties simple ? simple.withShortKeys() : properties;
		}

		private static LogProperties loadResource(String resource) {
			String path = resource;
			if (path.startsWith("classpath:")) {
				path = path.substring("classpath:".length());
			}
			while (path.startsWith("/")) {
				path = path.substring(1);
			}
			ClassLoader cl = SimpleProperties.class.getClassLoader();
			if (cl == null) {
				cl = Thread.currentThread().getContextClassLoader();
			}
			try (InputStream in = cl == null ? null : cl.getResourceAsStream(path)) {
				if (in == null) {
					return LogProperties.StandardProperties.EMPTY;
				}
				String content = new String(in.readAllBytes(), StandardCharsets.UTF_8);
				return SimpleLogProperties.read(new StringReader(content), resource);
			}
			catch (IOException e) {
				throw new UncheckedIOException("Failed to read classpath resource: " + resource, e);
			}
		}

	}

}