Skip to main content

presentar_terminal/widgets/
display_rules.rs

1//! Display Rules - Grammar of Graphics formatting primitives for TUI
2//!
3//! SPEC-024 Section 28: Display Rules Framework
4#![allow(clippy::collapsible_if)] // Intentional for clarity in command parsing
5#![allow(clippy::branches_sharing_code)] // Intentional for clarity in conditional flow
6//!
7//! All text and numbers in a TUI MUST follow consistent display rules:
8//!
9//! 1. **Numbers**: Use human-readable units (1.2G not 1234567890)
10//! 2. **Truncation**: Smart truncation preserving meaningful content
11//! 3. **Columns**: Width-aware formatting that never bleeds
12//! 4. **Search**: O(1) fuzzy matching with relevance scoring
13//!
14//! # Performance Targets (from pzsh/aprender-shell patterns)
15//! - Search: <1ms for 5000 items
16//! - Format: <100µs per cell
17//! - Truncate: <10µs per string
18
19use std::borrow::Cow;
20
21// =============================================================================
22// BYTE FORMATTING (1000 vs 1024 base)
23// =============================================================================
24
25/// Format bytes with SI units (1000-based: KB, MB, GB)
26///
27/// # Examples
28/// ```ignore
29/// assert_eq!(format_bytes_si(1500), "1.5K");
30/// assert_eq!(format_bytes_si(1_500_000), "1.5M");
31/// assert_eq!(format_bytes_si(1_500_000_000), "1.5G");
32/// ```
33#[must_use]
34pub fn format_bytes_si(bytes: u64) -> String {
35    const UNITS: &[&str] = &["B", "K", "M", "G", "T", "P"];
36    const BASE: f64 = 1000.0;
37
38    if bytes == 0 {
39        return "0B".to_string();
40    }
41
42    let bytes_f = bytes as f64;
43    let exp = (bytes_f.log10() / BASE.log10()).floor() as usize;
44    let exp = exp.min(UNITS.len() - 1);
45    let value = bytes_f / BASE.powi(exp as i32);
46
47    if exp == 0 {
48        format!("{bytes}B")
49    } else if value >= 100.0 {
50        format!("{:.0}{}", value, UNITS[exp])
51    } else if value >= 10.0 {
52        format!("{:.1}{}", value, UNITS[exp])
53    } else {
54        format!("{:.2}{}", value, UNITS[exp])
55    }
56}
57
58/// Format bytes with IEC units (1024-based: KiB, MiB, GiB)
59#[must_use]
60pub fn format_bytes_iec(bytes: u64) -> String {
61    const UNITS: &[&str] = &["B", "Ki", "Mi", "Gi", "Ti", "Pi"];
62    const BASE: f64 = 1024.0;
63
64    if bytes == 0 {
65        return "0B".to_string();
66    }
67
68    let bytes_f = bytes as f64;
69    let exp = (bytes_f.log2() / 10.0).floor() as usize;
70    let exp = exp.min(UNITS.len() - 1);
71    let value = bytes_f / BASE.powi(exp as i32);
72
73    if exp == 0 {
74        format!("{bytes}B")
75    } else if value >= 100.0 {
76        format!("{:.0}{}", value, UNITS[exp])
77    } else if value >= 10.0 {
78        format!("{:.1}{}", value, UNITS[exp])
79    } else {
80        format!("{:.2}{}", value, UNITS[exp])
81    }
82}
83
84/// Format bytes/second as transfer rate
85#[must_use]
86pub fn format_rate(bytes_per_sec: u64) -> String {
87    format!("{}/s", format_bytes_si(bytes_per_sec))
88}
89
90// =============================================================================
91// PERCENTAGE FORMATTING
92// =============================================================================
93
94/// Format percentage with smart precision
95///
96/// - 0-9.99%: 1 decimal (e.g., "5.2%")
97/// - 10-99.9%: 1 decimal (e.g., "45.3%")
98/// - 100%+: 0 decimal (e.g., "153%")
99#[must_use]
100pub fn format_percent(value: f32) -> String {
101    if value >= 100.0 {
102        format!("{value:.0}%")
103    } else if value >= 10.0 {
104        format!("{value:.1}%")
105    } else if value >= 0.1 {
106        format!("{value:.1}%")
107    } else if value > 0.0 {
108        format!("{value:.2}%")
109    } else {
110        "0%".to_string()
111    }
112}
113
114/// Format percentage clamped to 0-100 range
115#[must_use]
116pub fn format_percent_clamped(value: f32) -> String {
117    format_percent(value.clamp(0.0, 100.0))
118}
119
120/// Format percentage with fixed width (right-aligned)
121#[must_use]
122pub fn format_percent_fixed(value: f32, width: usize) -> String {
123    let s = format_percent(value);
124    if s.len() >= width {
125        s
126    } else {
127        format!("{s:>width$}")
128    }
129}
130
131// =============================================================================
132// TIME/DURATION FORMATTING
133// =============================================================================
134
135/// Format duration in human-readable form
136///
137/// - <60s: "45s"
138/// - <60m: "5m 30s" or "5:30"
139/// - <24h: "3h 15m" or "3:15:00"
140/// - ≥24h: "2d 5h" or just "2d"
141#[must_use]
142pub fn format_duration(secs: u64) -> String {
143    if secs < 60 {
144        format!("{secs}s")
145    } else if secs < 3600 {
146        let m = secs / 60;
147        let s = secs % 60;
148        if s == 0 {
149            format!("{m}m")
150        } else {
151            format!("{m}:{s:02}")
152        }
153    } else if secs < 86400 {
154        let h = secs / 3600;
155        let m = (secs % 3600) / 60;
156        let s = secs % 60;
157        if s == 0 && m == 0 {
158            format!("{h}h")
159        } else if s == 0 {
160            format!("{h}:{m:02}")
161        } else {
162            format!("{h}:{m:02}:{s:02}")
163        }
164    } else {
165        let d = secs / 86400;
166        let h = (secs % 86400) / 3600;
167        if h == 0 {
168            format!("{d}d")
169        } else {
170            format!("{d}d {h}h")
171        }
172    }
173}
174
175/// Format duration compact (for tight columns)
176#[must_use]
177pub fn format_duration_compact(secs: u64) -> String {
178    if secs < 60 {
179        format!("{secs}s")
180    } else if secs < 3600 {
181        format!("{}m", secs / 60)
182    } else if secs < 86400 {
183        format!("{}h", secs / 3600)
184    } else {
185        format!("{}d", secs / 86400)
186    }
187}
188
189// =============================================================================
190// FREQUENCY FORMATTING
191// =============================================================================
192
193/// Format frequency (MHz to GHz conversion)
194#[must_use]
195pub fn format_freq_mhz(mhz: u64) -> String {
196    if mhz >= 1000 {
197        let ghz = mhz as f64 / 1000.0;
198        if ghz >= 10.0 {
199            format!("{ghz:.1}G")
200        } else {
201            format!("{ghz:.2}G")
202        }
203    } else {
204        format!("{mhz}M")
205    }
206}
207
208// =============================================================================
209// TEMPERATURE FORMATTING
210// =============================================================================
211
212/// Format temperature with unit
213#[must_use]
214pub fn format_temp_c(celsius: f32) -> String {
215    if celsius >= 100.0 {
216        format!("{celsius:.0}°C")
217    } else {
218        format!("{celsius:.1}°C")
219    }
220}
221
222// =============================================================================
223// TEXT TRUNCATION STRATEGIES
224// =============================================================================
225
226/// Truncation strategy
227#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
228pub enum TruncateStrategy {
229    /// Truncate from end: "`long_text`..." → "long_..."
230    #[default]
231    End,
232    /// Truncate from start: "`long_text`..." → "..._text"
233    Start,
234    /// Truncate from middle: "`long_text_here`" → "long_...here"
235    Middle,
236    /// Path-aware: "/home/user/very/long/path" → "/home/.../path"
237    Path,
238    /// Command-aware: "opt 9i94wsqoafn" → "opt …afn" (keep prefix + suffix)
239    Command,
240}
241
242/// Truncate string to fit within width using specified strategy
243///
244/// # Arguments
245/// * `s` - Input string
246/// * `width` - Maximum width in characters
247/// * `strategy` - Truncation strategy
248///
249/// # Returns
250/// Truncated string with ellipsis if needed
251#[must_use]
252pub fn truncate(s: &str, width: usize, strategy: TruncateStrategy) -> Cow<'_, str> {
253    let char_count = s.chars().count();
254
255    if char_count <= width {
256        return Cow::Borrowed(s);
257    }
258
259    if width <= 3 {
260        return Cow::Owned("…".repeat(width.min(1)));
261    }
262
263    match strategy {
264        TruncateStrategy::End => {
265            let take = width - 1; // Leave room for ellipsis
266            let truncated: String = s.chars().take(take).collect();
267            Cow::Owned(format!("{truncated}…"))
268        }
269        TruncateStrategy::Start => {
270            let skip = char_count - width + 1;
271            let truncated: String = s.chars().skip(skip).collect();
272            Cow::Owned(format!("…{truncated}"))
273        }
274        TruncateStrategy::Middle => {
275            let half = (width - 1) / 2;
276            let start: String = s.chars().take(half).collect();
277            let end: String = s.chars().skip(char_count - half).collect();
278            Cow::Owned(format!("{start}…{end}"))
279        }
280        TruncateStrategy::Path => truncate_path(s, width),
281        TruncateStrategy::Command => truncate_command(s, width),
282    }
283}
284
285/// Path-aware truncation: "/home/user/very/long/path" → "/home/.../path"
286fn truncate_path(path: &str, width: usize) -> Cow<'_, str> {
287    if path.chars().count() <= width {
288        return Cow::Borrowed(path);
289    }
290
291    let parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
292
293    if parts.is_empty() {
294        return Cow::Borrowed(path);
295    }
296
297    if parts.len() == 1 {
298        return truncate(path, width, TruncateStrategy::End);
299    }
300
301    // Keep first and last parts, replace middle with ...
302    let first = parts.first().unwrap_or(&"");
303    let last = parts.last().unwrap_or(&"");
304
305    let prefix = if path.starts_with('/') { "/" } else { "" };
306    let result = format!("{prefix}{first}/…/{last}");
307
308    // If still too long, truncate the result
309    if result.chars().count() > width {
310        let truncated: String = result.chars().take(width - 1).collect();
311        Cow::Owned(format!("{truncated}…"))
312    } else {
313        Cow::Owned(result)
314    }
315}
316
317/// Extract "key" arguments that contain identifiers (IDs, ports, paths).
318///
319/// Key patterns: contains digits, '=', or is a flag with numeric value following.
320#[inline]
321fn extract_key_args<'a>(args: &[&'a str]) -> Vec<&'a str> {
322    let mut key_args: Vec<&str> = Vec::new();
323    let mut i = 0;
324    while i < args.len() {
325        let arg = args[i];
326        let is_key = arg.contains('=')
327            || arg.chars().any(|c| c.is_ascii_digit())
328            || (arg.starts_with('-')
329                && i + 1 < args.len()
330                && args[i + 1].chars().any(|c| c.is_ascii_digit()));
331
332        if is_key {
333            if arg.starts_with('-') && !arg.contains('=') && i + 1 < args.len() {
334                key_args.push(arg);
335                key_args.push(args[i + 1]);
336                i += 2;
337            } else {
338                key_args.push(arg);
339                i += 1;
340            }
341        } else {
342            i += 1;
343        }
344    }
345    key_args
346}
347
348/// Build suffix string from key args (most recent first), fitting within max_width.
349#[inline]
350fn build_suffix_from_key_args(key_args: &[&str], max_width: usize) -> String {
351    let mut suffix = String::new();
352    for &arg in key_args.iter().rev() {
353        let arg_len = arg.chars().count();
354        let new_len = if suffix.is_empty() {
355            arg_len
356        } else {
357            suffix.chars().count() + 1 + arg_len
358        };
359        if new_len <= max_width {
360            if suffix.is_empty() {
361                suffix = arg.to_string();
362            } else {
363                suffix = format!("{arg} {suffix}");
364            }
365        } else {
366            break;
367        }
368    }
369    suffix
370}
371
372/// Simple end truncation with ellipsis.
373#[inline]
374fn simple_truncate(s: &str, width: usize) -> String {
375    let truncated: String = s.chars().take(width - 1).collect();
376    format!("{truncated}…")
377}
378
379/// Build truncated command from components.
380fn build_command_with_args(
381    basename: &str,
382    first_arg: &str,
383    key_args: &[&str],
384    width: usize,
385) -> String {
386    let ellipsis = " … ";
387    let base_len = basename.chars().count();
388
389    let mut result = basename.to_string();
390    let mut current_len = base_len;
391
392    // Add first arg if space
393    if !first_arg.is_empty() && current_len + 1 + first_arg.chars().count() + 4 < width {
394        result.push(' ');
395        result.push_str(first_arg);
396        current_len = result.chars().count();
397    }
398
399    // Add key args suffix if space
400    let space_for_keys = width.saturating_sub(current_len + ellipsis.chars().count());
401    if !key_args.is_empty() && space_for_keys > 5 {
402        let suffix = build_suffix_from_key_args(key_args, space_for_keys);
403        if !suffix.is_empty() {
404            result.push_str(ellipsis);
405            result.push_str(&suffix);
406        }
407    }
408
409    // Final safety: ensure we don't exceed width
410    if result.chars().count() > width {
411        simple_truncate(&result, width)
412    } else {
413        result
414    }
415}
416
417/// Command-aware truncation using Basename + Key Args pattern (htop-style)
418///
419/// Strategy:
420/// 1. Extract basename from executable path (`/usr/lib/firefox/firefox` → `firefox`)
421/// 2. Identify "key args" with identifiers (childID, port, pid, etc.)
422/// 3. Show: `basename [first-arg] … [key-args-from-end]`
423///
424/// # Examples
425/// - `/usr/lib/firefox/firefox -contentproc -childID 5 -isForBrowser -prefsLen 31398`
426///   → `firefox -contentproc … -childID 5 -isForBrowser` (width=45)
427/// - `python /home/user/scripts/long/path/script.py --port=8080`
428///   → `python script.py --port=8080` (width=30)
429fn truncate_command(cmd: &str, width: usize) -> Cow<'_, str> {
430    if cmd.chars().count() <= width {
431        return Cow::Borrowed(cmd);
432    }
433
434    if width <= 3 {
435        return Cow::Owned("…".repeat(width.min(1)));
436    }
437
438    if width < 12 {
439        return Cow::Owned(simple_truncate(cmd, width));
440    }
441
442    // Parse command into parts
443    let parts: Vec<&str> = cmd.split_whitespace().collect();
444    if parts.is_empty() {
445        return Cow::Borrowed(cmd);
446    }
447
448    // Extract basename from first part (executable)
449    let basename = parts[0].rsplit('/').next().unwrap_or(parts[0]);
450
451    // Handle single-part command
452    if parts.len() == 1 {
453        return if basename.len() <= width {
454            Cow::Owned(basename.to_string())
455        } else {
456            Cow::Owned(simple_truncate(basename, width))
457        };
458    }
459
460    // Build with args using helper
461    let args = &parts[1..];
462    let key_args = extract_key_args(args);
463    let first_arg = args.first().copied().unwrap_or("");
464    Cow::Owned(build_command_with_args(
465        basename, first_arg, &key_args, width,
466    ))
467}
468
469// =============================================================================
470// COLUMN FORMATTING (PREVENTS BLEEDING)
471// =============================================================================
472
473/// Column alignment
474#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
475pub enum ColumnAlign {
476    #[default]
477    Left,
478    Right,
479    Center,
480}
481
482/// Format a value to fit exactly within a column width
483///
484/// **GUARANTEE**: Output will NEVER exceed `width` characters
485///
486/// # Arguments
487/// * `value` - The string to format
488/// * `width` - Exact column width
489/// * `align` - Alignment within column
490/// * `truncate_strategy` - How to truncate if too long
491#[must_use]
492pub fn format_column(
493    value: &str,
494    width: usize,
495    align: ColumnAlign,
496    truncate_strategy: TruncateStrategy,
497) -> String {
498    let truncated = truncate(value, width, truncate_strategy);
499    let len = truncated.chars().count();
500
501    if len >= width {
502        // Already at or over width, just take exactly width chars
503        truncated.chars().take(width).collect()
504    } else {
505        let padding = width - len;
506        match align {
507            ColumnAlign::Left => format!("{truncated}{}", " ".repeat(padding)),
508            ColumnAlign::Right => format!("{}{truncated}", " ".repeat(padding)),
509            ColumnAlign::Center => {
510                let left = padding / 2;
511                let right = padding - left;
512                format!("{}{truncated}{}", " ".repeat(left), " ".repeat(right))
513            }
514        }
515    }
516}
517
518/// Format a number column (right-aligned, numeric formatting)
519#[must_use]
520pub fn format_number_column(value: f64, width: usize, decimals: usize) -> String {
521    let formatted = if decimals == 0 {
522        format!("{value:.0}")
523    } else {
524        format!("{value:.decimals$}")
525    };
526    format_column(&formatted, width, ColumnAlign::Right, TruncateStrategy::End)
527}
528
529/// Format a percentage column
530#[must_use]
531pub fn format_percent_column(value: f32, width: usize) -> String {
532    let formatted = format_percent(value);
533    format_column(&formatted, width, ColumnAlign::Right, TruncateStrategy::End)
534}
535
536/// Format a bytes column
537#[must_use]
538pub fn format_bytes_column(bytes: u64, width: usize) -> String {
539    let formatted = format_bytes_si(bytes);
540    format_column(&formatted, width, ColumnAlign::Right, TruncateStrategy::End)
541}
542
543// =============================================================================
544// O(1) FUZZY SEARCH (from pzsh/aprender-shell patterns)
545// =============================================================================
546
547/// Search result with relevance scoring
548#[derive(Debug, Clone)]
549pub struct SearchResult<T> {
550    /// The matched item
551    pub item: T,
552    /// Relevance score (0.0 - 1.0, higher is better)
553    pub score: f32,
554    /// Match positions (for highlighting)
555    pub matches: Vec<usize>,
556}
557
558/// Fast fuzzy search with O(1) amortized lookup via pre-computed index
559///
560/// Performance targets:
561/// - Build: O(n) where n = number of items
562/// - Search: O(m * k) where m = query length, k = average results
563/// - Memory: O(n * `avg_key_length`)
564#[derive(Debug, Clone)]
565pub struct FuzzyIndex<T: Clone> {
566    /// Items to search
567    items: Vec<T>,
568    /// Pre-computed lowercase keys for fast comparison
569    keys: Vec<String>,
570    /// Trigram index for O(1) candidate lookup
571    trigrams: std::collections::HashMap<[u8; 3], Vec<usize>>,
572    /// Character index for single-char queries
573    char_index: std::collections::HashMap<char, Vec<usize>>,
574}
575
576impl<T: Clone> FuzzyIndex<T> {
577    /// Build a fuzzy search index from items
578    ///
579    /// # Arguments
580    /// * `items` - Items to index
581    /// * `key_fn` - Function to extract searchable key from each item
582    pub fn new<F>(items: Vec<T>, key_fn: F) -> Self
583    where
584        F: Fn(&T) -> String,
585    {
586        let keys: Vec<String> = items
587            .iter()
588            .map(|item| key_fn(item).to_lowercase())
589            .collect();
590
591        let mut trigrams: std::collections::HashMap<[u8; 3], Vec<usize>> =
592            std::collections::HashMap::new();
593        let mut char_index: std::collections::HashMap<char, Vec<usize>> =
594            std::collections::HashMap::new();
595
596        for (idx, key) in keys.iter().enumerate() {
597            // Build character index
598            for ch in key.chars() {
599                char_index.entry(ch).or_default().push(idx);
600            }
601
602            // Build trigram index
603            let bytes = key.as_bytes();
604            if bytes.len() >= 3 {
605                for window in bytes.windows(3) {
606                    let trigram: [u8; 3] = [window[0], window[1], window[2]];
607                    trigrams.entry(trigram).or_default().push(idx);
608                }
609            }
610        }
611
612        // Deduplicate indices
613        for indices in trigrams.values_mut() {
614            indices.sort_unstable();
615            indices.dedup();
616        }
617        for indices in char_index.values_mut() {
618            indices.sort_unstable();
619            indices.dedup();
620        }
621
622        Self {
623            items,
624            keys,
625            trigrams,
626            char_index,
627        }
628    }
629
630    /// Search for items matching query
631    ///
632    /// # Arguments
633    /// * `query` - Search query (case-insensitive)
634    /// * `limit` - Maximum results to return
635    ///
636    /// # Returns
637    /// Sorted results by relevance (highest first)
638    #[must_use]
639    pub fn search(&self, query: &str, limit: usize) -> Vec<SearchResult<T>> {
640        if query.is_empty() {
641            return Vec::new();
642        }
643
644        let query_lower = query.to_lowercase();
645        let query_chars: Vec<char> = query_lower.chars().collect();
646
647        // Get candidate indices
648        let candidates = self.get_candidates(&query_lower);
649
650        // Score and filter candidates
651        let mut results: Vec<SearchResult<T>> = candidates
652            .into_iter()
653            .filter_map(|idx| {
654                let key = &self.keys[idx];
655                let (score, matches) = self.score_match(key, &query_chars);
656                if score > 0.0 {
657                    Some(SearchResult {
658                        item: self.items[idx].clone(),
659                        score,
660                        matches,
661                    })
662                } else {
663                    None
664                }
665            })
666            .collect();
667
668        // Sort by score (descending)
669        results.sort_by(|a, b| {
670            b.score
671                .partial_cmp(&a.score)
672                .unwrap_or(std::cmp::Ordering::Equal)
673        });
674        results.truncate(limit);
675
676        results
677    }
678
679    /// Get candidate indices using index
680    fn get_candidates(&self, query: &str) -> Vec<usize> {
681        let bytes = query.as_bytes();
682
683        // Use trigram index if query is long enough
684        if bytes.len() >= 3 {
685            let trigram: [u8; 3] = [bytes[0], bytes[1], bytes[2]];
686            if let Some(indices) = self.trigrams.get(&trigram) {
687                return indices.clone();
688            }
689        }
690
691        // Fall back to character index
692        if let Some(first_char) = query.chars().next() {
693            if let Some(indices) = self.char_index.get(&first_char) {
694                return indices.clone();
695            }
696        }
697
698        // Fall back to scanning all items
699        (0..self.items.len()).collect()
700    }
701
702    /// Score a match using fuzzy substring matching
703    fn score_match(&self, key: &str, query_chars: &[char]) -> (f32, Vec<usize>) {
704        if query_chars.is_empty() {
705            return (0.0, Vec::new());
706        }
707
708        let key_chars: Vec<char> = key.chars().collect();
709        let mut matches = Vec::new();
710        let mut query_idx = 0;
711
712        // Find subsequence matches
713        for (key_idx, &key_char) in key_chars.iter().enumerate() {
714            if query_idx < query_chars.len() && key_char == query_chars[query_idx] {
715                matches.push(key_idx);
716                query_idx += 1;
717            }
718        }
719
720        // Must match all query characters
721        if query_idx != query_chars.len() {
722            return (0.0, Vec::new());
723        }
724
725        // Calculate score based on:
726        // - Consecutive matches (bonus)
727        // - Position of first match (earlier is better)
728        // - Match density (matches / key length)
729
730        let mut score = 1.0;
731
732        // Consecutive bonus
733        let mut consecutive = 0;
734        for i in 1..matches.len() {
735            if matches[i] == matches[i - 1] + 1 {
736                consecutive += 1;
737            }
738        }
739        score += consecutive as f32 * 0.1;
740
741        // Early match bonus
742        if !matches.is_empty() {
743            score += (1.0 - matches[0] as f32 / key_chars.len() as f32) * 0.3;
744        }
745
746        // Exact prefix bonus
747        if key.starts_with(&query_chars.iter().collect::<String>()) {
748            score += 0.5;
749        }
750
751        // Exact match bonus
752        if key_chars.len() == query_chars.len() {
753            score += 1.0;
754        }
755
756        // Density factor
757        score *= query_chars.len() as f32 / key_chars.len() as f32;
758
759        (score.min(1.0), matches)
760    }
761}
762
763// =============================================================================
764// TESTS
765// =============================================================================
766
767#[cfg(test)]
768mod tests {
769    use super::*;
770
771    // =========================================================================
772    // BYTE FORMATTING TESTS
773    // =========================================================================
774
775    #[test]
776    fn test_format_bytes_si() {
777        assert_eq!(format_bytes_si(0), "0B");
778        assert_eq!(format_bytes_si(100), "100B");
779        assert_eq!(format_bytes_si(1000), "1.00K");
780        assert_eq!(format_bytes_si(1500), "1.50K");
781        assert_eq!(format_bytes_si(1_000_000), "1.00M");
782        assert_eq!(format_bytes_si(1_500_000_000), "1.50G");
783        assert_eq!(format_bytes_si(1_000_000_000_000), "1.00T");
784    }
785
786    #[test]
787    fn test_format_bytes_si_large() {
788        // Test 100+ values
789        assert_eq!(format_bytes_si(150_000_000_000), "150G");
790    }
791
792    #[test]
793    fn test_format_bytes_iec() {
794        assert_eq!(format_bytes_iec(0), "0B");
795        assert_eq!(format_bytes_iec(100), "100B");
796        assert_eq!(format_bytes_iec(1024), "1.00Ki");
797        assert_eq!(format_bytes_iec(1024 * 1024), "1.00Mi");
798        assert_eq!(format_bytes_iec(1024 * 1024 * 1024), "1.00Gi");
799    }
800
801    #[test]
802    fn test_format_rate() {
803        assert_eq!(format_rate(1000), "1.00K/s");
804        assert_eq!(format_rate(1_000_000), "1.00M/s");
805    }
806
807    // =========================================================================
808    // PERCENTAGE FORMATTING TESTS
809    // =========================================================================
810
811    #[test]
812    fn test_format_percent() {
813        assert_eq!(format_percent(0.0), "0%");
814        assert_eq!(format_percent(5.0), "5.0%");
815        assert_eq!(format_percent(45.3), "45.3%");
816        assert_eq!(format_percent(100.0), "100%");
817        assert_eq!(format_percent(153.2), "153%");
818    }
819
820    #[test]
821    fn test_format_percent_small() {
822        assert_eq!(format_percent(0.05), "0.05%");
823    }
824
825    #[test]
826    fn test_format_percent_clamped() {
827        assert_eq!(format_percent_clamped(150.0), "100%");
828        assert_eq!(format_percent_clamped(-10.0), "0%");
829    }
830
831    #[test]
832    fn test_format_percent_fixed() {
833        let result = format_percent_fixed(50.0, 8);
834        assert_eq!(result.chars().count(), 8);
835    }
836
837    #[test]
838    fn test_format_percent_fixed_no_padding() {
839        let result = format_percent_fixed(100.0, 3);
840        assert_eq!(result, "100%");
841    }
842
843    // =========================================================================
844    // DURATION FORMATTING TESTS
845    // =========================================================================
846
847    #[test]
848    fn test_format_duration() {
849        assert_eq!(format_duration(45), "45s");
850        assert_eq!(format_duration(90), "1:30");
851        assert_eq!(format_duration(3661), "1:01:01");
852        assert_eq!(format_duration(86400), "1d");
853        assert_eq!(format_duration(90000), "1d 1h");
854    }
855
856    #[test]
857    fn test_format_duration_exact_minutes() {
858        assert_eq!(format_duration(60), "1m");
859        assert_eq!(format_duration(120), "2m");
860    }
861
862    #[test]
863    fn test_format_duration_exact_hours() {
864        assert_eq!(format_duration(3600), "1h");
865        assert_eq!(format_duration(7200), "2h");
866    }
867
868    #[test]
869    fn test_format_duration_hours_minutes() {
870        assert_eq!(format_duration(3660), "1:01");
871    }
872
873    #[test]
874    fn test_format_duration_compact() {
875        assert_eq!(format_duration_compact(30), "30s");
876        assert_eq!(format_duration_compact(120), "2m");
877        assert_eq!(format_duration_compact(7200), "2h");
878        assert_eq!(format_duration_compact(172800), "2d");
879    }
880
881    // =========================================================================
882    // FREQUENCY FORMATTING TESTS
883    // =========================================================================
884
885    #[test]
886    fn test_format_freq_mhz() {
887        assert_eq!(format_freq_mhz(500), "500M");
888        assert_eq!(format_freq_mhz(1000), "1.00G");
889        assert_eq!(format_freq_mhz(3500), "3.50G");
890        assert_eq!(format_freq_mhz(10500), "10.5G");
891    }
892
893    // =========================================================================
894    // TEMPERATURE FORMATTING TESTS
895    // =========================================================================
896
897    #[test]
898    fn test_format_temp_c() {
899        assert_eq!(format_temp_c(45.5), "45.5°C");
900        assert_eq!(format_temp_c(105.0), "105°C");
901    }
902
903    // =========================================================================
904    // TRUNCATION TESTS
905    // =========================================================================
906
907    #[test]
908    fn test_truncate_strategy_default() {
909        assert_eq!(TruncateStrategy::default(), TruncateStrategy::End);
910    }
911
912    #[test]
913    fn test_truncate_end() {
914        assert_eq!(truncate("hello", 10, TruncateStrategy::End), "hello");
915        assert_eq!(
916            truncate("hello world", 8, TruncateStrategy::End),
917            "hello w…"
918        );
919        assert_eq!(truncate("hi", 1, TruncateStrategy::End), "…");
920    }
921
922    #[test]
923    fn test_truncate_start() {
924        // "hello world" is 11 chars, width 8, skip = 11 - 8 + 1 = 4
925        // Skips "hell", keeps "o world" with ellipsis = "…o world"
926        assert_eq!(
927            truncate("hello world", 8, TruncateStrategy::Start),
928            "…o world"
929        );
930    }
931
932    #[test]
933    fn test_truncate_middle() {
934        assert_eq!(
935            truncate("hello_world_here", 10, TruncateStrategy::Middle),
936            "hell…here"
937        );
938    }
939
940    #[test]
941    fn test_truncate_very_short() {
942        assert_eq!(truncate("hello", 2, TruncateStrategy::End), "…");
943        assert_eq!(truncate("hello", 3, TruncateStrategy::End), "…");
944    }
945
946    #[test]
947    fn test_truncate_path() {
948        assert_eq!(
949            truncate("/home/user/documents/file.txt", 20, TruncateStrategy::Path),
950            "/home/…/file.txt"
951        );
952    }
953
954    #[test]
955    fn test_truncate_path_single_part() {
956        let result = truncate("filename", 5, TruncateStrategy::Path);
957        assert!(result.chars().count() <= 5);
958    }
959
960    #[test]
961    fn test_truncate_path_empty() {
962        let result = truncate("", 10, TruncateStrategy::Path);
963        assert_eq!(result, "");
964    }
965
966    #[test]
967    fn test_truncate_path_no_slash() {
968        let result = truncate("verylongfilename.txt", 10, TruncateStrategy::Path);
969        assert!(result.chars().count() <= 10);
970    }
971
972    #[test]
973    fn test_truncate_command() {
974        let cmd = "/usr/bin/python script.py";
975        let result = truncate(cmd, 20, TruncateStrategy::Command);
976        assert!(result.starts_with("python"));
977
978        let long_cmd = "/usr/lib/firefox/firefox -contentproc -parentBuildID 20240101 -childID 5 -isForBrowser";
979        let result = truncate(long_cmd, 50, TruncateStrategy::Command);
980        assert!(result.starts_with("firefox"));
981        assert!(result.contains("…"));
982        assert!(result.contains('5'));
983
984        assert_eq!(
985            truncate("command arg1 arg2", 8, TruncateStrategy::Command),
986            "command…"
987        );
988
989        let very_long = "/usr/lib/firefox/firefox -contentproc -childID 5 -isForBrowser -prefsLen 31398 -prefMapSize 244787";
990        let result = truncate(very_long, 40, TruncateStrategy::Command);
991        assert!(
992            result.chars().count() <= 40,
993            "Result '{}' exceeds 40 chars",
994            result
995        );
996
997        let with_eq = "python script.py --port=8080";
998        let result = truncate(with_eq, 30, TruncateStrategy::Command);
999        assert!(
1000            result.contains("8080") || result == "python script.py --port=8080",
1001            "Result '{}' should contain 8080 or fit entirely",
1002            result
1003        );
1004    }
1005
1006    #[test]
1007    fn test_truncate_command_short_width() {
1008        let result = truncate("/usr/bin/python", 5, TruncateStrategy::Command);
1009        assert!(result.chars().count() <= 5);
1010    }
1011
1012    #[test]
1013    fn test_truncate_command_single_word() {
1014        // When the input fits within width, it's returned unchanged
1015        let result = truncate("/usr/bin/python", 15, TruncateStrategy::Command);
1016        assert_eq!(result, "/usr/bin/python");
1017
1018        // When it needs truncation, basename is used
1019        let result = truncate("/usr/bin/python", 10, TruncateStrategy::Command);
1020        assert!(result.chars().count() <= 10);
1021    }
1022
1023    #[test]
1024    fn test_truncate_command_basename_only_too_long() {
1025        let result = truncate(
1026            "/usr/bin/verylongexecutablename",
1027            10,
1028            TruncateStrategy::Command,
1029        );
1030        assert!(result.chars().count() <= 10);
1031    }
1032
1033    // =========================================================================
1034    // COLUMN FORMATTING TESTS
1035    // =========================================================================
1036
1037    #[test]
1038    fn test_column_align_default() {
1039        assert_eq!(ColumnAlign::default(), ColumnAlign::Left);
1040    }
1041
1042    #[test]
1043    fn test_format_column_never_bleeds() {
1044        let result = format_column(
1045            "very_long_text_that_should_be_truncated",
1046            10,
1047            ColumnAlign::Left,
1048            TruncateStrategy::End,
1049        );
1050        assert_eq!(result.chars().count(), 10);
1051
1052        let result = format_column("short", 10, ColumnAlign::Right, TruncateStrategy::End);
1053        assert_eq!(result.chars().count(), 10);
1054        assert!(result.starts_with("     "));
1055    }
1056
1057    #[test]
1058    fn test_format_column_center() {
1059        let result = format_column("hi", 10, ColumnAlign::Center, TruncateStrategy::End);
1060        assert_eq!(result.chars().count(), 10);
1061        // Centered: "    hi    "
1062    }
1063
1064    #[test]
1065    fn test_format_number_column() {
1066        let result = format_number_column(1.23456, 8, 2);
1067        assert_eq!(result.chars().count(), 8);
1068    }
1069
1070    #[test]
1071    fn test_format_number_column_no_decimals() {
1072        let result = format_number_column(42.0, 6, 0);
1073        assert_eq!(result.chars().count(), 6);
1074    }
1075
1076    #[test]
1077    fn test_format_percent_column() {
1078        let result = format_percent_column(50.0, 8);
1079        assert_eq!(result.chars().count(), 8);
1080    }
1081
1082    #[test]
1083    fn test_format_bytes_column() {
1084        let result = format_bytes_column(1_000_000, 8);
1085        assert_eq!(result.chars().count(), 8);
1086    }
1087
1088    // =========================================================================
1089    // FUZZY SEARCH TESTS
1090    // =========================================================================
1091
1092    #[test]
1093    fn test_fuzzy_search() {
1094        let items = vec![
1095            "firefox".to_string(),
1096            "thunderbird".to_string(),
1097            "chrome".to_string(),
1098            "chromium".to_string(),
1099            "firefox-developer".to_string(),
1100        ];
1101
1102        let index = FuzzyIndex::new(items, |s| s.clone());
1103
1104        let results = index.search("fire", 5);
1105        assert!(!results.is_empty());
1106        assert!(results[0].item.contains("fire"));
1107
1108        let results = index.search("chro", 5);
1109        assert!(results.len() >= 2);
1110    }
1111
1112    #[test]
1113    fn test_fuzzy_search_empty_query() {
1114        let items = vec!["test".to_string()];
1115        let index = FuzzyIndex::new(items, |s| s.clone());
1116        let results = index.search("", 5);
1117        assert!(results.is_empty());
1118    }
1119
1120    #[test]
1121    fn test_fuzzy_search_no_match() {
1122        let items = vec!["apple".to_string(), "banana".to_string()];
1123        let index = FuzzyIndex::new(items, |s| s.clone());
1124        let results = index.search("xyz", 5);
1125        assert!(results.is_empty());
1126    }
1127
1128    #[test]
1129    fn test_fuzzy_search_exact_match() {
1130        let items = vec!["test".to_string(), "testing".to_string()];
1131        let index = FuzzyIndex::new(items, |s| s.clone());
1132        let results = index.search("test", 5);
1133        assert!(!results.is_empty());
1134        // Exact match should score higher
1135        assert_eq!(results[0].item, "test");
1136    }
1137
1138    #[test]
1139    fn test_fuzzy_search_single_char() {
1140        let items = vec![
1141            "apple".to_string(),
1142            "banana".to_string(),
1143            "apricot".to_string(),
1144        ];
1145        let index = FuzzyIndex::new(items, |s| s.clone());
1146        let results = index.search("a", 5);
1147        assert!(results.len() >= 2); // apple and apricot
1148    }
1149}
1150
1151// =============================================================================
1152// DECLARATIVE DISPLAY RULES (SPEC-024 Appendix F)
1153// =============================================================================
1154
1155/// Action to take based on display rule evaluation
1156///
1157/// SPEC-024 Appendix F.2.2: Display Rules Grammar
1158#[derive(Debug, Clone, PartialEq, Eq)]
1159pub enum DisplayAction {
1160    /// Show panel normally
1161    Show,
1162    /// Hide panel completely (do not render)
1163    Hide,
1164    /// Show placeholder text instead of content
1165    ShowPlaceholder(String),
1166    /// Use compact/minimal detail level
1167    Compact,
1168    /// Expand to show more detail
1169    Expand,
1170}
1171
1172impl Default for DisplayAction {
1173    fn default() -> Self {
1174        Self::Show
1175    }
1176}
1177
1178/// System capabilities for display rule evaluation
1179#[derive(Debug, Clone, Default)]
1180pub struct SystemCapabilities {
1181    /// NVIDIA GPU available
1182    pub has_nvidia: bool,
1183    /// AMD GPU available
1184    pub has_amd: bool,
1185    /// Apple Silicon available
1186    pub has_apple_silicon: bool,
1187    /// PSI (Pressure Stall Information) available
1188    pub has_psi: bool,
1189    /// Hardware sensors available
1190    pub has_sensors: bool,
1191    /// Battery present
1192    pub has_battery: bool,
1193    /// Container runtime detected
1194    pub in_container: bool,
1195}
1196
1197impl SystemCapabilities {
1198    /// Detect system capabilities at startup
1199    #[cfg(target_os = "linux")]
1200    pub fn detect() -> Self {
1201        use std::path::Path;
1202
1203        Self {
1204            has_nvidia: Path::new("/dev/nvidia0").exists()
1205                || Path::new("/proc/driver/nvidia").exists(),
1206            has_amd: Path::new("/sys/class/drm/card0/device/vendor").exists(),
1207            has_apple_silicon: false,
1208            has_psi: Path::new("/proc/pressure/cpu").exists(),
1209            has_sensors: Path::new("/sys/class/hwmon/hwmon0").exists(),
1210            has_battery: Path::new("/sys/class/power_supply/BAT0").exists()
1211                || Path::new("/sys/class/power_supply/BAT1").exists(),
1212            in_container: Path::new("/.dockerenv").exists() || std::env::var("container").is_ok(),
1213        }
1214    }
1215
1216    #[cfg(not(target_os = "linux"))]
1217    pub fn detect() -> Self {
1218        Self::default()
1219    }
1220}
1221
1222/// Terminal size for display rule evaluation
1223#[derive(Debug, Clone, Copy, Default)]
1224pub struct TerminalSize {
1225    pub width: u16,
1226    pub height: u16,
1227}
1228
1229/// Data availability flags for display rule evaluation
1230///
1231/// Each flag indicates whether the corresponding data source has valid data
1232#[derive(Debug, Clone, Default)]
1233pub struct DataAvailability {
1234    /// PSI data available and meaningful (>0.01% pressure)
1235    pub psi_available: bool,
1236    /// Sensor readings available
1237    pub sensors_available: bool,
1238    /// Sensor count (for compact vs full display)
1239    pub sensor_count: usize,
1240    /// GPU data available
1241    pub gpu_available: bool,
1242    /// Battery data available
1243    pub battery_available: bool,
1244    /// Treemap/files data ready (not scanning)
1245    pub treemap_ready: bool,
1246    /// Connection data available
1247    pub connections_available: bool,
1248    /// Connection count
1249    pub connection_count: usize,
1250}
1251
1252/// Context for evaluating display rules
1253///
1254/// SPEC-024 Appendix F.2.3: Framework Trait
1255#[derive(Debug)]
1256pub struct DisplayContext<'a> {
1257    /// System capabilities (detected at startup)
1258    pub system: &'a SystemCapabilities,
1259    /// Current terminal size
1260    pub terminal: TerminalSize,
1261    /// Data availability flags
1262    pub data: DataAvailability,
1263}
1264
1265/// Trait for declarative display rules evaluation
1266///
1267/// SPEC-024 Appendix F.2.3: Every panel MUST implement this trait
1268/// to enable YAML-controlled visibility.
1269pub trait DisplayRules {
1270    /// Evaluate display rules and return action
1271    ///
1272    /// Called before rendering to determine if panel should be shown,
1273    /// hidden, or displayed in a modified state.
1274    fn evaluate(&self, ctx: &DisplayContext<'_>) -> DisplayAction;
1275
1276    /// Get panel identifier for YAML configuration
1277    fn panel_id(&self) -> &'static str;
1278}
1279
1280/// Default display rules for panels without custom logic
1281///
1282/// Behavior: Always show unless data is explicitly unavailable
1283#[derive(Debug)]
1284pub struct DefaultDisplayRules {
1285    panel_id: &'static str,
1286}
1287
1288impl DefaultDisplayRules {
1289    pub fn new(panel_id: &'static str) -> Self {
1290        Self { panel_id }
1291    }
1292}
1293
1294impl DisplayRules for DefaultDisplayRules {
1295    fn evaluate(&self, _ctx: &DisplayContext<'_>) -> DisplayAction {
1296        DisplayAction::Show
1297    }
1298
1299    fn panel_id(&self) -> &'static str {
1300        self.panel_id
1301    }
1302}
1303
1304/// PSI panel display rules
1305///
1306/// Hide if PSI not available (non-Linux or disabled kernel config)
1307#[derive(Debug)]
1308pub struct PsiDisplayRules;
1309
1310impl DisplayRules for PsiDisplayRules {
1311    fn evaluate(&self, ctx: &DisplayContext<'_>) -> DisplayAction {
1312        if !ctx.system.has_psi || !ctx.data.psi_available {
1313            DisplayAction::Hide
1314        } else {
1315            DisplayAction::Show
1316        }
1317    }
1318
1319    fn panel_id(&self) -> &'static str {
1320        "psi"
1321    }
1322}
1323
1324/// Sensors panel display rules
1325///
1326/// Hide if no sensors, compact if few sensors
1327#[derive(Debug)]
1328pub struct SensorsDisplayRules;
1329
1330impl DisplayRules for SensorsDisplayRules {
1331    fn evaluate(&self, ctx: &DisplayContext<'_>) -> DisplayAction {
1332        if !ctx.system.has_sensors || !ctx.data.sensors_available {
1333            DisplayAction::Hide
1334        } else if ctx.data.sensor_count < 3 {
1335            DisplayAction::Compact
1336        } else {
1337            DisplayAction::Show
1338        }
1339    }
1340
1341    fn panel_id(&self) -> &'static str {
1342        "sensors"
1343    }
1344}
1345
1346/// GPU panel display rules
1347///
1348/// Hide if no GPU detected
1349#[derive(Debug)]
1350pub struct GpuDisplayRules;
1351
1352impl DisplayRules for GpuDisplayRules {
1353    fn evaluate(&self, ctx: &DisplayContext<'_>) -> DisplayAction {
1354        if ctx.data.gpu_available {
1355            DisplayAction::Show
1356        } else {
1357            DisplayAction::Hide
1358        }
1359    }
1360
1361    fn panel_id(&self) -> &'static str {
1362        "gpu"
1363    }
1364}
1365
1366/// Battery panel display rules
1367///
1368/// Hide if no battery (desktop systems)
1369#[derive(Debug)]
1370pub struct BatteryDisplayRules;
1371
1372impl DisplayRules for BatteryDisplayRules {
1373    fn evaluate(&self, ctx: &DisplayContext<'_>) -> DisplayAction {
1374        if !ctx.system.has_battery || !ctx.data.battery_available {
1375            DisplayAction::Hide
1376        } else {
1377            DisplayAction::Show
1378        }
1379    }
1380
1381    fn panel_id(&self) -> &'static str {
1382        "battery"
1383    }
1384}
1385
1386/// Files panel display rules
1387///
1388/// Show placeholder while scanning
1389#[derive(Debug)]
1390pub struct FilesDisplayRules;
1391
1392impl DisplayRules for FilesDisplayRules {
1393    fn evaluate(&self, ctx: &DisplayContext<'_>) -> DisplayAction {
1394        if ctx.data.treemap_ready {
1395            DisplayAction::Show
1396        } else {
1397            DisplayAction::ShowPlaceholder("Scanning filesystem...".to_string())
1398        }
1399    }
1400
1401    fn panel_id(&self) -> &'static str {
1402        "files"
1403    }
1404}
1405
1406#[cfg(test)]
1407mod display_rules_tests {
1408    use super::*;
1409
1410    #[test]
1411    fn test_psi_hides_when_unavailable() {
1412        let rules = PsiDisplayRules;
1413        let system = SystemCapabilities {
1414            has_psi: false,
1415            ..Default::default()
1416        };
1417        let ctx = DisplayContext {
1418            system: &system,
1419            terminal: TerminalSize::default(),
1420            data: DataAvailability::default(),
1421        };
1422
1423        assert_eq!(rules.evaluate(&ctx), DisplayAction::Hide);
1424    }
1425
1426    #[test]
1427    fn test_psi_shows_when_available() {
1428        let rules = PsiDisplayRules;
1429        let system = SystemCapabilities {
1430            has_psi: true,
1431            ..Default::default()
1432        };
1433        let ctx = DisplayContext {
1434            system: &system,
1435            terminal: TerminalSize::default(),
1436            data: DataAvailability {
1437                psi_available: true,
1438                ..Default::default()
1439            },
1440        };
1441
1442        assert_eq!(rules.evaluate(&ctx), DisplayAction::Show);
1443    }
1444
1445    #[test]
1446    fn test_sensors_compact_with_few() {
1447        let rules = SensorsDisplayRules;
1448        let system = SystemCapabilities {
1449            has_sensors: true,
1450            ..Default::default()
1451        };
1452        let ctx = DisplayContext {
1453            system: &system,
1454            terminal: TerminalSize::default(),
1455            data: DataAvailability {
1456                sensors_available: true,
1457                sensor_count: 2,
1458                ..Default::default()
1459            },
1460        };
1461
1462        assert_eq!(rules.evaluate(&ctx), DisplayAction::Compact);
1463    }
1464
1465    #[test]
1466    fn test_battery_hides_on_desktop() {
1467        let rules = BatteryDisplayRules;
1468        let system = SystemCapabilities {
1469            has_battery: false,
1470            ..Default::default()
1471        };
1472        let ctx = DisplayContext {
1473            system: &system,
1474            terminal: TerminalSize::default(),
1475            data: DataAvailability::default(),
1476        };
1477
1478        assert_eq!(rules.evaluate(&ctx), DisplayAction::Hide);
1479    }
1480
1481    #[test]
1482    fn test_files_placeholder_while_scanning() {
1483        let rules = FilesDisplayRules;
1484        let system = SystemCapabilities::default();
1485        let ctx = DisplayContext {
1486            system: &system,
1487            terminal: TerminalSize::default(),
1488            data: DataAvailability {
1489                treemap_ready: false,
1490                ..Default::default()
1491            },
1492        };
1493
1494        match rules.evaluate(&ctx) {
1495            DisplayAction::ShowPlaceholder(msg) => {
1496                assert!(msg.contains("Scanning"));
1497            }
1498            _ => panic!("Expected ShowPlaceholder"),
1499        }
1500    }
1501
1502    // Additional tests for byte formatting
1503
1504    #[test]
1505    fn test_format_bytes_si_zero() {
1506        assert_eq!(format_bytes_si(0), "0B");
1507    }
1508
1509    #[test]
1510    fn test_format_bytes_si_small() {
1511        assert_eq!(format_bytes_si(500), "500B");
1512        assert_eq!(format_bytes_si(999), "999B");
1513    }
1514
1515    #[test]
1516    fn test_format_bytes_si_kilobytes() {
1517        assert_eq!(format_bytes_si(1000), "1.00K");
1518        assert_eq!(format_bytes_si(1500), "1.50K");
1519        assert_eq!(format_bytes_si(15000), "15.0K");
1520    }
1521
1522    #[test]
1523    fn test_format_bytes_si_megabytes() {
1524        assert_eq!(format_bytes_si(1_000_000), "1.00M");
1525        assert_eq!(format_bytes_si(1_500_000), "1.50M");
1526        assert_eq!(format_bytes_si(100_000_000), "100M");
1527    }
1528
1529    #[test]
1530    fn test_format_bytes_si_gigabytes() {
1531        assert_eq!(format_bytes_si(1_000_000_000), "1.00G");
1532        assert_eq!(format_bytes_si(10_000_000_000), "10.0G");
1533    }
1534
1535    #[test]
1536    fn test_format_bytes_si_terabytes() {
1537        assert_eq!(format_bytes_si(1_000_000_000_000), "1.00T");
1538    }
1539
1540    #[test]
1541    fn test_format_bytes_iec_zero() {
1542        assert_eq!(format_bytes_iec(0), "0B");
1543    }
1544
1545    #[test]
1546    fn test_format_bytes_iec_small() {
1547        assert_eq!(format_bytes_iec(500), "500B");
1548        assert_eq!(format_bytes_iec(1023), "1023B");
1549    }
1550
1551    #[test]
1552    fn test_format_bytes_iec_kibibytes() {
1553        assert_eq!(format_bytes_iec(1024), "1.00Ki");
1554        assert_eq!(format_bytes_iec(1536), "1.50Ki");
1555    }
1556
1557    #[test]
1558    fn test_format_bytes_iec_mebibytes() {
1559        assert_eq!(format_bytes_iec(1024 * 1024), "1.00Mi");
1560        assert_eq!(format_bytes_iec(1024 * 1024 * 100), "100Mi");
1561    }
1562
1563    #[test]
1564    fn test_format_bytes_iec_gibibytes() {
1565        assert_eq!(format_bytes_iec(1024 * 1024 * 1024), "1.00Gi");
1566    }
1567
1568    #[test]
1569    fn test_format_rate() {
1570        assert_eq!(format_rate(0), "0B/s");
1571        assert_eq!(format_rate(1500), "1.50K/s");
1572    }
1573
1574    // Percentage formatting tests
1575
1576    #[test]
1577    fn test_format_percent_small() {
1578        let result = format_percent(5.25);
1579        assert!(result.contains("5."));
1580    }
1581
1582    #[test]
1583    fn test_format_percent_medium() {
1584        let result = format_percent(45.3);
1585        assert!(result.contains("45"));
1586    }
1587
1588    #[test]
1589    fn test_format_percent_full() {
1590        let result = format_percent(100.0);
1591        assert!(result.contains("100"));
1592    }
1593
1594    #[test]
1595    fn test_format_percent_over() {
1596        let result = format_percent(153.5);
1597        assert!(result.contains("153") || result.contains("154"));
1598    }
1599
1600    // Truncation tests
1601
1602    #[test]
1603    fn test_truncate_middle_short() {
1604        let result = truncate("hello", 10, TruncateStrategy::Middle);
1605        assert_eq!(result, "hello");
1606    }
1607
1608    #[test]
1609    fn test_truncate_middle_exact() {
1610        let result = truncate("hello", 5, TruncateStrategy::Middle);
1611        assert_eq!(result, "hello");
1612    }
1613
1614    #[test]
1615    fn test_truncate_middle_needs_truncation() {
1616        let result = truncate("hello world", 8, TruncateStrategy::Middle);
1617        assert!(result.chars().count() <= 8);
1618        assert!(result.contains("…"));
1619    }
1620
1621    #[test]
1622    fn test_truncate_end_short() {
1623        let result = truncate("hello", 10, TruncateStrategy::End);
1624        assert_eq!(result, "hello");
1625    }
1626
1627    #[test]
1628    fn test_truncate_end_needs_truncation() {
1629        let result = truncate("hello world", 8, TruncateStrategy::End);
1630        assert!(result.chars().count() <= 8);
1631    }
1632
1633    #[test]
1634    fn test_truncate_start_short() {
1635        let result = truncate("hello", 10, TruncateStrategy::Start);
1636        assert_eq!(result, "hello");
1637    }
1638
1639    #[test]
1640    fn test_truncate_start_needs_truncation() {
1641        let result = truncate("/very/long/path/to/file.txt", 15, TruncateStrategy::Start);
1642        assert!(result.chars().count() <= 15);
1643    }
1644
1645    #[test]
1646    fn test_truncate_path_strategy() {
1647        let result = truncate(
1648            "/home/user/very/long/path/file.txt",
1649            20,
1650            TruncateStrategy::Path,
1651        );
1652        assert!(result.chars().count() <= 20);
1653    }
1654
1655    #[test]
1656    fn test_truncate_command_strategy() {
1657        let result = truncate(
1658            "command --with-very-long-argument",
1659            15,
1660            TruncateStrategy::Command,
1661        );
1662        assert!(result.chars().count() <= 15);
1663    }
1664
1665    #[test]
1666    fn test_truncate_very_small_width() {
1667        let result = truncate("hello world", 2, TruncateStrategy::End);
1668        assert!(result.chars().count() <= 2);
1669    }
1670
1671    #[test]
1672    fn test_truncate_strategy_default() {
1673        let strategy = TruncateStrategy::default();
1674        assert_eq!(strategy, TruncateStrategy::End);
1675    }
1676
1677    // Display action tests
1678
1679    #[test]
1680    fn test_display_action_eq() {
1681        assert_eq!(DisplayAction::Show, DisplayAction::Show);
1682        assert_eq!(DisplayAction::Hide, DisplayAction::Hide);
1683        assert_eq!(DisplayAction::Compact, DisplayAction::Compact);
1684        assert_ne!(DisplayAction::Show, DisplayAction::Hide);
1685    }
1686
1687    #[test]
1688    fn test_display_action_placeholder_eq() {
1689        assert_eq!(
1690            DisplayAction::ShowPlaceholder("Loading".to_string()),
1691            DisplayAction::ShowPlaceholder("Loading".to_string())
1692        );
1693        assert_ne!(
1694            DisplayAction::ShowPlaceholder("Loading".to_string()),
1695            DisplayAction::ShowPlaceholder("Other".to_string())
1696        );
1697    }
1698
1699    #[test]
1700    fn test_display_action_debug() {
1701        let action = DisplayAction::Show;
1702        let debug = format!("{:?}", action);
1703        assert!(debug.contains("Show"));
1704    }
1705
1706    #[test]
1707    fn test_display_action_clone() {
1708        let action = DisplayAction::ShowPlaceholder("test".to_string());
1709        let cloned = action.clone();
1710        assert_eq!(action, cloned);
1711    }
1712
1713    // System capabilities tests
1714
1715    #[test]
1716    fn test_system_capabilities_default() {
1717        let caps = SystemCapabilities::default();
1718        assert!(!caps.has_battery);
1719        assert!(!caps.has_nvidia);
1720        assert!(!caps.has_psi);
1721    }
1722
1723    #[test]
1724    fn test_system_capabilities_clone() {
1725        let caps = SystemCapabilities {
1726            has_battery: true,
1727            has_nvidia: true,
1728            has_sensors: true,
1729            has_psi: true,
1730            ..Default::default()
1731        };
1732        let cloned = caps;
1733        assert!(cloned.has_battery);
1734        assert!(cloned.has_nvidia);
1735    }
1736
1737    // Terminal size tests
1738
1739    #[test]
1740    fn test_terminal_size_default() {
1741        let size = TerminalSize::default();
1742        assert_eq!(size.width, 0);
1743        assert_eq!(size.height, 0);
1744    }
1745
1746    #[test]
1747    fn test_terminal_size_clone() {
1748        let size = TerminalSize {
1749            width: 120,
1750            height: 40,
1751        };
1752        let cloned = size;
1753        assert_eq!(cloned.width, 120);
1754        assert_eq!(cloned.height, 40);
1755    }
1756
1757    // Data availability tests
1758
1759    #[test]
1760    fn test_data_availability_default() {
1761        let data = DataAvailability::default();
1762        assert!(!data.psi_available);
1763        assert!(!data.gpu_available);
1764        assert!(!data.battery_available);
1765    }
1766
1767    #[test]
1768    fn test_data_availability_clone() {
1769        let data = DataAvailability {
1770            psi_available: true,
1771            gpu_available: true,
1772            battery_available: true,
1773            sensors_available: true,
1774            treemap_ready: true,
1775            sensor_count: 10,
1776            ..Default::default()
1777        };
1778        let cloned = data;
1779        assert_eq!(cloned.sensor_count, 10);
1780    }
1781
1782    // DisplayRules trait tests
1783
1784    #[test]
1785    fn test_psi_panel_id() {
1786        let rules = PsiDisplayRules;
1787        assert_eq!(rules.panel_id(), "psi");
1788    }
1789
1790    #[test]
1791    fn test_sensors_panel_id() {
1792        let rules = SensorsDisplayRules;
1793        assert_eq!(rules.panel_id(), "sensors");
1794    }
1795
1796    #[test]
1797    fn test_gpu_panel_id() {
1798        let rules = GpuDisplayRules;
1799        assert_eq!(rules.panel_id(), "gpu");
1800    }
1801
1802    #[test]
1803    fn test_battery_panel_id() {
1804        let rules = BatteryDisplayRules;
1805        assert_eq!(rules.panel_id(), "battery");
1806    }
1807
1808    #[test]
1809    fn test_files_panel_id() {
1810        let rules = FilesDisplayRules;
1811        assert_eq!(rules.panel_id(), "files");
1812    }
1813
1814    #[test]
1815    fn test_gpu_shows_when_available() {
1816        let rules = GpuDisplayRules;
1817        let system = SystemCapabilities::default();
1818        let ctx = DisplayContext {
1819            system: &system,
1820            terminal: TerminalSize::default(),
1821            data: DataAvailability {
1822                gpu_available: true,
1823                ..Default::default()
1824            },
1825        };
1826        assert_eq!(rules.evaluate(&ctx), DisplayAction::Show);
1827    }
1828
1829    #[test]
1830    fn test_gpu_hides_when_unavailable() {
1831        let rules = GpuDisplayRules;
1832        let system = SystemCapabilities::default();
1833        let ctx = DisplayContext {
1834            system: &system,
1835            terminal: TerminalSize::default(),
1836            data: DataAvailability {
1837                gpu_available: false,
1838                ..Default::default()
1839            },
1840        };
1841        assert_eq!(rules.evaluate(&ctx), DisplayAction::Hide);
1842    }
1843
1844    #[test]
1845    fn test_sensors_hides_when_no_sensors() {
1846        let rules = SensorsDisplayRules;
1847        let system = SystemCapabilities {
1848            has_sensors: false,
1849            ..Default::default()
1850        };
1851        let ctx = DisplayContext {
1852            system: &system,
1853            terminal: TerminalSize::default(),
1854            data: DataAvailability::default(),
1855        };
1856        assert_eq!(rules.evaluate(&ctx), DisplayAction::Hide);
1857    }
1858
1859    #[test]
1860    fn test_sensors_shows_with_many() {
1861        let rules = SensorsDisplayRules;
1862        let system = SystemCapabilities {
1863            has_sensors: true,
1864            ..Default::default()
1865        };
1866        let ctx = DisplayContext {
1867            system: &system,
1868            terminal: TerminalSize::default(),
1869            data: DataAvailability {
1870                sensors_available: true,
1871                sensor_count: 10,
1872                ..Default::default()
1873            },
1874        };
1875        assert_eq!(rules.evaluate(&ctx), DisplayAction::Show);
1876    }
1877
1878    #[test]
1879    fn test_battery_shows_when_available() {
1880        let rules = BatteryDisplayRules;
1881        let system = SystemCapabilities {
1882            has_battery: true,
1883            ..Default::default()
1884        };
1885        let ctx = DisplayContext {
1886            system: &system,
1887            terminal: TerminalSize::default(),
1888            data: DataAvailability {
1889                battery_available: true,
1890                ..Default::default()
1891            },
1892        };
1893        assert_eq!(rules.evaluate(&ctx), DisplayAction::Show);
1894    }
1895
1896    #[test]
1897    fn test_files_shows_when_ready() {
1898        let rules = FilesDisplayRules;
1899        let system = SystemCapabilities::default();
1900        let ctx = DisplayContext {
1901            system: &system,
1902            terminal: TerminalSize::default(),
1903            data: DataAvailability {
1904                treemap_ready: true,
1905                ..Default::default()
1906            },
1907        };
1908        assert_eq!(rules.evaluate(&ctx), DisplayAction::Show);
1909    }
1910
1911    // Additional formatting tests
1912
1913    #[test]
1914    fn test_format_duration_seconds() {
1915        assert_eq!(format_duration(30), "30s");
1916        assert_eq!(format_duration(59), "59s");
1917    }
1918
1919    #[test]
1920    fn test_format_duration_minutes() {
1921        assert_eq!(format_duration(60), "1m");
1922        assert_eq!(format_duration(90), "1:30");
1923        assert_eq!(format_duration(3599), "59:59");
1924    }
1925
1926    #[test]
1927    fn test_format_duration_hours() {
1928        assert_eq!(format_duration(3600), "1h");
1929        assert_eq!(format_duration(7200), "2h");
1930        assert_eq!(format_duration(5400), "1:30");
1931    }
1932
1933    #[test]
1934    fn test_format_duration_days() {
1935        assert_eq!(format_duration(86400), "1d");
1936        assert_eq!(format_duration(90000), "1d 1h");
1937    }
1938
1939    #[test]
1940    fn test_format_duration_compact() {
1941        assert_eq!(format_duration_compact(30), "30s");
1942        assert_eq!(format_duration_compact(90), "1m");
1943        assert_eq!(format_duration_compact(7200), "2h");
1944        assert_eq!(format_duration_compact(90000), "1d");
1945    }
1946
1947    #[test]
1948    fn test_format_freq_mhz() {
1949        assert_eq!(format_freq_mhz(800), "800M");
1950        assert_eq!(format_freq_mhz(1000), "1.00G");
1951        assert_eq!(format_freq_mhz(3600), "3.60G");
1952        assert_eq!(format_freq_mhz(10000), "10.0G");
1953    }
1954
1955    #[test]
1956    fn test_format_temp_c() {
1957        assert_eq!(format_temp_c(45.5), "45.5°C");
1958        assert_eq!(format_temp_c(100.0), "100°C");
1959    }
1960
1961    #[test]
1962    fn test_format_percent_clamped() {
1963        assert!(format_percent_clamped(150.0).contains("100"));
1964        assert!(format_percent_clamped(-10.0).contains('0'));
1965    }
1966
1967    #[test]
1968    fn test_format_percent_fixed() {
1969        let result = format_percent_fixed(5.0, 8);
1970        assert_eq!(result.len(), 8);
1971    }
1972
1973    #[test]
1974    fn test_truncate_strategy_debug() {
1975        let strategy = TruncateStrategy::End;
1976        let debug = format!("{:?}", strategy);
1977        assert!(debug.contains("End"));
1978    }
1979
1980    #[test]
1981    fn test_truncate_strategy_clone() {
1982        let strategy = TruncateStrategy::Middle;
1983        let cloned = strategy;
1984        assert_eq!(strategy, cloned);
1985    }
1986}