josie / alder-tools

//! htop-style TUI front-end. Consumes `sensors::` snapshots on a ~250 ms
//! tick; never touches sysfs itself. Style per design.md: terminal-default
//! background, one-line htop meters, ANSI color classes only.

use std::time::{Duration, Instant};

use crossterm::event::{self, Event, KeyCode};
use ratatui::{
    Frame,
    layout::Rect,
    style::{Color, Modifier, Style},
    text::{Line, Span},
};

use crate::config;
use crate::plot::{self, Ring};
use crate::sensors;

const PEAK_WINDOW: Duration = Duration::from_secs(5 * 60);
/// vCore shown Yellow when within this margin of the limit, Red over it.
const VCORE_MARGIN: f64 = 0.05;
/// Auto-scale headroom above the session peak, as a fraction.
const SCALE_MARGIN: f64 = 0.05;
/// Temp bar scale, °C.
const TEMP_BAR_MAX: f64 = 100.0;
/// Graph rows in the package zone (below each caption line).
const GRAPHS_MIN_WIDTH: u16 = 70;
/// Width at/below which core sections stack to one column.
const DUAL_COLUMN_MIN_WIDTH: u16 = 100;

pub struct App {
    cfg: config::Config,
    vcore: Option<f64>,
    vcore_peak: Option<f64>,
    /// (timestamp, volts) per-tick maxima inside the 5-min peak window
    peak_ring: Vec<(Instant, f64)>,
    /// Highest vCore ever seen this session (auto-scale anchor)
    vcore_all_time_peak: f64,
    package_watts: Option<f64>,
    /// Highest pkg watts ever seen this session (auto-scale anchor)
    watts_all_time_peak: f64,
    package_temp: Option<f64>,
    /// Per physical core: label (coretemp "Core N"), freq kHz, bar-max kHz
    /// (that core's cpuinfo_max_freq), temperature °C.
    cores: Vec<CoreRow>,
    last_pkg_energy: Option<(u64, Instant)>,
    /// Graph history (newest last) for the package-zone plots.
    vcore_graph: Ring,
    watts_graph: Ring,
    temp_graph: Ring,
    /// Settings pane open (F2); F10 commits+writes conf, Esc closes.
    settings_open: bool,
    /// Which settings row the cursor is on (index into SETTINGS_ROWS).
    settings_row: usize,
    /// Edit buffers (digit keys append, backspace pops; Enter applies).
    edit_buf: String,
    /// Last settings-input error (inline, cleared on next keypress).
    settings_error: Option<String>,
}

/// Settings-pane rows: (label, config field edited, unit).
const SETTINGS_ROWS: [&str; 2] = ["Poll period", "Graph window"];

fn setting_value(app: &App, row: usize) -> String {
    match row {
        0 => format!("{} ms", app.cfg.poll_ms),
        _ => format!("{} s", app.cfg.graph_secs),
    }
}

fn setting_absorb(app: &mut App, row: usize, raw: &str) -> Result<(), String> {
    match row {
        0 => {
            let v: u64 = raw
                .parse()
                .map_err(|_| format!("'{}' is not a number", raw))?;
            if !(50..=10_000).contains(&v) {
                return Err("poll_ms must be 50..10000".into());
            }
            app.cfg.poll_ms = v;
        }
        _ => {
            let v: u64 = raw
                .parse()
                .map_err(|_| format!("'{}' is not a number", raw))?;
            if !(5..=3600).contains(&v) {
                return Err("graph_secs must be 5..3600".into());
            }
            app.cfg.graph_secs = v;
 app.vcore_graph = Ring::new(ring_capacity(&app.cfg));
            app.watts_graph = Ring::new(ring_capacity(&app.cfg));
            app.temp_graph = Ring::new(ring_capacity(&app.cfg));
        }
    }
    Ok(())
}

/// Ring size = graph window at the poll period, clamped to 512..=20000.
fn ring_capacity(cfg: &config::Config) -> usize {
    ((cfg.graph_secs * 1000 / cfg.poll_ms) as usize).clamp(512, 20_000)
}

/// Ticks (samples) shown in a plot for the configured window.
fn graph_ticks(cfg: &config::Config) -> usize {
    (cfg.graph_secs * 1000 / cfg.poll_ms).max(1) as usize
}

