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