chute_kun/lib/
config.rs

1//! App configuration loaded from a config file or defaults.
2//! - Supports day start time (HH:MM) and key bindings.
3//! - Defaults: day start 09:00 and built-in keymap compatible with current tests.
4
5use anyhow::{anyhow, Context, Result};
6use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
7use ratatui::style::Color;
8use serde::Deserialize;
9use std::fs;
10use std::path::PathBuf;
11
12#[derive(Debug, Clone)]
13pub struct Config {
14    pub day_start_minutes: u16,
15    pub keys: KeyMap,
16    pub categories: CategoryTheme,
17}
18
19impl Default for Config {
20    fn default() -> Self {
21        Self {
22            day_start_minutes: 9 * 60,
23            keys: KeyMap::default(),
24            categories: CategoryTheme::default(),
25        }
26    }
27}
28
29// ---- Category theme (names + colors) ----
30
31#[derive(Debug, Clone)]
32pub struct CategoryTheme {
33    pub general: CategoryStyle,
34    pub work: CategoryStyle,
35    pub home: CategoryStyle,
36    pub hobby: CategoryStyle,
37}
38
39#[derive(Debug, Clone)]
40pub struct CategoryStyle {
41    pub name: String,
42    pub color: Color,
43}
44
45impl Default for CategoryTheme {
46    fn default() -> Self {
47        CategoryTheme {
48            general: CategoryStyle { name: "General".into(), color: Color::White },
49            work: CategoryStyle { name: "Work".into(), color: Color::Blue },
50            home: CategoryStyle { name: "Home".into(), color: Color::Yellow },
51            hobby: CategoryStyle { name: "Hobby".into(), color: Color::Magenta },
52        }
53    }
54}
55
56impl Config {
57    pub fn category_color(&self, cat: crate::task::Category) -> Color {
58        match cat {
59            crate::task::Category::General => self.categories.general.color,
60            crate::task::Category::Work => self.categories.work.color,
61            crate::task::Category::Home => self.categories.home.color,
62            crate::task::Category::Hobby => self.categories.hobby.color,
63        }
64    }
65    pub fn category_name(&self, cat: crate::task::Category) -> String {
66        match cat {
67            crate::task::Category::General => self.categories.general.name.clone(),
68            crate::task::Category::Work => self.categories.work.name.clone(),
69            crate::task::Category::Home => self.categories.home.name.clone(),
70            crate::task::Category::Hobby => self.categories.hobby.name.clone(),
71        }
72    }
73}
74
75#[derive(Debug, Clone)]
76pub struct KeyMap {
77    pub quit: Vec<KeySpec>,
78    pub add_task: Vec<KeySpec>,
79    pub add_interrupt: Vec<KeySpec>,
80    pub start_or_resume: Vec<KeySpec>,
81    pub finish_active: Vec<KeySpec>,
82    pub popup: Vec<KeySpec>,
83    pub delete: Vec<KeySpec>,
84    pub reorder_up: Vec<KeySpec>,
85    pub reorder_down: Vec<KeySpec>,
86    pub estimate_plus: Vec<KeySpec>,
87    pub postpone: Vec<KeySpec>,
88    pub bring_to_today: Vec<KeySpec>,
89    pub view_next: Vec<KeySpec>,
90    pub view_prev: Vec<KeySpec>,
91    pub select_up: Vec<KeySpec>,
92    pub select_down: Vec<KeySpec>,
93    pub toggle_blocks: Vec<KeySpec>,
94    pub category_cycle: Vec<KeySpec>,
95    pub category_picker: Vec<KeySpec>,
96}
97
98impl Default for KeyMap {
99    fn default() -> Self {
100        use KeySpec as K;
101        let k = |s| K::parse(s).expect("valid default key spec");
102        KeyMap {
103            quit: vec![k("q")],
104            add_task: vec![k("i")],
105            add_interrupt: vec![k("I")],
106            start_or_resume: vec![k("Enter")],
107            finish_active: vec![k("Shift+Enter"), k("f")],
108            popup: vec![k("Space")],
109            delete: vec![k("x")],
110            reorder_up: vec![k("[")],
111            reorder_down: vec![k("]")],
112            estimate_plus: vec![k("e")],
113            postpone: vec![k("p")],
114            bring_to_today: vec![k("b")],
115            view_next: vec![k("Tab")],
116            view_prev: vec![k("BackTab")],
117            select_up: vec![k("Up"), k("k")],
118            select_down: vec![k("Down"), k("j")],
119            toggle_blocks: vec![k("t")],
120            category_cycle: vec![k("c")],
121            category_picker: vec![k("Shift+c")],
122        }
123    }
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum Action {
128    Quit,
129    AddTask,
130    AddInterrupt,
131    StartOrResume,
132    FinishActive,
133    OpenPopup,
134    Delete,
135    ReorderUp,
136    ReorderDown,
137    EstimatePlus,
138    Postpone,
139    BringToToday,
140    ViewNext,
141    ViewPrev,
142    SelectUp,
143    SelectDown,
144    ToggleBlocks,
145    CategoryCycle,
146    CategoryPicker,
147}
148
149impl KeyMap {
150    pub fn action_for(&self, ev: &KeyEvent) -> Option<Action> {
151        let matches = |list: &Vec<KeySpec>| list.iter().any(|k| k.matches(ev));
152        if matches(&self.quit) {
153            Some(Action::Quit)
154        } else if matches(&self.add_task) {
155            Some(Action::AddTask)
156        } else if matches(&self.add_interrupt) {
157            Some(Action::AddInterrupt)
158        } else if matches(&self.start_or_resume) {
159            Some(Action::StartOrResume)
160        } else if matches(&self.finish_active) {
161            Some(Action::FinishActive)
162        } else if matches(&self.popup) {
163            Some(Action::OpenPopup)
164        } else if matches(&self.delete) {
165            Some(Action::Delete)
166        } else if matches(&self.reorder_up) {
167            Some(Action::ReorderUp)
168        } else if matches(&self.reorder_down) {
169            Some(Action::ReorderDown)
170        } else if matches(&self.estimate_plus) {
171            Some(Action::EstimatePlus)
172        } else if matches(&self.postpone) {
173            Some(Action::Postpone)
174        } else if matches(&self.bring_to_today) {
175            Some(Action::BringToToday)
176        } else if matches(&self.view_next) {
177            Some(Action::ViewNext)
178        } else if matches(&self.view_prev) {
179            Some(Action::ViewPrev)
180        } else if matches(&self.select_up) {
181            Some(Action::SelectUp)
182        } else if matches(&self.select_down) {
183            Some(Action::SelectDown)
184        } else if matches(&self.toggle_blocks) {
185            Some(Action::ToggleBlocks)
186        } else if matches(&self.category_cycle) {
187            Some(Action::CategoryCycle)
188        } else if matches(&self.category_picker) {
189            Some(Action::CategoryPicker)
190        } else {
191            None
192        }
193    }
194}
195
196#[derive(Debug, Clone, Copy)]
197pub struct KeySpec {
198    pub code: KeyCode,
199    pub modifiers: KeyModifiers,
200}
201
202impl KeySpec {
203    pub fn parse(s: &str) -> Result<Self> {
204        let s = s.trim();
205        if s.is_empty() {
206            return Err(anyhow!("empty key spec"));
207        }
208        let mut parts = s.split('+').map(str::trim).collect::<Vec<_>>();
209        let key_str = parts.pop().unwrap();
210        let mut mods = KeyModifiers::empty();
211        for m in parts {
212            match m.to_ascii_lowercase().as_str() {
213                "shift" => mods |= KeyModifiers::SHIFT,
214                "ctrl" | "control" => mods |= KeyModifiers::CONTROL,
215                "alt" => mods |= KeyModifiers::ALT,
216                other => return Err(anyhow!("unsupported modifier: {}", other)),
217            }
218        }
219        let mut code = match key_str {
220            "Enter" => KeyCode::Enter,
221            "Space" => KeyCode::Char(' '),
222            "Tab" => KeyCode::Tab,
223            "BackTab" => KeyCode::BackTab,
224            "Up" => KeyCode::Up,
225            "Down" => KeyCode::Down,
226            // common punctuation and single char letters
227            s if s.len() == 1 => KeyCode::Char(s.chars().next().unwrap()),
228            other => return Err(anyhow!("unsupported key: {}", other)),
229        };
230        // Normalize semantics:
231        // - Uppercase single letters mean Shift+lowercase (e.g., "C" -> Shift+"c").
232        // - Ctrl+letter is case-insensitive; compare on lowercase.
233        if let KeyCode::Char(c) = code {
234            let is_alpha = c.is_ascii_alphabetic();
235            let is_upper = c.is_ascii_uppercase();
236            let has_ctrl = mods.contains(KeyModifiers::CONTROL);
237            if is_alpha && is_upper && !has_ctrl {
238                // Interpret bare uppercase as Shift+lowercase (but not when Ctrl is present)
239                mods |= KeyModifiers::SHIFT;
240                code = KeyCode::Char(c.to_ascii_lowercase());
241            }
242            if has_ctrl && is_alpha {
243                // Ctrl+letter is matched case-insensitively on lowercase
244                code = KeyCode::Char(c.to_ascii_lowercase());
245            }
246        }
247        Ok(KeySpec { code, modifiers: mods })
248    }
249
250    pub fn matches(&self, ev: &KeyEvent) -> bool {
251        use KeyCode::*;
252        // Treat Shift+Tab and BackTab as equivalent across terminals
253        let (mut sc, mut sm) = (self.code, self.modifiers);
254        let (mut ec, mut em) = (ev.code, ev.modifiers);
255        // Normalize letters:
256        // - Uppercase chars imply Shift+lowercase for matching (some terminals send 'C' with/without SHIFT).
257        // - Ctrl+letter is matched case-insensitively on lowercase.
258        if let KeyCode::Char(c) = sc {
259            let is_alpha = c.is_ascii_alphabetic();
260            let is_upper = c.is_ascii_uppercase();
261            let has_ctrl = sm.contains(KeyModifiers::CONTROL);
262            if is_alpha && is_upper && !has_ctrl {
263                sc = KeyCode::Char(c.to_ascii_lowercase());
264                sm |= KeyModifiers::SHIFT;
265            }
266            if has_ctrl && is_alpha {
267                sc = KeyCode::Char(c.to_ascii_lowercase());
268            }
269        }
270        if let KeyCode::Char(c) = ec {
271            let is_alpha = c.is_ascii_alphabetic();
272            let is_upper = c.is_ascii_uppercase();
273            let has_ctrl = em.contains(KeyModifiers::CONTROL);
274            if is_alpha && is_upper && !has_ctrl {
275                ec = KeyCode::Char(c.to_ascii_lowercase());
276                em |= KeyModifiers::SHIFT;
277            }
278            if has_ctrl && is_alpha {
279                ec = KeyCode::Char(c.to_ascii_lowercase());
280            }
281        }
282        let self_is_shift_tab = (sc == Tab && sm.contains(KeyModifiers::SHIFT)) || sc == BackTab;
283        let ev_is_shift_tab = (ec == Tab && em.contains(KeyModifiers::SHIFT)) || ec == BackTab;
284        if self_is_shift_tab && ev_is_shift_tab {
285            return true;
286        }
287        ec == sc && em == sm
288    }
289
290    /// Human‑readable key label used in help text.
291    /// Examples: "q", "Enter", "Space", "Shift+Enter", "Ctrl+C", "Tab", "BackTab".
292    pub fn label(&self) -> String {
293        use KeyCode::*;
294        let base = match self.code {
295            Enter => "Enter".to_string(),
296            Tab => "Tab".to_string(),
297            BackTab => "Shift+Tab".to_string(),
298            Up => "Up".to_string(),
299            Down => "Down".to_string(),
300            KeyCode::Char(' ') => "Space".to_string(),
301            KeyCode::Char(c) => c.to_string(),
302            _ => format!("{:?}", self.code),
303        };
304        // Canonical modifier order: Shift, Ctrl, Alt
305        let mut parts: Vec<&'static str> = Vec::new();
306        if self.modifiers.contains(KeyModifiers::SHIFT) && self.code != BackTab {
307            parts.push("Shift");
308        }
309        if self.modifiers.contains(KeyModifiers::CONTROL) {
310            parts.push("Ctrl");
311        }
312        if self.modifiers.contains(KeyModifiers::ALT) {
313            parts.push("Alt");
314        }
315        if parts.is_empty() {
316            base
317        } else {
318            format!("{}+{}", parts.join("+"), base)
319        }
320    }
321}
322
323/// Join labels for multiple keys using '/' (e.g., "Shift+Enter/f").
324pub fn join_key_labels(keys: &[KeySpec]) -> String {
325    keys.iter().map(|k| k.label()).collect::<Vec<_>>().join("/")
326}
327
328// ----- Loading / Parsing -----
329
330#[derive(Debug, Deserialize)]
331struct RawConfig {
332    #[serde(default)]
333    day_start: Option<String>,
334    #[serde(default)]
335    keys: Option<RawKeys>,
336    #[serde(default)]
337    categories: Option<RawCategories>,
338}
339
340#[derive(Debug, Deserialize, Default)]
341struct RawKeys {
342    quit: Option<OneOrMany>,
343    add_task: Option<OneOrMany>,
344    add_interrupt: Option<OneOrMany>,
345    start_or_resume: Option<OneOrMany>,
346    finish_active: Option<OneOrMany>,
347    popup: Option<OneOrMany>,
348    delete: Option<OneOrMany>,
349    reorder_up: Option<OneOrMany>,
350    reorder_down: Option<OneOrMany>,
351    estimate_plus: Option<OneOrMany>,
352    postpone: Option<OneOrMany>,
353    bring_to_today: Option<OneOrMany>,
354    view_next: Option<OneOrMany>,
355    view_prev: Option<OneOrMany>,
356    select_up: Option<OneOrMany>,
357    select_down: Option<OneOrMany>,
358    toggle_blocks: Option<OneOrMany>,
359    category_cycle: Option<OneOrMany>,
360    category_picker: Option<OneOrMany>,
361}
362
363#[derive(Debug, Deserialize, Default)]
364struct RawCategories {
365    #[serde(default)]
366    general: Option<RawCategoryStyle>,
367    #[serde(default)]
368    work: Option<RawCategoryStyle>,
369    #[serde(default)]
370    home: Option<RawCategoryStyle>,
371    #[serde(default)]
372    hobby: Option<RawCategoryStyle>,
373}
374
375#[derive(Debug, Deserialize, Default, Clone)]
376struct RawCategoryStyle {
377    #[serde(default)]
378    name: Option<String>,
379    #[serde(default)]
380    color: Option<String>,
381}
382
383#[derive(Debug, Deserialize)]
384#[serde(untagged)]
385enum OneOrMany {
386    One(String),
387    Many(Vec<String>),
388}
389
390impl OneOrMany {
391    fn into_vec(self) -> Vec<String> {
392        match self {
393            OneOrMany::One(s) => vec![s],
394            OneOrMany::Many(v) => v,
395        }
396    }
397}
398
399fn parse_hhmm_to_minutes(s: &str) -> Result<u16> {
400    let parts: Vec<&str> = s.split(':').collect();
401    if parts.len() != 2 {
402        return Err(anyhow!("invalid time format, expected HH:MM: {}", s));
403    }
404    let h: u16 = parts[0].parse().context("invalid hour")?;
405    let m: u16 = parts[1].parse().context("invalid minute")?;
406    Ok((h % 24) * 60 + (m % 60))
407}
408
409fn parse_color(s: &str) -> Result<Color> {
410    let lower = s.trim().to_ascii_lowercase();
411    let named = match lower.as_str() {
412        "white" => Some(Color::White),
413        "blue" => Some(Color::Blue),
414        "yellow" => Some(Color::Yellow),
415        "magenta" => Some(Color::Magenta),
416        "red" => Some(Color::Red),
417        "green" => Some(Color::Green),
418        "cyan" => Some(Color::Cyan),
419        "black" => Some(Color::Black),
420        "gray" | "grey" => Some(Color::Gray),
421        "darkgray" | "darkgrey" => Some(Color::DarkGray),
422        _ => None,
423    };
424    if let Some(c) = named {
425        return Ok(c);
426    }
427    let s = lower.trim();
428    if s.starts_with('#') && s.len() == 7 {
429        let r = u8::from_str_radix(&s[1..3], 16).map_err(|_| anyhow!("bad hex color"))?;
430        let g = u8::from_str_radix(&s[3..5], 16).map_err(|_| anyhow!("bad hex color"))?;
431        let b = u8::from_str_radix(&s[5..7], 16).map_err(|_| anyhow!("bad hex color"))?;
432        return Ok(Color::Rgb(r, g, b));
433    }
434    Err(anyhow!("unknown color: {}", s))
435}
436
437impl Config {
438    pub fn from_toml_str(s: &str) -> Result<Self> {
439        let raw: RawConfig = toml::from_str(s).context("parse config toml")?;
440        let mut cfg = Config::default();
441        if let Some(ds) = raw.day_start {
442            cfg.day_start_minutes = parse_hhmm_to_minutes(&ds)?;
443        }
444        if let Some(keys) = raw.keys {
445            let mut km = KeyMap::default();
446            let apply = |dst: &mut Vec<KeySpec>, src: OneOrMany| -> Result<()> {
447                *dst = src
448                    .into_vec()
449                    .into_iter()
450                    .map(|s| KeySpec::parse(&s))
451                    .collect::<Result<Vec<_>>>()?;
452                Ok(())
453            };
454            if let Some(v) = keys.quit {
455                apply(&mut km.quit, v)?;
456            }
457            if let Some(v) = keys.add_task {
458                apply(&mut km.add_task, v)?;
459            }
460            if let Some(v) = keys.add_interrupt {
461                apply(&mut km.add_interrupt, v)?;
462            }
463            if let Some(v) = keys.start_or_resume {
464                apply(&mut km.start_or_resume, v)?;
465            }
466            if let Some(v) = keys.finish_active {
467                apply(&mut km.finish_active, v)?;
468            }
469            if let Some(v) = keys.popup {
470                apply(&mut km.popup, v)?;
471            }
472            if let Some(v) = keys.delete {
473                apply(&mut km.delete, v)?;
474            }
475            if let Some(v) = keys.reorder_up {
476                apply(&mut km.reorder_up, v)?;
477            }
478            if let Some(v) = keys.reorder_down {
479                apply(&mut km.reorder_down, v)?;
480            }
481            if let Some(v) = keys.estimate_plus {
482                apply(&mut km.estimate_plus, v)?;
483            }
484            if let Some(v) = keys.postpone {
485                apply(&mut km.postpone, v)?;
486            }
487            if let Some(v) = keys.bring_to_today {
488                apply(&mut km.bring_to_today, v)?;
489            }
490            if let Some(v) = keys.view_next {
491                apply(&mut km.view_next, v)?;
492            }
493            if let Some(v) = keys.view_prev {
494                apply(&mut km.view_prev, v)?;
495            }
496            if let Some(v) = keys.select_up {
497                apply(&mut km.select_up, v)?;
498            }
499            if let Some(v) = keys.select_down {
500                apply(&mut km.select_down, v)?;
501            }
502            if let Some(v) = keys.toggle_blocks {
503                apply(&mut km.toggle_blocks, v)?;
504            }
505            if let Some(v) = keys.category_cycle {
506                apply(&mut km.category_cycle, v)?;
507            }
508            if let Some(v) = keys.category_picker {
509                apply(&mut km.category_picker, v)?;
510            }
511            cfg.keys = km;
512        }
513        if let Some(cats) = raw.categories {
514            let apply = |dst: &mut CategoryStyle, ent: Option<RawCategoryStyle>| -> Result<()> {
515                if let Some(e) = ent {
516                    if let Some(n) = e.name {
517                        dst.name = n;
518                    }
519                    if let Some(c) = e.color {
520                        dst.color = parse_color(&c)?;
521                    }
522                }
523                Ok(())
524            };
525            apply(&mut cfg.categories.general, cats.general)?;
526            apply(&mut cfg.categories.work, cats.work)?;
527            apply(&mut cfg.categories.home, cats.home)?;
528            apply(&mut cfg.categories.hobby, cats.hobby)?;
529        }
530        Ok(cfg)
531    }
532
533    pub fn load() -> Self {
534        // In tests (integration/unit), avoid reading external user config for determinism.
535        // Detect by env var set by Rust test harness.
536        if std::env::var("RUST_TEST_THREADS").is_ok()
537            || std::env::var("CHUTE_KUN_DISABLE_CONFIG").is_ok()
538        {
539            return Config::default();
540        }
541        if let Ok(path) = std::env::var("CHUTE_KUN_CONFIG") {
542            if let Ok(s) = fs::read_to_string(&path) {
543                if let Ok(cfg) = Self::from_toml_str(&s) {
544                    return cfg;
545                }
546            }
547        }
548        let path = default_config_path();
549        if let Some(path) = path {
550            if path.exists() {
551                if let Ok(s) = fs::read_to_string(&path) {
552                    if let Ok(cfg) = Self::from_toml_str(&s) {
553                        return cfg;
554                    }
555                }
556            }
557        }
558        Config::default()
559    }
560
561    /// Render a default TOML string users can customize.
562    pub fn default_toml() -> String {
563        // Keep keys aligned with KeyMap::default()
564        r##"# Chute-kun configuration
565# 設定ファイルの場所: $XDG_CONFIG_HOME/chute_kun/config.toml (なければ ~/.config/chute_kun/config.toml)
566
567# 1日の開始時刻(固定表示)。"HH:MM" 形式。既定は 09:00。
568day_start = "09:00"
569
570[keys]
571# 既定のキーバインド。必要なものだけ上書きできます。
572quit = "q"
573add_task = "i"
574add_interrupt = "Shift+i"
575start_or_resume = "Enter"
576finish_active = ["Shift+Enter", "f"]
577popup = "Space"
578delete = "x"
579reorder_up = "["
580reorder_down = "]"
581estimate_plus = "e"
582postpone = "p"
583bring_to_today = "b"
584view_next = "Tab"
585view_prev = "BackTab"
586select_up = ["Up", "k"]
587select_down = ["Down", "j"]
588toggle_blocks = "t"
589category_cycle = "c"
590category_picker = "Shift+c"
591
592[categories]
593# カテゴリ名と色("white"/"blue"/"yellow"/"magenta"/"red"/"green"/"cyan"/"black"/"gray"/"darkgray" または "#RRGGBB")
594[categories.general]
595name = "General"
596color = "white"
597
598[categories.work]
599name = "Work"
600color = "blue"
601
602[categories.home]
603name = "Home"
604color = "yellow"
605
606[categories.hobby]
607name = "Hobby"
608color = "magenta"
609"##.to_string()
610    }
611
612    /// Write a default config file to the resolved path.
613    /// - If `CHUTE_KUN_CONFIG` is set, writes there; otherwise XDG default.
614    /// - Creates parent directories when必要.
615    /// - If file already exists, leaves it as-is and returns Ok(path).
616    pub fn write_default_file() -> Result<std::path::PathBuf> {
617        let path = if let Ok(p) = std::env::var("CHUTE_KUN_CONFIG") {
618            std::path::PathBuf::from(p)
619        } else {
620            default_config_path().ok_or_else(|| anyhow!("could not resolve config path"))?
621        };
622        if let Some(parent) = path.parent() {
623            std::fs::create_dir_all(parent).ok();
624        }
625        if !path.exists() {
626            std::fs::write(&path, Self::default_toml()).context("write default config")?;
627        }
628        Ok(path)
629    }
630}
631
632pub fn default_config_path() -> Option<PathBuf> {
633    if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
634        return Some(PathBuf::from(xdg).join("chute_kun").join("config.toml"));
635    }
636    // macOS: prefer ~/.config over ~/Library/Application Support to keep cross‑platform consistency
637    if cfg!(target_os = "macos") {
638        if let Some(home) = std::env::var_os("HOME") {
639            return Some(PathBuf::from(home).join(".config").join("chute_kun").join("config.toml"));
640        }
641    }
642    // Fallback to OS default (Linux uses ~/.config; Windows gets %APPDATA%)
643    dirs::config_dir().map(|b| b.join("chute_kun").join("config.toml"))
644}
645
646// ---- Helpers for updating day_start persistently and parsing flexible inputs ----
647
648/// Update or insert the `day_start = "HH:MM"` line in a TOML string.
649pub fn set_day_start_in_toml(contents: &str, hhmm: &str) -> String {
650    let mut replaced = false;
651    let mut out = String::with_capacity(contents.len() + 32);
652    for line in contents.lines() {
653        let trimmed = line.trim_start();
654        if trimmed.starts_with("day_start") {
655            out.push_str(&format!("day_start = \"{}\"\n", hhmm));
656            replaced = true;
657        } else {
658            out.push_str(line);
659            out.push('\n');
660        }
661    }
662    if !replaced {
663        let mut inserted = String::new();
664        inserted.push_str(&format!("day_start = \"{}\"\n", hhmm));
665        inserted.push_str(&out);
666        return inserted;
667    }
668    out
669}
670
671/// Parse time in "HH:MM" or compact "HHMM" (3-4 digits) to (hour, minute).
672pub fn parse_hhmm_or_compact(s: &str) -> Result<(u16, u16)> {
673    let s = s.trim();
674    if let Some(colon) = s.find(':') {
675        // Standard HH:MM
676        let h: u16 = s[..colon].parse().context("invalid hour")?;
677        let m: u16 = s[colon + 1..].parse().context("invalid minute")?;
678        if h > 23 || m > 59 {
679            return Err(anyhow!("time out of range"));
680        }
681        return Ok((h, m));
682    }
683    // Compact HHMM (3-4 digits). Last two are minutes.
684    if s.chars().all(|c| c.is_ascii_digit()) && (s.len() == 3 || s.len() == 4) {
685        let (h_str, m_str) = s.split_at(s.len() - 2);
686        let h: u16 = h_str.parse().context("invalid hour")?;
687        let m: u16 = m_str.parse().context("invalid minute")?;
688        if h > 23 || m > 59 {
689            return Err(anyhow!("time out of range"));
690        }
691        return Ok((h, m));
692    }
693    Err(anyhow!("invalid time format, expected HH:MM or HHMM"))
694}
695
696/// Ensure a config file exists (respecting `CHUTE_KUN_CONFIG`/default path),
697/// update its `day_start` to the provided hour/minute, and write it back.
698/// Returns the path written.
699pub fn write_day_start(h: u16, m: u16) -> Result<PathBuf> {
700    if h > 23 || m > 59 {
701        return Err(anyhow!("time out of range"));
702    }
703    let path = Config::write_default_file()?;
704    let normalized = format!("{:02}:{:02}", h, m);
705    let contents = std::fs::read_to_string(&path).unwrap_or_else(|_| Config::default_toml());
706    let updated = set_day_start_in_toml(&contents, &normalized);
707    std::fs::write(&path, updated).context("write updated day_start to config")?;
708    Ok(path)
709}