Files
pkh/src/config.rs
T
vhaudiquet 9acf650fc9 deb,config: the staged-tree budget replaces the size cap
The size cap was the wrong shape: an --incremental user asked for a
carried tree, and the invariant that matters is which trees survive —
the newest successful one (the next run's automatic fork base) and the
newest one (what a failed run is re-forked from explicitly). The cap
is gone, with the tree measurement and manifest field it needed; the
retention planner now works on the records' outcomes alone and fills a
budget: staged_trees staged trees per target, the newer ones first,
the pair always among them. When the budget cannot hold the pair (one
staged tree configured, the newest build failed and an older
successful tree present) only one of the two is successful and that
one is kept; with no successful tree at all, the most recent one is.

The budget is a setting of the nested config file:

    deb:
      incremental:
        staged_trees: 2

(default 2; 0 clamps to 1 with a warning — the newest successful tree
always survives). The fork-base selection no longer truncates the
candidate list to the budget before ranking it: with a budget of one
and a failed newest tree, that truncation hid the successful record
and forked the failed one instead.
2026-10-03 23:43:10 +02:00

145 lines
4.7 KiB
Rust

//! User settings: the optional `config.yml` in the pkh configuration
//! directory (the same `~/.config/pkh/` that holds `contexts.json`),
//! and their defaults.
//!
//! A missing file carries the defaults — the file is written only when
//! a user creates it, never generated. A file that cannot be read or
//! parsed must not take the whole program down: like the context
//! configuration, it is backed up to `<path>.bak` (best effort) so a
//! later save cannot silently destroy it, and the defaults apply. Every
//! struct level is individually defaulted (`#[serde(default)]`), so any
//! hand-written file may carry a subset of the settings:
//!
//! ```yaml
//! deb:
//! incremental:
//! staged_trees: 2
//! ```
use serde::Deserialize;
use std::path::PathBuf;
/// The pkh configuration directory (the one holding `contexts.json`
/// and `config.yml`).
pub fn config_dir() -> Option<PathBuf> {
directories::ProjectDirs::from("com", "pkh", "pkh").map(|dirs| dirs.config_dir().to_path_buf())
}
/// The settings file in [`config_dir`].
fn config_path() -> Option<PathBuf> {
config_dir().map(|dir| dir.join("config.yml"))
}
/// The user-adjustable settings, as stored in `config.yml`. New fields
/// must default — the file always may carry a subset.
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default)]
pub struct Settings {
/// `pkh deb` settings.
pub deb: DebSettings,
}
/// The `pkh deb` settings.
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default)]
pub struct DebSettings {
/// `pkh deb --incremental` settings.
pub incremental: IncrementalSettings,
}
/// The `pkh deb --incremental` settings.
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default)]
pub struct IncrementalSettings {
/// How many staged trees `pkh deb` retains per target: the newer
/// ones first, with the newest successful one and the newest one
/// always among them.
pub staged_trees: Option<usize>,
}
/// How many staged trees `pkh deb` retains per target: the setting's
/// value, or the default (2 — the newest tree and the newest successful
/// one). A configured 0 cannot honor the retention invariant (the
/// newest successful tree always survives), so it clamps to 1 with a
/// warning.
pub fn staged_trees() -> usize {
const DEFAULT: usize = 2;
let Some(path) = config_path() else {
return DEFAULT;
};
let Ok(text) = std::fs::read_to_string(&path) else {
return DEFAULT;
};
let settings: Settings = match serde_yaml::from_str(&text) {
Ok(settings) => settings,
Err(e) => {
log::error!(
"Settings file {} is corrupt ({e}); using the defaults",
path.display()
);
backup(&path);
return DEFAULT;
}
};
match settings.deb.incremental.staged_trees {
Some(0) => {
log::warn!(
"Settings file {} carries staged_trees: 0; the newest \
successful staged tree is always retained, so 1 applies",
path.display()
);
1
}
Some(count) => count,
None => DEFAULT,
}
}
/// Back up a corrupt settings file next to itself (best effort), so a
/// later save cannot silently destroy its content.
fn backup(path: &std::path::Path) {
let mut os = path.as_os_str().to_os_string();
os.push(".bak");
let backup_path = PathBuf::from(os);
match std::fs::copy(path, &backup_path) {
Ok(_) => log::warn!(
"Corrupt settings file backed up to {}",
backup_path.display()
),
Err(e) => log::warn!(
"Could not back up corrupt settings file to {}: {e}",
backup_path.display()
),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The nested setting parses; every level may be missing and every
/// missing level defaults.
#[test]
fn settings_parse_the_nested_shape() {
let settings: Settings =
serde_yaml::from_str("deb:\n incremental:\n staged_trees: 3").unwrap();
assert_eq!(settings.deb.incremental.staged_trees, Some(3));
let settings: Settings = serde_yaml::from_str("deb: {}").unwrap();
assert_eq!(settings.deb.incremental.staged_trees, None);
let settings: Settings = serde_yaml::from_str("{}").unwrap();
assert_eq!(settings.deb.incremental.staged_trees, None);
}
/// A value of the wrong type is a file error, not a default for the
/// field: the whole file is corrupt (the caller backs it up and
/// applies the defaults).
#[test]
fn a_mistyped_value_is_a_corrupt_file() {
let result =
serde_yaml::from_str::<Settings>("deb:\n incremental:\n staged_trees: two\n");
assert!(result.is_err());
}
}