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/// How long a ready check must run before its spinner is shown.
1234///
1235/// Daemons that are ready right away (e.g. `ready_delay = 0`) never flash a
1236/// spinner, which matters when output lands in a new shell's prompt.
1237const READY_SPINNER_DELAY: Duration = Duration::from_millis(400);
1238
1239/// Whether a ready-check spinner that is still hidden should now appear.
1240fn spinner_due(elapsed: Duration) -> bool {
1241    elapsed >= READY_SPINNER_DELAY
1242}
1243
1244/// Starts the spinner of a hidden ready-check job once the threshold has passed.
1245fn reveal_spinner_if_due(
1246    job: &clx::progress::ProgressJob,
1247    started: std::time::Instant,
1248    revealed: &mut bool,
1249) {
1250    if !*revealed && spinner_due(started.elapsed()) {
1251        *revealed = true;
1252        job.set_status(clx::progress::ProgressStatus::Running);
1253    }
1254}
1255
1256/// Creates a progress job for a ready check.
1257///
1258/// The job starts hidden; [`stream_startup_logs`] reveals the spinner once the
1259/// check has run for [`READY_SPINNER_DELAY`]. Returns a `Arc<ProgressJob>` that the caller should update:
1260/// - Set body to success message and status to `Done` when the daemon is ready
1261/// - Set body to failure message and status to `Failed` when the daemon fails
1262pub fn create_ready_check_job(
1263    daemon_id: &DaemonId,
1264    check_type: &ReadyCheckType,
1265) -> std::sync::Arc<clx::progress::ProgressJob> {
1266    use clx::progress::{ProgressJobBuilder, ProgressJobDoneBehavior, ProgressStatus};
1267
1268    let is_tty = std::io::stderr().is_terminal();
1269    let colors_enabled = is_tty && console::colors_enabled_stderr();
1270    let id_label = colored_id_label(&daemon_id.qualified(), colors_enabled);
1271    let show_ts = crate::settings::settings().general.startup_log_timestamps;
1272
1273    // When timestamps are off, {{spinner()}} renders as an animated spinner
1274    // (1 char wide) matching the "•" prefix used by println.  When on,
1275    // we show a dim timestamp instead.
1276    let prefix = if show_ts {
1277        // The timestamp updates each refresh via the now() tera function.
1278        // We use a fixed-width format (HH:MM:SS = 8 chars) for alignment.
1279        edim(chrono::Local::now().format("%H:%M:%S").to_string()).to_string()
1280    } else {
1281        "{{spinner()}}".to_string()
1282    };
1283
1284    ProgressJobBuilder::new()
1285        .body(format!(
1286            "{} pitchfork: {} waiting for {{{{ check_type }}}}...",
1287            prefix, id_label
1288        ))
1289        .prop("check_type", &check_type.to_string())
1290        .status(ProgressStatus::Hide)
1291        .on_done(ProgressJobDoneBehavior::Keep)
1292        .start()
1293}
1294
1295/// Collects startup log lines for a single daemon (does not print).
1296///
1297/// Returns a list of `(time, daemon_id_qualified, message)` tuples for log
1298/// entries written after `from`.
1299pub fn collect_startup_logs(
1300    daemon_id: &DaemonId,
1301    from: DateTime<Local>,
1302) -> Result<Vec<(String, String, String)>> {
1303    let entries = LOG_STORE.query(&LogQuery {
1304        daemon_ids: vec![daemon_id.qualified()],
1305        from: Some(from),
1306        to: None,
1307        limit: None,
1308        order_desc: false,
1309        after_id: None,
1310        before_id: None,
1311        message_filters: Vec::new(),
1312        field_filters: Vec::new(),
1313        include_structured: false,
1314    })?;
1315    let log_lines = entries
1316        .into_iter()
1317        .map(|e| {
1318            let ts = e.timestamp.format("%Y-%m-%d %H:%M:%S").to_string();
1319            (ts, e.daemon_id, e.message)
1320        })
1321        .collect();
1322
1323    Ok(log_lines)
1324}
1325
1326/// Stream startup logs for a daemon to a progress job in real-time.
1327///
1328/// Spawns a background tokio task that polls the daemon's log store
1329/// and calls `job.println()` for each new line. Returns a watch sender
1330/// that stops the streaming when sent `true`.
1331pub fn stream_startup_logs(
1332    daemon_id: &DaemonId,
1333    job: std::sync::Arc<clx::progress::ProgressJob>,
1334) -> (
1335    tokio::sync::watch::Sender<bool>,
1336    tokio::task::JoinHandle<()>,
1337) {
1338    let (tx, mut rx) = tokio::sync::watch::channel(false);
1339    let id = daemon_id.clone();
1340
1341    let show_ts = crate::settings::settings().general.startup_log_timestamps;
1342
1343    // Anchor to the daemon's current max log id *synchronously* before
1344    // spawning the streaming task. This must happen before ipc.run() starts
1345    // the daemon, otherwise early output could be written to the log store
1346    // before the anchor is established and get skipped as "already seen".
1347    let anchor_id: i64 = LOG_STORE
1348        .query(&LogQuery {
1349            daemon_ids: vec![id.qualified()],
1350            limit: Some(1),
1351            order_desc: true,
1352            ..Default::default()
1353        })
1354        .ok()
1355        .and_then(|entries| entries.last().map(|e| e.id))
1356        .unwrap_or(0);
1357
1358    let started = std::time::Instant::now();
1359    let handle = tokio::spawn(async move {
1360        let mut revealed = false;
1361        let is_tty = std::io::stderr().is_terminal();
1362        let colors_enabled = is_tty && console::colors_enabled_stderr();
1363        let id_label = colored_id_label(&id.qualified(), colors_enabled);
1364        let prefix = if show_ts {
1365            String::new()
1366        } else {
1367            edim("•").to_string()
1368        };
1369
1370        let mut last_id = anchor_id;
1371
1372        // Initial fetch: catch any logs already written since the anchor.
1373        if let Ok(entries) = LOG_STORE.tail(&id, Some(last_id)) {
1374            for entry in &entries {
1375                let time = entry.timestamp.format("%H:%M:%S").to_string();
1376                let msg = strip_pty_controls(&entry.message);
1377                let msg = if is_tty {
1378                    msg
1379                } else {
1380                    console::strip_ansi_codes(&msg).to_string()
1381                };
1382                let line_prefix = if show_ts {
1383                    edim(time).to_string()
1384                } else {
1385                    prefix.clone()
1386                };
1387                job.println(&format!("{} {} {}", line_prefix, id_label, msg));
1388            }
1389            if let Some(last) = entries.last() {
1390                last_id = last.id;
1391            }
1392        }
1393
1394        loop {
1395            tokio::select! {
1396                _ = tokio::time::sleep(Duration::from_millis(200)) => {
1397                    reveal_spinner_if_due(&job, started, &mut revealed);
1398                    if let Ok(entries) = LOG_STORE.tail(&id, Some(last_id)) {
1399                        for entry in &entries {
1400                            let time = entry.timestamp.format("%H:%M:%S").to_string();
1401                            let msg = strip_pty_controls(&entry.message);
1402                            let msg = if is_tty {
1403                                msg
1404                            } else {
1405                                console::strip_ansi_codes(&msg).to_string()
1406                            };
1407                            let line_prefix = if show_ts {
1408                                edim(time).to_string()
1409                            } else {
1410                                prefix.clone()
1411                            };
1412                            job.println(&format!("{} {} {}", line_prefix, id_label, msg));
1413                        }
1414                        if let Some(last) = entries.last() {
1415                            last_id = last.id;
1416                        }
1417                    }
1418                }
1419                _ = rx.changed() => {
1420                    break;
1421                }
1422            }
1423        }
1424
1425        // Final drain
1426        if let Ok(entries) = LOG_STORE.tail(&id, Some(last_id)) {
1427            for entry in &entries {
1428                let time = entry.timestamp.format("%H:%M:%S").to_string();
1429                let msg = strip_pty_controls(&entry.message);
1430                let msg = if is_tty {
1431                    msg
1432                } else {
1433                    console::strip_ansi_codes(&msg).to_string()
1434                };
1435                let line_prefix = if show_ts {
1436                    edim(time).to_string()
1437                } else {
1438                    prefix.clone()
1439                };
1440                job.println(&format!("{} {} {}", line_prefix, id_label, msg));
1441            }
1442        }
1443    });
1444
1445    (tx, handle)
1446}
1447
1448/// Strips PTY control sequences from a string while preserving SGR (color/style) codes.
1449///
1450/// Removes CSI sequences that control cursor movement, screen clearing, erasing, etc.,
1451/// but keeps `\x1b[...m` (SGR) sequences so colors are retained.
1452fn strip_pty_controls(s: &str) -> String {
1453    struct Stripper {
1454        result: String,
1455    }
1456
1457    impl vte::Perform for Stripper {
1458        fn print(&mut self, c: char) {
1459            self.result.push(c);
1460        }
1461
1462        fn execute(&mut self, byte: u8) {
1463            // Keep \n and \t; drop other control characters (BEL, BS, CR, etc.)
1464            if byte == b'\n' || byte == b'\t' {
1465                self.result.push(byte as char);
1466            }
1467        }
1468
1469        fn csi_dispatch(
1470            &mut self,
1471            params: &vte::Params,
1472            _intermediates: &[u8],
1473            _ignore: bool,
1474            action: char,
1475        ) {
1476            // Keep SGR sequences (final byte 'm')
1477            if action == 'm' {
1478                self.result.push_str("\x1b[");
1479                let mut first = true;
1480                for sub in params.iter() {
1481                    if !first {
1482                        self.result.push(';');
1483                    }
1484                    first = false;
1485                    for (i, &p) in sub.iter().enumerate() {
1486                        if i > 0 {
1487                            self.result.push(':');
1488                        }
1489                        self.result.push_str(&p.to_string());
1490                    }
1491                }
1492                self.result.push('m');
1493            }
1494            // All other CSI sequences (cursor move, clear, erase, etc.) are dropped
1495        }
1496
1497        fn osc_dispatch(&mut self, _params: &[&[u8]], _bell_terminated: bool) {
1498            // Drop OSC sequences (e.g. window title)
1499        }
1500
1501        fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, _byte: u8) {
1502            // Drop ESC sequences (e.g. ESC c = reset terminal)
1503        }
1504
1505        fn hook(
1506            &mut self,
1507            _params: &vte::Params,
1508            _intermediates: &[u8],
1509            _ignore: bool,
1510            _action: char,
1511        ) {
1512            // Drop DCS hooks
1513        }
1514
1515        fn put(&mut self, _byte: u8) {
1516            // Drop DCS data
1517        }
1518
1519        fn unhook(&mut self) {
1520            // Drop DCS unhook
1521        }
1522    }
1523
1524    let mut parser = vte::Parser::new();
1525    let mut stripper = Stripper {
1526        result: String::with_capacity(s.len()),
1527    };
1528    parser.advance(&mut stripper, s.as_bytes());
1529    stripper.result
1530}
1531
1532#[cfg(test)]
1533mod tests {
1534    use super::*;
1535
1536    #[test]
1537    fn spinner_waits_for_threshold() {
1538        assert!(!spinner_due(Duration::ZERO));
1539        assert!(!spinner_due(READY_SPINNER_DELAY - Duration::from_millis(1)));
1540        assert!(spinner_due(READY_SPINNER_DELAY));
1541    }
1542
1543    #[test]
1544    fn ready_spinner_starts_hidden_and_reveals_after_threshold() {
1545        let id = DaemonId::new("spinner-test", "ready-check");
1546        let job = create_ready_check_job(&id, &ReadyCheckType::Delay(0));
1547        assert!(!job.is_running(), "job must start hidden");
1548
1549        let mut revealed = false;
1550        let now = std::time::Instant::now();
1551        reveal_spinner_if_due(&job, now, &mut revealed);
1552        assert!(
1553            !revealed && !job.is_running(),
1554            "hidden before the threshold"
1555        );
1556
1557        let past = now - READY_SPINNER_DELAY;
1558        reveal_spinner_if_due(&job, past, &mut revealed);
1559        assert!(revealed && job.is_running(), "visible after the threshold");
1560        job.remove();
1561    }
1562
1563    /// Newest first, as the query returns them: row `i` is the `i`-th newest.
1564    fn fetch_from(rows: &[u32], reads: &mut Vec<usize>) -> impl FnMut(usize) -> Result<Vec<u32>> {
1565        move |window| {
1566            reads.push(window);
1567            Ok(rows.iter().copied().take(window).collect())
1568        }
1569    }
1570
1571    #[test]
1572    fn last_matches_reads_one_window_when_the_newest_rows_match() {
1573        let rows: Vec<u32> = (0..100_000).collect();
1574        let mut reads = Vec::new();
1575        let found = last_matches(2, fetch_from(&rows, &mut reads), |r| {
1576            r.into_iter().filter(|i| i % 10 == 0).collect()
1577        })
1578        .unwrap();
1579        assert_eq!(found, vec![0, 10]);
1580        assert_eq!(reads, vec![MIN_MATCH_WINDOW]);
1581    }
1582
1583    #[test]
1584    fn last_matches_widens_the_window_until_it_holds_n_matches() {
1585        let rows: Vec<u32> = (0..100_000).collect();
1586        let mut reads = Vec::new();
1587        let found = last_matches(2, fetch_from(&rows, &mut reads), |r| {
1588            r.into_iter().filter(|i| *i >= 3000).collect()
1589        })
1590        .unwrap();
1591        assert_eq!(found, vec![3000, 3001]);
1592        assert_eq!(reads, vec![1000, 2000, 4000]);
1593    }
1594
1595    #[test]
1596    fn last_matches_stops_once_every_row_is_read() {
1597        let rows: Vec<u32> = (0..2500).collect();
1598        let mut reads = Vec::new();
1599        let found = last_matches(5, fetch_from(&rows, &mut reads), |r| {
1600            r.into_iter().filter(|i| *i == 2400).collect()
1601        })
1602        .unwrap();
1603        assert_eq!(found, vec![2400]);
1604        assert_eq!(reads, vec![1000, 2000, 4000]);
1605    }
1606}