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, PartialEq, Eq)]
42pub enum Invocation {
43 Help,
44 Log,
45 Local(CliCommand),
46 ParseError(CliError),
47 LaunchApp,
48}
49
50impl Invocation {
51 pub fn produces_console_output(&self) -> bool {
55 !matches!(self, Invocation::LaunchApp)
56 }
57}
58
59pub 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#[cfg(windows)]
82pub fn attach_parent_console() {
83 use windows_sys::Win32::System::Console::{AttachConsole, ATTACH_PARENT_PROCESS};
84 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
180fn 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 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}