Skip to main content

par_term_config/
snippets.rs

1//! Configuration types for snippets and custom actions.
2//!
3//! This module provides:
4//! - Snippet definitions with variable substitution
5//! - Custom action definitions (shell commands, text insertion, key sequences)
6//! - Built-in and custom variable support
7
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11/// A text snippet that can be inserted into the terminal.
12///
13/// Snippets support variable substitution using \(variable\) syntax.
14/// Example: "echo 'Today is \(date)'" will replace \(date) with the current date.
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16pub struct SnippetConfig {
17    /// Unique identifier for the snippet
18    pub id: String,
19
20    /// Human-readable title for the snippet
21    pub title: String,
22
23    /// The text content to insert (may contain variables)
24    pub content: String,
25
26    /// Optional keyboard shortcut to trigger the snippet (e.g., "Ctrl+Shift+D")
27    #[serde(default)]
28    pub keybinding: Option<String>,
29
30    /// Whether the keybinding is enabled (default: true)
31    /// If false, the keybinding won't be registered even if keybinding is set
32    #[serde(default = "crate::defaults::bool_true")]
33    pub keybinding_enabled: bool,
34
35    /// Optional folder/collection for organization (e.g., "Git", "Docker")
36    #[serde(default)]
37    pub folder: Option<String>,
38
39    /// Whether this snippet is enabled
40    #[serde(default = "crate::defaults::bool_true")]
41    pub enabled: bool,
42
43    /// Optional description of what the snippet does
44    #[serde(default)]
45    pub description: Option<String>,
46
47    /// Whether to automatically send Enter after inserting the snippet (default: false)
48    /// If true, a newline character is appended to execute the command immediately
49    #[serde(default)]
50    pub auto_execute: bool,
51
52    /// Custom variables defined for this snippet
53    #[serde(default)]
54    pub variables: HashMap<String, String>,
55}
56
57impl SnippetConfig {
58    /// Create a new snippet with the given ID and title.
59    pub fn new(id: String, title: String, content: String) -> Self {
60        Self {
61            id,
62            title,
63            content,
64            keybinding: None,
65            keybinding_enabled: true,
66            folder: None,
67            enabled: true,
68            description: None,
69            auto_execute: false,
70            variables: HashMap::new(),
71        }
72    }
73
74    /// Add a keybinding to the snippet.
75    pub fn with_keybinding(mut self, keybinding: String) -> Self {
76        self.keybinding = Some(keybinding);
77        self
78    }
79
80    /// Disable the keybinding for this snippet.
81    pub fn with_keybinding_disabled(mut self) -> Self {
82        self.keybinding_enabled = false;
83        self
84    }
85
86    /// Add a folder to the snippet.
87    pub fn with_folder(mut self, folder: String) -> Self {
88        self.folder = Some(folder);
89        self
90    }
91
92    /// Add a custom variable to the snippet.
93    pub fn with_variable(mut self, name: String, value: String) -> Self {
94        self.variables.insert(name, value);
95        self
96    }
97
98    /// Enable auto-execute (send Enter after inserting the snippet).
99    pub fn with_auto_execute(mut self) -> Self {
100        self.auto_execute = true;
101        self
102    }
103}
104
105/// A portable snippet library for import/export.
106///
107/// Wraps a list of snippets for serialization to/from YAML files.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct SnippetLibrary {
110    /// The snippets in this library
111    pub snippets: Vec<SnippetConfig>,
112}
113
114/// A custom action that can be triggered via keybinding.
115///
116/// Actions can execute shell commands, insert text, or simulate key sequences.
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
118#[serde(tag = "type", rename_all = "snake_case")]
119pub enum CustomActionConfig {
120    /// Execute a shell command
121    ShellCommand {
122        /// Action identifier (for keybinding reference)
123        id: String,
124
125        /// Human-readable title
126        title: String,
127
128        /// Command to execute (e.g., "git", "npm")
129        command: String,
130
131        /// Command arguments (e.g., ["status", "--short"])
132        #[serde(default)]
133        args: Vec<String>,
134
135        /// Whether to show command output in a notification
136        #[serde(default)]
137        notify_on_success: bool,
138
139        /// Optional keyboard shortcut to trigger the action (e.g., "Ctrl+Shift+R")
140        #[serde(default)]
141        keybinding: Option<String>,
142
143        /// Whether the keybinding is enabled (default: true)
144        #[serde(default = "crate::defaults::bool_true")]
145        keybinding_enabled: bool,
146
147        /// Optional description
148        #[serde(default)]
149        description: Option<String>,
150    },
151
152    /// Insert text into the terminal (like a snippet but no editing UI)
153    InsertText {
154        /// Action identifier
155        id: String,
156
157        /// Human-readable title
158        title: String,
159
160        /// Text to insert (supports variable substitution)
161        text: String,
162
163        /// Custom variables for substitution
164        #[serde(default)]
165        variables: HashMap<String, String>,
166
167        /// Optional keyboard shortcut to trigger the action
168        #[serde(default)]
169        keybinding: Option<String>,
170
171        /// Whether the keybinding is enabled (default: true)
172        #[serde(default = "crate::defaults::bool_true")]
173        keybinding_enabled: bool,
174
175        /// Optional description
176        #[serde(default)]
177        description: Option<String>,
178    },
179
180    /// Simulate a key sequence
181    KeySequence {
182        /// Action identifier
183        id: String,
184
185        /// Human-readable title
186        title: String,
187
188        /// Key sequence to simulate (e.g., "Ctrl+C", "Up Up Down Down")
189        keys: String,
190
191        /// Optional keyboard shortcut to trigger the action
192        #[serde(default)]
193        keybinding: Option<String>,
194
195        /// Whether the keybinding is enabled (default: true)
196        #[serde(default = "crate::defaults::bool_true")]
197        keybinding_enabled: bool,
198
199        /// Optional description
200        #[serde(default)]
201        description: Option<String>,
202    },
203}
204
205impl CustomActionConfig {
206    /// Get the action ID (for keybinding reference).
207    pub fn id(&self) -> &str {
208        match self {
209            Self::ShellCommand { id, .. } => id,
210            Self::InsertText { id, .. } => id,
211            Self::KeySequence { id, .. } => id,
212        }
213    }
214
215    /// Get the action title (for UI display).
216    pub fn title(&self) -> &str {
217        match self {
218            Self::ShellCommand { title, .. } => title,
219            Self::InsertText { title, .. } => title,
220            Self::KeySequence { title, .. } => title,
221        }
222    }
223
224    /// Get the optional keybinding for this action.
225    pub fn keybinding(&self) -> Option<&str> {
226        match self {
227            Self::ShellCommand { keybinding, .. }
228            | Self::InsertText { keybinding, .. }
229            | Self::KeySequence { keybinding, .. } => keybinding.as_deref(),
230        }
231    }
232
233    /// Check if the keybinding is enabled.
234    pub fn keybinding_enabled(&self) -> bool {
235        match self {
236            Self::ShellCommand {
237                keybinding_enabled, ..
238            }
239            | Self::InsertText {
240                keybinding_enabled, ..
241            }
242            | Self::KeySequence {
243                keybinding_enabled, ..
244            } => *keybinding_enabled,
245        }
246    }
247
248    /// Set the keybinding for this action.
249    pub fn set_keybinding(&mut self, kb: Option<String>) {
250        match self {
251            Self::ShellCommand { keybinding, .. }
252            | Self::InsertText { keybinding, .. }
253            | Self::KeySequence { keybinding, .. } => *keybinding = kb,
254        }
255    }
256
257    /// Set whether the keybinding is enabled.
258    pub fn set_keybinding_enabled(&mut self, enabled: bool) {
259        match self {
260            Self::ShellCommand {
261                keybinding_enabled, ..
262            }
263            | Self::InsertText {
264                keybinding_enabled, ..
265            }
266            | Self::KeySequence {
267                keybinding_enabled, ..
268            } => *keybinding_enabled = enabled,
269        }
270    }
271
272    /// Check if this is a shell command action.
273    pub fn is_shell_command(&self) -> bool {
274        matches!(self, Self::ShellCommand { .. })
275    }
276
277    /// Check if this is an insert text action.
278    pub fn is_insert_text(&self) -> bool {
279        matches!(self, Self::InsertText { .. })
280    }
281
282    /// Check if this is a key sequence action.
283    pub fn is_key_sequence(&self) -> bool {
284        matches!(self, Self::KeySequence { .. })
285    }
286}
287
288/// Built-in variables available for snippet substitution.
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
290pub enum BuiltInVariable {
291    /// Current date (YYYY-MM-DD)
292    Date,
293    /// Current time (HH:MM:SS)
294    Time,
295    /// Current date and time
296    DateTime,
297    /// System hostname
298    Hostname,
299    /// Current username
300    User,
301    /// Current working directory
302    Path,
303    /// Current git branch (if in a git repository)
304    GitBranch,
305    /// Current git commit hash (if in a git repository)
306    GitCommit,
307    /// Random UUID
308    Uuid,
309    /// Random number (0-999999)
310    Random,
311}
312
313impl BuiltInVariable {
314    /// Get all built-in variables for UI display.
315    pub fn all() -> &'static [(&'static str, &'static str)] {
316        &[
317            ("date", "Current date (YYYY-MM-DD)"),
318            ("time", "Current time (HH:MM:SS)"),
319            ("datetime", "Current date and time"),
320            ("hostname", "System hostname"),
321            ("user", "Current username"),
322            ("path", "Current working directory"),
323            ("git_branch", "Current git branch"),
324            ("git_commit", "Current git commit hash"),
325            ("uuid", "Random UUID"),
326            ("random", "Random number (0-999999)"),
327        ]
328    }
329
330    /// Parse a variable name into a BuiltInVariable.
331    pub fn parse(name: &str) -> Option<Self> {
332        match name {
333            "date" => Some(Self::Date),
334            "time" => Some(Self::Time),
335            "datetime" => Some(Self::DateTime),
336            "hostname" => Some(Self::Hostname),
337            "user" => Some(Self::User),
338            "path" => Some(Self::Path),
339            "git_branch" => Some(Self::GitBranch),
340            "git_commit" => Some(Self::GitCommit),
341            "uuid" => Some(Self::Uuid),
342            "random" => Some(Self::Random),
343            _ => None,
344        }
345    }
346
347    /// Resolve the variable to its string value.
348    pub fn resolve(&self) -> String {
349        match self {
350            Self::Date => {
351                use std::time::{SystemTime, UNIX_EPOCH};
352                let duration = SystemTime::now()
353                    .duration_since(UNIX_EPOCH)
354                    .unwrap_or_default();
355                let secs = duration.as_secs();
356                let days_since_epoch = secs / 86400;
357
358                // Simple date calculation (days since 1970-01-01)
359                let years = 1970 + days_since_epoch / 365;
360                let day_of_year = (days_since_epoch % 365) as u32;
361                let month = (day_of_year / 30) + 1;
362                let day = (day_of_year % 30) + 1;
363
364                format!("{:04}-{:02}-{:02}", years, month, day)
365            }
366            Self::Time => {
367                use std::time::{SystemTime, UNIX_EPOCH};
368                let duration = SystemTime::now()
369                    .duration_since(UNIX_EPOCH)
370                    .unwrap_or_default();
371                let secs = duration.as_secs();
372                let hours = (secs % 86400) / 3600;
373                let minutes = (secs % 3600) / 60;
374                let seconds = secs % 60;
375
376                format!("{:02}:{:02}:{:02}", hours, minutes, seconds)
377            }
378            Self::DateTime => {
379                format!("{} {}", Self::Date.resolve(), Self::Time.resolve())
380            }
381            Self::Hostname => {
382                std::env::var("HOSTNAME")
383                    .or_else(|_| std::env::var("HOST"))
384                    .unwrap_or_else(|_| {
385                        // Fallback to system hostname
386                        hostname::get()
387                            .ok()
388                            .and_then(|s| s.into_string().ok())
389                            .unwrap_or_else(|| "unknown".to_string())
390                    })
391            }
392            Self::User => std::env::var("USER")
393                .or_else(|_| std::env::var("USERNAME"))
394                .unwrap_or_else(|_| "unknown".to_string()),
395            Self::Path => std::env::current_dir()
396                .ok()
397                .and_then(|p| p.to_str().map(|s| s.to_string()))
398                .unwrap_or_else(|| ".".to_string()),
399            Self::GitBranch => {
400                // Try to get git branch from environment or command
401                match std::env::var("GIT_BRANCH") {
402                    Ok(branch) => branch,
403                    Err(_) => {
404                        // Try running git command
405                        std::process::Command::new("git")
406                            .args(["rev-parse", "--abbrev-ref", "HEAD"])
407                            .output()
408                            .ok()
409                            .and_then(|o| String::from_utf8(o.stdout).ok())
410                            .map(|s| s.trim().to_string())
411                            .unwrap_or_default()
412                    }
413                }
414            }
415            Self::GitCommit => {
416                // Try to get git commit from environment or command
417                match std::env::var("GIT_COMMIT") {
418                    Ok(commit) => commit,
419                    Err(_) => std::process::Command::new("git")
420                        .args(["rev-parse", "--short", "HEAD"])
421                        .output()
422                        .ok()
423                        .and_then(|o| String::from_utf8(o.stdout).ok())
424                        .map(|s| s.trim().to_string())
425                        .unwrap_or_default(),
426                }
427            }
428            Self::Uuid => uuid::Uuid::new_v4().to_string(),
429            Self::Random => {
430                use std::time::{SystemTime, UNIX_EPOCH};
431                let duration = SystemTime::now()
432                    .duration_since(UNIX_EPOCH)
433                    .unwrap_or_default();
434                format!("{}", (duration.as_nanos() % 1_000_000) as u32)
435            }
436        }
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    #[test]
445    fn test_snippet_new() {
446        let snippet = SnippetConfig::new(
447            "test".to_string(),
448            "Test Snippet".to_string(),
449            "echo 'hello'".to_string(),
450        );
451
452        assert_eq!(snippet.id, "test");
453        assert_eq!(snippet.title, "Test Snippet");
454        assert_eq!(snippet.content, "echo 'hello'");
455        assert!(snippet.enabled);
456        assert!(snippet.keybinding.is_none());
457        assert!(snippet.folder.is_none());
458        assert!(snippet.variables.is_empty());
459    }
460
461    #[test]
462    fn test_snippet_builder() {
463        let snippet = SnippetConfig::new(
464            "test".to_string(),
465            "Test Snippet".to_string(),
466            "echo 'hello'".to_string(),
467        )
468        .with_keybinding("Ctrl+Shift+T".to_string())
469        .with_folder("Test".to_string())
470        .with_variable("name".to_string(), "value".to_string());
471
472        assert_eq!(snippet.keybinding, Some("Ctrl+Shift+T".to_string()));
473        assert_eq!(snippet.folder, Some("Test".to_string()));
474        assert_eq!(snippet.variables.get("name"), Some(&"value".to_string()));
475    }
476
477    #[test]
478    fn test_builtin_variable_resolution() {
479        // These should not panic
480        let date = BuiltInVariable::Date.resolve();
481        assert!(!date.is_empty());
482
483        let time = BuiltInVariable::Time.resolve();
484        assert!(!time.is_empty());
485
486        let user = BuiltInVariable::User.resolve();
487        assert!(!user.is_empty());
488
489        let path = BuiltInVariable::Path.resolve();
490        assert!(!path.is_empty());
491    }
492
493    #[test]
494    fn test_builtin_variable_parse() {
495        assert_eq!(BuiltInVariable::parse("date"), Some(BuiltInVariable::Date));
496        assert_eq!(BuiltInVariable::parse("time"), Some(BuiltInVariable::Time));
497        assert_eq!(BuiltInVariable::parse("unknown"), None);
498    }
499
500    #[test]
501    fn test_custom_action_id() {
502        let action = CustomActionConfig::ShellCommand {
503            id: "test-action".to_string(),
504            title: "Test Action".to_string(),
505            command: "echo".to_string(),
506            args: vec!["hello".to_string()],
507            notify_on_success: false,
508            keybinding: None,
509            keybinding_enabled: true,
510            description: None,
511        };
512
513        assert_eq!(action.id(), "test-action");
514        assert_eq!(action.title(), "Test Action");
515        assert!(action.is_shell_command());
516        assert!(!action.is_insert_text());
517        assert!(!action.is_key_sequence());
518    }
519}