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    fn build_message_filters(&self) -> Result<Vec<MessageFilter>> {
517        if self.case_sensitive && self.grep.is_empty() {
518            warn!("--case-sensitive has no effect without --grep");
519        }
520        let mut filters = Vec::new();
521        for pattern in &self.grep {
522            filters.push(MessageFilter::Contains {
523                pattern: pattern.clone(),
524                case_sensitive: self.case_sensitive,
525            });
526        }
527        if let Some(pattern) = self.regex.as_ref() {
528            // Validate the regex early so the user gets a clear CLI error
529            // instead of a SQLite user-function failure at query time.
530            let _ = regex::Regex::new(pattern)
531                .into_diagnostic()
532                .map_err(|e| miette::miette!("invalid regex pattern: {e}"))?;
533            filters.push(MessageFilter::Regex {
534                pattern: pattern.clone(),
535            });
536        }
537        Ok(filters)
538    }
539
540    fn build_field_filters(&self) -> Result<Vec<FieldFilter>> {
541        let mut filters = Vec::new();
542        if let Some(level) = self.level.as_ref() {
543            // Normalize the user-supplied level so "ERROR", "Error", "fatal"
544            // all match the stored canonical form.
545            let normalized = crate::log_parse::normalize_level_str(level).ok_or_else(|| {
546                miette::miette!(
547                    "invalid level '{level}'; expected one of: \
548                         error, err, fatal, critical, panic, alert, emerg, \
549                         warn, warning, info, inf, information, notice, \
550                         debug, dbg, trace, trc"
551                )
552            })?;
553            filters.push(FieldFilter::LevelMin(normalized));
554        }
555        for pair in &self.field {
556            let (key, value) = pair
557                .split_once('=')
558                .ok_or_else(|| miette::miette!("--field expects KEY=VALUE, got: {pair}"))?;
559            if key.is_empty() {
560                miette::bail!("--field key cannot be empty: {pair}");
561            }
562            // Whitelist key characters to prevent SQL injection via
563            // json_extract path interpolation.
564            if !key
565                .chars()
566                .all(|c| c.is_alphanumeric() || c == '_' || c == '.')
567            {
568                miette::bail!(
569                    "--field key may only contain alphanumeric, underscore, and dot; got: {key}"
570                );
571            }
572            filters.push(FieldFilter::FieldEq {
573                key: key.to_string(),
574                value: value.to_string(),
575            });
576        }
577        Ok(filters)
578    }
579
580    /// Query log entries and stream them directly to output, bypassing the
581    /// intermediate `Vec<(String, String, String)>` layer. This eliminates
582    /// 3 String allocations per row (timestamp format, daemon_id clone,
583    /// message clone) by borrowing directly from `LogEntry` and reusing a
584    /// single date buffer.
585    #[allow(clippy::too_many_arguments)]
586    fn query_and_output(
587        &self,
588        resolved_ids: &[DaemonId],
589        from: Option<DateTime<Local>>,
590        to: Option<DateTime<Local>>,
591        message_filters: Vec<MessageFilter>,
592        field_filters: Vec<FieldFilter>,
593        jq_filter: Option<&crate::log_jq::JqFilter>,
594        single_daemon: bool,
595        has_time_filter: bool,
596        show_timestamp: bool,
597    ) -> Result<()> {
598        let daemon_ids: Vec<String> = resolved_ids.iter().map(|id| id.qualified()).collect();
599
600        let opts = LogQuery {
601            daemon_ids,
602            from,
603            to,
604            limit: if !has_time_filter { self.n } else { None },
605            order_desc: !has_time_filter,
606            after_id: None,
607            before_id: None,
608            message_filters,
609            field_filters,
610            include_structured: jq_filter.is_some() || !self.raw,
611        };
612        let mut entries = LOG_STORE.query(&opts)?;
613
614        // Apply jq filter if present.
615        if let Some(jq) = jq_filter {
616            entries = jq.filter(entries);
617        }
618
619        // Apply time-filter take-last-N or reverse for chronological display.
620        if has_time_filter {
621            if let Some(n) = self.n {
622                let len = entries.len();
623                if len > n {
624                    entries = entries.split_off(len - n);
625                }
626            }
627        } else {
628            entries.reverse();
629        }
630
631        if entries.is_empty() {
632            return Ok(());
633        }
634
635        let strip_ansi = self.raw || !console::colors_enabled();
636        let use_pager = !self.tail && !self.no_pager && should_use_pager(entries.len());
637        let ts_format = &settings().logs.timestamp_format;
638
639        // Reusable buffer for timestamp formatting — avoids N allocations.
640        let mut date_buf = String::with_capacity(ts_format.len() + 6);
641
642        let mut write_entries = |w: &mut dyn Write| -> io::Result<()> {
643            for entry in &entries {
644                date_buf.clear();
645                write!(date_buf, "{}", entry.timestamp.format(ts_format))
646                    .map_err(io::Error::other)?;
647                write_formatted_log(
648                    w,
649                    entry,
650                    &date_buf,
651                    single_daemon,
652                    strip_ansi,
653                    show_timestamp,
654                    self.raw,
655                )?;
656            }
657            Ok(())
658        };
659
660        if use_pager {
661            let pager_config = PagerConfig::new(!has_time_filter);
662            match pager_config.spawn_piped() {
663                Ok(mut child) => {
664                    if let Some(stdin) = child.stdin.as_mut() {
665                        if let Err(e) = write_entries(stdin) {
666                            debug!("pager write error: {e}");
667                        }
668                        let _ = child.wait();
669                    } else {
670                        debug!("Failed to get pager stdin, falling back to direct output");
671                        let stdout = io::stdout();
672                        let mut buf = io::BufWriter::new(stdout.lock());
673                        write_entries(&mut buf).into_diagnostic()?;
674                    }
675                }
676                Err(e) => {
677                    debug!("Failed to spawn pager: {e}, falling back to direct output");
678                    let stdout = io::stdout();
679                    let mut buf = io::BufWriter::new(stdout.lock());
680                    write_entries(&mut buf).into_diagnostic()?;
681                }
682            }
683        } else {
684            let stdout = io::stdout();
685            let mut buf = io::BufWriter::new(stdout.lock());
686            write_entries(&mut buf).into_diagnostic()?;
687        }
688
689        Ok(())
690    }
691
692    fn output_json(
693        &self,
694        resolved_ids: &[DaemonId],
695        from: Option<DateTime<Local>>,
696        to: Option<DateTime<Local>>,
697        message_filters: Vec<MessageFilter>,
698        field_filters: Vec<FieldFilter>,
699        jq_filter: Option<&crate::log_jq::JqFilter>,
700    ) -> Result<()> {
701        let daemon_ids: Vec<String> = resolved_ids.iter().map(|id| id.qualified()).collect();
702        let has_time_filter = from.is_some() || to.is_some();
703
704        let opts = LogQuery {
705            daemon_ids,
706            from,
707            to,
708            limit: if !has_time_filter { self.n } else { None },
709            order_desc: !has_time_filter,
710            after_id: None,
711            before_id: None,
712            message_filters,
713            field_filters,
714            include_structured: true,
715        };
716        let entries = LOG_STORE.query(&opts)?;
717
718        // Apply jq filter if present.
719        let entries = match jq_filter {
720            Some(jq) => jq.filter(entries),
721            None => entries,
722        };
723
724        // Reverse only when the SQL query returned DESC (no time filter),
725        // to produce chronological (oldest-first) output. With a time
726        // filter, the query is ASC and already chronological.
727        let mut entries: Vec<_> = if has_time_filter {
728            entries
729        } else {
730            entries.into_iter().rev().collect()
731        };
732
733        // When a time filter is active, the SQL query returned all matching
734        // entries without LIMIT. Trim to the last N here.
735        if has_time_filter
736            && let Some(n) = self.n
737            && entries.len() > n
738        {
739            entries = entries.split_off(entries.len() - n);
740        }
741
742        let json_entries: Vec<JsonLogEntry> = entries.into_iter().map(Into::into).collect();
743
744        print_json(&json_entries)
745    }
746}
747
748fn should_use_pager(line_count: usize) -> bool {
749    if !io::stdout().is_terminal() {
750        return false;
751    }
752
753    let terminal_height = get_terminal_height().unwrap_or(24);
754    line_count > terminal_height
755}
756
757fn get_terminal_height() -> Option<usize> {
758    if let Ok(rows) = std::env::var("LINES")
759        && let Ok(h) = rows.parse::<usize>()
760    {
761        return Some(h);
762    }
763
764    crossterm::terminal::size().ok().map(|(_, h)| h as usize)
765}
766
767/// Rename legacy log directories that predate namespace-qualified daemon IDs.
768///
769/// Old layout: `PITCHFORK_LOGS_DIR/<name>/<name>.log`
770/// New layout: `PITCHFORK_LOGS_DIR/legacy--<name>/legacy--<name>.log`
771///
772/// Only directories that clearly match the old layout are migrated:
773/// - directory name does not contain `"--"`
774/// - directory contains `<name>.log`
775/// - `<name>` is a valid daemon short name under current DaemonId rules
776fn migrate_legacy_log_dirs() {
777    let known_safe_paths = known_daemon_safe_paths();
778    let dirs = match xx::file::ls(&*env::PITCHFORK_LOGS_DIR) {
779        Ok(d) => d,
780        Err(_) => return,
781    };
782    for dir in dirs {
783        if dir.starts_with(".") || !dir.is_dir() {
784            continue;
785        }
786        let name = match dir.file_name().map(|f| f.to_string_lossy().to_string()) {
787            Some(n) => n,
788            None => continue,
789        };
790        // Skip the supervisor's own log directory.
791        if name == "pitchfork" {
792            continue;
793        }
794        // New-format directories usually contain "--". For safety, only treat
795        // them as new-format if they match a known daemon ID safe-path.
796        if name.contains("--") {
797            // If it parses as a valid safe-path, treat it as already migrated
798            // and keep idempotent behavior silent.
799            if DaemonId::from_safe_path(&name).is_ok() {
800                continue;
801            }
802            // Keep noisy warnings only for invalid/ambiguous names that cannot
803            // be interpreted as new-format IDs.
804            if known_safe_paths.contains(&name) {
805                continue;
806            }
807            warn!(
808                "Skipping invalid legacy log directory '{name}': contains '--' but is not a valid daemon safe-path"
809            );
810            continue;
811        }
812
813        // Migrate only explicit old-layout directories to avoid renaming
814        // unrelated folders under logs/.
815        let old_log = dir.join(format!("{name}.log"));
816        if !old_log.exists() {
817            continue;
818        }
819        if DaemonId::try_new("legacy", &name).is_err() {
820            warn!("Skipping invalid legacy log directory '{name}': not a valid daemon ID");
821            continue;
822        }
823
824        let new_name = format!("legacy--{name}");
825        let new_dir = env::PITCHFORK_LOGS_DIR.join(&new_name);
826        // Skip if a target directory already exists to avoid clobbering data.
827        if new_dir.exists() {
828            continue;
829        }
830        if std::fs::rename(&dir, &new_dir).is_err() {
831            continue;
832        }
833        // Also rename the log file inside the directory.
834        let old_log = new_dir.join(format!("{name}.log"));
835        let new_log = new_dir.join(format!("{new_name}.log"));
836        if old_log.exists() {
837            let _ = std::fs::rename(&old_log, &new_log);
838        }
839        debug!("Migrated legacy log dir '{name}' → '{new_name}'");
840    }
841}
842
843fn known_daemon_safe_paths() -> BTreeSet<String> {
844    let mut out = BTreeSet::new();
845
846    match StateFile::read(&*env::PITCHFORK_STATE_FILE) {
847        Ok(state) => {
848            for id in state.daemons.keys() {
849                out.insert(id.safe_path());
850            }
851        }
852        Err(e) => {
853            warn!("Failed to read state while checking known daemon IDs: {e}");
854        }
855    }
856
857    match PitchforkToml::all_merged() {
858        Ok(config) => {
859            for id in config.daemons.keys() {
860                out.insert(id.safe_path());
861            }
862        }
863        Err(e) => {
864            warn!("Failed to read config while checking known daemon IDs: {e}");
865        }
866    }
867
868    out
869}
870
871fn get_all_daemon_ids() -> Result<Vec<DaemonId>> {
872    let mut ids = BTreeSet::new();
873
874    match StateFile::read(&*env::PITCHFORK_STATE_FILE) {
875        Ok(state) => ids.extend(state.daemons.keys().cloned()),
876        Err(e) => warn!("Failed to read state for log daemon discovery: {e}"),
877    }
878
879    match PitchforkToml::all_merged() {
880        Ok(config) => ids.extend(config.daemons.keys().cloned()),
881        Err(e) => warn!("Failed to read config for log daemon discovery: {e}"),
882    }
883
884    let logged_ids: std::collections::HashSet<String> =
885        LOG_STORE.list_daemon_ids()?.into_iter().collect();
886    Ok(ids
887        .into_iter()
888        .filter(|id| logged_ids.contains(&id.qualified()))
889        .collect())
890}
891
892#[allow(clippy::too_many_arguments)]
893pub async fn tail_logs(
894    names: &[DaemonId],
895    single_daemon: bool,
896    start_from_end: bool,
897    message_filters: Vec<MessageFilter>,
898    field_filters: Vec<FieldFilter>,
899    jq_filter: Option<&crate::log_jq::JqFilter>,
900    show_timestamp: bool,
901    raw: bool,
902) -> Result<()> {
903    // Poll SQLite log store for new entries since last known row id.
904    let strip_ansi = raw || !console::colors_enabled();
905
906    let mut states: std::collections::HashMap<String, i64> = names
907        .iter()
908        .map(|id| {
909            let since = if start_from_end {
910                // Anchor to the last entry overall, not the last filtered entry,
911                // so --tail combined with a filter does not rescan from row 1
912                // on every poll when no message matches yet.
913                LOG_STORE.last_id(id).unwrap_or(None).unwrap_or(0)
914            } else {
915                0
916            };
917            (id.qualified(), since)
918        })
919        .collect();
920
921    let interval = tokio::time::interval(Duration::from_millis(200));
922    tokio::pin!(interval);
923
924    loop {
925        interval.tick().await;
926
927        let mut out = vec![];
928        for id in names {
929            let after_id = states.get(&id.qualified()).copied();
930            match LOG_STORE.query(&LogQuery {
931                daemon_ids: vec![id.qualified()],
932                from: None,
933                to: None,
934                limit: None,
935                order_desc: false,
936                after_id,
937                before_id: None,
938                message_filters: message_filters.clone(),
939                field_filters: field_filters.clone(),
940                include_structured: jq_filter.is_some() || !raw,
941            }) {
942                Ok(raw_entries) => {
943                    // Save the max id from SQL results (before jq filtering)
944                    // so the cursor always advances past evaluated rows, even
945                    // when jq rejects all of them.
946                    let last_raw_id = raw_entries.last().map(|e| e.id);
947
948                    // Apply jq filter if present.
949                    let entries = match jq_filter {
950                        Some(jq) => jq.filter(raw_entries),
951                        None => raw_entries,
952                    };
953                    out.extend(entries);
954                    // Advance the cursor past rows already evaluated.
955                    //
956                    // Use the max id from the raw SQL result (not the jq-filtered
957                    // subset) so the cursor advances past all rows the database
958                    // returned, preventing re-evaluation on every poll.
959                    //
960                    // When the filter is active and no entries matched, fall
961                    // back to last_id to advance past non-matching rows so they
962                    // are not re-evaluated on every poll. The narrow race here
963                    // (a matching row written between query and last_id) is
964                    // accepted as the cost of bounded re-scanning.
965                    let has_sql_filter = !message_filters.is_empty() || !field_filters.is_empty();
966                    let new_cursor = if let Some(id) = last_raw_id {
967                        Some(id)
968                    } else if has_sql_filter || jq_filter.is_some() {
969                        LOG_STORE.last_id(id).ok().flatten()
970                    } else {
971                        None
972                    };
973                    if let Some(last_id) = new_cursor {
974                        states.insert(id.qualified(), last_id);
975                    }
976                }
977                Err(e) => {
978                    error!("Failed to tail logs for {}: {e}", id.qualified());
979                }
980            }
981        }
982
983        if !out.is_empty() {
984            // Single-daemon: entries are already in chronological order from
985            // the SQL query (order_desc: false). Skip the sort.
986            let out: Vec<LogEntry> = if single_daemon {
987                out
988            } else {
989                out.into_iter()
990                    .sorted_by(|a, b| a.timestamp.cmp(&b.timestamp).then(a.id.cmp(&b.id)))
991                    .collect()
992            };
993            let stdout = io::stdout();
994            let mut buf = io::BufWriter::new(stdout.lock());
995            let ts_format = &settings().logs.timestamp_format;
996            let mut date_buf = String::with_capacity(ts_format.len() + 6);
997            for entry in &out {
998                date_buf.clear();
999                write!(date_buf, "{}", entry.timestamp.format(ts_format))
1000                    .map_err(io::Error::other)
1001                    .into_diagnostic()?;
1002                write_formatted_log(
1003                    &mut buf,
1004                    entry,
1005                    &date_buf,
1006                    single_daemon,
1007                    strip_ansi,
1008                    show_timestamp,
1009                    raw,
1010                )
1011                .into_diagnostic()?;
1012            }
1013        }
1014    }
1015}
1016
1017fn parse_datetime(s: &str) -> Result<DateTime<Local>> {
1018    let naive_dt = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").into_diagnostic()?;
1019    Local
1020        .from_local_datetime(&naive_dt)
1021        .single()
1022        .ok_or_else(|| miette::miette!("Invalid or ambiguous datetime: '{}'. ", s))
1023}
1024
1025/// Parse time input string into DateTime.
1026///
1027/// `is_since` indicates whether this is for --since (true) or --until (false).
1028/// The "yesterday fallback" only applies to --since: if the time is in the future,
1029/// assume the user meant yesterday. For --until, future times are kept as-is.
1030fn parse_time_input(s: &str, is_since: bool) -> Result<DateTime<Local>> {
1031    let s = s.trim();
1032
1033    // Try full datetime first (YYYY-MM-DD HH:MM:SS)
1034    if let Ok(dt) = parse_datetime(s) {
1035        return Ok(dt);
1036    }
1037
1038    // Try datetime without seconds (YYYY-MM-DD HH:MM)
1039    if let Ok(naive_dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M") {
1040        return Local
1041            .from_local_datetime(&naive_dt)
1042            .single()
1043            .ok_or_else(|| miette::miette!("Invalid or ambiguous datetime: '{}'", s));
1044    }
1045
1046    // Try time-only format (HH:MM:SS or HH:MM)
1047    // Note: This branch won't be reached for inputs like "10:30" that could match
1048    // parse_datetime, because parse_datetime expects a full date prefix and will fail.
1049    if let Ok(time) = parse_time_only(s) {
1050        let now = Local::now();
1051        let today = now.date_naive();
1052        let mut naive_dt = NaiveDateTime::new(today, time);
1053        let mut dt = Local
1054            .from_local_datetime(&naive_dt)
1055            .single()
1056            .ok_or_else(|| miette::miette!("Invalid or ambiguous datetime: '{}'", s))?;
1057
1058        // If the interpreted time for today is in the future, assume the user meant yesterday
1059        // BUT only for --since. For --until, a future time today is valid.
1060        if is_since
1061            && dt > now
1062            && let Some(yesterday) = today.pred_opt()
1063        {
1064            naive_dt = NaiveDateTime::new(yesterday, time);
1065            dt = Local
1066                .from_local_datetime(&naive_dt)
1067                .single()
1068                .ok_or_else(|| miette::miette!("Invalid or ambiguous datetime: '{}'", s))?;
1069        }
1070        return Ok(dt);
1071    }
1072
1073    if let Ok(duration) = humantime::parse_duration(s) {
1074        let now = Local::now();
1075        let target = now - chrono::Duration::from_std(duration).into_diagnostic()?;
1076        return Ok(target);
1077    }
1078
1079    Err(miette::miette!(
1080        "Invalid time format: '{}'. Expected formats:\n\
1081         - Full datetime: \"YYYY-MM-DD HH:MM:SS\" or \"YYYY-MM-DD HH:MM\"\n\
1082         - Time only: \"HH:MM:SS\" or \"HH:MM\" (uses today's date)\n\
1083         - Relative time: \"5min\", \"2h\", \"1d\" (e.g., last 5 minutes)",
1084        s
1085    ))
1086}
1087
1088fn parse_time_only(s: &str) -> Result<NaiveTime> {
1089    if let Ok(time) = NaiveTime::parse_from_str(s, "%H:%M:%S") {
1090        return Ok(time);
1091    }
1092
1093    if let Ok(time) = NaiveTime::parse_from_str(s, "%H:%M") {
1094        return Ok(time);
1095    }
1096
1097    Err(miette::miette!("Invalid time format: '{}'", s))
1098}
1099
1100/// Prints error log lines in a styled block matching the startup logs format.
1101///
1102/// Format:
1103/// ```text
1104///  ERROR LOGS
1105///  12:00:00 error message
1106/// ```
1107///
1108/// Timestamps use dimmed red. The tag uses white text on red background.
1109pub fn print_error_logs_block(log_lines: &[(String, String, String)]) {
1110    if log_lines.is_empty() {
1111        return;
1112    }
1113
1114    let is_tty = std::io::stderr().is_terminal();
1115    let format_msg = |msg: &str| -> String {
1116        let stripped = strip_pty_controls(msg);
1117        if is_tty {
1118            stripped
1119        } else {
1120            console::strip_ansi_codes(&stripped).to_string()
1121        }
1122    };
1123
1124    let tag = estyle(" ERROR LOGS ").white().on_red();
1125    eprintln!("\n{tag}");
1126
1127    // Determine if we need to show daemon IDs (same logic as startup logs)
1128    let unique_ids: BTreeSet<&str> = log_lines.iter().map(|(_, id, _)| id.as_str()).collect();
1129    let show_id = unique_ids.len() > 1;
1130
1131    if show_id {
1132        for (date, id, msg) in log_lines {
1133            let time = date.split(' ').nth(1).unwrap_or(date);
1134            let colored = colored_id_label(id, is_tty && console::colors_enabled_stderr());
1135            eprintln!(
1136                "{} {} {}",
1137                estyle(time).red().dim(),
1138                colored,
1139                format_msg(msg)
1140            );
1141        }
1142    } else {
1143        for (date, _, msg) in log_lines {
1144            let time = date.split(' ').nth(1).unwrap_or(date);
1145            eprintln!("{} {}", estyle(time).red().dim(), format_msg(msg));
1146        }
1147    }
1148}
1149
1150/// Describes the type of ready check being performed for display purposes.
1151pub enum ReadyCheckType {
1152    Output(String),
1153    Http(String),
1154    Port(u16),
1155    Cmd(String),
1156    Delay(u64),
1157    /// A `oneshot` daemon: ready when the process exits 0.
1158    Completion,
1159    Default,
1160}
1161
1162impl std::fmt::Display for ReadyCheckType {
1163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1164        match self {
1165            ReadyCheckType::Output(pattern) => write!(f, "output matching '{pattern}'"),
1166            ReadyCheckType::Http(url) => write!(f, "HTTP {url}"),
1167            ReadyCheckType::Port(port) => write!(f, "TCP port {port}"),
1168            ReadyCheckType::Cmd(cmd) => write!(f, "command '{cmd}'"),
1169            ReadyCheckType::Delay(secs) => write!(f, "delay ({secs}s)"),
1170            ReadyCheckType::Completion => write!(f, "completion"),
1171            ReadyCheckType::Default => write!(f, "default readiness check"),
1172        }
1173    }
1174}
1175
1176/// Creates a progress job showing a spinner while waiting for a ready check.
1177///
1178/// Returns a `Arc<ProgressJob>` that the caller should update:
1179/// - Set body to success message and status to `Done` when the daemon is ready
1180/// - Set body to failure message and status to `Failed` when the daemon fails
1181pub fn create_ready_check_job(
1182    daemon_id: &DaemonId,
1183    check_type: &ReadyCheckType,
1184) -> std::sync::Arc<clx::progress::ProgressJob> {
1185    use clx::progress::{ProgressJobBuilder, ProgressJobDoneBehavior, ProgressStatus};
1186
1187    let is_tty = std::io::stderr().is_terminal();
1188    let colors_enabled = is_tty && console::colors_enabled_stderr();
1189    let id_label = colored_id_label(&daemon_id.qualified(), colors_enabled);
1190    let show_ts = crate::settings::settings().general.startup_log_timestamps;
1191
1192    // When timestamps are off, {{spinner()}} renders as an animated spinner
1193    // (1 char wide) matching the "•" prefix used by println.  When on,
1194    // we show a dim timestamp instead.
1195    let prefix = if show_ts {
1196        // The timestamp updates each refresh via the now() tera function.
1197        // We use a fixed-width format (HH:MM:SS = 8 chars) for alignment.
1198        edim(chrono::Local::now().format("%H:%M:%S").to_string()).to_string()
1199    } else {
1200        "{{spinner()}}".to_string()
1201    };
1202
1203    ProgressJobBuilder::new()
1204        .body(format!(
1205            "{} {} waiting for {{{{ check_type }}}}...",
1206            prefix, id_label
1207        ))
1208        .prop("check_type", &check_type.to_string())
1209        .status(ProgressStatus::Running)
1210        .on_done(ProgressJobDoneBehavior::Keep)
1211        .start()
1212}
1213
1214/// Collects startup log lines for a single daemon (does not print).
1215///
1216/// Returns a list of `(time, daemon_id_qualified, message)` tuples for log
1217/// entries written after `from`.
1218pub fn collect_startup_logs(
1219    daemon_id: &DaemonId,
1220    from: DateTime<Local>,
1221) -> Result<Vec<(String, String, String)>> {
1222    let entries = LOG_STORE.query(&LogQuery {
1223        daemon_ids: vec![daemon_id.qualified()],
1224        from: Some(from),
1225        to: None,
1226        limit: None,
1227        order_desc: false,
1228        after_id: None,
1229        before_id: None,
1230        message_filters: Vec::new(),
1231        field_filters: Vec::new(),
1232        include_structured: false,
1233    })?;
1234    let log_lines = entries
1235        .into_iter()
1236        .map(|e| {
1237            let ts = e.timestamp.format("%Y-%m-%d %H:%M:%S").to_string();
1238            (ts, e.daemon_id, e.message)
1239        })
1240        .collect();
1241
1242    Ok(log_lines)
1243}
1244
1245/// Stream startup logs for a daemon to a progress job in real-time.
1246///
1247/// Spawns a background tokio task that polls the daemon's log store
1248/// and calls `job.println()` for each new line. Returns a watch sender
1249/// that stops the streaming when sent `true`.
1250pub fn stream_startup_logs(
1251    daemon_id: &DaemonId,
1252    job: std::sync::Arc<clx::progress::ProgressJob>,
1253) -> (
1254    tokio::sync::watch::Sender<bool>,
1255    tokio::task::JoinHandle<()>,
1256) {
1257    let (tx, mut rx) = tokio::sync::watch::channel(false);
1258    let id = daemon_id.clone();
1259
1260    let show_ts = crate::settings::settings().general.startup_log_timestamps;
1261
1262    // Anchor to the daemon's current max log id *synchronously* before
1263    // spawning the streaming task. This must happen before ipc.run() starts
1264    // the daemon, otherwise early output could be written to the log store
1265    // before the anchor is established and get skipped as "already seen".
1266    let anchor_id: i64 = LOG_STORE
1267        .query(&LogQuery {
1268            daemon_ids: vec![id.qualified()],
1269            limit: Some(1),
1270            order_desc: true,
1271            ..Default::default()
1272        })
1273        .ok()
1274        .and_then(|entries| entries.last().map(|e| e.id))
1275        .unwrap_or(0);
1276
1277    let handle = tokio::spawn(async move {
1278        let is_tty = std::io::stderr().is_terminal();
1279        let colors_enabled = is_tty && console::colors_enabled_stderr();
1280        let id_label = colored_id_label(&id.qualified(), colors_enabled);
1281        let prefix = if show_ts {
1282            String::new()
1283        } else {
1284            edim("•").to_string()
1285        };
1286
1287        let mut last_id = anchor_id;
1288
1289        // Initial fetch: catch any logs already written since the anchor.
1290        if let Ok(entries) = LOG_STORE.tail(&id, Some(last_id)) {
1291            for entry in &entries {
1292                let time = entry.timestamp.format("%H:%M:%S").to_string();
1293                let msg = strip_pty_controls(&entry.message);
1294                let msg = if is_tty {
1295                    msg
1296                } else {
1297                    console::strip_ansi_codes(&msg).to_string()
1298                };
1299                let line_prefix = if show_ts {
1300                    edim(time).to_string()
1301                } else {
1302                    prefix.clone()
1303                };
1304                job.println(&format!("{} {} {}", line_prefix, id_label, msg));
1305            }
1306            if let Some(last) = entries.last() {
1307                last_id = last.id;
1308            }
1309        }
1310
1311        loop {
1312            tokio::select! {
1313                _ = tokio::time::sleep(Duration::from_millis(200)) => {
1314                    if let Ok(entries) = LOG_STORE.tail(&id, Some(last_id)) {
1315                        for entry in &entries {
1316                            let time = entry.timestamp.format("%H:%M:%S").to_string();
1317                            let msg = strip_pty_controls(&entry.message);
1318                            let msg = if is_tty {
1319                                msg
1320                            } else {
1321                                console::strip_ansi_codes(&msg).to_string()
1322                            };
1323                            let line_prefix = if show_ts {
1324                                edim(time).to_string()
1325                            } else {
1326                                prefix.clone()
1327                            };
1328                            job.println(&format!("{} {} {}", line_prefix, id_label, msg));
1329                        }
1330                        if let Some(last) = entries.last() {
1331                            last_id = last.id;
1332                        }
1333                    }
1334                }
1335                _ = rx.changed() => {
1336                    break;
1337                }
1338            }
1339        }
1340
1341        // Final drain
1342        if let Ok(entries) = LOG_STORE.tail(&id, Some(last_id)) {
1343            for entry in &entries {
1344                let time = entry.timestamp.format("%H:%M:%S").to_string();
1345                let msg = strip_pty_controls(&entry.message);
1346                let msg = if is_tty {
1347                    msg
1348                } else {
1349                    console::strip_ansi_codes(&msg).to_string()
1350                };
1351                let line_prefix = if show_ts {
1352                    edim(time).to_string()
1353                } else {
1354                    prefix.clone()
1355                };
1356                job.println(&format!("{} {} {}", line_prefix, id_label, msg));
1357            }
1358        }
1359    });
1360
1361    (tx, handle)
1362}
1363
1364/// Strips PTY control sequences from a string while preserving SGR (color/style) codes.
1365///
1366/// Removes CSI sequences that control cursor movement, screen clearing, erasing, etc.,
1367/// but keeps `\x1b[...m` (SGR) sequences so colors are retained.
1368fn strip_pty_controls(s: &str) -> String {
1369    struct Stripper {
1370        result: String,
1371    }
1372
1373    impl vte::Perform for Stripper {
1374        fn print(&mut self, c: char) {
1375            self.result.push(c);
1376        }
1377
1378        fn execute(&mut self, byte: u8) {
1379            // Keep \n and \t; drop other control characters (BEL, BS, CR, etc.)
1380            if byte == b'\n' || byte == b'\t' {
1381                self.result.push(byte as char);
1382            }
1383        }
1384
1385        fn csi_dispatch(
1386            &mut self,
1387            params: &vte::Params,
1388            _intermediates: &[u8],
1389            _ignore: bool,
1390            action: char,
1391        ) {
1392            // Keep SGR sequences (final byte 'm')
1393            if action == 'm' {
1394                self.result.push_str("\x1b[");
1395                let mut first = true;
1396                for sub in params.iter() {
1397                    if !first {
1398                        self.result.push(';');
1399                    }
1400                    first = false;
1401                    for (i, &p) in sub.iter().enumerate() {
1402                        if i > 0 {
1403                            self.result.push(':');
1404                        }
1405                        self.result.push_str(&p.to_string());
1406                    }
1407                }
1408                self.result.push('m');
1409            }
1410            // All other CSI sequences (cursor move, clear, erase, etc.) are dropped
1411        }
1412
1413        fn osc_dispatch(&mut self, _params: &[&[u8]], _bell_terminated: bool) {
1414            // Drop OSC sequences (e.g. window title)
1415        }
1416
1417        fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, _byte: u8) {
1418            // Drop ESC sequences (e.g. ESC c = reset terminal)
1419        }
1420
1421        fn hook(
1422            &mut self,
1423            _params: &vte::Params,
1424            _intermediates: &[u8],
1425            _ignore: bool,
1426            _action: char,
1427        ) {
1428            // Drop DCS hooks
1429        }
1430
1431        fn put(&mut self, _byte: u8) {
1432            // Drop DCS data
1433        }
1434
1435        fn unhook(&mut self) {
1436            // Drop DCS unhook
1437        }
1438    }
1439
1440    let mut parser = vte::Parser::new();
1441    let mut stripper = Stripper {
1442        result: String::with_capacity(s.len()),
1443    };
1444    parser.advance(&mut stripper, s.as_bytes());
1445    stripper.result
1446}