/// Bar max = static default, or last peak + margin once the peak exceeds it.
/// The margin keeps the value text off the bar's right edge at the peak.
fn dynamic_max(peak: f64, static_max: f64, margin: f64) -> f64 {
    (peak * (1.0 + margin)).max(static_max)
}

/// One physical core's row in the per-core section.
#[derive(Clone)]
struct CoreRow {
    label: String,
    e_core: bool,
    freq_khz: Option<u64>,
    freq_min_khz: u64,
    freq_max_khz: u64,
    temp: Option<f64>,
}

impl App {
    pub fn new() -> App {
        let cfg = config::load();
        let cap = ring_capacity(&cfg);
        App {
            cfg,
            vcore: None,
            vcore_peak: None,
            peak_ring: Vec::new(),
            vcore_all_time_peak: 0.0,
            package_watts: None,
            watts_all_time_peak: 0.0,
            package_temp: None,
            cores: Vec::new(),
            last_pkg_energy: None,
            vcore_graph: Ring::new(cap),
            watts_graph: Ring::new(cap),
            temp_graph: Ring::new(cap),
            settings_open: false,
            settings_row: 0,
            edit_buf: String::new(),
            settings_error: None,
        }
    }

    fn poll(&mut self) {
        self.vcore = sensors::sio_vcore();
        if let Some(v) = self.vcore {
            self.vcore_graph.push(v);
            let now = Instant::now();
            self.peak_ring.push((now, v));
            self.peak_ring
                .retain(|(t, _)| now.duration_since(*t) <= PEAK_WINDOW);
            self.vcore_peak = Some(
                self.peak_ring
                    .iter()
                    .map(|(_, v)| *v)
                    .fold(f64::MIN, f64::max),
            );
            self.vcore_all_time_peak = self.vcore_all_time_peak.max(v);
        }

        if let Some(chip) = sensors::discover_chips()
            .into_iter()
            .find(|c| c.name == "coretemp")
        {
            let temps = sensors::temperatures(&chip);
            self.package_temp = temps
                .iter()
                .find(|(l, _)| l.contains("Package"))
                .map(|(_, v)| *v);
            if let Some(t) = self.package_temp {
                self.temp_graph.push(t);
            }

            // Per physical core: coretemp label "Core N" N == topology
            // core_id; cpufreq is per logical cpu, so a core's speed is the
            // max across its HT siblings. Verified mapping for the 12600KF:
            // P core_ids 0/4/8/12/16/20 → cpus (0,1)..(10,11), E 28..31 →
            // cpu12..15 (no HT).
            let freqs = sensors::cpu_frequencies();
            self.cores = temps
                .iter()
                .filter(|(l, _)| l.starts_with("Core "))
                .map(|(label, t)| {
                    let core_id: u64 = label
                        .strip_prefix("Core ")
                        .and_then(|d| d.parse().ok())
                        .unwrap_or(u64::MAX);
                    let cpus = core_to_cpus(core_id);
                    let e_core = cpus.first().is_some_and(|c| *c >= 12);
                    let freq_khz = cpus
                        .iter()
                        .filter_map(|c| {
                            freqs.iter().find(|(cpu, _)| cpu == c).map(|(_, k)| *k)
                        })
                        .max();
                    let freq_max_khz = cpus
                        .iter()
                        .filter_map(|c| sensors::cpu_max_freq(*c))
                        .max()
                        .unwrap_or(0);
                    let freq_min_khz = cpus
                        .iter()
                        .filter_map(|c| sensors::cpu_min_freq(*c))
                        .max()
                        .unwrap_or(0);
                    CoreRow {
                        label: label.clone(),
                        e_core,
                        freq_khz,
                        freq_min_khz,
                        freq_max_khz,
                        temp: Some(*t),
                    }
                })
                .collect();
            self.cores
                .sort_by_key(|c| (c.e_core, c.label[5..].parse::<u64>().unwrap_or(u64::MAX)));
        }

        // Watts = energy delta over the tick (poll cadence IS the sample
        // interval; no extra sleep like the dump's fixed 500 ms window).
        if let Some(d) = sensors::rapl_domains()
            .iter()
            .find(|d| d.name.starts_with("package"))
        {
            if let Some(e) = sensors::rapl_energy_uj(&d.id) {
                self.last_pkg_energy = match self.last_pkg_energy {
                    Some((prev, t0)) => {
                        let dt = t0.elapsed().as_secs_f64();
                        if dt > 0.0 && e >= prev {
                            let w = (e - prev) as f64 / 1_000_000.0 / dt;
                            self.package_watts = Some(w);
                            self.watts_graph.push(w);
                            if w.is_finite() {
                                self.watts_all_time_peak =
                                    self.watts_all_time_peak.max(w);
                            }
                        }
                        Some((e, Instant::now()))
                    }
                    None => Some((e, Instant::now())),
                };
            }
        }
    }
}

