Skip to main content

blz_cli/args/
context.rs

1//! Context line argument groups for CLI commands.
2//!
3//! This module provides reusable context arguments for commands that
4//! retrieve content and can include surrounding context lines.
5//!
6//! # Design
7//!
8//! Follows grep-style conventions:
9//! - `-C NUM`: Symmetric context (same lines before and after)
10//! - `-A NUM`: After context (lines after match)
11//! - `-B NUM`: Before context (lines before match)
12//! - `--context all`: Full section/block expansion
13//!
14//! # Examples
15//!
16//! ```bash
17//! blz find "useEffect" -C 5           # 5 lines before and after
18//! blz find "useEffect" -A 3 -B 2      # 2 lines before, 3 after
19//! blz find "useEffect" --context all  # Full section
20//! ```
21
22use clap::Args;
23use serde::{Deserialize, Serialize};
24
25/// Context mode for result expansion.
26///
27/// Represents how much surrounding context to include when retrieving
28/// content matches.
29#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
30pub enum ContextMode {
31    /// Symmetric context (same number of lines before and after).
32    Symmetric(usize),
33    /// Asymmetric context (different lines before and after).
34    Asymmetric {
35        /// Lines of context before the match.
36        before: usize,
37        /// Lines of context after the match.
38        after: usize,
39    },
40    /// Full section/block expansion.
41    All,
42}
43
44impl ContextMode {
45    /// Get the before and after context line counts.
46    ///
47    /// Returns `(before, after)` tuple. For `All` mode, returns `None`.
48    #[must_use]
49    pub const fn lines(&self) -> Option<(usize, usize)> {
50        match self {
51            Self::Symmetric(n) => Some((*n, *n)),
52            Self::Asymmetric { before, after } => Some((*before, *after)),
53            Self::All => None,
54        }
55    }
56
57    /// Check if this is the All (full section) mode.
58    #[must_use]
59    pub const fn is_all(&self) -> bool {
60        matches!(self, Self::All)
61    }
62
63    /// Merge two context modes, taking the maximum value for each direction.
64    #[must_use]
65    pub fn merge(self, other: Self) -> Self {
66        match (self, other) {
67            // All takes precedence over everything
68            (Self::All, _) | (_, Self::All) => Self::All,
69            // Extract line counts and compute maximum for each direction
70            (a, b) => {
71                let (a_before, a_after) = a.lines().unwrap_or((0, 0));
72                let (b_before, b_after) = b.lines().unwrap_or((0, 0));
73                let before = a_before.max(b_before);
74                let after = a_after.max(b_after);
75                if before == after {
76                    Self::Symmetric(before)
77                } else {
78                    Self::Asymmetric { before, after }
79                }
80            },
81        }
82    }
83}
84
85impl std::str::FromStr for ContextMode {
86    type Err = String;
87
88    fn from_str(s: &str) -> Result<Self, Self::Err> {
89        if s.eq_ignore_ascii_case("all") {
90            Ok(Self::All)
91        } else {
92            s.parse::<usize>()
93                .map(Self::Symmetric)
94                .map_err(|_| format!("Invalid context value: '{s}'. Expected a number or 'all'"))
95        }
96    }
97}
98
99impl std::fmt::Display for ContextMode {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        match self {
102            Self::Symmetric(n) => write!(f, "{n}"),
103            Self::Asymmetric { before, after } => write!(f, "B{before}:A{after}"),
104            Self::All => write!(f, "all"),
105        }
106    }
107}
108
109/// Shared context arguments for commands that retrieve content.
110///
111/// This group provides grep-style context line options for commands
112/// that return content with surrounding lines (search, get, find, etc.).
113///
114/// # Usage
115///
116/// Flatten into command structs:
117///
118/// ```ignore
119/// #[derive(Args)]
120/// struct GetArgs {
121///     #[command(flatten)]
122///     context: ContextArgs,
123///     // ... other args
124/// }
125/// ```
126///
127/// Then resolve to a `ContextMode`:
128///
129/// ```ignore
130/// let mode = args.context.resolve();
131/// ```
132#[derive(Args, Clone, Debug, Default, PartialEq, Eq)]
133pub struct ContextArgs {
134    /// Lines of context before and after each match (grep-style).
135    ///
136    /// Use a number for symmetric context, or "all" for full section.
137    ///
138    /// Examples:
139    ///   -C 5          # 5 lines before and after
140    ///   --context 10  # 10 lines before and after
141    ///   --context all # Full section
142    #[arg(
143        short = 'C',
144        long = "context",
145        value_name = "LINES",
146        display_order = 30
147    )]
148    pub context: Option<ContextMode>,
149
150    /// Lines of context after each match.
151    ///
152    /// Can be combined with -B for asymmetric context.
153    ///
154    /// Examples:
155    ///   -A 5              # 5 lines after match
156    ///   --after-context 3 # 3 lines after match
157    #[arg(
158        short = 'A',
159        long = "after-context",
160        value_name = "LINES",
161        display_order = 31
162    )]
163    pub after_context: Option<usize>,
164
165    /// Lines of context before each match.
166    ///
167    /// Can be combined with -A for asymmetric context.
168    ///
169    /// Examples:
170    ///   -B 5               # 5 lines before match
171    ///   --before-context 3 # 3 lines before match
172    #[arg(
173        short = 'B',
174        long = "before-context",
175        value_name = "LINES",
176        display_order = 32
177    )]
178    pub before_context: Option<usize>,
179}
180
181impl ContextArgs {
182    /// Create context args with symmetric context.
183    #[must_use]
184    pub const fn symmetric(lines: usize) -> Self {
185        Self {
186            context: Some(ContextMode::Symmetric(lines)),
187            after_context: None,
188            before_context: None,
189        }
190    }
191
192    /// Create context args for full section expansion.
193    #[must_use]
194    pub const fn all() -> Self {
195        Self {
196            context: Some(ContextMode::All),
197            after_context: None,
198            before_context: None,
199        }
200    }
201
202    /// Resolve the context arguments into a single `ContextMode`.
203    ///
204    /// Implements grep-style merging logic:
205    /// - `-C` provides symmetric context
206    /// - `-A` and `-B` can be combined for asymmetric context
207    /// - If multiple flags are provided, takes maximum value for each direction
208    #[must_use]
209    pub fn resolve(&self) -> Option<ContextMode> {
210        // Start with the primary context flag
211        let mut result = self.context.clone();
212
213        // Merge in -A and -B flags if present
214        if let Some(after) = self.after_context {
215            let new_mode = self
216                .before_context
217                .map_or(ContextMode::Asymmetric { before: 0, after }, |before| {
218                    ContextMode::Asymmetric { before, after }
219                });
220
221            result = Some(match result.take() {
222                Some(existing) => existing.merge(new_mode),
223                None => new_mode,
224            });
225        } else if let Some(before) = self.before_context {
226            // Only -B specified, create asymmetric mode with 0 after
227            let new_mode = ContextMode::Asymmetric { before, after: 0 };
228            result = Some(match result.take() {
229                Some(existing) => existing.merge(new_mode),
230                None => new_mode,
231            });
232        }
233
234        result
235    }
236
237    /// Check if any context is requested.
238    #[must_use]
239    pub const fn has_context(&self) -> bool {
240        self.context.is_some() || self.after_context.is_some() || self.before_context.is_some()
241    }
242}
243
244#[cfg(test)]
245#[allow(clippy::unwrap_used)]
246mod tests {
247    use super::*;
248
249    mod context_mode {
250        use super::*;
251
252        #[test]
253        fn test_symmetric_lines() {
254            let mode = ContextMode::Symmetric(5);
255            assert_eq!(mode.lines(), Some((5, 5)));
256        }
257
258        #[test]
259        fn test_asymmetric_lines() {
260            let mode = ContextMode::Asymmetric {
261                before: 3,
262                after: 7,
263            };
264            assert_eq!(mode.lines(), Some((3, 7)));
265        }
266
267        #[test]
268        fn test_all_lines() {
269            let mode = ContextMode::All;
270            assert_eq!(mode.lines(), None);
271        }
272
273        #[test]
274        fn test_is_all() {
275            assert!(ContextMode::All.is_all());
276            assert!(!ContextMode::Symmetric(5).is_all());
277            assert!(
278                !ContextMode::Asymmetric {
279                    before: 1,
280                    after: 1
281                }
282                .is_all()
283            );
284        }
285
286        #[test]
287        fn test_merge_all_precedence() {
288            let sym = ContextMode::Symmetric(5);
289            let all = ContextMode::All;
290
291            assert!(matches!(sym.clone().merge(all.clone()), ContextMode::All));
292            assert!(matches!(all.merge(sym), ContextMode::All));
293        }
294
295        #[test]
296        fn test_merge_symmetric() {
297            let a = ContextMode::Symmetric(3);
298            let b = ContextMode::Symmetric(5);
299
300            assert_eq!(a.merge(b), ContextMode::Symmetric(5));
301        }
302
303        #[test]
304        fn test_merge_asymmetric() {
305            let a = ContextMode::Asymmetric {
306                before: 3,
307                after: 2,
308            };
309            let b = ContextMode::Asymmetric {
310                before: 1,
311                after: 5,
312            };
313
314            assert_eq!(
315                a.merge(b),
316                ContextMode::Asymmetric {
317                    before: 3,
318                    after: 5
319                }
320            );
321        }
322
323        #[test]
324        fn test_parse_number() {
325            assert_eq!(
326                "5".parse::<ContextMode>().unwrap(),
327                ContextMode::Symmetric(5)
328            );
329            assert_eq!(
330                "0".parse::<ContextMode>().unwrap(),
331                ContextMode::Symmetric(0)
332            );
333        }
334
335        #[test]
336        fn test_parse_all() {
337            assert_eq!("all".parse::<ContextMode>().unwrap(), ContextMode::All);
338            assert_eq!("ALL".parse::<ContextMode>().unwrap(), ContextMode::All);
339            assert_eq!("All".parse::<ContextMode>().unwrap(), ContextMode::All);
340        }
341
342        #[test]
343        fn test_parse_invalid() {
344            assert!("abc".parse::<ContextMode>().is_err());
345            assert!("-1".parse::<ContextMode>().is_err());
346        }
347
348        #[test]
349        fn test_display() {
350            assert_eq!(ContextMode::Symmetric(5).to_string(), "5");
351            assert_eq!(
352                ContextMode::Asymmetric {
353                    before: 2,
354                    after: 3
355                }
356                .to_string(),
357                "B2:A3"
358            );
359            assert_eq!(ContextMode::All.to_string(), "all");
360        }
361    }
362
363    mod context_args {
364        use super::*;
365
366        #[test]
367        fn test_default() {
368            let args = ContextArgs::default();
369            assert_eq!(args.context, None);
370            assert_eq!(args.after_context, None);
371            assert_eq!(args.before_context, None);
372            assert!(!args.has_context());
373        }
374
375        #[test]
376        fn test_symmetric() {
377            let args = ContextArgs::symmetric(5);
378            assert_eq!(args.resolve(), Some(ContextMode::Symmetric(5)));
379            assert!(args.has_context());
380        }
381
382        #[test]
383        fn test_all() {
384            let args = ContextArgs::all();
385            assert_eq!(args.resolve(), Some(ContextMode::All));
386            assert!(args.has_context());
387        }
388
389        #[test]
390        fn test_resolve_only_after() {
391            let args = ContextArgs {
392                context: None,
393                after_context: Some(5),
394                before_context: None,
395            };
396            assert_eq!(
397                args.resolve(),
398                Some(ContextMode::Asymmetric {
399                    before: 0,
400                    after: 5
401                })
402            );
403        }
404
405        #[test]
406        fn test_resolve_only_before() {
407            let args = ContextArgs {
408                context: None,
409                after_context: None,
410                before_context: Some(5),
411            };
412            assert_eq!(
413                args.resolve(),
414                Some(ContextMode::Asymmetric {
415                    before: 5,
416                    after: 0
417                })
418            );
419        }
420
421        #[test]
422        fn test_resolve_both_before_after() {
423            let args = ContextArgs {
424                context: None,
425                after_context: Some(3),
426                before_context: Some(5),
427            };
428            assert_eq!(
429                args.resolve(),
430                Some(ContextMode::Asymmetric {
431                    before: 5,
432                    after: 3
433                })
434            );
435        }
436
437        #[test]
438        fn test_resolve_merge_with_context() {
439            let args = ContextArgs {
440                context: Some(ContextMode::Symmetric(2)),
441                after_context: Some(5),
442                before_context: None,
443            };
444            // Should merge: symmetric(2) + asymmetric(0, 5) = asymmetric(2, 5)
445            assert_eq!(
446                args.resolve(),
447                Some(ContextMode::Asymmetric {
448                    before: 2,
449                    after: 5
450                })
451            );
452        }
453    }
454}