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::{FieldFilter, LogEntry, LogQuery, LogStore, MessageFilter};
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::fmt::Write as _;
16use std::io::{self, IsTerminal, Write};
17use std::process::{Child, Command, Stdio};
18use std::time::Duration;
19
20/// Pager configuration for displaying logs
21struct PagerConfig {
22    command: String,
23    args: Vec<String>,
24}
25
26impl PagerConfig {
27    /// Select and configure the appropriate pager.
28    /// Uses $PAGER environment variable if set, otherwise defaults to less.
29    fn new(start_at_end: bool) -> Self {
30        let command = std::env::var("PAGER").unwrap_or_else(|_| "less".to_string());
31        let args = Self::build_args(&command, start_at_end);
32        Self { command, args }
33    }
34
35    fn build_args(pager: &str, start_at_end: bool) -> Vec<String> {
36        let mut args = vec![];
37        if pager == "less" {
38            args.push("-R".to_string());
39            if start_at_end {
40                args.push("+G".to_string());
41            }
42        }
43        args
44    }
45
46    /// Spawn the pager with piped stdin
47    fn spawn_piped(&self) -> io::Result<Child> {
48        Command::new(&self.command)
49            .args(&self.args)
50            .stdin(Stdio::piped())
51            .spawn()
52    }
53}
54
55/// Fields already displayed in dedicated positions; excluded from the
56/// extra-fields section of structured log output.
57const KNOWN_FIELD_KEYS: &[&str] = &[
58    "level",
59    "severity",
60    "lvl",
61    "PRIORITY",
62    "@level",
63    "msg",
64    "message",
65    "event",
66    "@message",
67    "logger",
68    "name",
69    "component",
70    "module",
71    "timestamp",
72    "ts",
73    "time",
74    "@timestamp",
75];
76
77/// Format a log level badge: `[ERR]`, `[WRN|`, etc.
78///
79/// Brackets use the same dim color as the logger; the level letters use the
80/// level's accent color in bold.
81fn level_badge(level: &str) -> String {
82    let label = match level {
83        "error" => console::style("ERR").red().bold(),
84        "warn" => console::style("WRN").yellow().bold(),
85        "info" => console::style("INF").cyan().bold(),
86        "debug" => console::style("DBG").magenta().bold(),
87        "trace" => console::style("TRC").dim().bold(),
88        _ => return String::new(),
89    };
90    format!("{}{}{}", ndim("["), label, ndim("]"))
91}
92
93/// Format a single JSON value for display in the fields section.
94///
95/// Strings that look like booleans, null, numbers, or contain spaces are
96/// quoted to avoid ambiguity. Numbers are green, booleans are
97/// yellow/red, null is dim.
98fn format_field_value(value: &serde_json::Value) -> String {
99    match value {
100        serde_json::Value::String(s) => {
101            let needs_quotes = s.is_empty()
102                || s.contains(' ')
103                || s.contains('=')
104                || s.contains('\t')
105                || s == "true"
106                || s == "false"
107                || s == "null"
108                || s.parse::<f64>().is_ok();
109            if needs_quotes {
110                format!("\"{s}\"")
111            } else {
112                s.clone()
113            }
114        }
115        serde_json::Value::Number(n) => console::style(n).green().to_string(),
116        serde_json::Value::Bool(true) => console::style("true").yellow().to_string(),
117        serde_json::Value::Bool(false) => console::style("false").red().to_string(),
118        serde_json::Value::Null => console::style("null").dim().to_string(),
119        serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
120            serde_json::to_string(value).unwrap_or_else(|_| value.to_string())
121        }
122    }
123}
124
125/// Format structured fields as `key=value` pairs, excluding known keys.
126fn format_fields(fields_json: &str) -> String {
127    let Ok(serde_json::Value::Object(obj)) = serde_json::from_str(fields_json) else {
128        return String::new();
129    };
130    let mut parts = Vec::new();
131    for (key, value) in &obj {
132        if KNOWN_FIELD_KEYS.contains(&key.as_str()) {
133            continue;
134        }
135        let key_styled = console::style(key.as_str()).blue().to_string();
136        let value_styled = format_field_value(value);
137        parts.push(format!("{key_styled}={value_styled}"));
138    }
139    parts.join(" ")
140}
141
142/// Format and write a single log entry with structured field highlighting.
143///
144/// When `raw` is set, the original message is emitted verbatim (after ANSI
145/// stripping if requested), ignoring any structured fields. This keeps `--raw`
146/// honored even when `--jq` populates `fields_json` for filtering.
147///
148/// Otherwise, when the entry has structured fields (`fields_json` is `Some`),
149/// renders as: `<timestamp> [LEVEL] logger: message > key=value key=value`
150/// For warn/error levels, the timestamp and message use the level's accent color.
151///
152/// Falls back to plain display: `<timestamp> message`
153#[allow(clippy::too_many_arguments)]
154fn write_formatted_log(
155    w: &mut dyn Write,
156    entry: &LogEntry,
157    date: &str,
158    single_daemon: bool,
159    strip_ansi: bool,
160    show_timestamp: bool,
161    raw: bool,
162) -> io::Result<()> {
163    // Always strip PTY control sequences (cursor moves, clear screen, etc.)
164    // while preserving SGR color codes. PTY daemons emit these sequences which
165    // can corrupt pager output (e.g. triggering page breaks in `less`).
166    let clean_msg = strip_pty_controls(&entry.message);
167    let raw_msg: std::borrow::Cow<'_, str> = if strip_ansi {
168        console::strip_ansi_codes(&clean_msg)
169    } else {
170        std::borrow::Cow::Owned(clean_msg)
171    };
172
173    let mut out = String::with_capacity(256);
174
175    if raw {
176        // Raw mode: emit timestamp + original line verbatim. Structured
177        // fields may still be populated (e.g. --jq needs them for
178        // filtering) but must not alter the raw output format.
179        if show_timestamp {
180            out.push_str(&ndim(date).to_string());
181            out.push(' ');
182        }
183        if !single_daemon {
184            let colors_on = !strip_ansi && console::colors_enabled();
185            out.push_str(&colored_id_label(&entry.daemon_id, colors_on));
186            out.push(' ');
187        }
188        out.push_str(&raw_msg);
189    } else if entry.fields_json.is_some() {
190        // Structured display: [LEVEL] logger: message > key=value
191        let level = entry.level.as_deref();
192        let accent = match level {
193            Some("error") => "error",
194            Some("warn") => "warn",
195            _ => "",
196        };
197
198        // Timestamp — accent-colored for warn/error
199        if show_timestamp {
200            let ts = match accent {
201                "error" => console::style(date).red().to_string(),
202                "warn" => console::style(date).yellow().to_string(),
203                _ => ndim(date).to_string(),
204            };
205            out.push_str(&ts);
206            out.push(' ');
207        }
208
209        // Daemon ID (multi-daemon mode)
210        if !single_daemon {
211            let colors_on = !strip_ansi && console::colors_enabled();
212            out.push_str(&colored_id_label(&entry.daemon_id, colors_on));
213            out.push(' ');
214        }
215
216        let mut need_sep = false;
217
218        // Level badge: [ERR] with dim brackets + bold accent letters
219        if let Some(lvl) = level {
220            let badge = level_badge(lvl);
221            if !badge.is_empty() {
222                out.push_str(&badge);
223                need_sep = true;
224            }
225        }
226
227        // Separators (colon, arrow): always dim regardless of level
228        let sep = ndim(":").to_string();
229        let arrow = ndim(" > ").to_string();
230
231        // Logger: italic + colon
232        if let Some(logger) = &entry.logger {
233            if need_sep {
234                out.push(' ');
235            }
236            out.push_str(&console::style(logger).italic().dim().to_string());
237            out.push_str(&sep);
238            need_sep = true;
239        }
240
241        // Message (bold, accent-colored for warn/error) + fields
242        let msg_cow = entry.msg.as_deref().filter(|s| !s.is_empty()).map(|s| {
243            let cleaned = strip_pty_controls(s);
244            if strip_ansi {
245                std::borrow::Cow::<str>::Owned(console::strip_ansi_codes(&cleaned).to_string())
246            } else {
247                std::borrow::Cow::<str>::Owned(cleaned)
248            }
249        });
250        let fields_str = entry
251            .fields_json
252            .as_deref()
253            .map(format_fields)
254            .filter(|s| !s.is_empty());
255
256        if msg_cow.is_some() || fields_str.is_some() {
257            if need_sep {
258                out.push(' ');
259            }
260            if let Some(msg) = &msg_cow {
261                let styled = match accent {
262                    "error" => console::style(msg.as_ref()).red().bold().to_string(),
263                    "warn" => console::style(msg.as_ref()).yellow().bold().to_string(),
264                    _ => console::style(msg.as_ref()).bold().to_string(),
265                };
266                out.push_str(&styled);
267                if fields_str.is_some() {
268                    out.push_str(&arrow);
269                }
270            }
271            if let Some(fields) = &fields_str {
272                out.push_str(fields);
273            }
274        } else if !need_sep {
275            // No level badge, logger, message, or fields — fall back to
276            // the raw message so the line isn't empty.
277            out.push_str(&raw_msg);
278        }
279    } else {
280        // Unstructured log: timestamp + raw message
281        if show_timestamp {
282            out.push_str(&ndim(date).to_string());
283            out.push(' ');
284        }
285        if !single_daemon {
286            let colors_on = !strip_ansi && console::colors_enabled();
287            out.push_str(&colored_id_label(&entry.daemon_id, colors_on));
288            out.push(' ');
289        }
290        out.push_str(&raw_msg);
291    }
292
293    out.push('\n');
294    w.write_all(out.as_bytes())
295}
296
297/// Return a colorized `[namespace/id]` label for display in progress jobs.
298/// Uses brighter colors than `dimmed_id` and includes the square brackets.
299pub fn colored_id_label(id: &str, colors_enabled: bool) -> String {
300    if !colors_enabled {
301        return format!("[{}]", id);
302    }
303    // Same palette as mise: Blue, Magenta, Cyan, Green
304    // Excludes Red/Yellow to avoid confusion with errors/warnings.
305    let colors: [u8; 4] = [34, 35, 36, 32]; // ANSI: Blue, Magenta, Cyan, Green
306    let mut h: usize = 0x811C_9DC5; // FNV offset basis
307    for b in id.bytes() {
308        h = h.wrapping_mul(0x0100_0193).wrapping_add(b as usize);
309    }
310    let color = colors[h % colors.len()];
311    format!("\x1b[{color}m[{id}]\x1b[0m")
312}
313
314/// Displays logs for daemon(s)
315#[derive(Debug, usage_rs::Args)]
316#[usage(
317    verbatim_doc_comment,
318    long_about = "\
319Displays logs for daemon(s)
320
321Shows logs from managed daemons. Logs are stored in the pitchfork logs directory
322and include timestamps for filtering.
323
324Examples:
325
326    pitchfork logs api              Show all logs for 'api' (paged if needed)
327    pitchfork logs api worker       Show logs for multiple daemons
328    pitchfork logs                  Show logs for all daemons
329    pitchfork logs api -n 50        Show last 50 lines
330    pitchfork logs api --follow     Follow logs in real-time
331    pitchfork logs api --since '2024-01-15 10:00:00'
332                                    Show logs since a specific time (forward)
333    pitchfork logs api --since '10:30:00'
334                                    Show logs since 10:30:00 today
335    pitchfork logs api --since '10:30' --until '12:00'
336                                    Show logs since 10:30:00 until 12:00:00 today
337    pitchfork logs api --since 5min Show logs from last 5 minutes
338    pitchfork logs api --raw        Output raw log lines without formatting
339    pitchfork logs api --raw -n 100 Output last 100 raw log lines
340    pitchfork logs api --clear      Delete logs for 'api'
341    pitchfork logs --clear          Delete logs for all daemons"
342)]
343pub struct Logs {
344    /// Show only logs for the specified daemon(s)
345    id: Vec<String>,
346
347    /// Delete logs
348    #[usage(short, long, effect = "destructive")]
349    clear: bool,
350
351    /// Show last N lines of logs
352    ///
353    /// With --since/--until, shows the last N matching lines in that time range.
354    /// Without this option, all logs are shown.
355    #[usage(short)]
356    n: Option<usize>,
357
358    /// Show logs in real-time
359    #[usage(short = 't', short = 'f', long, visible_alias = "follow")]
360    tail: bool,
361
362    /// Show logs from this time
363    ///
364    /// Supports multiple formats:
365    /// - Full datetime: "YYYY-MM-DD HH:MM:SS" or "YYYY-MM-DD HH:MM"
366    /// - Time only: "HH:MM:SS" or "HH:MM" (uses today's date)
367    /// - Relative time: "5min", "2h", "1d" (e.g., last 5 minutes)
368    #[usage(short = 's', long)]
369    since: Option<String>,
370
371    /// Show logs until this time
372    ///
373    /// Supports multiple formats:
374    /// - Full datetime: "YYYY-MM-DD HH:MM:SS" or "YYYY-MM-DD HH:MM"
375    /// - Time only: "HH:MM:SS" or "HH:MM" (uses today's date)
376    #[usage(short = 'u', long)]
377    until: Option<String>,
378
379    /// Disable pager even in interactive terminal
380    #[usage(long)]
381    no_pager: bool,
382
383    /// Output raw log lines without color or formatting
384    #[usage(long)]
385    raw: bool,
386
387    /// Output in JSON format
388    #[usage(long, conflicts = "raw", conflicts = "tail")]
389    json: bool,
390
391    /// Filter logs by case-insensitive substring (can be repeated)
392    ///
393    /// Multiple --grep options are combined with OR.
394    #[usage(long)]
395    grep: Vec<String>,
396
397    /// Filter logs by regular expression
398    #[usage(long)]
399    regex: Option<String>,
400
401    /// Make --grep matching case-sensitive
402    #[usage(long)]
403    case_sensitive: bool,
404
405    /// Filter by minimum log level (error, warn, info, debug, trace)
406    ///
407    /// Shows entries at or above the given severity.
408    /// For example, `--level warn` shows warn and error.
409    /// Only effective for daemons with log_format json or logfmt.
410    #[usage(long)]
411    level: Option<String>,
412
413    /// Filter by structured field value (KEY=VALUE, can be repeated)
414    ///
415    /// Extracts the value from fields_json using json_extract($.KEY).
416    /// Multiple --field options are combined with AND.
417    #[usage(long, value_name = "KEY=VALUE")]
418    field: Vec<String>,
419
420    /// Filter log entries with a jq expression
421    ///
422    /// Each log entry is serialized as a JSON object with fields:
423    /// timestamp, daemon_id, message, level, msg, logger, fields.
424    /// Entries for which the expression produces a truthy value are shown.
425    #[usage(long, value_name = "EXPR")]
426    jq: Option<String>,
427
428    /// Omit timestamps from log output
429    #[usage(long)]
430    no_timestamp: bool,
431}
432
433impl Logs {
434    pub async fn run(&self) -> Result<()> {
435        migrate_legacy_log_dirs();
436
437        let resolved_ids: Vec<DaemonId> = if self.id.is_empty() {
438            get_all_daemon_ids()?
439        } else {
440            PitchforkToml::resolve_ids(&self.id)?
441        };
442
443        if self.clear {
444            LOG_STORE.clear(&resolved_ids)?;
445            return Ok(());
446        }
447
448        let from = if let Some(since) = self.since.as_ref() {
449            Some(parse_time_input(since, true)?)
450        } else {
451            None
452        };
453        let to = if let Some(until) = self.until.as_ref() {
454            Some(parse_time_input(until, false)?)
455        } else {
456            None
457        };
458
459        let message_filters = self.build_message_filters()?;
460        let field_filters = self.build_field_filters()?;
461
462        // Compile jq filter early so parse errors surface before any query.
463        let jq_filter = match self.jq.as_deref() {
464            Some(expr) => Some(crate::log_jq::JqFilter::new(expr)?),
465            None => None,
466        };
467
468        if self.json {
469            return self.output_json(
470                &resolved_ids,
471                from,
472                to,
473                message_filters,
474                field_filters,
475                jq_filter.as_ref(),
476            );
477        }
478
479        // Only suppress the daemon id label when the user explicitly asked
480        // for a single daemon. When no daemon is named on the command line
481        // (e.g. `pf logs`), ids are shown even if only one daemon has logs,
482        // so the user can tell where each line came from.
483        let single_daemon = resolved_ids.len() == 1 && !self.id.is_empty();
484        let show_timestamp = settings().logs.timestamp && !self.no_timestamp && !self.raw;
485        let has_time_filter = from.is_some() || to.is_some();
486
487        self.query_and_output(
488            &resolved_ids,
489            from,
490            to,
491            message_filters.clone(),
492            field_filters.clone(),
493            jq_filter.as_ref(),
494            single_daemon,
495            has_time_filter,
496            show_timestamp,
497        )?;
498
499        if self.tail {
500            tail_logs(
501                &resolved_ids,
502                single_daemon,
503                true,
504                message_filters,
505                field_filters,
506                jq_filter.as_ref(),
507                show_timestamp,
508                self.raw,
509            )
510            .await?;
511        }
512
513        Ok(())
514    }
515
516    /// The row limit for the SQL query. `-n` is applied there only when
517    /// nothing filters the rows afterwards: a time filter trims to the last N
518    /// once the rows are in chronological order, and a jq filter runs after
519    /// the query, so limiting first would show only the matches among the
520    /// last N entries rather than the last N matches.
521    fn query_limit(&self, has_time_filter: bool, has_jq_filter: bool) -> Option<usize> {
522        if has_time_filter || has_jq_filter {
523            None
524        } else {
525            self.n
526        }
527    }
528
529    /// Run `opts` and apply the jq filter, if any: newest first without a
530    /// time filter, chronological with one.
531    ///
532    /// With `--jq` and `-n` the last N matches are wanted, but jq runs after
533    /// the query, so the query reads a window of the newest rows that grows
534    /// until it holds N matches or covers them all (see [`last_matches`]).
535    fn query_entries(
536        &self,
537        mut opts: LogQuery,
538        jq_filter: Option<&crate::log_jq::JqFilter>,
539        has_time_filter: bool,
540    ) -> Result<Vec<LogEntry>> {
541        let Some(jq) = jq_filter else {
542            return LOG_STORE.query(&opts);
543        };
544        match self.n {
545            Some(n) if !has_time_filter => last_matches(
546                n,
547                |window| {
548                    opts.limit = Some(window);
549                    LOG_STORE.query(&opts)
550                },
551                |rows| jq.filter(rows),
552            ),
553            _ => Ok(jq.filter(LOG_STORE.query(&opts)?)),
554        }
555    }
556
557    fn build_message_filters(&self) -> Result<Vec<MessageFilter>> {
558        if self.case_sensitive && self.grep.is_empty() {
559            warn!("--case-sensitive has no effect without --grep");
560        }
561        let mut filters = Vec::new();
562        for pattern in &self.grep {
563            filters.push(MessageFilter::Contains {
564                pattern: pattern.clone(),
565                case_sensitive: self.case_sensitive,
566            });
567        }
568        if let Some(pattern) = self.regex.as_ref() {
569            // Validate the regex early so the user gets a clear CLI error
570            // instead of a SQLite user-function failure at query time.
571            let _ = regex::Regex::new(pattern)
572                .into_diagnostic()
573                .map_err(|e| miette::miette!("invalid regex pattern: {e}"))?;
574            filters.push(MessageFilter::Regex {
575                pattern: pattern.clone(),
576            });
577        }
578        Ok(filters)
579    }
580
581    fn build_field_filters(&self) -> Result<Vec<FieldFilter>> {
582        let mut filters = Vec::new();
583        if let Some(level) = self.level.as_ref() {
584            // Normalize the user-supplied level so "ERROR", "Error", "fatal"
585            // all match the stored canonical form.
586            let normalized = crate::log_parse::normalize_level_str(level).ok_or_else(|| {
587                miette::miette!(
588                    "invalid level '{level}'; expected one of: \
589                         error, err, fatal, critical, panic, alert, emerg, \
590                         warn, warning, info, inf, information, notice, \
591                         debug, dbg, trace, trc"
592                )
593            })?;
594            filters.push(FieldFilter::LevelMin(normalized));
595        }
596        for pair in &self.field {
597            let (key, value) = pair
598                .split_once('=')
599                .ok_or_else(|| miette::miette!("--field expects KEY=VALUE, got: {pair}"))?;
600            if key.is_empty() {
601                miette::bail!("--field key cannot be empty: {pair}");
602            }
603            // Whitelist key characters to prevent SQL injection via
604            // json_extract path interpolation.
605            if !key
606                .chars()
607                .all(|c| c.is_alphanumeric() || c == '_' || c == '.')
608            {
609                miette::bail!(
610                    "--field key may only contain alphanumeric, underscore, and dot; got: {key}"
611                );
612            }
613            filters.push(FieldFilter::FieldEq {
614                key: key.to_string(),
615                value: value.to_string(),
616            });
617        }
618        Ok(filters)
619    }
620
621    /// Query log entries and stream them directly to output, bypassing the
622    /// intermediate `Vec<(String, String, String)>` layer. This eliminates
623    /// 3 String allocations per row (timestamp format, daemon_id clone,
624    /// message clone) by borrowing directly from `LogEntry` and reusing a
625    /// single date buffer.
626    #[allow(clippy::too_many_arguments)]
627    fn query_and_output(
628        &self,
629        resolved_ids: &[DaemonId],
630        from: Option<DateTime<Local>>,
631        to: Option<DateTime<Local>>,
632        message_filters: Vec<MessageFilter>,
633        field_filters: Vec<FieldFilter>,
634        jq_filter: Option<&crate::log_jq::JqFilter>,
635        single_daemon: bool,
636        has_time_filter: bool,
637        show_timestamp: bool,
638    ) -> Result<()> {
639        let daemon_ids: Vec<String> = resolved_ids.iter().map(|id| id.qualified()).collect();
640
641        let opts = LogQuery {
642            daemon_ids,
643            from,
644            to,
645            limit: self.query_limit(has_time_filter, jq_filter.is_some()),
646            order_desc: !has_time_filter,
647            after_id: None,
648            before_id: None,
649            message_filters,
650            field_filters,
651            include_structured: jq_filter.is_some() || !self.raw,
652        };
653        let mut entries = self.query_entries(opts, jq_filter, has_time_filter)?;
654
655        // Apply time-filter take-last-N or reverse for chronological display.
656        if has_time_filter {
657            if let Some(n) = self.n {
658                let len = entries.len();
659                if len > n {
660                    entries = entries.split_off(len - n);
661                }
662            }
663        } else {
664            entries.reverse();
665        }
666
667        if entries.is_empty() {
668            return Ok(());
669        }
670
671        let strip_ansi = self.raw || !console::colors_enabled();
672        let use_pager = !self.tail && !self.no_pager && should_use_pager(entries.len());
673        let ts_format = &settings().logs.timestamp_format;
674
675        // Reusable buffer for timestamp formatting — avoids N allocations.
676        let mut date_buf = String::with_capacity(ts_format.len() + 6);
677
678        let mut write_entries = |w: &mut dyn Write| -> io::Result<()> {
679            for entry in &entries {
680                date_buf.clear();
681                write!(date_buf, "{}", entry.timestamp.format(ts_format))
682                    .map_err(io::Error::other)?;
683                write_formatted_log(
684                    w,
685                    entry,
686                    &date_buf,
687                    single_daemon,
688                    strip_ansi,
689                    show_timestamp,
690                    self.raw,
691                )?;
692            }
693            Ok(())
694        };
695
696        if use_pager {
697            let pager_config = PagerConfig::new(!has_time_filter);
698            match pager_config.spawn_piped() {
699                Ok(mut child) => {
700                    if let Some(stdin) = child.stdin.as_mut() {
701                        if let Err(e) = write_entries(stdin) {
702                            debug!("pager write error: {e}");
703                        }
704                        let _ = child.wait();
705                    } else {
706                        debug!("Failed to get pager stdin, falling back to direct output");
707                        let stdout = io::stdout();
708                        let mut buf = io::BufWriter::new(stdout.lock());
709                        write_entries(&mut buf).into_diagnostic()?;
710                    }
711                }
712                Err(e) => {
713                    debug!("Failed to spawn pager: {e}, falling back to direct output");
714                    let stdout = io::stdout();
715                    let mut buf = io::BufWriter::new(stdout.lock());
716                    write_entries(&mut buf).into_diagnostic()?;
717                }
718            }
719        } else {
720            let stdout = io::stdout();
721            let mut buf = io::BufWriter::new(stdout.lock());
722            write_entries(&mut buf).into_diagnostic()?;
723        }
724
725        Ok(())
726    }
727
728    fn output_json(
729        &self,
730        resolved_ids: &[DaemonId],
731        from: Option<DateTime<Local>>,
732        to: Option<DateTime<Local>>,
733        message_filters: Vec<MessageFilter>,
734        field_filters: Vec<FieldFilter>,
735        jq_filter: Option<&crate::log_jq::JqFilter>,
736    ) -> Result<()> {
737        let daemon_ids: Vec<String> = resolved_ids.iter().map(|id| id.qualified()).collect();
738        let has_time_filter = from.is_some() || to.is_some();
739
740        let opts = LogQuery {
741            daemon_ids,
742            from,
743            to,
744            limit: self.query_limit(has_time_filter, jq_filter.is_some()),
745            order_desc: !has_time_filter,
746            after_id: None,
747            before_id: None,
748            message_filters,
749            field_filters,
750            include_structured: true,
751        };
752        let entries = self.query_entries(opts, jq_filter, has_time_filter)?;
753
754        // Reverse only when the SQL query returned DESC (no time filter),
755        // to produce chronological (oldest-first) output. With a time
756        // filter, the query is ASC and already chronological.
757        let mut entries: Vec<_> = if has_time_filter {
758            entries
759        } else {
760            entries.into_iter().rev().collect()
761        };
762
763        // When a time filter is active, the SQL query returned all matching
764        // entries without LIMIT. Trim to the last N here.
765        if has_time_filter
766            && let Some(n) = self.n
767            && entries.len() > n
768        {
769            entries = entries.split_off(entries.len() - n);
770        }
771
772        let json_entries: Vec<JsonLogEntry> = entries.into_iter().map(Into::into).collect();
773
774        print_json(&json_entries)
775    }
776}
777
778/// The smallest window of newest rows [`last_matches`] reads.
779const MIN_MATCH_WINDOW: usize = 1000;
780
781/// The last `n` rows that `filter` keeps, newest first, reading no more of
782/// the history than needed: `fetch(window)` returns the newest `window` rows,
783/// and the window doubles until it holds `n` matches or all the rows.
784///
785/// Each read starts again from the newest row rather than paging on, so the
786/// rows keep exactly the order the query gives them.
787fn last_matches<T>(
788    n: usize,
789    mut fetch: impl FnMut(usize) -> Result<Vec<T>>,
790    filter: impl Fn(Vec<T>) -> Vec<T>,
791) -> Result<Vec<T>> {
792    let mut window = n.max(MIN_MATCH_WINDOW);
793    loop {
794        let rows = fetch(window)?;
795        let exhausted = rows.len() < window;
796        let mut matches = filter(rows);
797        if matches.len() >= n || exhausted {
798            matches.truncate(n);
799            return Ok(matches);
800        }
801        window = window.saturating_mul(2);
802    }
803}
804
805fn should_use_pager(line_count: usize) -> bool {
806    if !io::stdout().is_terminal() {
807        return false;
808    }
809
810    let terminal_height = get_terminal_height().unwrap_or(24);
811    line_count > terminal_height
812}
813
814fn get_terminal_height() -> Option<usize> {
815    if let Ok(rows) = std::env::var("LINES")
816        && let Ok(h) = rows.parse::<usize>()
817    {
818        return Some(h);
819    }
820
821    crossterm::terminal::size().ok().map(|(_, h)| h as usize)
822}
823
824/// Rename legacy log directories that predate namespace-qualified daemon IDs.
825///
826/// Old layout: `PITCHFORK_LOGS_DIR/<name>/<name>.log`
827/// New layout: `PITCHFORK_LOGS_DIR/legacy--<name>/legacy--<name>.log`
828///
829/// Only directories that clearly match the old layout are migrated:
830/// - directory name does not contain `"--"`
831/// - directory contains `<name>.log`
832/// - `<name>` is a valid daemon short name under current DaemonId rules
833fn migrate_legacy_log_dirs() {
834    let known_safe_paths = known_daemon_safe_paths();
835    let dirs = match xx::file::ls(&*env::PITCHFORK_LOGS_DIR) {
836        Ok(d) => d,
837        Err(_) => return,
838    };
839    for dir in dirs {
840        if dir.starts_with(".") || !dir.is_dir() {
841            continue;
842        }
843        let name = match dir.file_name().map(|f| f.to_string_lossy().to_string()) {
844            Some(n) => n,
845            None => continue,
846        };
847        // Skip the supervisor's own log directory.
848        if name == "pitchfork" {
849            continue;
850        }
851        // New-format directories usually contain "--". For safety, only treat
852        // them as new-format if they match a known daemon ID safe-path.
853        if name.contains("--") {
854            // If it parses as a valid safe-path, treat it as already migrated
855            // and keep idempotent behavior silent.
856            if DaemonId::from_safe_path(&name).is_ok() {
857                continue;
858            }
859            // Keep noisy warnings only for invalid/ambiguous names that cannot
860            // be interpreted as new-format IDs.
861            if known_safe_paths.contains(&name) {
862                continue;
863            }
864            warn!(
865                "Skipping invalid legacy log directory '{name}': contains '--' but is not a valid daemon safe-path"
866            );
867            continue;
868        }
869
870        // Migrate only explicit old-layout directories to avoid renaming
871        // unrelated folders under logs/.
872        let old_log = dir.join(format!("{name}.log"));
873        if !old_log.exists() {
874            continue;
875        }
876        if DaemonId::try_new("legacy", &name).is_err() {
877            warn!("Skipping invalid legacy log directory '{name}': not a valid daemon ID");
878            continue;
879        }
880
881        let new_name = format!("legacy--{name}");
882        let new_dir = env::PITCHFORK_LOGS_DIR.join(&new_name);
883        // Skip if a target directory already exists to avoid clobbering data.
884        if new_dir.exists() {
885            continue;
886        }
887        if std::fs::rename(&dir, &new_dir).is_err() {
888            continue;
889        }
890        // Also rename the log file inside the directory.
891        let old_log = new_dir.join(format!("{name}.log"));
892        let new_log = new_dir.join(format!("{new_name}.log"));
893        if old_log.exists() {
894            let _ = std::fs::rename(&old_log, &new_log);
895        }
896        debug!("Migrated legacy log dir '{name}' → '{new_name}'");
897    }
898}
899
900fn known_daemon_safe_paths() -> BTreeSet<String> {
901    let mut out = BTreeSet::new();
902
903    match StateFile::read(&*env::PITCHFORK_STATE_FILE) {
904        Ok(state) => {
905            for id in state.daemons.keys() {
906                out.insert(id.safe_path());
907            }
908        }
909        Err(e) => {
910            warn!("Failed to read state while checking known daemon IDs: {e}");
911        }
912    }
913
914    match PitchforkToml::all_merged() {
915        Ok(config) => {
916            for id in config.daemons.keys() {
917                out.insert(id.safe_path());
918            }
919        }
920        Err(e) => {
921            warn!("Failed to read config while checking known daemon IDs: {e}");
922        }
923    }
924
925    out
926}
927
928fn get_all_daemon_ids() -> Result<Vec<DaemonId>> {
929    let mut ids = BTreeSet::new();
930
931    match StateFile::read(&*env::PITCHFORK_STATE_FILE) {
932        Ok(state) => ids.extend(state.daemons.keys().cloned()),
933        Err(e) => warn!("Failed to read state for log daemon discovery: {e}"),
934    }
935
936    match PitchforkToml::all_merged() {
937        Ok(config) => ids.extend(config.daemons.keys().cloned()),
938        Err(e) => warn!("Failed to read config for log daemon discovery: {e}"),
939    }
940
941    let logged_ids: std::collections::HashSet<String> =
942        LOG_STORE.list_daemon_ids()?.into_iter().collect();
943    Ok(ids
944        .into_iter()
945        .filter(|id| logged_ids.contains(&id.qualified()))
946        .collect())
947}
948
949#[allow(clippy::too_many_arguments)]
950pub async fn tail_logs(
951    names: &[DaemonId],
952    single_daemon: bool,
953    start_from_end: bool,
954    message_filters: Vec<MessageFilter>,
955    field_filters: Vec<FieldFilter>,
956    jq_filter: Option<&crate::log_jq::JqFilter>,
957    show_timestamp: bool,
958    raw: bool,
959) -> Result<()> {
960    // Poll SQLite log store for new entries since last known row id.
961    let strip_ansi = raw || !console::colors_enabled();
962
963    let mut states: std::collections::HashMap<String, i64> = names
964        .iter()
965        .map(|id| {
966            let since = if start_from_end {
967                // Anchor to the last entry overall, not the last filtered entry,
968                // so --tail combined with a filter does not rescan from row 1
969                // on every poll when no message matches yet.
970                LOG_STORE.last_id(id).unwrap_or(None).unwrap_or(0)
971            } else {
972                0
973            };
974            (id.qualified(), since)
975        })
976        .collect();
977
978    let interval = tokio::time::interval(Duration::from_millis(200));
979    tokio::pin!(interval);
980
981    loop {
982        interval.tick().await;
983
984        let mut out = vec![];
985        for id in names {
986            let after_id = states.get(&id.qualified()).copied();
987            match LOG_STORE.query(&LogQuery {
988                daemon_ids: vec![id.qualified()],
989                from: None,
990                to: None,
991                limit: None,
992                order_desc: false,
993                after_id,
994                before_id: None,
995                message_filters: message_filters.clone(),
996                field_filters: field_filters.clone(),
997                include_structured: jq_filter.is_some() || !raw,
998            }) {
999                Ok(raw_entries) => {
1000                    // Save the max id from SQL results (before jq filtering)
1001                    // so the cursor always advances past evaluated rows, even
1002                    // when jq rejects all of them.
1003                    let last_raw_id = raw_entries.last().map(|e| e.id);
1004
1005                    // Apply jq filter if present.
1006                    let entries = match jq_filter {
1007                        Some(jq) => jq.filter(raw_entries),
1008                        None => raw_entries,
1009                    };
1010                    out.extend(entries);
1011                    // Advance the cursor past rows already evaluated.
1012                    //
1013                    // Use the max id from the raw SQL result (not the jq-filtered
1014                    // subset) so the cursor advances past all rows the database
1015                    // returned, preventing re-evaluation on every poll.
1016                    //
1017                    // When the filter is active and no entries matched, fall
1018                    // back to last_id to advance past non-matching rows so they
1019                    // are not re-evaluated on every poll. The narrow race here
1020                    // (a matching row written between query and last_id) is
1021                    // accepted as the cost of bounded re-scanning.
1022                    let has_sql_filter = !message_filters.is_empty() || !field_filters.is_empty();
1023                    let new_cursor = if let Some(id) = last_raw_id {
1024                        Some(id)
1025                    } else if has_sql_filter || jq_filter.is_some() {
1026                        LOG_STORE.last_id(id).ok().flatten()
1027                    } else {
1028                        None
1029                    };
1030                    if let Some(last_id) = new_cursor {
1031                        states.insert(id.qualified(), last_id);
1032                    }
1033                }
1034                Err(e) => {
1035                    error!("Failed to tail logs for {}: {e}", id.qualified());
1036                }
1037            }
1038        }
1039
1040        if !out.is_empty() {
1041            // Single-daemon: entries are already in chronological order from
1042            // the SQL query (order_desc: false). Skip the sort.
1043            let out: Vec<LogEntry> = if single_daemon {
1044                out
1045            } else {
1046                out.into_iter()
1047                    .sorted_by(|a, b| a.timestamp.cmp(&b.timestamp).then(a.id.cmp(&b.id)))
1048                    .collect()
1049            };
1050            let stdout = io::stdout();
1051            let mut buf = io::BufWriter::new(stdout.lock());
1052            let ts_format = &settings().logs.timestamp_format;
1053            let mut date_buf = String::with_capacity(ts_format.len() + 6);
1054            for entry in &out {
1055                date_buf.clear();
1056                write!(date_buf, "{}", entry.timestamp.format(ts_format))
1057                    .map_err(io::Error::other)
1058                    .into_diagnostic()?;
1059                write_formatted_log(
1060                    &mut buf,
1061                    entry,
1062                    &date_buf,
1063                    single_daemon,
1064                    strip_ansi,
1065                    show_timestamp,
1066                    raw,
1067                )
1068                .into_diagnostic()?;
1069            }
1070        }
1071    }
1072}
1073
1074fn parse_datetime(s: &str) -> Result<DateTime<Local>> {
1075    let naive_dt = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").into_diagnostic()?;
1076    Local
1077        .from_local_datetime(&naive_dt)
1078        .single()
1079        .ok_or_else(|| miette::miette!("Invalid or ambiguous datetime: '{}'. ", s))
1080}
1081
1082/// Parse time input string into DateTime.
1083///
1084/// `is_since` indicates whether this is for --since (true) or --until (false).
1085/// The "yesterday fallback" only applies to --since: if the time is in the future,
1086/// assume the user meant yesterday. For --until, future times are kept as-is.
1087fn parse_time_input(s: &str, is_since: bool) -> Result<DateTime<Local>> {
1088    let s = s.trim();
1089
1090    // Try full datetime first (YYYY-MM-DD HH:MM:SS)
1091    if let Ok(dt) = parse_datetime(s) {
1092        return Ok(dt);
1093    }
1094
1095    // Try datetime without seconds (YYYY-MM-DD HH:MM)
1096    if let Ok(naive_dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M") {
1097        return Local
1098            .from_local_datetime(&naive_dt)
1099            .single()
1100            .ok_or_else(|| miette::miette!("Invalid or ambiguous datetime: '{}'", s));
1101    }
1102
1103    // Try time-only format (HH:MM:SS or HH:MM)
1104    // Note: This branch won't be reached for inputs like "10:30" that could match
1105    // parse_datetime, because parse_datetime expects a full date prefix and will fail.
1106    if let Ok(time) = parse_time_only(s) {
1107        let now = Local::now();
1108        let today = now.date_naive();
1109        let mut naive_dt = NaiveDateTime::new(today, time);
1110        let mut dt = Local
1111            .from_local_datetime(&naive_dt)
1112            .single()
1113            .ok_or_else(|| miette::miette!("Invalid or ambiguous datetime: '{}'", s))?;
1114
1115        // If the interpreted time for today is in the future, assume the user meant yesterday
1116        // BUT only for --since. For --until, a future time today is valid.
1117        if is_since
1118            && dt > now
1119            && let Some(yesterday) = today.pred_opt()
1120        {
1121            naive_dt = NaiveDateTime::new(yesterday, time);
1122            dt = Local
1123                .from_local_datetime(&naive_dt)
1124                .single()
1125                .ok_or_else(|| miette::miette!("Invalid or ambiguous datetime: '{}'", s))?;
1126        }
1127        return Ok(dt);
1128    }
1129
1130    if let Ok(duration) = humantime::parse_duration(s) {
1131        let now = Local::now();
1132        let target = now - chrono::Duration::from_std(duration).into_diagnostic()?;
1133        return Ok(target);
1134    }
1135
1136    Err(miette::miette!(
1137        "Invalid time format: '{}'. Expected formats:\n\
1138         - Full datetime: \"YYYY-MM-DD HH:MM:SS\" or \"YYYY-MM-DD HH:MM\"\n\
1139         - Time only: \"HH:MM:SS\" or \"HH:MM\" (uses today's date)\n\
1140         - Relative time: \"5min\", \"2h\", \"1d\" (e.g., last 5 minutes)",
1141        s
1142    ))
1143}
1144
1145fn parse_time_only(s: &str) -> Result<NaiveTime> {
1146    if let Ok(time) = NaiveTime::parse_from_str(s, "%H:%M:%S") {
1147        return Ok(time);
1148    }
1149
1150    if let Ok(time) = NaiveTime::parse_from_str(s, "%H:%M") {
1151        return Ok(time);
1152    }
1153
1154    Err(miette::miette!("Invalid time format: '{}'", s))
1155}
1156
1157/// Prints error log lines in a styled block matching the startup logs format.
1158///
1159/// Format:
1160/// ```text
1161///  ERROR LOGS
1162///  12:00:00 error message
1163/// ```
1164///
1165/// Timestamps use dimmed red. The tag uses white text on red background.
1166pub fn print_error_logs_block(log_lines: &[(String, String, String)]) {
1167    if log_lines.is_empty() {
1168        return;
1169    }
1170
1171    let is_tty = std::io::stderr().is_terminal();
1172    let format_msg = |msg: &str| -> String {
1173        let stripped = strip_pty_controls(msg);
1174        if is_tty {
1175            stripped
1176        } else {
1177            console::strip_ansi_codes(&stripped).to_string()
1178        }
1179    };
1180
1181    let tag = estyle(" ERROR LOGS ").white().on_red();
1182    eprintln!("\n{tag}");
1183
1184    // Determine if we need to show daemon IDs (same logic as startup logs)
1185    let unique_ids: BTreeSet<&str> = log_lines.iter().map(|(_, id, _)| id.as_str()).collect();
1186    let show_id = unique_ids.len() > 1;
1187
1188    if show_id {
1189        for (date, id, msg) in log_lines {
1190            let time = date.split(' ').nth(1).unwrap_or(date);
1191            let colored = colored_id_label(id, is_tty && console::colors_enabled_stderr());
1192            eprintln!(
1193                "{} {} {}",
1194                estyle(time).red().dim(),
1195                colored,
1196                format_msg(msg)
1197            );
1198        }
1199    } else {
1200        for (date, _, msg) in log_lines {
1201            let time = date.split(' ').nth(1).unwrap_or(date);
1202            eprintln!("{} {}", estyle(time).red().dim(), format_msg(msg));
1203        }
1204    }
1205}
1206
1207/// Describes the type of ready check being performed for display purposes.
1208pub enum ReadyCheckType {
1209    Output(String),
1210    Http(String),
1211    Port(u16),
1212    Cmd(String),
1213    Delay(u64),
1214    /// A `oneshot` daemon: ready when the process exits 0.
1215    Completion,
1216    Default,
1217}
1218
1219impl std::fmt::Display for ReadyCheckType {
1220    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1221        match self {
1222            ReadyCheckType::Output(pattern) => write!(f, "output matching '{pattern}'"),
1223            ReadyCheckType::Http(url) => write!(f, "HTTP {url}"),
1224            ReadyCheckType::Port(port) => write!(f, "TCP port {port}"),
1225            ReadyCheckType::Cmd(cmd) => write!(f, "command '{cmd}'"),
1226            ReadyCheckType::Delay(secs) => write!(f, "delay ({secs}s)"),
1227            ReadyCheckType::Completion => write!(f, "completion"),
1228            ReadyCheckType::Default => write!(f, "default readiness check"),
1229        }
1230    }
1231}
1232
1233/// Creates a progress job showing a spinner while waiting for a ready check.
1234///
1235/// Returns a `Arc<ProgressJob>` that the caller should update:
1236/// - Set body to success message and status to `Done` when the daemon is ready
1237/// - Set body to failure message and status to `Failed` when the daemon fails
1238pub fn create_ready_check_job(
1239    daemon_id: &DaemonId,
1240    check_type: &ReadyCheckType,
1241) -> std::sync::Arc<clx::progress::ProgressJob> {
1242    use clx::progress::{ProgressJobBuilder, ProgressJobDoneBehavior, ProgressStatus};
1243
1244    let is_tty = std::io::stderr().is_terminal();
1245    let colors_enabled = is_tty && console::colors_enabled_stderr();
1246    let id_label = colored_id_label(&daemon_id.qualified(), colors_enabled);
1247    let show_ts = crate::settings::settings().general.startup_log_timestamps;
1248
1249    // When timestamps are off, {{spinner()}} renders as an animated spinner
1250    // (1 char wide) matching the "•" prefix used by println.  When on,
1251    // we show a dim timestamp instead.
1252    let prefix = if show_ts {
1253        // The timestamp updates each refresh via the now() tera function.
1254        // We use a fixed-width format (HH:MM:SS = 8 chars) for alignment.
1255        edim(chrono::Local::now().format("%H:%M:%S").to_string()).to_string()
1256    } else {
1257        "{{spinner()}}".to_string()
1258    };
1259
1260    ProgressJobBuilder::new()
1261        .body(format!(
1262            "{} {} waiting for {{{{ check_type }}}}...",
1263            prefix, id_label
1264        ))
1265        .prop("check_type", &check_type.to_string())
1266        .status(ProgressStatus::Running)
1267        .on_done(ProgressJobDoneBehavior::Keep)
1268        .start()
1269}
1270
1271/// Collects startup log lines for a single daemon (does not print).
1272///
1273/// Returns a list of `(time, daemon_id_qualified, message)` tuples for log
1274/// entries written after `from`.
1275pub fn collect_startup_logs(
1276    daemon_id: &DaemonId,
1277    from: DateTime<Local>,
1278) -> Result<Vec<(String, String, String)>> {
1279    let entries = LOG_STORE.query(&LogQuery {
1280        daemon_ids: vec![daemon_id.qualified()],
1281        from: Some(from),
1282        to: None,
1283        limit: None,
1284        order_desc: false,
1285        after_id: None,
1286        before_id: None,
1287        message_filters: Vec::new(),
1288        field_filters: Vec::new(),
1289        include_structured: false,
1290    })?;
1291    let log_lines = entries
1292        .into_iter()
1293        .map(|e| {
1294            let ts = e.timestamp.format("%Y-%m-%d %H:%M:%S").to_string();
1295            (ts, e.daemon_id, e.message)
1296        })
1297        .collect();
1298
1299    Ok(log_lines)
1300}
1301
1302/// Stream startup logs for a daemon to a progress job in real-time.
1303///
1304/// Spawns a background tokio task that polls the daemon's log store
1305/// and calls `job.println()` for each new line. Returns a watch sender
1306/// that stops the streaming when sent `true`.
1307pub fn stream_startup_logs(
1308    daemon_id: &DaemonId,
1309    job: std::sync::Arc<clx::progress::ProgressJob>,
1310) -> (
1311    tokio::sync::watch::Sender<bool>,
1312    tokio::task::JoinHandle<()>,
1313) {
1314    let (tx, mut rx) = tokio::sync::watch::channel(false);
1315    let id = daemon_id.clone();
1316
1317    let show_ts = crate::settings::settings().general.startup_log_timestamps;
1318
1319    // Anchor to the daemon's current max log id *synchronously* before
1320    // spawning the streaming task. This must happen before ipc.run() starts
1321    // the daemon, otherwise early output could be written to the log store
1322    // before the anchor is established and get skipped as "already seen".
1323    let anchor_id: i64 = LOG_STORE
1324        .query(&LogQuery {
1325            daemon_ids: vec![id.qualified()],
1326            limit: Some(1),
1327            order_desc: true,
1328            ..Default::default()
1329        })
1330        .ok()
1331        .and_then(|entries| entries.last().map(|e| e.id))
1332        .unwrap_or(0);
1333
1334    let handle = tokio::spawn(async move {
1335        let is_tty = std::io::stderr().is_terminal();
1336        let colors_enabled = is_tty && console::colors_enabled_stderr();
1337        let id_label = colored_id_label(&id.qualified(), colors_enabled);
1338        let prefix = if show_ts {
1339            String::new()
1340        } else {
1341            edim("•").to_string()
1342        };
1343
1344        let mut last_id = anchor_id;
1345
1346        // Initial fetch: catch any logs already written since the anchor.
1347        if let Ok(entries) = LOG_STORE.tail(&id, Some(last_id)) {
1348            for entry in &entries {
1349                let time = entry.timestamp.format("%H:%M:%S").to_string();
1350                let msg = strip_pty_controls(&entry.message);
1351                let msg = if is_tty {
1352                    msg
1353                } else {
1354                    console::strip_ansi_codes(&msg).to_string()
1355                };
1356                let line_prefix = if show_ts {
1357                    edim(time).to_string()
1358                } else {
1359                    prefix.clone()
1360                };
1361                job.println(&format!("{} {} {}", line_prefix, id_label, msg));
1362            }
1363            if let Some(last) = entries.last() {
1364                last_id = last.id;
1365            }
1366        }
1367
1368        loop {
1369            tokio::select! {
1370                _ = tokio::time::sleep(Duration::from_millis(200)) => {
1371                    if let Ok(entries) = LOG_STORE.tail(&id, Some(last_id)) {
1372                        for entry in &entries {
1373                            let time = entry.timestamp.format("%H:%M:%S").to_string();
1374                            let msg = strip_pty_controls(&entry.message);
1375                            let msg = if is_tty {
1376                                msg
1377                            } else {
1378                                console::strip_ansi_codes(&msg).to_string()
1379                            };
1380                            let line_prefix = if show_ts {
1381                                edim(time).to_string()
1382                            } else {
1383                                prefix.clone()
1384                            };
1385                            job.println(&format!("{} {} {}", line_prefix, id_label, msg));
1386                        }
1387                        if let Some(last) = entries.last() {
1388                            last_id = last.id;
1389                        }
1390                    }
1391                }
1392                _ = rx.changed() => {
1393                    break;
1394                }
1395            }
1396        }
1397
1398        // Final drain
1399        if let Ok(entries) = LOG_STORE.tail(&id, Some(last_id)) {
1400            for entry in &entries {
1401                let time = entry.timestamp.format("%H:%M:%S").to_string();
1402                let msg = strip_pty_controls(&entry.message);
1403                let msg = if is_tty {
1404                    msg
1405                } else {
1406                    console::strip_ansi_codes(&msg).to_string()
1407                };
1408                let line_prefix = if show_ts {
1409                    edim(time).to_string()
1410                } else {
1411                    prefix.clone()
1412                };
1413                job.println(&format!("{} {} {}", line_prefix, id_label, msg));
1414            }
1415        }
1416    });
1417
1418    (tx, handle)
1419}
1420
1421/// Strips PTY control sequences from a string while preserving SGR (color/style) codes.
1422///
1423/// Removes CSI sequences that control cursor movement, screen clearing, erasing, etc.,
1424/// but keeps `\x1b[...m` (SGR) sequences so colors are retained.
1425fn strip_pty_controls(s: &str) -> String {
1426    struct Stripper {
1427        result: String,
1428    }
1429
1430    impl vte::Perform for Stripper {
1431        fn print(&mut self, c: char) {
1432            self.result.push(c);
1433        }
1434
1435        fn execute(&mut self, byte: u8) {
1436            // Keep \n and \t; drop other control characters (BEL, BS, CR, etc.)
1437            if byte == b'\n' || byte == b'\t' {
1438                self.result.push(byte as char);
1439            }
1440        }
1441
1442        fn csi_dispatch(
1443            &mut self,
1444            params: &vte::Params,
1445            _intermediates: &[u8],
1446            _ignore: bool,
1447            action: char,
1448        ) {
1449            // Keep SGR sequences (final byte 'm')
1450            if action == 'm' {
1451                self.result.push_str("\x1b[");
1452                let mut first = true;
1453                for sub in params.iter() {
1454                    if !first {
1455                        self.result.push(';');
1456                    }
1457                    first = false;
1458                    for (i, &p) in sub.iter().enumerate() {
1459                        if i > 0 {
1460                            self.result.push(':');
1461                        }
1462                        self.result.push_str(&p.to_string());
1463                    }
1464                }
1465                self.result.push('m');
1466            }
1467            // All other CSI sequences (cursor move, clear, erase, etc.) are dropped
1468        }
1469
1470        fn osc_dispatch(&mut self, _params: &[&[u8]], _bell_terminated: bool) {
1471            // Drop OSC sequences (e.g. window title)
1472        }
1473
1474        fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, _byte: u8) {
1475            // Drop ESC sequences (e.g. ESC c = reset terminal)
1476        }
1477
1478        fn hook(
1479            &mut self,
1480            _params: &vte::Params,
1481            _intermediates: &[u8],
1482            _ignore: bool,
1483            _action: char,
1484        ) {
1485            // Drop DCS hooks
1486        }
1487
1488        fn put(&mut self, _byte: u8) {
1489            // Drop DCS data
1490        }
1491
1492        fn unhook(&mut self) {
1493            // Drop DCS unhook
1494        }
1495    }
1496
1497    let mut parser = vte::Parser::new();
1498    let mut stripper = Stripper {
1499        result: String::with_capacity(s.len()),
1500    };
1501    parser.advance(&mut stripper, s.as_bytes());
1502    stripper.result
1503}
1504
1505#[cfg(test)]
1506mod tests {
1507    use super::*;
1508
1509    /// Newest first, as the query returns them: row `i` is the `i`-th newest.
1510    fn fetch_from(rows: &[u32], reads: &mut Vec<usize>) -> impl FnMut(usize) -> Result<Vec<u32>> {
1511        move |window| {
1512            reads.push(window);
1513            Ok(rows.iter().copied().take(window).collect())
1514        }
1515    }
1516
1517    #[test]
1518    fn last_matches_reads_one_window_when_the_newest_rows_match() {
1519        let rows: Vec<u32> = (0..100_000).collect();
1520        let mut reads = Vec::new();
1521        let found = last_matches(2, fetch_from(&rows, &mut reads), |r| {
1522            r.into_iter().filter(|i| i % 10 == 0).collect()
1523        })
1524        .unwrap();
1525        assert_eq!(found, vec![0, 10]);
1526        assert_eq!(reads, vec![MIN_MATCH_WINDOW]);
1527    }
1528
1529    #[test]
1530    fn last_matches_widens_the_window_until_it_holds_n_matches() {
1531        let rows: Vec<u32> = (0..100_000).collect();
1532        let mut reads = Vec::new();
1533        let found = last_matches(2, fetch_from(&rows, &mut reads), |r| {
1534            r.into_iter().filter(|i| *i >= 3000).collect()
1535        })
1536        .unwrap();
1537        assert_eq!(found, vec![3000, 3001]);
1538        assert_eq!(reads, vec![1000, 2000, 4000]);
1539    }
1540
1541    #[test]
1542    fn last_matches_stops_once_every_row_is_read() {
1543        let rows: Vec<u32> = (0..2500).collect();
1544        let mut reads = Vec::new();
1545        let found = last_matches(5, fetch_from(&rows, &mut reads), |r| {
1546            r.into_iter().filter(|i| *i == 2400).collect()
1547        })
1548        .unwrap();
1549        assert_eq!(found, vec![2400]);
1550        assert_eq!(reads, vec![1000, 2000, 4000]);
1551    }
1552}