/// Verified topology mapping for the 12600KF: physical core N with HT has
/// logical cpus (2k, 2k+1); P core_ids 0/4/8/12/16/20 map to pairs starting
/// at core_id/2·2. E-cores 28..31 map 1:1 to cpu12..15.
fn core_to_cpus(core_id: u64) -> Vec<usize> {
    match core_id {
        0 => vec![0, 1],
        4 => vec![2, 3],
        8 => vec![4, 5],
        12 => vec![6, 7],
        16 => vec![8, 9],
        20 => vec![10, 11],
        28..=31 => vec![core_id as usize - 16],
        _ => Vec::new(),
    }
}

fn temp_color(t: f64, warn: f64, crit: f64) -> Color {
    if t >= crit {
        Color::Red
    } else if t >= warn {
        Color::Yellow
    } else {
        Color::Green
    }
}

fn vcore_color(v: f64, limit: f64) -> Color {
    if v >= limit {
        Color::Red
    } else if v >= limit - VCORE_MARGIN {
        Color::Yellow
    } else {
        Color::Green
    }
}

/// One htop-style meter line: `Label [|||||  1.152 V  ]`.
/// `ratio` is fill fraction (None → empty, sensor missing). `marker_at`
/// (fraction) draws a bold `!` at that bar position — the vCore limit.
fn meter_line(
    label: &str,
    value_text: &str,
    ratio: Option<f64>,
    color: Color,
    width: u16,
    marker_at: Option<f64>,
) -> Line<'static> {
    let cap_w = 12usize;
    let inner = width.saturating_sub(cap_w as u16 + 2) as usize; // room for [ ]
    let mut spans = vec![Span::styled(
        format!("{:<w$}", label, w = cap_w),
        Style::default().fg(Color::Cyan),
    )];

    let ratio = ratio.unwrap_or(0.0).clamp(0.0, 1.0);
    let fill = (ratio * inner as f64).round() as usize;
    let marker_pos = marker_at
        .map(|m| ((m.clamp(0.0, 1.0)) * inner as f64).round() as usize)
        .map(|p| p.min(inner.saturating_sub(1)));

    // Value text right-aligned inside the bar (htop pattern); bold default fg.
    let text_len = value_text.chars().count();
    let text: Vec<char> = if text_len >= inner {
        value_text.chars().rev().take(inner).collect::<Vec<_>>()
            .into_iter().rev().collect()
    } else {
        let pad = inner - text_len;
        std::iter::repeat_n(' ', pad)
            .chain(value_text.chars())
            .collect()
    };

    spans.push(Span::raw("["));
    for (i, &tc) in text.iter().enumerate().take(inner) {
        // marker needs a blank cell underneath; never eat the value text
        let is_marker = Some(i) == marker_pos && tc == ' ';
        let bg_ch = if i < fill { '|' } else { ' ' };
        let (ch, style) = if is_marker {
            (
                '!',
                Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
            )
        } else if tc != ' ' {
            (tc, Style::default().add_modifier(Modifier::BOLD))
        } else if i < fill {
            (bg_ch, Style::default().fg(color))
        } else {
            (' ', Style::default())
        };
        spans.push(Span::styled(ch.to_string(), style));
    }
    spans.push(Span::raw("]"));
    Line::from(spans)
}

fn fmt_opt_watts(v: Option<f64>) -> String {
    match v {
        Some(w) => format!("{:.1} W", w),
        None => "unreadable (root)".to_string(),
    }
}

fn fmt_opt_temp(v: Option<f64>) -> String {
    match v {
        Some(t) => format!("{:.0} °C", t),
        None => "n/a".to_string(),
    }
}

