Skip to main content

entracte_lib/
cli.rs

1use std::time::Duration;
2
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub enum CliCommand {
5    Pause(PauseTarget),
6    Resume,
7    Trigger(BreakKindArg),
8    Skip(BreakKindArg),
9    Status,
10    ProfileList,
11    ProfileUse(String),
12    SettingsGet(String),
13    SettingsSet(String, String),
14    Quick {
15        profile: Option<String>,
16        colour: Option<String>,
17    },
18}
19
20impl CliCommand {
21    pub fn runs_locally(&self) -> bool {
22        matches!(
23            self,
24            CliCommand::Pause(_)
25                | CliCommand::Resume
26                | CliCommand::Trigger(_)
27                | CliCommand::Skip(_)
28                | CliCommand::Status
29                | CliCommand::ProfileList
30                | CliCommand::ProfileUse(_)
31                | CliCommand::SettingsGet(_)
32                | CliCommand::SettingsSet(_, _)
33                | CliCommand::Quick { .. }
34        )
35    }
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum PauseTarget {
40    Indefinite,
41    Duration(Duration),
42    UntilTomorrow,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum BreakKindArg {
47    Micro,
48    Long,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum CliError {
53    UnknownCommand(String),
54    MissingArg(&'static str),
55    InvalidDuration(String),
56    InvalidKind(String),
57    UnexpectedArg(String),
58}
59
60pub fn parse_cli(argv: &[String]) -> Result<Option<CliCommand>, CliError> {
61    let mut args = argv.iter().skip(1);
62    let Some(cmd) = args.next() else {
63        return Ok(None);
64    };
65    if cmd.starts_with("--profile=") || cmd.starts_with("--colour=") || cmd.starts_with("--color=")
66    {
67        return parse_quick_flags(cmd, args).map(Some);
68    }
69    let parsed = match cmd.as_str() {
70        "pause" => {
71            let target = match args.next() {
72                None => PauseTarget::Indefinite,
73                Some(arg) if arg == "until-tomorrow" => PauseTarget::UntilTomorrow,
74                Some(arg) => {
75                    PauseTarget::Duration(parse_duration(arg).map_err(CliError::InvalidDuration)?)
76                }
77            };
78            CliCommand::Pause(target)
79        }
80        "resume" => CliCommand::Resume,
81        "trigger" => CliCommand::Trigger(parse_kind(args.next())?),
82        "skip" => CliCommand::Skip(parse_kind(args.next())?),
83        "status" => CliCommand::Status,
84        "profile" => match args.next().map(|s| s.as_str()) {
85            Some("list") => CliCommand::ProfileList,
86            Some("use") => {
87                let name = args
88                    .next()
89                    .ok_or(CliError::MissingArg("profile name"))?
90                    .clone();
91                CliCommand::ProfileUse(name)
92            }
93            Some(other) => return Err(CliError::UnknownCommand(format!("profile {other}"))),
94            None => return Err(CliError::MissingArg("profile subcommand (list | use NAME)")),
95        },
96        "settings" => match args.next().map(|s| s.as_str()) {
97            Some("get") => {
98                let key = args
99                    .next()
100                    .ok_or(CliError::MissingArg("settings key"))?
101                    .clone();
102                CliCommand::SettingsGet(key)
103            }
104            Some("set") => {
105                let key = args
106                    .next()
107                    .ok_or(CliError::MissingArg("settings key"))?
108                    .clone();
109                let value = args
110                    .next()
111                    .ok_or(CliError::MissingArg("settings value (JSON literal)"))?
112                    .clone();
113                CliCommand::SettingsSet(key, value)
114            }
115            Some(other) => return Err(CliError::UnknownCommand(format!("settings {other}"))),
116            None => {
117                return Err(CliError::MissingArg(
118                    "settings subcommand (get KEY | set KEY VALUE)",
119                ));
120            }
121        },
122        other => return Err(CliError::UnknownCommand(other.to_string())),
123    };
124    expect_no_more(args)?;
125    Ok(Some(parsed))
126}
127
128// Reject anything left in the argv after a command and its expected args
129// were consumed. Without this, `pause 1h 30m` silently parsed as `1h` and
130// dropped `30m` — confusing if you're scripting against the CLI.
131fn expect_no_more<'a, I>(mut args: I) -> Result<(), CliError>
132where
133    I: Iterator<Item = &'a String>,
134{
135    if let Some(extra) = args.next() {
136        let mut rest = vec![extra.clone()];
137        rest.extend(args.cloned());
138        return Err(CliError::UnexpectedArg(rest.join(" ")));
139    }
140    Ok(())
141}
142
143fn parse_quick_flags<'a>(
144    first: &'a str,
145    rest: impl Iterator<Item = &'a String>,
146) -> Result<CliCommand, CliError> {
147    let mut profile: Option<String> = None;
148    let mut colour: Option<String> = None;
149    let mut handle = |raw: &str| -> Result<(), CliError> {
150        if let Some(v) = raw.strip_prefix("--profile=") {
151            if v.is_empty() {
152                return Err(CliError::MissingArg("--profile=NAME"));
153            }
154            profile = Some(v.to_string());
155            Ok(())
156        } else if let Some(v) = raw
157            .strip_prefix("--colour=")
158            .or_else(|| raw.strip_prefix("--color="))
159        {
160            if v.is_empty() {
161                return Err(CliError::MissingArg("--colour=VALUE"));
162            }
163            colour = Some(v.to_string());
164            Ok(())
165        } else {
166            Err(CliError::UnknownCommand(raw.to_string()))
167        }
168    };
169    handle(first)?;
170    for arg in rest {
171        handle(arg.as_str())?;
172    }
173    Ok(CliCommand::Quick { profile, colour })
174}
175
176fn parse_kind(raw: Option<&String>) -> Result<BreakKindArg, CliError> {
177    let Some(raw) = raw else {
178        return Err(CliError::MissingArg("kind (micro | long)"));
179    };
180    match raw.to_lowercase().as_str() {
181        "micro" => Ok(BreakKindArg::Micro),
182        "long" => Ok(BreakKindArg::Long),
183        other => Err(CliError::InvalidKind(other.to_string())),
184    }
185}
186
187fn parse_duration(raw: &str) -> Result<Duration, String> {
188    let trimmed = raw.trim().to_lowercase();
189    if trimmed.is_empty() {
190        return Err(raw.to_string());
191    }
192    let (num_part, unit_part): (String, String) = trimmed.chars().partition(|c| c.is_ascii_digit());
193    if num_part.is_empty() {
194        return Err(raw.to_string());
195    }
196    let n: u64 = num_part.parse().map_err(|_| raw.to_string())?;
197    let secs = match unit_part.as_str() {
198        "" | "s" | "sec" | "secs" | "second" | "seconds" => n,
199        "m" | "min" | "mins" | "minute" | "minutes" => {
200            n.checked_mul(60).ok_or_else(|| raw.to_string())?
201        }
202        "h" | "hr" | "hrs" | "hour" | "hours" => {
203            n.checked_mul(3600).ok_or_else(|| raw.to_string())?
204        }
205        _ => return Err(raw.to_string()),
206    };
207    Ok(Duration::from_secs(secs))
208}
209
210pub fn help_text() -> &'static str {
211    "Usage: entracte [COMMAND] [ARGS]\n\
212     \n\
213     Action commands (forward to the running app):\n\
214     \tpause [DURATION | until-tomorrow]   Pause breaks. Duration like 30m, 1h, 90, or omit for indefinite.\n\
215     \tresume                              Resume scheduled breaks.\n\
216     \ttrigger {micro | long}              Fire a break immediately.\n\
217     \tskip    {micro | long}              Skip the next break of that kind.\n\
218     \n\
219     Query / mutation commands (require the app to be running, print to your terminal):\n\
220     \tstatus                              Print pause state and active profile.\n\
221     \tprofile list                        List profile names.\n\
222     \tprofile use NAME                    Switch the active profile.\n\
223     \tsettings get KEY                    Print one Settings field as JSON.\n\
224     \tsettings set KEY VALUE              Update one Settings field. VALUE is a JSON literal\n\
225     \t                                    (true, 1500, \"dark\", [\"foo\",\"bar\"]).\n\
226     \n\
227     Local commands:\n\
228     \tlog                                 Print the entracte log file and follow new entries.\n\
229     \thelp                                Show this help text.\n\
230     \n\
231     Convenience flags (combine freely, applied via IPC):\n\
232     \t--profile=NAME                      Switch the active profile.\n\
233     \t--colour=VALUE                      Set overlay colour. VALUE is a preset name\n\
234     \t                                    (dark|midnight|forest|rose|sunset) or a hex code\n\
235     \t                                    (#abc, #aabbcc). Hex flips theme to 'custom'.\n\
236     \t                                    --color= is also accepted.\n\
237     \n\
238     With no command, launches the Entracte tray app.\n"
239}
240
241pub fn log_path() -> Option<std::path::PathBuf> {
242    use std::path::PathBuf;
243    const BUNDLE: &str = "app.entracte";
244    const FILE: &str = "entracte.log";
245    #[cfg(target_os = "macos")]
246    {
247        std::env::var_os("HOME").map(|h| {
248            PathBuf::from(h)
249                .join("Library/Logs")
250                .join(BUNDLE)
251                .join(FILE)
252        })
253    }
254    #[cfg(target_os = "linux")]
255    {
256        let base = std::env::var_os("XDG_STATE_HOME")
257            .map(PathBuf::from)
258            .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")));
259        base.map(|d| d.join(BUNDLE).join("logs").join(FILE))
260    }
261    #[cfg(target_os = "windows")]
262    {
263        std::env::var_os("LOCALAPPDATA")
264            .map(|d| PathBuf::from(d).join(BUNDLE).join("logs").join(FILE))
265    }
266}
267
268pub fn run_local_ipc(cmd: CliCommand) -> i32 {
269    use crate::ipc::{self, IpcRequest};
270    let Some(data_dir) = ipc::ipc_data_dir() else {
271        eprintln!("entracte: cannot resolve app data dir on this platform");
272        return 1;
273    };
274
275    let requests: Vec<IpcRequest> = match &cmd {
276        CliCommand::Status => vec![IpcRequest::Status],
277        CliCommand::ProfileList => vec![IpcRequest::ProfileList],
278        CliCommand::ProfileUse(name) => vec![IpcRequest::ProfileUse { name: name.clone() }],
279        CliCommand::SettingsGet(key) => vec![IpcRequest::SettingsGet { key: key.clone() }],
280        CliCommand::SettingsSet(key, raw) => {
281            let value: serde_json::Value = match serde_json::from_str(raw) {
282                Ok(v) => v,
283                Err(_) => serde_json::Value::String(raw.clone()),
284            };
285            vec![IpcRequest::SettingsSet {
286                key: key.clone(),
287                value,
288            }]
289        }
290        CliCommand::Pause(target) => {
291            let duration_secs = match target {
292                PauseTarget::Indefinite => None,
293                PauseTarget::Duration(d) => Some(d.as_secs()),
294                PauseTarget::UntilTomorrow => Some(crate::tray::seconds_until_tomorrow_morning()),
295            };
296            vec![IpcRequest::Pause { duration_secs }]
297        }
298        CliCommand::Resume => vec![IpcRequest::Resume],
299        CliCommand::Trigger(kind) => vec![IpcRequest::Trigger {
300            kind: match kind {
301                BreakKindArg::Micro => "micro".to_string(),
302                BreakKindArg::Long => "long".to_string(),
303            },
304        }],
305        CliCommand::Skip(kind) => vec![IpcRequest::Skip {
306            kind: match kind {
307                BreakKindArg::Micro => "micro".to_string(),
308                BreakKindArg::Long => "long".to_string(),
309            },
310        }],
311        CliCommand::Quick { profile, colour } => {
312            let mut reqs: Vec<IpcRequest> = Vec::new();
313            if let Some(name) = profile {
314                reqs.push(IpcRequest::ProfileUse { name: name.clone() });
315            }
316            if let Some(value) = colour {
317                match expand_colour(value) {
318                    Ok(updates) => {
319                        for (key, json_value) in updates {
320                            reqs.push(IpcRequest::SettingsSet {
321                                key,
322                                value: json_value,
323                            });
324                        }
325                    }
326                    Err(e) => {
327                        eprintln!("entracte: {e}");
328                        return 1;
329                    }
330                }
331            }
332            if reqs.is_empty() {
333                eprintln!("entracte: no flags supplied (need --profile= and/or --colour=)");
334                return 2;
335            }
336            reqs
337        }
338    };
339
340    let mut last_ok_data: Option<serde_json::Value> = None;
341    for req in &requests {
342        match ipc::call(req, &data_dir) {
343            Ok(resp) if resp.ok => last_ok_data = resp.data,
344            Ok(resp) => {
345                eprintln!(
346                    "entracte: {}",
347                    resp.error.unwrap_or_else(|| "unknown error".into())
348                );
349                return 1;
350            }
351            Err(e) => {
352                eprintln!("entracte: {e}");
353                return 1;
354            }
355        }
356    }
357    if let Some(d) = last_ok_data {
358        println!("{}", serde_json::to_string_pretty(&d).unwrap_or_default());
359    }
360    0
361}
362
363fn expand_colour(value: &str) -> Result<Vec<(String, serde_json::Value)>, String> {
364    const PRESETS: &[&str] = &["dark", "midnight", "forest", "rose", "sunset", "rotate"];
365    let trimmed = value.trim();
366    if PRESETS.contains(&trimmed.to_lowercase().as_str()) {
367        return Ok(vec![(
368            "overlay_color".to_string(),
369            serde_json::Value::String(trimmed.to_lowercase()),
370        )]);
371    }
372    if let Some(rgb_csv) = hex_to_rgb_csv(trimmed) {
373        return Ok(vec![
374            (
375                "overlay_color".to_string(),
376                serde_json::Value::String("custom".to_string()),
377            ),
378            (
379                "overlay_custom_rgb".to_string(),
380                serde_json::Value::String(rgb_csv),
381            ),
382        ]);
383    }
384    Err(format!(
385        "invalid --colour value: {value:?} (expected preset name or hex #abc/#aabbcc)"
386    ))
387}
388
389fn hex_to_rgb_csv(raw: &str) -> Option<String> {
390    let cleaned = raw.trim().trim_start_matches('#');
391    let normalized = match cleaned.len() {
392        3 => cleaned
393            .chars()
394            .flat_map(|c| std::iter::repeat_n(c, 2))
395            .collect::<String>(),
396        6 => cleaned.to_string(),
397        _ => return None,
398    };
399    if !normalized.chars().all(|c| c.is_ascii_hexdigit()) {
400        return None;
401    }
402    let n = u32::from_str_radix(&normalized, 16).ok()?;
403    Some(format!(
404        "{}, {}, {}",
405        (n >> 16) & 0xff,
406        (n >> 8) & 0xff,
407        n & 0xff
408    ))
409}
410
411pub fn stream_log() {
412    use std::io::{Read, Seek, SeekFrom, Write};
413    use std::thread;
414    use std::time::Duration;
415
416    let Some(path) = log_path() else {
417        eprintln!("entracte: could not resolve log path on this platform");
418        return;
419    };
420
421    let mut file = match std::fs::File::open(&path) {
422        Ok(f) => f,
423        Err(e) => {
424            eprintln!("entracte: cannot open {}: {e}", path.display());
425            return;
426        }
427    };
428
429    let mut buf = String::new();
430    if let Err(e) = file.read_to_string(&mut buf) {
431        eprintln!("entracte: error reading {}: {e}", path.display());
432        return;
433    }
434    let mut stdout = std::io::stdout();
435    let _ = stdout.write_all(buf.as_bytes());
436    let _ = stdout.flush();
437
438    let mut pos = match file.metadata() {
439        Ok(m) => m.len(),
440        Err(_) => return,
441    };
442
443    loop {
444        thread::sleep(Duration::from_millis(500));
445        let len = match std::fs::metadata(&path) {
446            Ok(m) => m.len(),
447            Err(_) => continue,
448        };
449        if len < pos {
450            pos = 0;
451        }
452        if len > pos {
453            if file.seek(SeekFrom::Start(pos)).is_err() {
454                continue;
455            }
456            let mut chunk = Vec::with_capacity((len - pos) as usize);
457            if file.read_to_end(&mut chunk).is_err() {
458                continue;
459            }
460            let _ = stdout.write_all(&chunk);
461            let _ = stdout.flush();
462            pos = len;
463        }
464    }
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470
471    fn argv(args: &[&str]) -> Vec<String> {
472        std::iter::once("entracte")
473            .chain(args.iter().copied())
474            .map(|s| s.to_string())
475            .collect()
476    }
477
478    #[test]
479    fn no_args_returns_none() {
480        assert_eq!(parse_cli(&argv(&[])).unwrap(), None);
481    }
482
483    #[test]
484    fn pause_without_arg_is_indefinite() {
485        assert_eq!(
486            parse_cli(&argv(&["pause"])).unwrap(),
487            Some(CliCommand::Pause(PauseTarget::Indefinite)),
488        );
489    }
490
491    #[test]
492    fn pause_with_until_tomorrow() {
493        assert_eq!(
494            parse_cli(&argv(&["pause", "until-tomorrow"])).unwrap(),
495            Some(CliCommand::Pause(PauseTarget::UntilTomorrow)),
496        );
497    }
498
499    #[test]
500    fn pause_duration_parses_minutes_and_hours() {
501        assert_eq!(
502            parse_cli(&argv(&["pause", "30m"])).unwrap(),
503            Some(CliCommand::Pause(PauseTarget::Duration(
504                Duration::from_secs(1800)
505            ))),
506        );
507        assert_eq!(
508            parse_cli(&argv(&["pause", "2h"])).unwrap(),
509            Some(CliCommand::Pause(PauseTarget::Duration(
510                Duration::from_secs(7200)
511            ))),
512        );
513        assert_eq!(
514            parse_cli(&argv(&["pause", "45"])).unwrap(),
515            Some(CliCommand::Pause(PauseTarget::Duration(
516                Duration::from_secs(45)
517            ))),
518        );
519        assert_eq!(
520            parse_cli(&argv(&["pause", "10minutes"])).unwrap(),
521            Some(CliCommand::Pause(PauseTarget::Duration(
522                Duration::from_secs(600)
523            ))),
524        );
525    }
526
527    #[test]
528    fn pause_with_malformed_duration_errors() {
529        assert!(matches!(
530            parse_cli(&argv(&["pause", "abc"])),
531            Err(CliError::InvalidDuration(_))
532        ));
533        assert!(matches!(
534            parse_cli(&argv(&["pause", "30x"])),
535            Err(CliError::InvalidDuration(_))
536        ));
537    }
538
539    #[test]
540    fn resume_parses() {
541        assert_eq!(
542            parse_cli(&argv(&["resume"])).unwrap(),
543            Some(CliCommand::Resume)
544        );
545    }
546
547    #[test]
548    fn trigger_requires_kind() {
549        assert!(matches!(
550            parse_cli(&argv(&["trigger"])),
551            Err(CliError::MissingArg(_))
552        ));
553        assert_eq!(
554            parse_cli(&argv(&["trigger", "micro"])).unwrap(),
555            Some(CliCommand::Trigger(BreakKindArg::Micro)),
556        );
557        assert_eq!(
558            parse_cli(&argv(&["trigger", "Long"])).unwrap(),
559            Some(CliCommand::Trigger(BreakKindArg::Long)),
560        );
561    }
562
563    #[test]
564    fn skip_requires_kind() {
565        assert!(matches!(
566            parse_cli(&argv(&["skip", "weird"])),
567            Err(CliError::InvalidKind(_))
568        ));
569        assert_eq!(
570            parse_cli(&argv(&["skip", "micro"])).unwrap(),
571            Some(CliCommand::Skip(BreakKindArg::Micro)),
572        );
573    }
574
575    #[test]
576    fn unknown_command_errors() {
577        assert!(matches!(
578            parse_cli(&argv(&["doomsday"])),
579            Err(CliError::UnknownCommand(_))
580        ));
581    }
582
583    #[test]
584    fn extra_args_after_pause_duration_are_rejected() {
585        // Pre-fix this silently parsed as `1h` and dropped `30m`.
586        assert!(matches!(
587            parse_cli(&argv(&["pause", "1h", "30m"])),
588            Err(CliError::UnexpectedArg(_)),
589        ));
590    }
591
592    #[test]
593    fn extra_args_after_resume_are_rejected() {
594        assert!(matches!(
595            parse_cli(&argv(&["resume", "now"])),
596            Err(CliError::UnexpectedArg(_)),
597        ));
598    }
599
600    #[test]
601    fn extra_args_after_trigger_kind_are_rejected() {
602        assert!(matches!(
603            parse_cli(&argv(&["trigger", "micro", "long"])),
604            Err(CliError::UnexpectedArg(_)),
605        ));
606    }
607
608    #[test]
609    fn extra_args_after_settings_set_are_rejected() {
610        assert!(matches!(
611            parse_cli(&argv(&[
612                "settings",
613                "set",
614                "micro_interval_secs",
615                "1500",
616                "extra"
617            ])),
618            Err(CliError::UnexpectedArg(_)),
619        ));
620    }
621
622    #[test]
623    fn unexpected_arg_message_includes_all_trailing_args() {
624        match parse_cli(&argv(&["pause", "1h", "30m", "later"])) {
625            Err(CliError::UnexpectedArg(rest)) => {
626                assert!(rest.contains("30m"));
627                assert!(rest.contains("later"));
628            }
629            other => panic!("expected UnexpectedArg, got {other:?}"),
630        }
631    }
632
633    #[test]
634    fn help_text_mentions_each_command() {
635        let h = help_text();
636        for needle in &["pause", "resume", "trigger", "skip", "log", "help"] {
637            assert!(h.contains(needle), "help text missing '{needle}': {h}");
638        }
639    }
640
641    #[test]
642    fn quick_profile_only() {
643        let out = parse_cli(&argv(&["--profile=Wellness"])).unwrap();
644        match out {
645            Some(CliCommand::Quick { profile, colour }) => {
646                assert_eq!(profile.as_deref(), Some("Wellness"));
647                assert!(colour.is_none());
648            }
649            _ => panic!("expected Quick variant: {out:?}"),
650        }
651    }
652
653    #[test]
654    fn quick_colour_with_us_spelling() {
655        let out = parse_cli(&argv(&["--color=midnight"])).unwrap();
656        match out {
657            Some(CliCommand::Quick { profile, colour }) => {
658                assert!(profile.is_none());
659                assert_eq!(colour.as_deref(), Some("midnight"));
660            }
661            _ => panic!("expected Quick variant: {out:?}"),
662        }
663    }
664
665    #[test]
666    fn quick_combined_profile_and_colour() {
667        let out = parse_cli(&argv(&["--profile=Focus", "--colour=#1f293a"])).unwrap();
668        match out {
669            Some(CliCommand::Quick { profile, colour }) => {
670                assert_eq!(profile.as_deref(), Some("Focus"));
671                assert_eq!(colour.as_deref(), Some("#1f293a"));
672            }
673            _ => panic!("expected Quick variant: {out:?}"),
674        }
675    }
676
677    #[test]
678    fn quick_rejects_empty_flag_value() {
679        assert!(matches!(
680            parse_cli(&argv(&["--profile="])),
681            Err(CliError::MissingArg(_))
682        ));
683        assert!(matches!(
684            parse_cli(&argv(&["--colour="])),
685            Err(CliError::MissingArg(_))
686        ));
687    }
688
689    #[test]
690    fn expand_colour_preset_returns_overlay_color() {
691        let out = expand_colour("midnight").unwrap();
692        assert_eq!(out.len(), 1);
693        assert_eq!(out[0].0, "overlay_color");
694        assert_eq!(out[0].1, serde_json::Value::String("midnight".to_string()));
695    }
696
697    #[test]
698    fn expand_colour_hex_three_digit_expands_to_six() {
699        let out = expand_colour("#abc").unwrap();
700        assert_eq!(out.len(), 2);
701        assert_eq!(out[0].0, "overlay_color");
702        assert_eq!(out[0].1, serde_json::Value::String("custom".to_string()));
703        assert_eq!(out[1].0, "overlay_custom_rgb");
704        assert_eq!(
705            out[1].1,
706            serde_json::Value::String("170, 187, 204".to_string())
707        );
708    }
709
710    #[test]
711    fn expand_colour_hex_six_digit_with_hash() {
712        let out = expand_colour("#1f293a").unwrap();
713        assert_eq!(
714            out[1].1,
715            serde_json::Value::String("31, 41, 58".to_string())
716        );
717    }
718
719    #[test]
720    fn expand_colour_rejects_garbage() {
721        assert!(expand_colour("not-a-colour").is_err());
722        assert!(expand_colour("#zzzzzz").is_err());
723        assert!(expand_colour("#abcd").is_err());
724    }
725
726    #[test]
727    fn log_path_uses_bundle_subdir() {
728        let p = log_path().expect("log_path resolves on the test platform");
729        let s = p.to_string_lossy();
730        assert!(s.contains("app.entracte"), "missing bundle id in {s}");
731        assert!(s.ends_with("entracte.log"), "wrong filename in {s}");
732    }
733}