Skip to main content

pitchfork_cli/cli/
logs.rs

1use crate::cli::json_output::{JsonLogEntry, print_json};
2use crate::daemon_id::DaemonId;
3use crate::log_store::sqlite::LOG_STORE;
4use crate::log_store::{LogQuery, LogStore};
5use crate::pitchfork_toml::PitchforkToml;
6use crate::settings::settings;
7use crate::state_file::StateFile;
8use crate::ui::style::{edim, estyle, ndim};
9use crate::{Result, env};
10use chrono::{DateTime, Local, NaiveDateTime, NaiveTime, TimeZone};
11use console;
12use itertools::Itertools;
13use miette::IntoDiagnostic;
14use std::collections::BTreeSet;
15use std::io::{self, IsTerminal, Write};
16use std::process::{Child, Command, Stdio};
17use std::time::Duration;
18
19/// Pager configuration for displaying logs
20struct PagerConfig {
21    command: String,
22    args: Vec<String>,
23}
24
25impl PagerConfig {
26    /// Select and configure the appropriate pager.
27    /// Uses $PAGER environment variable if set, otherwise defaults to less.
28    fn new(start_at_end: bool) -> Self {
29        let command = std::env::var("PAGER").unwrap_or_else(|_| "less".to_string());
30        let args = Self::build_args(&command, start_at_end);
31        Self { command, args }
32    }
33
34    fn build_args(pager: &str, start_at_end: bool) -> Vec<String> {
35        let mut args = vec![];
36        if pager == "less" {
37            args.push("-R".to_string());
38            if start_at_end {
39                args.push("+G".to_string());
40            }
41        }
42        args
43    }
44
45    /// Spawn the pager with piped stdin
46    fn spawn_piped(&self) -> io::Result<Child> {
47        Command::new(&self.command)
48            .args(&self.args)
49            .stdin(Stdio::piped())
50            .spawn()
51    }
52}
53
54/// Format a single log line for output.
55/// When `single_daemon` is true, omits the daemon ID from the output.
56/// `id_width` is the display width used to pad the daemon name column
57/// so messages line up vertically across different daemon names.
58/// When `strip_ansi` is true, strips ANSI escape codes from the message.
59fn format_log_line(
60    date: &str,
61    id: &str,
62    msg: &str,
63    single_daemon: bool,
64    id_width: usize,
65    strip_ansi: bool,
66    show_timestamp: bool,
67) -> String {
68    let msg = if strip_ansi {
69        console::strip_ansi_codes(msg).to_string()
70    } else {
71        msg.to_string()
72    };
73    if single_daemon {
74        if show_timestamp {
75            format!("{} {}", ndim(date), msg)
76        } else {
77            msg
78        }
79    } else {
80        let colors_on = !strip_ansi && console::colors_enabled();
81        let colored = dimmed_id(id, colors_on);
82        let padded = console::pad_str(&colored, id_width, console::Alignment::Left, None);
83        if show_timestamp {
84            format!("{}  {} {}", padded, ndim(date), msg)
85        } else {
86            format!("{}  {}", padded, msg)
87        }
88    }
89}
90
91/// Return a dimmed, colorized daemon ID string for display.
92/// Each daemon gets a deterministic color via FNV-1a hash so that
93/// multiple daemons are visually distinguishable while remaining subtle.
94fn dimmed_id(id: &str, colors_enabled: bool) -> String {
95    if !colors_enabled {
96        return id.to_string();
97    }
98    let colors = [
99        (180, 120, 120), // dim red
100        (180, 160, 100), // dim yellow
101        (120, 180, 120), // dim green
102        (120, 180, 180), // dim cyan
103        (180, 120, 180), // dim magenta
104        (120, 160, 180), // dim blue
105    ];
106    let mut h: usize = 0x811C_9DC5; // FNV offset basis
107    for b in id.bytes() {
108        h = h.wrapping_mul(0x0100_0193).wrapping_add(b as usize);
109    }
110    let (r, g, b) = colors[h % colors.len()];
111    format!("\x1b[2;38;2;{};{};{}m{}\x1b[0m", r, g, b, id)
112}
113
114/// Return a colorized `[namespace/id]` label for display in progress jobs.
115/// Uses brighter colors than `dimmed_id` and includes the square brackets.
116pub fn colored_id_label(id: &str, colors_enabled: bool) -> String {
117    if !colors_enabled {
118        return format!("[{}]", id);
119    }
120    // Same palette as mise: Blue, Magenta, Cyan, Green
121    // Excludes Red/Yellow to avoid confusion with errors/warnings.
122    let colors: [u8; 4] = [34, 35, 36, 32]; // ANSI: Blue, Magenta, Cyan, Green
123    let mut h: usize = 0x811C_9DC5; // FNV offset basis
124    for b in id.bytes() {
125        h = h.wrapping_mul(0x0100_0193).wrapping_add(b as usize);
126    }
127    let color = colors[h % colors.len()];
128    format!("\x1b[{color}m[{id}]\x1b[0m")
129}
130
131/// Displays logs for daemon(s)
132#[derive(Debug, clap::Args)]
133#[clap(
134    visible_alias = "l",
135    verbatim_doc_comment,
136    long_about = "\
137Displays logs for daemon(s)
138
139Shows logs from managed daemons. Logs are stored in the pitchfork logs directory
140and include timestamps for filtering.
141
142Examples:
143  pitchfork logs api              Show all logs for 'api' (paged if needed)
144  pitchfork logs api worker       Show logs for multiple daemons
145  pitchfork logs                  Show logs for all daemons
146  pitchfork logs api -n 50        Show last 50 lines
147  pitchfork logs api --follow     Follow logs in real-time
148  pitchfork logs api --since '2024-01-15 10:00:00'
149                                  Show logs since a specific time (forward)
150  pitchfork logs api --since '10:30:00'
151                                  Show logs since 10:30:00 today
152  pitchfork logs api --since '10:30' --until '12:00'
153                                  Show logs since 10:30:00 until 12:00:00 today
154  pitchfork logs api --since 5min Show logs from last 5 minutes
155  pitchfork logs api --raw        Output raw log lines without formatting
156  pitchfork logs api --raw -n 100 Output last 100 raw log lines
157  pitchfork logs api --clear      Delete logs for 'api'
158  pitchfork logs --clear          Delete logs for all daemons"
159)]
160pub struct Logs {
161    /// Show only logs for the specified daemon(s)
162    id: Vec<String>,
163
164    /// Delete logs
165    #[clap(short, long)]
166    clear: bool,
167
168    /// Show last N lines of logs
169    ///
170    /// Only applies when --since/--until is not used.
171    /// Without this option, all logs are shown.
172    #[clap(short)]
173    n: Option<usize>,
174
175    /// Show logs in real-time
176    #[clap(short = 't', short_alias = 'f', long, visible_alias = "follow")]
177    tail: bool,
178
179    /// Show logs from this time
180    ///
181    /// Supports multiple formats:
182    /// - Full datetime: "YYYY-MM-DD HH:MM:SS" or "YYYY-MM-DD HH:MM"
183    /// - Time only: "HH:MM:SS" or "HH:MM" (uses today's date)
184    /// - Relative time: "5min", "2h", "1d" (e.g., last 5 minutes)
185    #[clap(short = 's', long)]
186    since: Option<String>,
187
188    /// Show logs until this time
189    ///
190    /// Supports multiple formats:
191    /// - Full datetime: "YYYY-MM-DD HH:MM:SS" or "YYYY-MM-DD HH:MM"
192    /// - Time only: "HH:MM:SS" or "HH:MM" (uses today's date)
193    #[clap(short = 'u', long)]
194    until: Option<String>,
195
196    /// Disable pager even in interactive terminal
197    #[clap(long)]
198    no_pager: bool,
199
200    /// Output raw log lines without color or formatting
201    #[clap(long)]
202    raw: bool,
203
204    /// Output in JSON format
205    #[clap(long, conflicts_with = "raw", conflicts_with = "tail")]
206    json: bool,
207
208    /// Omit timestamps from log output
209    #[clap(long)]
210    no_timestamp: bool,
211}
212
213impl Logs {
214    pub async fn run(&self) -> Result<()> {
215        migrate_legacy_log_dirs();
216
217        let resolved_ids: Vec<DaemonId> = if self.id.is_empty() {
218            get_all_daemon_ids()?
219        } else {
220            PitchforkToml::resolve_ids(&self.id)?
221        };
222
223        if self.clear {
224            LOG_STORE.clear(&resolved_ids)?;
225            return Ok(());
226        }
227
228        let from = if let Some(since) = self.since.as_ref() {
229            Some(parse_time_input(since, true)?)
230        } else {
231            None
232        };
233        let to = if let Some(until) = self.until.as_ref() {
234            Some(parse_time_input(until, false)?)
235        } else {
236            None
237        };
238
239        if self.json {
240            return self.output_json(&resolved_ids, from, to);
241        }
242
243        let single_daemon = resolved_ids.len() == 1;
244        let show_timestamp = settings().logs.timestamp && !self.no_timestamp;
245        let log_lines = self.fetch_log_lines(&resolved_ids, from, to)?;
246        let has_time_filter = from.is_some() || to.is_some();
247        self.output_logs(
248            log_lines,
249            single_daemon,
250            has_time_filter,
251            self.tail,
252            show_timestamp,
253        )?;
254        if self.tail {
255            tail_logs(&resolved_ids, single_daemon, true, show_timestamp).await?;
256        }
257
258        Ok(())
259    }
260
261    fn fetch_log_lines(
262        &self,
263        resolved_ids: &[DaemonId],
264        from: Option<DateTime<Local>>,
265        to: Option<DateTime<Local>>,
266    ) -> Result<Vec<(String, String, String)>> {
267        let daemon_ids: Vec<String> = resolved_ids.iter().map(|id| id.qualified()).collect();
268        let has_time_filter = from.is_some() || to.is_some();
269
270        let opts = LogQuery {
271            daemon_ids: daemon_ids.clone(),
272            from,
273            to,
274            limit: if !has_time_filter { self.n } else { None },
275            order_desc: !has_time_filter,
276            after_id: None,
277        };
278        let entries = LOG_STORE.query(&opts)?;
279        let log_lines: Vec<(String, String, String)> = entries
280            .into_iter()
281            .map(|e| {
282                let ts = e.timestamp.format("%Y-%m-%d %H:%M:%S").to_string();
283                (ts, e.daemon_id, e.message)
284            })
285            .collect();
286
287        let log_lines = if has_time_filter {
288            if let Some(n) = self.n {
289                let len = log_lines.len();
290                if len > n {
291                    log_lines.into_iter().skip(len - n).collect_vec()
292                } else {
293                    log_lines
294                }
295            } else {
296                log_lines
297            }
298        } else if let Some(n) = self.n {
299            let len = log_lines.len();
300            if len > n {
301                log_lines.into_iter().skip(len - n).rev().collect_vec()
302            } else {
303                log_lines.into_iter().rev().collect_vec()
304            }
305        } else {
306            log_lines.into_iter().rev().collect_vec()
307        };
308
309        Ok(log_lines)
310    }
311
312    fn output_json(
313        &self,
314        resolved_ids: &[DaemonId],
315        from: Option<DateTime<Local>>,
316        to: Option<DateTime<Local>>,
317    ) -> Result<()> {
318        let log_lines = self.fetch_log_lines(resolved_ids, from, to)?;
319
320        let json_entries: Vec<JsonLogEntry> = log_lines
321            .into_iter()
322            .map(|(timestamp, daemon_id, message)| JsonLogEntry {
323                timestamp,
324                daemon_id,
325                message: console::strip_ansi_codes(&message).to_string(),
326            })
327            .collect();
328
329        print_json(&json_entries)
330    }
331
332    fn output_logs(
333        &self,
334        log_lines: Vec<(String, String, String)>,
335        single_daemon: bool,
336        has_time_filter: bool,
337        force_no_pager: bool,
338        show_timestamp: bool,
339    ) -> Result<()> {
340        if log_lines.is_empty() {
341            return Ok(());
342        }
343
344        let id_width = log_lines
345            .iter()
346            .map(|(_, id, _)| id.len())
347            .max()
348            .unwrap_or(0);
349        let strip_ansi = self.raw || !console::colors_enabled();
350
351        if self.raw {
352            for (date, id, msg) in log_lines {
353                let line = format_log_line(
354                    &date,
355                    &id,
356                    &msg,
357                    single_daemon,
358                    id_width,
359                    strip_ansi,
360                    show_timestamp,
361                );
362                println!("{line}");
363            }
364            return Ok(());
365        }
366
367        let use_pager = !force_no_pager && !self.no_pager && should_use_pager(log_lines.len());
368
369        if use_pager {
370            self.output_with_pager(
371                log_lines,
372                single_daemon,
373                id_width,
374                has_time_filter,
375                strip_ansi,
376                show_timestamp,
377            )?;
378        } else {
379            for (date, id, msg) in log_lines {
380                println!(
381                    "{}",
382                    format_log_line(
383                        &date,
384                        &id,
385                        &msg,
386                        single_daemon,
387                        id_width,
388                        strip_ansi,
389                        show_timestamp,
390                    )
391                );
392            }
393        }
394
395        Ok(())
396    }
397
398    fn output_with_pager(
399        &self,
400        log_lines: Vec<(String, String, String)>,
401        single_daemon: bool,
402        id_width: usize,
403        has_time_filter: bool,
404        strip_ansi: bool,
405        show_timestamp: bool,
406    ) -> Result<()> {
407        // When time filter is used, start at top; otherwise start at end
408        let pager_config = PagerConfig::new(!has_time_filter);
409
410        match pager_config.spawn_piped() {
411            Ok(mut child) => {
412                if let Some(stdin) = child.stdin.as_mut() {
413                    for (date, id, msg) in log_lines {
414                        let line = format!(
415                            "{}\n",
416                            format_log_line(
417                                &date,
418                                &id,
419                                &msg,
420                                single_daemon,
421                                id_width,
422                                strip_ansi,
423                                show_timestamp,
424                            )
425                        );
426                        if stdin.write_all(line.as_bytes()).is_err() {
427                            break;
428                        }
429                    }
430                    let _ = child.wait();
431                } else {
432                    debug!("Failed to get pager stdin, falling back to direct output");
433                    for (date, id, msg) in log_lines {
434                        println!(
435                            "{}",
436                            format_log_line(
437                                &date,
438                                &id,
439                                &msg,
440                                single_daemon,
441                                id_width,
442                                strip_ansi,
443                                show_timestamp,
444                            )
445                        );
446                    }
447                }
448            }
449            Err(e) => {
450                debug!("Failed to spawn pager: {e}, falling back to direct output");
451                for (date, id, msg) in log_lines {
452                    println!(
453                        "{}",
454                        format_log_line(
455                            &date,
456                            &id,
457                            &msg,
458                            single_daemon,
459                            id_width,
460                            strip_ansi,
461                            show_timestamp,
462                        )
463                    );
464                }
465            }
466        }
467
468        Ok(())
469    }
470}
471
472fn should_use_pager(line_count: usize) -> bool {
473    if !io::stdout().is_terminal() {
474        return false;
475    }
476
477    let terminal_height = get_terminal_height().unwrap_or(24);
478    line_count > terminal_height
479}
480
481fn get_terminal_height() -> Option<usize> {
482    if let Ok(rows) = std::env::var("LINES")
483        && let Ok(h) = rows.parse::<usize>()
484    {
485        return Some(h);
486    }
487
488    crossterm::terminal::size().ok().map(|(_, h)| h as usize)
489}
490
491/// Rename legacy log directories that predate namespace-qualified daemon IDs.
492///
493/// Old layout: `PITCHFORK_LOGS_DIR/<name>/<name>.log`
494/// New layout: `PITCHFORK_LOGS_DIR/legacy--<name>/legacy--<name>.log`
495///
496/// Only directories that clearly match the old layout are migrated:
497/// - directory name does not contain `"--"`
498/// - directory contains `<name>.log`
499/// - `<name>` is a valid daemon short name under current DaemonId rules
500fn migrate_legacy_log_dirs() {
501    let known_safe_paths = known_daemon_safe_paths();
502    let dirs = match xx::file::ls(&*env::PITCHFORK_LOGS_DIR) {
503        Ok(d) => d,
504        Err(_) => return,
505    };
506    for dir in dirs {
507        if dir.starts_with(".") || !dir.is_dir() {
508            continue;
509        }
510        let name = match dir.file_name().map(|f| f.to_string_lossy().to_string()) {
511            Some(n) => n,
512            None => continue,
513        };
514        // Skip the supervisor's own log directory.
515        if name == "pitchfork" {
516            continue;
517        }
518        // New-format directories usually contain "--". For safety, only treat
519        // them as new-format if they match a known daemon ID safe-path.
520        if name.contains("--") {
521            // If it parses as a valid safe-path, treat it as already migrated
522            // and keep idempotent behavior silent.
523            if DaemonId::from_safe_path(&name).is_ok() {
524                continue;
525            }
526            // Keep noisy warnings only for invalid/ambiguous names that cannot
527            // be interpreted as new-format IDs.
528            if known_safe_paths.contains(&name) {
529                continue;
530            }
531            warn!(
532                "Skipping invalid legacy log directory '{name}': contains '--' but is not a valid daemon safe-path"
533            );
534            continue;
535        }
536
537        // Migrate only explicit old-layout directories to avoid renaming
538        // unrelated folders under logs/.
539        let old_log = dir.join(format!("{name}.log"));
540        if !old_log.exists() {
541            continue;
542        }
543        if DaemonId::try_new("legacy", &name).is_err() {
544            warn!("Skipping invalid legacy log directory '{name}': not a valid daemon ID");
545            continue;
546        }
547
548        let new_name = format!("legacy--{name}");
549        let new_dir = env::PITCHFORK_LOGS_DIR.join(&new_name);
550        // Skip if a target directory already exists to avoid clobbering data.
551        if new_dir.exists() {
552            continue;
553        }
554        if std::fs::rename(&dir, &new_dir).is_err() {
555            continue;
556        }
557        // Also rename the log file inside the directory.
558        let old_log = new_dir.join(format!("{name}.log"));
559        let new_log = new_dir.join(format!("{new_name}.log"));
560        if old_log.exists() {
561            let _ = std::fs::rename(&old_log, &new_log);
562        }
563        debug!("Migrated legacy log dir '{name}' → '{new_name}'");
564    }
565}
566
567fn known_daemon_safe_paths() -> BTreeSet<String> {
568    let mut out = BTreeSet::new();
569
570    match StateFile::read(&*env::PITCHFORK_STATE_FILE) {
571        Ok(state) => {
572            for id in state.daemons.keys() {
573                out.insert(id.safe_path());
574            }
575        }
576        Err(e) => {
577            warn!("Failed to read state while checking known daemon IDs: {e}");
578        }
579    }
580
581    match PitchforkToml::all_merged() {
582        Ok(config) => {
583            for id in config.daemons.keys() {
584                out.insert(id.safe_path());
585            }
586        }
587        Err(e) => {
588            warn!("Failed to read config while checking known daemon IDs: {e}");
589        }
590    }
591
592    out
593}
594
595fn get_all_daemon_ids() -> Result<Vec<DaemonId>> {
596    let mut ids = BTreeSet::new();
597
598    match StateFile::read(&*env::PITCHFORK_STATE_FILE) {
599        Ok(state) => ids.extend(state.daemons.keys().cloned()),
600        Err(e) => warn!("Failed to read state for log daemon discovery: {e}"),
601    }
602
603    match PitchforkToml::all_merged() {
604        Ok(config) => ids.extend(config.daemons.keys().cloned()),
605        Err(e) => warn!("Failed to read config for log daemon discovery: {e}"),
606    }
607
608    let logged_ids: std::collections::HashSet<String> =
609        LOG_STORE.list_daemon_ids()?.into_iter().collect();
610    Ok(ids
611        .into_iter()
612        .filter(|id| logged_ids.contains(&id.qualified()))
613        .collect())
614}
615
616pub async fn tail_logs(
617    names: &[DaemonId],
618    single_daemon: bool,
619    start_from_end: bool,
620    show_timestamp: bool,
621) -> Result<()> {
622    // Poll SQLite log store for new entries since last known row id.
623    let id_width = names
624        .iter()
625        .map(|id| id.qualified().len())
626        .max()
627        .unwrap_or(0);
628
629    let strip_ansi = !console::colors_enabled();
630
631    let mut states: std::collections::HashMap<String, i64> = names
632        .iter()
633        .map(|id| {
634            let since = if start_from_end {
635                match LOG_STORE.query(&LogQuery {
636                    daemon_ids: vec![id.qualified()],
637                    from: None,
638                    to: None,
639                    limit: Some(1),
640                    order_desc: true,
641                    after_id: None,
642                }) {
643                    Ok(entries) => entries.first().map(|e| e.id).unwrap_or(0),
644                    Err(_) => 0,
645                }
646            } else {
647                0
648            };
649            (id.qualified(), since)
650        })
651        .collect();
652
653    let interval = tokio::time::interval(Duration::from_millis(200));
654    tokio::pin!(interval);
655
656    loop {
657        interval.tick().await;
658
659        let mut out = vec![];
660        for id in names {
661            let after_id = states.get(&id.qualified()).copied();
662            match LOG_STORE.tail(id, after_id) {
663                Ok(entries) => {
664                    for entry in &entries {
665                        let ts = entry.timestamp.format("%Y-%m-%d %H:%M:%S").to_string();
666                        out.push((ts, entry.daemon_id.clone(), entry.message.clone()));
667                    }
668                    if let Some(last) = entries.last() {
669                        states.insert(id.qualified(), last.id);
670                    }
671                }
672                Err(e) => {
673                    error!("Failed to tail logs for {}: {e}", id.qualified());
674                }
675            }
676        }
677
678        if !out.is_empty() {
679            let out = out
680                .into_iter()
681                .sorted_by(|a, b| (&a.0, &a.1).cmp(&(&b.0, &b.1)))
682                .collect_vec();
683            for (date, name, msg) in out {
684                println!(
685                    "{}",
686                    format_log_line(
687                        &date,
688                        &name,
689                        &msg,
690                        single_daemon,
691                        id_width,
692                        strip_ansi,
693                        show_timestamp,
694                    )
695                );
696            }
697        }
698    }
699}
700
701fn parse_datetime(s: &str) -> Result<DateTime<Local>> {
702    let naive_dt = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").into_diagnostic()?;
703    Local
704        .from_local_datetime(&naive_dt)
705        .single()
706        .ok_or_else(|| miette::miette!("Invalid or ambiguous datetime: '{}'. ", s))
707}
708
709/// Parse time input string into DateTime.
710///
711/// `is_since` indicates whether this is for --since (true) or --until (false).
712/// The "yesterday fallback" only applies to --since: if the time is in the future,
713/// assume the user meant yesterday. For --until, future times are kept as-is.
714fn parse_time_input(s: &str, is_since: bool) -> Result<DateTime<Local>> {
715    let s = s.trim();
716
717    // Try full datetime first (YYYY-MM-DD HH:MM:SS)
718    if let Ok(dt) = parse_datetime(s) {
719        return Ok(dt);
720    }
721
722    // Try datetime without seconds (YYYY-MM-DD HH:MM)
723    if let Ok(naive_dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M") {
724        return Local
725            .from_local_datetime(&naive_dt)
726            .single()
727            .ok_or_else(|| miette::miette!("Invalid or ambiguous datetime: '{}'", s));
728    }
729
730    // Try time-only format (HH:MM:SS or HH:MM)
731    // Note: This branch won't be reached for inputs like "10:30" that could match
732    // parse_datetime, because parse_datetime expects a full date prefix and will fail.
733    if let Ok(time) = parse_time_only(s) {
734        let now = Local::now();
735        let today = now.date_naive();
736        let mut naive_dt = NaiveDateTime::new(today, time);
737        let mut dt = Local
738            .from_local_datetime(&naive_dt)
739            .single()
740            .ok_or_else(|| miette::miette!("Invalid or ambiguous datetime: '{}'", s))?;
741
742        // If the interpreted time for today is in the future, assume the user meant yesterday
743        // BUT only for --since. For --until, a future time today is valid.
744        if is_since
745            && dt > now
746            && let Some(yesterday) = today.pred_opt()
747        {
748            naive_dt = NaiveDateTime::new(yesterday, time);
749            dt = Local
750                .from_local_datetime(&naive_dt)
751                .single()
752                .ok_or_else(|| miette::miette!("Invalid or ambiguous datetime: '{}'", s))?;
753        }
754        return Ok(dt);
755    }
756
757    if let Ok(duration) = humantime::parse_duration(s) {
758        let now = Local::now();
759        let target = now - chrono::Duration::from_std(duration).into_diagnostic()?;
760        return Ok(target);
761    }
762
763    Err(miette::miette!(
764        "Invalid time format: '{}'. Expected formats:\n\
765         - Full datetime: \"YYYY-MM-DD HH:MM:SS\" or \"YYYY-MM-DD HH:MM\"\n\
766         - Time only: \"HH:MM:SS\" or \"HH:MM\" (uses today's date)\n\
767         - Relative time: \"5min\", \"2h\", \"1d\" (e.g., last 5 minutes)",
768        s
769    ))
770}
771
772fn parse_time_only(s: &str) -> Result<NaiveTime> {
773    if let Ok(time) = NaiveTime::parse_from_str(s, "%H:%M:%S") {
774        return Ok(time);
775    }
776
777    if let Ok(time) = NaiveTime::parse_from_str(s, "%H:%M") {
778        return Ok(time);
779    }
780
781    Err(miette::miette!("Invalid time format: '{}'", s))
782}
783
784/// Prints error log lines in a styled block matching the startup logs format.
785///
786/// Format:
787/// ```text
788///  ERROR LOGS
789///  12:00:00 error message
790/// ```
791///
792/// Timestamps use dimmed red. The tag uses white text on red background.
793pub fn print_error_logs_block(log_lines: &[(String, String, String)]) {
794    if log_lines.is_empty() {
795        return;
796    }
797
798    let is_tty = std::io::stderr().is_terminal();
799    let format_msg = |msg: &str| -> String {
800        let stripped = strip_pty_controls(msg);
801        if is_tty {
802            stripped
803        } else {
804            console::strip_ansi_codes(&stripped).to_string()
805        }
806    };
807
808    let tag = estyle(" ERROR LOGS ").white().on_red();
809    eprintln!("\n{tag}");
810
811    // Determine if we need to show daemon IDs (same logic as startup logs)
812    let unique_ids: BTreeSet<&str> = log_lines.iter().map(|(_, id, _)| id.as_str()).collect();
813    let show_id = unique_ids.len() > 1;
814
815    if show_id {
816        let id_width = log_lines
817            .iter()
818            .map(|(_, id, _)| console::measure_text_width(id))
819            .max()
820            .unwrap_or(0);
821        for (date, id, msg) in log_lines {
822            let time = date.split(' ').nth(1).unwrap_or(date);
823            let colored = dimmed_id(id, is_tty && console::colors_enabled_stderr());
824            let padded = console::pad_str(&colored, id_width, console::Alignment::Left, None);
825            eprintln!(
826                "{}  {} {}",
827                padded,
828                estyle(time).red().dim(),
829                format_msg(msg)
830            );
831        }
832    } else {
833        for (date, _, msg) in log_lines {
834            let time = date.split(' ').nth(1).unwrap_or(date);
835            eprintln!("{} {}", estyle(time).red().dim(), format_msg(msg));
836        }
837    }
838}
839
840/// Describes the type of ready check being performed for display purposes.
841pub enum ReadyCheckType {
842    Output(String),
843    Http(String),
844    Port(u16),
845    Cmd(String),
846    Delay(u64),
847    Default,
848}
849
850impl std::fmt::Display for ReadyCheckType {
851    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
852        match self {
853            ReadyCheckType::Output(pattern) => write!(f, "output matching '{pattern}'"),
854            ReadyCheckType::Http(url) => write!(f, "HTTP {url}"),
855            ReadyCheckType::Port(port) => write!(f, "TCP port {port}"),
856            ReadyCheckType::Cmd(cmd) => write!(f, "command '{cmd}'"),
857            ReadyCheckType::Delay(secs) => write!(f, "delay ({secs}s)"),
858            ReadyCheckType::Default => write!(f, "default readiness check"),
859        }
860    }
861}
862
863/// Creates a progress job showing a spinner while waiting for a ready check.
864///
865/// Returns a `Arc<ProgressJob>` that the caller should update:
866/// - Set body to success message and status to `Done` when the daemon is ready
867/// - Set body to failure message and status to `Failed` when the daemon fails
868pub fn create_ready_check_job(
869    daemon_id: &DaemonId,
870    check_type: &ReadyCheckType,
871) -> std::sync::Arc<clx::progress::ProgressJob> {
872    use clx::progress::{ProgressJobBuilder, ProgressJobDoneBehavior, ProgressStatus};
873
874    let is_tty = std::io::stderr().is_terminal();
875    let colors_enabled = is_tty && console::colors_enabled_stderr();
876    let id_label = colored_id_label(&daemon_id.qualified(), colors_enabled);
877    let show_ts = crate::settings::settings().general.startup_log_timestamps;
878
879    // When timestamps are off, {{spinner()}} renders as an animated spinner
880    // (1 char wide) matching the "•" prefix used by println.  When on,
881    // we show a dim timestamp instead.
882    let prefix = if show_ts {
883        // The timestamp updates each refresh via the now() tera function.
884        // We use a fixed-width format (HH:MM:SS = 8 chars) for alignment.
885        edim(chrono::Local::now().format("%H:%M:%S").to_string()).to_string()
886    } else {
887        "{{spinner()}}".to_string()
888    };
889
890    ProgressJobBuilder::new()
891        .body(format!(
892            "{} {} waiting for {{{{ check_type }}}}...",
893            prefix, id_label
894        ))
895        .prop("check_type", &check_type.to_string())
896        .status(ProgressStatus::Running)
897        .on_done(ProgressJobDoneBehavior::Keep)
898        .start()
899}
900
901/// Collects startup log lines for a single daemon (does not print).
902///
903/// Returns a list of `(time, daemon_id_qualified, message)` tuples for log
904/// entries written after `from`.
905pub fn collect_startup_logs(
906    daemon_id: &DaemonId,
907    from: DateTime<Local>,
908) -> Result<Vec<(String, String, String)>> {
909    let entries = LOG_STORE.query(&LogQuery {
910        daemon_ids: vec![daemon_id.qualified()],
911        from: Some(from),
912        to: None,
913        limit: None,
914        order_desc: false,
915        after_id: None,
916    })?;
917    let log_lines = entries
918        .into_iter()
919        .map(|e| {
920            let ts = e.timestamp.format("%Y-%m-%d %H:%M:%S").to_string();
921            (ts, e.daemon_id, e.message)
922        })
923        .collect();
924
925    Ok(log_lines)
926}
927
928/// Stream startup logs for a daemon to a progress job in real-time.
929///
930/// Spawns a background tokio task that polls the daemon's log store
931/// and calls `job.println()` for each new line. Returns a watch sender
932/// that stops the streaming when sent `true`.
933pub fn stream_startup_logs(
934    daemon_id: &DaemonId,
935    from: DateTime<Local>,
936    job: std::sync::Arc<clx::progress::ProgressJob>,
937) -> (
938    tokio::sync::watch::Sender<bool>,
939    tokio::task::JoinHandle<()>,
940) {
941    let (tx, mut rx) = tokio::sync::watch::channel(false);
942    let id = daemon_id.clone();
943
944    let show_ts = crate::settings::settings().general.startup_log_timestamps;
945
946    let handle = tokio::spawn(async move {
947        let is_tty = std::io::stderr().is_terminal();
948        let colors_enabled = is_tty && console::colors_enabled_stderr();
949        let id_label = colored_id_label(&id.qualified(), colors_enabled);
950        let prefix = if show_ts {
951            String::new()
952        } else {
953            edim("•").to_string()
954        };
955
956        let mut last_id: i64 = 0;
957
958        // Initial fetch: all logs since daemon start time
959        let initial_entries = LOG_STORE.query(&LogQuery {
960            daemon_ids: vec![id.qualified()],
961            from: Some(from),
962            to: None,
963            limit: None,
964            order_desc: false,
965            after_id: None,
966        });
967
968        if let Ok(entries) = initial_entries {
969            for entry in &entries {
970                let time = entry.timestamp.format("%H:%M:%S").to_string();
971                let msg = strip_pty_controls(&entry.message);
972                let msg = if is_tty {
973                    msg
974                } else {
975                    console::strip_ansi_codes(&msg).to_string()
976                };
977                let line_prefix = if show_ts {
978                    edim(time).to_string()
979                } else {
980                    prefix.clone()
981                };
982                job.println(&format!("{} {} {}", line_prefix, id_label, msg));
983            }
984            if let Some(last) = entries.last() {
985                last_id = last.id;
986            }
987        }
988
989        loop {
990            tokio::select! {
991                _ = tokio::time::sleep(Duration::from_millis(200)) => {
992                    if let Ok(entries) = LOG_STORE.tail(&id, Some(last_id)) {
993                        for entry in &entries {
994                            let time = entry.timestamp.format("%H:%M:%S").to_string();
995                            let msg = strip_pty_controls(&entry.message);
996                            let msg = if is_tty {
997                                msg
998                            } else {
999                                console::strip_ansi_codes(&msg).to_string()
1000                            };
1001                            let line_prefix = if show_ts {
1002                                edim(time).to_string()
1003                            } else {
1004                                prefix.clone()
1005                            };
1006                            job.println(&format!("{} {} {}", line_prefix, id_label, msg));
1007                        }
1008                        if let Some(last) = entries.last() {
1009                            last_id = last.id;
1010                        }
1011                    }
1012                }
1013                _ = rx.changed() => {
1014                    break;
1015                }
1016            }
1017        }
1018
1019        // Final drain
1020        if let Ok(entries) = LOG_STORE.tail(&id, Some(last_id)) {
1021            for entry in &entries {
1022                let time = entry.timestamp.format("%H:%M:%S").to_string();
1023                let msg = strip_pty_controls(&entry.message);
1024                let msg = if is_tty {
1025                    msg
1026                } else {
1027                    console::strip_ansi_codes(&msg).to_string()
1028                };
1029                let line_prefix = if show_ts {
1030                    edim(time).to_string()
1031                } else {
1032                    prefix.clone()
1033                };
1034                job.println(&format!("{} {} {}", line_prefix, id_label, msg));
1035            }
1036        }
1037    });
1038
1039    (tx, handle)
1040}
1041
1042/// Strips PTY control sequences from a string while preserving SGR (color/style) codes.
1043///
1044/// Removes CSI sequences that control cursor movement, screen clearing, erasing, etc.,
1045/// but keeps `\x1b[...m` (SGR) sequences so colors are retained.
1046fn strip_pty_controls(s: &str) -> String {
1047    struct Stripper {
1048        result: String,
1049    }
1050
1051    impl vte::Perform for Stripper {
1052        fn print(&mut self, c: char) {
1053            self.result.push(c);
1054        }
1055
1056        fn execute(&mut self, byte: u8) {
1057            // Keep \n and \t; drop other control characters (BEL, BS, CR, etc.)
1058            if byte == b'\n' || byte == b'\t' {
1059                self.result.push(byte as char);
1060            }
1061        }
1062
1063        fn csi_dispatch(
1064            &mut self,
1065            params: &vte::Params,
1066            _intermediates: &[u8],
1067            _ignore: bool,
1068            action: char,
1069        ) {
1070            // Keep SGR sequences (final byte 'm')
1071            if action == 'm' {
1072                self.result.push_str("\x1b[");
1073                let mut first = true;
1074                for sub in params.iter() {
1075                    if !first {
1076                        self.result.push(';');
1077                    }
1078                    first = false;
1079                    for (i, &p) in sub.iter().enumerate() {
1080                        if i > 0 {
1081                            self.result.push(':');
1082                        }
1083                        self.result.push_str(&p.to_string());
1084                    }
1085                }
1086                self.result.push('m');
1087            }
1088            // All other CSI sequences (cursor move, clear, erase, etc.) are dropped
1089        }
1090
1091        fn osc_dispatch(&mut self, _params: &[&[u8]], _bell_terminated: bool) {
1092            // Drop OSC sequences (e.g. window title)
1093        }
1094
1095        fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, _byte: u8) {
1096            // Drop ESC sequences (e.g. ESC c = reset terminal)
1097        }
1098
1099        fn hook(
1100            &mut self,
1101            _params: &vte::Params,
1102            _intermediates: &[u8],
1103            _ignore: bool,
1104            _action: char,
1105        ) {
1106            // Drop DCS hooks
1107        }
1108
1109        fn put(&mut self, _byte: u8) {
1110            // Drop DCS data
1111        }
1112
1113        fn unhook(&mut self) {
1114            // Drop DCS unhook
1115        }
1116    }
1117
1118    let mut parser = vte::Parser::new();
1119    let mut stripper = Stripper {
1120        result: String::with_capacity(s.len()),
1121    };
1122    parser.advance(&mut stripper, s.as_bytes());
1123    stripper.result
1124}