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
128fn 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 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}