Complete the PRD feature set with shell gating pipeline, cache
improvements, layout enhancements, and diagnostics:
- Shell: exec_gated with allowlist/denylist, circuit breaker, env merge
- Shell: parallel prefetch via std::thread::scope for cold renders
- Cache: TTL jitter (FNV-1a), config hash namespace, garbage collection
- Cache: diagnostic tracking (hit/miss, age) for dump-state
- Layout: gradual drop strategy (one-by-one vs tiered)
- Layout: render budget timer with graceful priority-based degradation
- Layout: breakpoint hysteresis to prevent preset toggling
- Width: detection source tracking for diagnostics
- CLI: --no-cache, --no-shell, --clear-cache, env var overrides
- Diagnostics: enhanced --dump-state with section timing and cache stats
Closes: bd-3oy, bd-62g, bd-khk, bd-3q1, bd-ywx, bd-3l2,
bd-2vm, bd-1if, bd-2qr, bd-30o, bd-3ax, bd-3uw, bd-4b1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
740 lines
18 KiB
Rust
740 lines
18 KiB
Rust
use serde::Deserialize;
|
|
use serde_json::Value;
|
|
use std::collections::HashMap;
|
|
|
|
const DEFAULTS_JSON: &str = include_str!("../defaults.json");
|
|
|
|
// ── Top-level Config ────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct Config {
|
|
pub version: u32,
|
|
pub global: GlobalConfig,
|
|
pub colors: ThemeColors,
|
|
pub glyphs: GlyphConfig,
|
|
pub presets: HashMap<String, Vec<Vec<String>>>,
|
|
pub layout: LayoutValue,
|
|
pub sections: Sections,
|
|
pub custom: Vec<CustomCommand>,
|
|
}
|
|
|
|
impl Default for Config {
|
|
fn default() -> Self {
|
|
serde_json::from_str(DEFAULTS_JSON).expect("embedded defaults must parse")
|
|
}
|
|
}
|
|
|
|
// ── Layout: preset name or explicit array ───────────────────────────────
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(untagged)]
|
|
pub enum LayoutValue {
|
|
Preset(String),
|
|
Custom(Vec<Vec<String>>),
|
|
}
|
|
|
|
impl Default for LayoutValue {
|
|
fn default() -> Self {
|
|
Self::Preset("standard".into())
|
|
}
|
|
}
|
|
|
|
// ── Global settings ─────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct GlobalConfig {
|
|
pub separator: String,
|
|
pub justify: JustifyMode,
|
|
pub vcs: String,
|
|
pub width: Option<u16>,
|
|
pub width_margin: u16,
|
|
pub cache_dir: String,
|
|
pub cache_gc_days: u16,
|
|
pub cache_gc_interval_hours: u16,
|
|
pub cache_ttl_jitter_pct: u8,
|
|
pub responsive: bool,
|
|
pub breakpoints: Breakpoints,
|
|
pub render_budget_ms: u64,
|
|
pub theme: String,
|
|
pub color: ColorMode,
|
|
pub warn_unknown_keys: bool,
|
|
pub shell_enabled: bool,
|
|
pub shell_allowlist: Vec<String>,
|
|
pub shell_denylist: Vec<String>,
|
|
pub shell_timeout_ms: u64,
|
|
pub shell_max_output_bytes: usize,
|
|
pub shell_failure_threshold: u8,
|
|
pub shell_cooldown_ms: u64,
|
|
pub shell_env: HashMap<String, String>,
|
|
pub cache_version: u32,
|
|
pub drop_strategy: String,
|
|
pub breakpoint_hysteresis: u16,
|
|
}
|
|
|
|
impl Default for GlobalConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
separator: " | ".into(),
|
|
justify: JustifyMode::Left,
|
|
vcs: "auto".into(),
|
|
width: None,
|
|
width_margin: 4,
|
|
cache_dir: "/tmp/claude-sl-{session_id}".into(),
|
|
cache_gc_days: 7,
|
|
cache_gc_interval_hours: 24,
|
|
cache_ttl_jitter_pct: 10,
|
|
responsive: true,
|
|
breakpoints: Breakpoints::default(),
|
|
render_budget_ms: 8,
|
|
theme: "auto".into(),
|
|
color: ColorMode::Auto,
|
|
warn_unknown_keys: true,
|
|
shell_enabled: true,
|
|
shell_allowlist: Vec::new(),
|
|
shell_denylist: Vec::new(),
|
|
shell_timeout_ms: 200,
|
|
shell_max_output_bytes: 8192,
|
|
shell_failure_threshold: 3,
|
|
shell_cooldown_ms: 30_000,
|
|
shell_env: HashMap::new(),
|
|
cache_version: 1,
|
|
drop_strategy: "tiered".into(),
|
|
breakpoint_hysteresis: 2,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum JustifyMode {
|
|
#[default]
|
|
Left,
|
|
Spread,
|
|
SpaceBetween,
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum ColorMode {
|
|
#[default]
|
|
Auto,
|
|
Always,
|
|
Never,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct Breakpoints {
|
|
pub narrow: u16,
|
|
pub medium: u16,
|
|
pub hysteresis: u16,
|
|
}
|
|
|
|
impl Default for Breakpoints {
|
|
fn default() -> Self {
|
|
Self {
|
|
narrow: 60,
|
|
medium: 100,
|
|
hysteresis: 2,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Color palettes ──────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Default, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct ThemeColors {
|
|
pub dark: HashMap<String, String>,
|
|
pub light: HashMap<String, String>,
|
|
}
|
|
|
|
// ── Glyph config ────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Default, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct GlyphConfig {
|
|
pub enabled: bool,
|
|
pub set: HashMap<String, String>,
|
|
pub fallback: HashMap<String, String>,
|
|
}
|
|
|
|
// ── Shared section base (flattened into each section) ───────────────────
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct SectionBase {
|
|
pub enabled: bool,
|
|
pub priority: u8,
|
|
pub flex: bool,
|
|
pub min_width: Option<u16>,
|
|
pub prefix: Option<String>,
|
|
pub suffix: Option<String>,
|
|
pub pad: Option<u16>,
|
|
pub align: Option<String>,
|
|
pub color: Option<String>,
|
|
}
|
|
|
|
impl Default for SectionBase {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: true,
|
|
priority: 2,
|
|
flex: false,
|
|
min_width: None,
|
|
prefix: None,
|
|
suffix: None,
|
|
pad: None,
|
|
align: None,
|
|
color: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Per-section typed configs ───────────────────────────────────────────
|
|
|
|
#[derive(Debug, Default, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct Sections {
|
|
pub model: SectionBase,
|
|
pub provider: SectionBase,
|
|
pub project: ProjectSection,
|
|
pub vcs: VcsSection,
|
|
pub beads: BeadsSection,
|
|
pub context_bar: ContextBarSection,
|
|
pub context_usage: ContextUsageSection,
|
|
pub context_remaining: ContextRemainingSection,
|
|
pub tokens_raw: TokensRawSection,
|
|
pub cache_efficiency: SectionBase,
|
|
pub cost: CostSection,
|
|
pub cost_velocity: SectionBase,
|
|
pub token_velocity: SectionBase,
|
|
pub cost_trend: TrendSection,
|
|
pub context_trend: ContextTrendSection,
|
|
pub lines_changed: SectionBase,
|
|
pub duration: SectionBase,
|
|
pub tools: ToolsSection,
|
|
pub turns: CachedSection,
|
|
pub load: CachedSection,
|
|
pub version: SectionBase,
|
|
pub time: TimeSection,
|
|
pub output_style: SectionBase,
|
|
pub hostname: SectionBase,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct ProjectSection {
|
|
#[serde(flatten)]
|
|
pub base: SectionBase,
|
|
pub truncate: TruncateConfig,
|
|
}
|
|
|
|
impl Default for ProjectSection {
|
|
fn default() -> Self {
|
|
Self {
|
|
base: SectionBase {
|
|
priority: 1,
|
|
..Default::default()
|
|
},
|
|
truncate: TruncateConfig {
|
|
enabled: true,
|
|
max: 30,
|
|
style: "middle".into(),
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct TruncateConfig {
|
|
pub enabled: bool,
|
|
pub max: usize,
|
|
pub style: String,
|
|
}
|
|
|
|
impl Default for TruncateConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: false,
|
|
max: 0,
|
|
style: "right".into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct VcsSection {
|
|
#[serde(flatten)]
|
|
pub base: SectionBase,
|
|
pub prefer: String,
|
|
pub show_ahead_behind: bool,
|
|
pub show_dirty: bool,
|
|
pub untracked: String,
|
|
pub submodules: bool,
|
|
pub fast_mode: bool,
|
|
pub truncate: TruncateConfig,
|
|
pub ttl: VcsTtl,
|
|
}
|
|
|
|
impl Default for VcsSection {
|
|
fn default() -> Self {
|
|
Self {
|
|
base: SectionBase {
|
|
priority: 1,
|
|
min_width: Some(8),
|
|
..Default::default()
|
|
},
|
|
prefer: "auto".into(),
|
|
show_ahead_behind: true,
|
|
show_dirty: true,
|
|
untracked: "normal".into(),
|
|
submodules: false,
|
|
fast_mode: false,
|
|
truncate: TruncateConfig {
|
|
enabled: true,
|
|
max: 25,
|
|
style: "right".into(),
|
|
},
|
|
ttl: VcsTtl::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct VcsTtl {
|
|
pub branch: u64,
|
|
pub dirty: u64,
|
|
pub ahead_behind: u64,
|
|
}
|
|
|
|
impl Default for VcsTtl {
|
|
fn default() -> Self {
|
|
Self {
|
|
branch: 3,
|
|
dirty: 5,
|
|
ahead_behind: 30,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct BeadsSection {
|
|
#[serde(flatten)]
|
|
pub base: SectionBase,
|
|
pub show_wip: bool,
|
|
pub show_wip_count: bool,
|
|
pub show_ready_count: bool,
|
|
pub show_open_count: bool,
|
|
pub show_closed_count: bool,
|
|
pub ttl: u64,
|
|
}
|
|
|
|
impl Default for BeadsSection {
|
|
fn default() -> Self {
|
|
Self {
|
|
base: SectionBase {
|
|
priority: 3,
|
|
..Default::default()
|
|
},
|
|
show_wip: true,
|
|
show_wip_count: true,
|
|
show_ready_count: true,
|
|
show_open_count: true,
|
|
show_closed_count: true,
|
|
ttl: 30,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct ContextBarSection {
|
|
#[serde(flatten)]
|
|
pub base: SectionBase,
|
|
pub bar_width: u16,
|
|
pub thresholds: Thresholds,
|
|
}
|
|
|
|
impl Default for ContextBarSection {
|
|
fn default() -> Self {
|
|
Self {
|
|
base: SectionBase {
|
|
priority: 1,
|
|
flex: true,
|
|
min_width: Some(15),
|
|
..Default::default()
|
|
},
|
|
bar_width: 10,
|
|
thresholds: Thresholds {
|
|
warn: 50.0,
|
|
danger: 70.0,
|
|
critical: 85.0,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct Thresholds {
|
|
pub warn: f64,
|
|
pub danger: f64,
|
|
pub critical: f64,
|
|
}
|
|
|
|
impl Default for Thresholds {
|
|
fn default() -> Self {
|
|
Self {
|
|
warn: 50.0,
|
|
danger: 70.0,
|
|
critical: 85.0,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct ContextUsageSection {
|
|
#[serde(flatten)]
|
|
pub base: SectionBase,
|
|
pub capacity: u64,
|
|
pub thresholds: Thresholds,
|
|
}
|
|
|
|
impl Default for ContextUsageSection {
|
|
fn default() -> Self {
|
|
Self {
|
|
base: SectionBase {
|
|
enabled: false,
|
|
priority: 2,
|
|
..Default::default()
|
|
},
|
|
capacity: 200_000,
|
|
thresholds: Thresholds::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct ContextRemainingSection {
|
|
#[serde(flatten)]
|
|
pub base: SectionBase,
|
|
pub format: String,
|
|
pub thresholds: Thresholds,
|
|
}
|
|
|
|
impl Default for ContextRemainingSection {
|
|
fn default() -> Self {
|
|
Self {
|
|
base: SectionBase {
|
|
enabled: false,
|
|
priority: 2,
|
|
..Default::default()
|
|
},
|
|
format: "{remaining} left".into(),
|
|
thresholds: Thresholds::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct TokensRawSection {
|
|
#[serde(flatten)]
|
|
pub base: SectionBase,
|
|
pub format: String,
|
|
}
|
|
|
|
impl Default for TokensRawSection {
|
|
fn default() -> Self {
|
|
Self {
|
|
base: SectionBase {
|
|
priority: 3,
|
|
..Default::default()
|
|
},
|
|
format: "{input} in/{output} out".into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct CostSection {
|
|
#[serde(flatten)]
|
|
pub base: SectionBase,
|
|
pub thresholds: Thresholds,
|
|
}
|
|
|
|
impl Default for CostSection {
|
|
fn default() -> Self {
|
|
Self {
|
|
base: SectionBase {
|
|
priority: 1,
|
|
..Default::default()
|
|
},
|
|
thresholds: Thresholds {
|
|
warn: 5.0,
|
|
danger: 8.0,
|
|
critical: 10.0,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct TrendSection {
|
|
#[serde(flatten)]
|
|
pub base: SectionBase,
|
|
pub width: u8,
|
|
}
|
|
|
|
impl Default for TrendSection {
|
|
fn default() -> Self {
|
|
Self {
|
|
base: SectionBase {
|
|
priority: 3,
|
|
..Default::default()
|
|
},
|
|
width: 8,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct ContextTrendSection {
|
|
#[serde(flatten)]
|
|
pub base: SectionBase,
|
|
pub width: u8,
|
|
pub thresholds: Thresholds,
|
|
}
|
|
|
|
impl Default for ContextTrendSection {
|
|
fn default() -> Self {
|
|
Self {
|
|
base: SectionBase {
|
|
priority: 3,
|
|
..Default::default()
|
|
},
|
|
width: 8,
|
|
thresholds: Thresholds::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct ToolsSection {
|
|
#[serde(flatten)]
|
|
pub base: SectionBase,
|
|
pub show_last_name: bool,
|
|
pub ttl: u64,
|
|
}
|
|
|
|
impl Default for ToolsSection {
|
|
fn default() -> Self {
|
|
Self {
|
|
base: SectionBase {
|
|
priority: 2,
|
|
min_width: Some(6),
|
|
..Default::default()
|
|
},
|
|
show_last_name: true,
|
|
ttl: 2,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct CachedSection {
|
|
#[serde(flatten)]
|
|
pub base: SectionBase,
|
|
pub ttl: u64,
|
|
}
|
|
|
|
impl Default for CachedSection {
|
|
fn default() -> Self {
|
|
Self {
|
|
base: SectionBase {
|
|
priority: 3,
|
|
..Default::default()
|
|
},
|
|
ttl: 10,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct TimeSection {
|
|
#[serde(flatten)]
|
|
pub base: SectionBase,
|
|
pub format: String,
|
|
}
|
|
|
|
impl Default for TimeSection {
|
|
fn default() -> Self {
|
|
Self {
|
|
base: SectionBase {
|
|
enabled: false,
|
|
priority: 3,
|
|
..Default::default()
|
|
},
|
|
format: "%H:%M".into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Custom command sections ─────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct CustomCommand {
|
|
pub id: String,
|
|
#[serde(default)]
|
|
pub command: Option<String>,
|
|
#[serde(default)]
|
|
pub exec: Option<Vec<String>>,
|
|
#[serde(default)]
|
|
pub format: Option<String>,
|
|
#[serde(default)]
|
|
pub label: Option<String>,
|
|
#[serde(default = "default_custom_ttl")]
|
|
pub ttl: u64,
|
|
#[serde(default = "default_priority")]
|
|
pub priority: u8,
|
|
#[serde(default)]
|
|
pub flex: bool,
|
|
#[serde(default)]
|
|
pub min_width: Option<u16>,
|
|
#[serde(default)]
|
|
pub color: Option<CustomColor>,
|
|
#[serde(default)]
|
|
pub default_color: Option<String>,
|
|
#[serde(default)]
|
|
pub prefix: Option<String>,
|
|
#[serde(default)]
|
|
pub suffix: Option<String>,
|
|
#[serde(default)]
|
|
pub pad: Option<u16>,
|
|
#[serde(default)]
|
|
pub align: Option<String>,
|
|
}
|
|
|
|
fn default_custom_ttl() -> u64 {
|
|
30
|
|
}
|
|
fn default_priority() -> u8 {
|
|
2
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct CustomColor {
|
|
#[serde(rename = "match", default)]
|
|
pub match_map: HashMap<String, String>,
|
|
}
|
|
|
|
// ── Deep merge ──────────────────────────────────────────────────────────
|
|
|
|
/// Recursive JSON merge: user values win, arrays replaced entirely.
|
|
pub fn deep_merge(base: &mut Value, patch: &Value) {
|
|
match (base, patch) {
|
|
(Value::Object(base_map), Value::Object(patch_map)) => {
|
|
for (k, v) in patch_map {
|
|
let entry = base_map.entry(k.clone()).or_insert(Value::Null);
|
|
deep_merge(entry, v);
|
|
}
|
|
}
|
|
(base, patch) => {
|
|
*base = patch.clone();
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Config loading ──────────────────────────────────────────────────────
|
|
|
|
/// Load config: embedded defaults deep-merged with user overrides.
|
|
/// Returns (Config, warnings, config_hash) where config_hash is 8-char hex MD5
|
|
/// of the merged JSON (for cache namespace invalidation on config change).
|
|
pub fn load_config(
|
|
explicit_path: Option<&str>,
|
|
) -> Result<(Config, Vec<String>, String), crate::Error> {
|
|
let mut base: Value = serde_json::from_str(DEFAULTS_JSON)?;
|
|
|
|
let user_path = explicit_path
|
|
.map(std::path::PathBuf::from)
|
|
.or_else(|| {
|
|
std::env::var("CLAUDE_STATUSLINE_CONFIG")
|
|
.ok()
|
|
.map(Into::into)
|
|
})
|
|
.or_else(xdg_config_path)
|
|
.or_else(dot_config_path)
|
|
.unwrap_or_else(|| {
|
|
let mut p = dirs_home().unwrap_or_default();
|
|
p.push(".claude/statusline.json");
|
|
p
|
|
});
|
|
|
|
if user_path.exists() {
|
|
let user_json: Value = serde_json::from_str(&std::fs::read_to_string(&user_path)?)?;
|
|
deep_merge(&mut base, &user_json);
|
|
} else if explicit_path.is_some() {
|
|
return Err(crate::Error::ConfigNotFound(
|
|
user_path.display().to_string(),
|
|
));
|
|
}
|
|
|
|
// Compute config hash from merged JSON before deserialize consumes it
|
|
let config_hash = compute_config_hash(&base);
|
|
|
|
let mut warnings = Vec::new();
|
|
let config: Config = serde_ignored::deserialize(base, |path| {
|
|
warnings.push(format!("unknown config key: {path}"));
|
|
})?;
|
|
|
|
Ok((config, warnings, config_hash))
|
|
}
|
|
|
|
/// MD5 of the merged JSON value, truncated to 8 hex chars.
|
|
/// Deterministic: serde_json produces stable output for the same Value.
|
|
fn compute_config_hash(merged: &Value) -> String {
|
|
use md5::{Digest, Md5};
|
|
let json_bytes = serde_json::to_string(merged).unwrap_or_default();
|
|
let hash = Md5::digest(json_bytes.as_bytes());
|
|
format!("{:x}", hash)[..8].to_string()
|
|
}
|
|
|
|
fn xdg_config_path() -> Option<std::path::PathBuf> {
|
|
let val = std::env::var("XDG_CONFIG_HOME").ok()?;
|
|
let mut p = std::path::PathBuf::from(val);
|
|
p.push("claude/statusline.json");
|
|
if p.exists() {
|
|
Some(p)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn dot_config_path() -> Option<std::path::PathBuf> {
|
|
let mut p = dirs_home()?;
|
|
p.push(".config/claude/statusline.json");
|
|
if p.exists() {
|
|
Some(p)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn dirs_home() -> Option<std::path::PathBuf> {
|
|
std::env::var("HOME").ok().map(Into::into)
|
|
}
|