/// Build a core-column's lines: section caption + one meter per core with
/// a blank separator after. Width adapts to the column rect.
fn core_section_lines(title: &str, cores: &[CoreRow], app: &App, width: u16) -> Vec<Line<'static>> {
    let mut lines = vec![Line::from(Span::styled(
        title.to_string(),
        Style::default().fg(Color::Cyan),
    ))];
    for core in cores {
        let text = match (core.freq_khz, core.temp) {
            (Some(f), Some(t)) => format!("{:.2}GHz  {:.0}°C", f as f64 / 1_000_000.0, t),
            (Some(f), None) => format!("{:.2}GHz", f as f64 / 1_000_000.0),
            (None, Some(t)) => format!("{:.0}°C", t),
            (None, None) => "n/a".to_string(),
        };
        // Bar spans the cpu's own [min_freq, max_freq] window so the
        // 800 MHz idle floor sits at 0% and turbo pegs 100%.
        let ratio = match (core.freq_khz, core.freq_max_khz) {
            (Some(f), max) if max > core.freq_min_khz => Some(
                ((f as f64 - core.freq_min_khz as f64)
                    / (max - core.freq_min_khz) as f64)
                    .clamp(0.0, 1.0),
            ),
            _ => None,
        };
        let color = match core.temp {
            Some(t) => temp_color(t, app.cfg.temp_warn, app.cfg.temp_crit),
            None => Color::DarkGray,
        };
        lines.push(meter_line(&core.label, &text, ratio, color, width, None));
    }
    lines.push(Line::from(""));
    lines
}

