///| Configuration file handling.
///
/// pm3 keeps its settings in `$PM3_HOME` and accepts exactly two syntaxes:
/// **TOML** (`config.toml`) and **JSON** (`config.json`). Other extensions are
/// rejected rather than guessed at, because a silently ignored config file is
/// worse than a loud error. `logrotate.json` is still read for compatibility
/// with pm3 releases that predate `config.*`.
///
/// Both syntaxes are parsed into a `Json` tree, so there is a single loader and
/// a single set of defaults after the first step:
///
/// ```text
/// config.toml ──┐
/// ├──▶ Json tree ──▶ LogrotateConfig
/// config.json ──┘
/// ```
///|
/// The two configuration syntaxes pm3 understands.
pub(all) enum ConfigFormat {
Toml
Json
} derive(Eq, Debug)
///|
/// A configuration file pm3 found: where it is and how to read it.
pub(all) struct ConfigSource {
path : String
format : ConfigFormat
} derive(Eq, Debug)
///|
/// Format implied by a file name, or `None` for an extension pm3 refuses to
/// guess at.
pub fn config_format_of(path : String) -> ConfigFormat? {
if path.has_suffix(".toml") {
Some(Toml)
} else if path.has_suffix(".json") {
Some(Json)
} else {
None
}
}
///|
/// The configuration file pm3 writes to when none exists yet.
pub fn default_config_source() -> ConfigSource {
{ path: pm3_config_toml_path(), format: Toml, }
}
///|
/// The first configuration file that exists, or `None`.
///
/// The order is TOML, JSON, then the legacy `logrotate.json`, so a user who
/// migrates by writing `config.toml` does not have to delete the old file.
pub fn find_config_source() -> ConfigSource? {
for path in pm3_config_candidates() {
if config_file_exists(path) {
match config_format_of(path) {
Some(format) => return Some({ path, format, })
None => continue
}
}
}
None
}
///|
/// The file pm3 will read, or the file it would create.
pub fn active_config_source() -> ConfigSource {
match find_config_source() {
Some(source) => source
None => default_config_source()
}
}
///|
/// Whether a path names an existing file. Kept synchronous and free of the
/// async runtime so that `pm3 set` works even when no daemon is running.
fn config_file_exists(path : String) -> Bool {
@fs.path_exists(path)
}
///|
/// Create `dir` and any missing parents. `@fs.create_dir` only makes the last
/// component, and `PM3_HOME` is user-supplied, so walk the path.
fn ensure_dir(dir : String) -> Result[Unit, String] {
if dir.is_empty() || dir == "/" || config_file_exists(dir) {
return Ok(())
}
let parent = path_dirname(dir)
if parent != dir && !parent.is_empty() {
match ensure_dir(parent) {
Ok(_) => ()
Err(msg) => return Err(msg)
}
}
@fs.create_dir(dir) catch {
_ =>
// A concurrent creator, or a directory that appeared meanwhile, is fine.
if !config_file_exists(dir) {
return Err("cannot create directory '" + dir + "'")
}
}
Ok(())
}
///|
/// Read a configuration file and parse it according to its extension.
///
/// Returns `Err` with a human-readable message for a missing file, an
/// unsupported extension, or a syntax error.
pub fn read_config_file(path : String) -> Result[Json, String] {
let format = match config_format_of(path) {
Some(f) => f
None =>
return Err(
"unsupported config file '" + path + "': pm3 reads .toml and .json only",
)
}
let text = @fs.read_file_to_string(path) catch {
_ => return Err("cannot read config file '" + path + "'")
}
parse_config_text(text, format)
}
///|
/// Parse configuration text in the given syntax.
pub fn parse_config_text(
text : String,
format : ConfigFormat,
) -> Result[Json, String] {
match format {
Toml =>
match parse_toml(text) {
Ok(json) => Ok(json)
Err(msg) => Err("cannot parse config: " + msg)
}
Json =>
Ok(@json.parse(text)) catch {
err => Err("cannot parse config: " + describe_error(err))
}
}
}
///|
/// Raised while reading a config document. Private, like `TomlError`: the
/// public entry points turn it into a `Result`.
priv suberror ConfigError {
ConfigError(String)
}
///|
/// Every key the rotation policy understands.
fn logrotate_field_names() -> Array[String] {
[
"max_size", "retain", "compress", "compression_level", "date_format", "worker_interval",
"rotate_interval", "tz", "rotate_module", "min_size", "max_age_days",
]
}
///|
/// Apply one optional field, forwarding a bad value as a `ConfigError`.
fn[T] apply_field(
slot : Result[T?, String],
set : (T) -> Unit,
) -> Unit raise ConfigError {
match slot {
Ok(Some(value)) => set(value)
Ok(None) => ()
Err(msg) => raise ConfigError(msg)
}
}
///|
/// Read a size-valued field. Accepts `"10M"`, `"10485760"` and a bare number,
/// so both hand-written and machine-written configs load.
fn read_size_field(
members : Map[String, Json],
key : String,
) -> Result[Int64?, String] {
match members.get(key) {
None => Ok(None)
Some(Json::String(text)) =>
match parse_size(text) {
Some(value) => Ok(Some(value))
None =>
Err(
"'" +
key +
"' must be a size such as 10M, 1G or 512K, got '" +
text +
"'",
)
}
Some(Json::Number(n, ..)) => Ok(Some(n.to_int64()))
Some(_) => Err("'" + key + "' must be a size")
}
}
///|
fn read_int_field(
members : Map[String, Json],
key : String,
) -> Result[Int?, String] {
match members.get(key) {
None => Ok(None)
Some(Json::Number(n, ..)) => Ok(Some(n.to_int()))
Some(Json::String(text)) => {
let parsed : Int? = Some(@string.parse_int(text)) catch { _ => None }
match parsed {
Some(value) => Ok(Some(value))
None => Err("'" + key + "' must be an integer, got '" + text + "'")
}
}
Some(_) => Err("'" + key + "' must be an integer")
}
}
///|
fn read_bool_field(
members : Map[String, Json],
key : String,
) -> Result[Bool?, String] {
match members.get(key) {
None => Ok(None)
Some(Json::True) => Ok(Some(true))
Some(Json::False) => Ok(Some(false))
Some(_) => Err("'" + key + "' must be true or false")
}
}
///|
fn read_text_field(
members : Map[String, Json],
key : String,
) -> Result[String?, String] {
match members.get(key) {
None => Ok(None)
Some(Json::String(text)) => Ok(Some(text))
Some(_) => Err("'" + key + "' must be a string")
}
}
///|
/// Extract the rotation policy from a parsed config document.
///
/// A `[logrotate]` table is the documented shape. A document that is itself a
/// policy (the legacy `logrotate.json`) is also accepted, so upgrading does not
/// require rewriting the file by hand. Unknown keys are rejected: a typo in a
/// config file should be loud, not silently ignored.
pub fn logrotate_from_json(json : Json) -> Result[LogrotateConfig, String] {
Ok(read_logrotate(json)) catch {
ConfigError(msg) => Err(msg)
}
}
///|
fn read_logrotate(json : Json) -> LogrotateConfig raise ConfigError {
let body = match json {
Object(members) =>
match members.get("logrotate") {
Some(section) => section
None => json
}
_ => json
}
let members = match body {
Object(m) => m
_ => raise ConfigError("the logrotate section must be a table or object")
}
let known = logrotate_field_names()
for key, _ in members {
if !known.contains(key) {
raise ConfigError(
"unknown logrotate setting '" +
key +
"'; expected one of " +
known.join(", "),
)
}
}
let config = default_logrotate_config()
apply_field(read_size_field(members, "max_size"), v => config.max_size = v)
apply_field(read_int_field(members, "retain"), v => config.retain = v)
apply_field(read_bool_field(members, "compress"), v => config.compress = v)
apply_field(read_int_field(members, "compression_level"), v => {
config.compression_level = v
})
apply_field(read_text_field(members, "date_format"), v => {
config.date_format = v
})
apply_field(read_int_field(members, "worker_interval"), v => {
config.worker_interval = v
})
apply_field(read_text_field(members, "rotate_interval"), v => {
config.rotate_interval = v
})
apply_field(read_text_field(members, "tz"), v => config.tz = v)
apply_field(read_bool_field(members, "rotate_module"), v => {
config.rotate_module = v
})
apply_field(read_size_field(members, "min_size"), v => config.min_size = v)
apply_field(read_int_field(members, "max_age_days"), v => {
config.max_age_days = v
})
if config.compression_level < 1 || config.compression_level > 9 {
raise ConfigError("'compression_level' must be between 1 and 9")
}
if config.worker_interval < 1 {
raise ConfigError("'worker_interval' must be at least 1 second")
}
config
}
///|
/// Load the rotation policy, together with the file it came from.
///
/// A missing file is not an error: pm3 falls back to the defaults and reports
/// where the settings would be written.
pub fn load_logrotate_config() -> (LogrotateConfig, ConfigSource) {
match find_config_source() {
None => (default_logrotate_config(), default_config_source())
Some(source) =>
match read_config_file(source.path) {
Err(_) => (default_logrotate_config(), source)
Ok(json) =>
match logrotate_from_json(json) {
Ok(config) => (config, source)
Err(_) => (default_logrotate_config(), source)
}
}
}
}
///|
/// The rotation policy as a `Json` object nested under `logrotate`.
pub fn logrotate_to_json(config : LogrotateConfig) -> Json {
let body : Map[String, Json] = Map([])
body.set("max_size", Json::string(format_size_exact(config.max_size)))
body.set("retain", Json::number(config.retain.to_double()))
body.set("compress", Json::boolean(config.compress))
body.set(
"compression_level",
Json::number(config.compression_level.to_double()),
)
body.set("date_format", Json::string(config.date_format))
body.set("worker_interval", Json::number(config.worker_interval.to_double()))
body.set("rotate_interval", Json::string(config.rotate_interval))
body.set("tz", Json::string(config.tz))
body.set("rotate_module", Json::boolean(config.rotate_module))
body.set("min_size", Json::string(format_size_exact(config.min_size)))
body.set("max_age_days", Json::number(config.max_age_days.to_double()))
let root : Map[String, Json] = Map([])
root.set("logrotate", Json::object(body))
Json::object(root)
}
///|
/// Render a TOML string literal, escaping the characters TOML treats specially.
fn toml_string(value : String) -> String {
let buf = StringBuilder()
buf.write_char('"')
for i in 0.. buf.write_string("\\\"")
Some('\\') => buf.write_string("\\\\")
Some('\n') => buf.write_string("\\n")
Some('\r') => buf.write_string("\\r")
Some('\t') => buf.write_string("\\t")
Some(c) => buf.write_char(c)
None => ()
}
}
buf.write_char('"')
buf.to_string()
}
///|
/// Serialise the rotation policy as a `config.toml` document.
///
/// The output is regenerated from scratch, so comments in a hand-edited file
/// are lost when `pm3 set` rewrites it. `pm3 config` prints the file path
/// first, which is the hint to edit it by hand instead.
pub fn logrotate_to_toml(config : LogrotateConfig) -> String {
let buf = StringBuilder()
buf.write_string("# pm3 configuration. Regenerated by `pm3 set`; see also\n")
buf.write_string("# https://github.com/tomasky/pm3#configuration\n\n")
buf.write_string("[logrotate]\n")
buf.write_string(
"max_size = " + toml_string(format_size_exact(config.max_size)) + "\n",
)
buf.write_string("retain = " + config.retain.to_string() + "\n")
buf.write_string("compress = " + config.compress.to_string() + "\n")
buf.write_string(
"compression_level = " + config.compression_level.to_string() + "\n",
)
buf.write_string("date_format = " + toml_string(config.date_format) + "\n")
buf.write_string(
"worker_interval = " + config.worker_interval.to_string() + "\n",
)
buf.write_string(
"rotate_interval = " + toml_string(config.rotate_interval) + "\n",
)
buf.write_string("tz = " + toml_string(config.tz) + "\n")
buf.write_string("rotate_module = " + config.rotate_module.to_string() + "\n")
buf.write_string(
"min_size = " + toml_string(format_size_exact(config.min_size)) + "\n",
)
buf.write_string("max_age_days = " + config.max_age_days.to_string() + "\n")
buf.to_string()
}
///|
/// Serialise the rotation policy in the syntax the source file uses.
pub fn render_config(config : LogrotateConfig, format : ConfigFormat) -> String {
match format {
Toml => logrotate_to_toml(config)
Json => logrotate_to_json(config).stringify(indent=2) + "\n"
}
}
///|
/// Persist the rotation policy back to its file, creating parent directories as
/// needed. The format follows the file name.
pub fn save_logrotate_config(
config : LogrotateConfig,
source : ConfigSource,
) -> Result[Unit, String] {
match config_format_of(source.path) {
None =>
Err(
"unsupported config file '" +
source.path +
"': pm3 reads .toml and .json only",
)
Some(format) => {
match ensure_dir(path_dirname(source.path)) {
Err(msg) => return Err(msg)
Ok(_) => ()
}
let text = render_config(config, format)
@fs.write_string_to_file(source.path, text) catch {
_ => return Err("cannot write config file '" + source.path + "'")
}
Ok(())
}
}
}
///|
/// Render any error type that implements `Debug` as a one-line message.
fn[T : @debug.Debug] describe_error(err : T) -> String {
@debug.render(@debug.Debug::to_repr(err))
}
///|
/// Create a configuration file seeded with the current effective policy.
///
/// Refuses to overwrite an existing file unless `force` is set, so a stray
/// invocation cannot silently discard a hand-written config. Returns the path
/// that was written.
pub fn write_default_config(
format : ConfigFormat,
force : Bool,
) -> Result[String, String] {
let (config, _) = load_logrotate_config()
let path = match format {
Toml => pm3_config_toml_path()
Json => pm3_config_json_path()
}
if !force && config_file_exists(path) {
return Err("'" + path + "' already exists (pass --force to overwrite)")
}
match save_logrotate_config(config, { path, format, }) {
Ok(_) => Ok(path)
Err(msg) => Err(msg)
}
}