/// Render one meter line inside a column rect (render_widget per line row).
fn render_lines(f: &mut Frame, area: Rect, lines: &[Line<'static>]) {
    for (i, line) in lines.iter().enumerate() {
        if (i as u16) >= area.height {
            break;
        }
        f.render_widget(
            ratatui::text::Text::from(line.clone()),
            Rect { x: area.x, y: area.y + i as u16, width: area.width, height: 1 },
        );
    }
}

pub fn draw(f: &mut Frame, app: &App) {
    let area = f.area();
    let w = area.width;
    let dual = match app.cfg.layout {
        config::Layout::Single => false,
        config::Layout::Dual => true,
        config::Layout::Auto => w >= DUAL_COLUMN_MIN_WIDTH,
    };

    // ---- vertical budget: graphs get 2/3 of the height, cores 1/3 ----
    let footer_h = 1u16;
    let graph_rows: u16 = if app.cfg.graphs && w >= GRAPHS_MIN_WIDTH {
        // 2/3 of rows minus caption + separator, floored at 3 rows.
        ((area.height * 2 / 3)
            .saturating_sub(2))
            .max(3)
    } else {
        0
    };
    let bottom_h: u16 = if graph_rows > 0 {
        graph_rows + 1 // caption line above the graph
    } else {
        2 // pkg power + pkg temp meter rows
    };
    let bottom_h = bottom_h + 1; // blank separator above footer
    let cores_h = area.height.saturating_sub(bottom_h + footer_h);
    let cores_area = Rect { height: cores_h, ..area };
    let bottom_y = area.y + cores_h;
    let footer_y = area.y + area.height.saturating_sub(footer_h);

    // ---- cores zone: P column | E column, or single column ----
    let p: Vec<CoreRow> = app.cores.iter().filter(|c| !c.e_core).cloned().collect();
    let e: Vec<CoreRow> = app.cores.iter().filter(|c| c.e_core).cloned().collect();
    let (col_w, used) = if dual {
        ((w / 2).max(1), 2)
    } else {
        (w, 1)
    };
    let sections = [
        ("P-cores", p, Rect { width: col_w, ..cores_area }),
        ("E-cores", e, Rect { x: area.x + col_w, width: w - col_w, ..cores_area }),
    ];
    for (i, (title, cores, rect)) in sections.into_iter().enumerate() {
        if i >= used || rect.width == 0 {
            continue;
        }
        let lines = core_section_lines(title, &cores, app, rect.width);
        render_lines(f, rect, &lines);
    }

    // ---- package zone (bottom) ----
    let bottom_area = Rect { y: bottom_y, height: bottom_h, ..area };
    let vc_max = dynamic_max(app.vcore_all_time_peak, app.cfg.vcore_bar_max, SCALE_MARGIN);
    let watt_max = dynamic_max(app.watts_all_time_peak, app.cfg.power_bar_max, SCALE_MARGIN);
    let vc_color = match app.vcore {
        Some(v) => vcore_color(v, app.cfg.vcore_limit),
        None => Color::DarkGray,
    };

    let hero_text = match (app.vcore, app.vcore_peak) {
        (Some(v), Some(p)) => format!("{:.3} V  peak {:.3}", v, p),
        (Some(v), None) => format!("{:.3} V", v),
        (None, _) => "n/a".to_string(),
    };

    if app.cfg.graphs && w >= GRAPHS_MIN_WIDTH {
        let thirds = w / 3;
        let mut x = area.x;
        // Graphs scale to the WINDOW's max (recent history), not the
        // session all-time peak — a flat idle line pinned to the bottom of
        // a 1.5 V scale reads as broken. Hero bar keeps all-time scale.
        let ticks = graph_ticks(&app.cfg);
        let ring_max = |ring: &Ring, floor: f64| -> f64 {
            (ring.max_window(ticks) * (1.0 + SCALE_MARGIN)).max(floor)
        };
        let vc_graph_max = ring_max(&app.vcore_graph, 1.0);
        let watt_graph_max = ring_max(&app.watts_graph, 10.0);
        for (i, (label, text, ring, max, color, marker)) in [
            ("vCore", hero_text.clone(), &app.vcore_graph, vc_graph_max, vc_color,
                Some(((1.0 - (app.cfg.vcore_limit / vc_graph_max).clamp(0.0, 1.0)) * (graph_rows - 1) as f64).round() as u16)),
            ("Pkg power", fmt_opt_watts(app.package_watts), &app.watts_graph, watt_graph_max, Color::Blue, None),
            ("Pkg temp", fmt_opt_temp(app.package_temp), &app.temp_graph,
                ring_max(&app.temp_graph, 0.0).max(50.0),
                app.package_temp.map_or(Color::DarkGray, |t| temp_color(t, app.cfg.temp_warn, app.cfg.temp_crit)), None),
        ].into_iter().enumerate() {
            let span_w = if i == 2 { w - thirds * 2 } else { thirds };
            let rect = Rect { x, y: bottom_y, width: span_w.max(1), height: bottom_h };
            let caption = Line::from(vec![
                Span::styled(label, Style::default().fg(Color::Cyan)),
                Span::raw("  "),
                Span::styled(text, Style::default().add_modifier(Modifier::BOLD)),
            ]);
            f.render_widget(ratatui::text::Text::from(caption), Rect { height: 1, ..rect });
            plot::render(f, Rect { y: rect.y + 1, height: graph_rows, ..rect }, ring, ticks, app.cfg.poll_ms, max, color, marker);
            x += thirds;
        }
    } else {
        let mut lines = vec![
            meter_line("vCore", &hero_text, app.vcore.map(|v| v / vc_max), vc_color, w,
                Some(app.cfg.vcore_limit / vc_max)),
        ];
        if !app.cfg.graphs || w < GRAPHS_MIN_WIDTH {
            lines.push(meter_line("Pkg power", &fmt_opt_watts(app.package_watts),
                app.package_watts.map(|v| v / watt_max), Color::Blue, w, None));
            lines.push(meter_line("Pkg temp", &fmt_opt_temp(app.package_temp),
                app.package_temp.map(|t| t / TEMP_BAR_MAX),
                app.package_temp.map_or(Color::DarkGray, |t| temp_color(t, app.cfg.temp_warn, app.cfg.temp_crit)), w, None));
            lines.push(Line::from(""));
        }
        render_lines(f, bottom_area, &lines);
    }

    // ---- footer (or settings pane overlay) ----
    if app.settings_open {
        draw_settings(f, area, app);
    } else {
        let footer = Line::from(vec![
            Span::styled("Max freq: ", Style::default().fg(Color::Cyan)),
            Span::raw(format!(
                "{:.2} GHz",
                app.cores.iter().filter_map(|c| c.freq_khz).max().unwrap_or(0) as f64 / 1_000_000.0
            )),
            Span::styled("    q: quit  F2: settings", Style::default().fg(Color::DarkGray)),
        ]);
        f.render_widget(ratatui::text::Text::from(footer), Rect { y: footer_y, height: 1, ..area });
    }
}

fn draw_settings(f: &mut Frame, area: Rect, app: &App) {
    let h = SETTINGS_ROWS.len() as u16 + 4; // title + rows + hint + margin
    let w = 44u16.min(area.width);
    let y = area.height.saturating_sub(h + 1) / 2;
    let rect = Rect { x: (area.width - w) / 2, y, width: w, height: h };

    let mut lines = vec![Line::from(Span::styled(
        "Settings",
        Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
    ))];
    for (i, label) in SETTINGS_ROWS.iter().enumerate() {
        let cursor = if i == app.settings_row { ">" } else { " " };
        let editing = app.settings_row == i && !app.edit_buf.is_empty();
        let value = if editing {
            format!("{}_", app.edit_buf)
        } else {
            setting_value(app, i)
        };
        let style = if i == app.settings_row {
            Style::default().add_modifier(Modifier::BOLD)
        } else {
            Style::default()
        };
        lines.push(Line::from(vec![
            Span::styled(format!("{} {:<14}", cursor, label), Style::default().fg(Color::Cyan)),
            Span::styled(value, style),
        ]));
    }
    if let Some(err) = &app.settings_error {
        lines.push(Line::from(Span::styled(err.clone(), Style::default().fg(Color::Red))));
    } else {
        lines.push(Line::from(""));
    }
    lines.push(Line::from(Span::styled(
        "↑↓ select  Enter edit/apply  F10 save  Esc close",
        Style::default().fg(Color::DarkGray),
    )));
    f.render_widget(ratatui::text::Text::from(lines), rect);
}

/// Settings-pane key handling. Returns Err(msg) shown inline on bad input.
fn settings_key(app: &mut App, key: KeyCode) -> Result<(), String> {
    match key {
        KeyCode::Up => {
            app.settings_row = app.settings_row.saturating_sub(1);
            app.edit_buf.clear();
        }
        KeyCode::Down => {
            app.settings_row = (app.settings_row + 1).min(SETTINGS_ROWS.len() - 1);
            app.edit_buf.clear();
        }
        KeyCode::Enter => {
            if app.edit_buf.is_empty() {
                return Ok(());
            }
            let raw = std::mem::take(&mut app.edit_buf);
            setting_absorb(app, app.settings_row, &raw)?;
        }
        KeyCode::Backspace => {
            app.edit_buf.pop();
        }
        KeyCode::Esc => {
            app.settings_open = false;
            app.edit_buf.clear();
        }
        KeyCode::Char(c) if c.is_ascii_digit() => app.edit_buf.push(c),
        _ => {}
    }
    Ok(())
}

pub fn run_tui() {
    let mut terminal = ratatui::init();
    let mut app = App::new();
    loop {
        app.poll();
        terminal.draw(|f| draw(f, &app)).expect("draw failed");
        let poll_ms = app.cfg.poll_ms;
        if event::poll(Duration::from_millis(poll_ms)).unwrap_or(false) {
            if let Ok(Event::Key(key)) = event::read() {
                if key.kind != crossterm::event::KeyEventKind::Press {
                    continue;
                }
                match key.code {
                    KeyCode::F(2) => {
                        app.settings_open = true;
                        app.settings_error = None;
                        app.edit_buf.clear();
                    }
                    KeyCode::F(10) if app.settings_open => {
                        save_conf(&app);
                        app.settings_open = false;
                    }
                    KeyCode::Char('q') | KeyCode::Esc
                        if !app.settings_open || key.code == KeyCode::Esc =>
                    {
                        // Esc inside the pane closes the pane; otherwise quit.
                        if app.settings_open {
                            app.settings_open = false;
                            app.edit_buf.clear();
                        } else {
                            break;
                        }
                    }
                    code if app.settings_open => {
                        app.settings_error = None;
                        if let Err(e) = settings_key(&mut app, code) {
                            app.settings_error = Some(e);
                        }
                    }
                    _ => {}
                }
            }
        }
    }
    ratatui::restore();
}

/// Write the settings block back to the conf the app loaded (F10). No-op
/// when no file was loaded (defaults only).
fn save_conf(app: &App) {
    if let Some(path) = &app.cfg.loaded_path {
        let text = std::fs::read_to_string(path).unwrap_or_default();
        let updated = config::update_conf_text(&text, &app.cfg);
        let _ = std::fs::write(path, updated);
    }
}