Rust · 8725 bytes Raw Blame History
1 //! Custom shell prompt with PS1 support
2 //!
3 //! Implements bash-compatible prompt escape sequences:
4 //! - `\u` - Username
5 //! - `\h` - Hostname (short)
6 //! - `\H` - Hostname (full)
7 //! - `\w` - Working directory (~ for home)
8 //! - `\W` - Basename of working directory
9 //! - `\$` - `#` if root, `$` otherwise
10 //! - `\n` - Newline
11 //! - `\r` - Carriage return
12 //! - `\t` - Current time 24-hour (HH:MM:SS)
13 //! - `\T` - Current time 12-hour (HH:MM:SS)
14 //! - `\@` - Current time 12-hour with AM/PM (HH:MM AM/PM)
15 //! - `\A` - Current time 24-hour (HH:MM)
16 //! - `\d` - Date (Day Mon Date)
17 //! - `\D{format}` - Custom strftime format (e.g., `\D{%Y-%m-%d}`)
18 //! - `\\` - Literal backslash
19
20 use reedline::Prompt;
21 use std::borrow::Cow;
22 use std::env;
23
24 /// Custom prompt that supports PS1-style escape sequences
25 pub struct RushPrompt {
26 /// Cached username
27 username: String,
28 /// Cached hostname (short)
29 hostname_short: String,
30 /// Cached hostname (full)
31 hostname_full: String,
32 /// Cached home directory
33 home_dir: Option<String>,
34 }
35
36 impl RushPrompt {
37 pub fn new() -> Self {
38 let username = env::var("USER")
39 .or_else(|_| env::var("USERNAME"))
40 .unwrap_or_else(|_| "user".to_string());
41
42 let hostname_full = hostname::get()
43 .map(|h| h.to_string_lossy().to_string())
44 .unwrap_or_else(|_| "localhost".to_string());
45
46 let hostname_short = hostname_full
47 .split('.')
48 .next()
49 .unwrap_or("localhost")
50 .to_string();
51
52 let home_dir = dirs::home_dir().map(|p| p.to_string_lossy().to_string());
53
54 Self {
55 username,
56 hostname_short,
57 hostname_full,
58 home_dir,
59 }
60 }
61
62 /// Expand PS1-style escape sequences
63 pub fn expand_ps1(&self, ps1: &str) -> String {
64 let mut result = String::with_capacity(ps1.len() * 2);
65 let mut chars = ps1.chars().peekable();
66
67 while let Some(ch) = chars.next() {
68 if ch == '\\' {
69 if let Some(&next) = chars.peek() {
70 chars.next();
71 match next {
72 'u' => result.push_str(&self.username),
73 'h' => result.push_str(&self.hostname_short),
74 'H' => result.push_str(&self.hostname_full),
75 'w' => result.push_str(&self.get_working_dir(false)),
76 'W' => result.push_str(&self.get_working_dir(true)),
77 '$' => {
78 // # for root, $ otherwise
79 if self.is_root() {
80 result.push('#');
81 } else {
82 result.push('$');
83 }
84 }
85 'n' => result.push('\n'),
86 'r' => result.push('\r'),
87 't' => result.push_str(&self.get_time_24()), // HH:MM:SS (24-hour)
88 'T' => result.push_str(&self.get_time_12()), // HH:MM:SS (12-hour)
89 '@' => result.push_str(&self.get_time_12_ampm()), // HH:MM AM/PM
90 'A' => result.push_str(&self.get_time_24_short()), // HH:MM (24-hour)
91 'd' => result.push_str(&self.get_date()),
92 'D' => {
93 // Custom date format: \D{format}
94 if chars.peek() == Some(&'{') {
95 chars.next(); // consume '{'
96 let mut format = String::new();
97 while let Some(&c) = chars.peek() {
98 chars.next();
99 if c == '}' {
100 break;
101 }
102 format.push(c);
103 }
104 result.push_str(&self.get_custom_datetime(&format));
105 } else {
106 // No format specified, use default
107 result.push_str(&self.get_date());
108 }
109 }
110 '\\' => result.push('\\'),
111 '[' => {} // Start of non-printing sequence (ignored for now)
112 ']' => {} // End of non-printing sequence (ignored for now)
113 _ => {
114 // Unknown escape, keep as-is
115 result.push('\\');
116 result.push(next);
117 }
118 }
119 } else {
120 result.push('\\');
121 }
122 } else {
123 result.push(ch);
124 }
125 }
126
127 result
128 }
129
130 /// Get the current working directory, optionally just the basename
131 fn get_working_dir(&self, basename_only: bool) -> String {
132 let cwd = env::current_dir()
133 .map(|p| p.to_string_lossy().to_string())
134 .unwrap_or_else(|_| "?".to_string());
135
136 if basename_only {
137 std::path::Path::new(&cwd)
138 .file_name()
139 .map(|n| n.to_string_lossy().to_string())
140 .unwrap_or_else(|| cwd.clone())
141 } else {
142 // Replace home directory with ~
143 if let Some(home) = &self.home_dir {
144 if cwd == *home {
145 "~".to_string()
146 } else if cwd.starts_with(home) {
147 format!("~{}", &cwd[home.len()..])
148 } else {
149 cwd
150 }
151 } else {
152 cwd
153 }
154 }
155 }
156
157 /// Check if the current user is root
158 fn is_root(&self) -> bool {
159 #[cfg(unix)]
160 {
161 nix::unistd::getuid().is_root()
162 }
163 #[cfg(not(unix))]
164 {
165 false
166 }
167 }
168
169 /// Get current time in HH:MM:SS 24-hour format (\t)
170 fn get_time_24(&self) -> String {
171 chrono::Local::now().format("%H:%M:%S").to_string()
172 }
173
174 /// Get current time in HH:MM:SS 12-hour format (\T)
175 fn get_time_12(&self) -> String {
176 chrono::Local::now().format("%I:%M:%S").to_string()
177 }
178
179 /// Get current time in HH:MM AM/PM format (\@)
180 fn get_time_12_ampm(&self) -> String {
181 chrono::Local::now().format("%I:%M %p").to_string()
182 }
183
184 /// Get current time in HH:MM 24-hour format (\A)
185 fn get_time_24_short(&self) -> String {
186 chrono::Local::now().format("%H:%M").to_string()
187 }
188
189 /// Get current date in "Day Mon Date" format (\d)
190 fn get_date(&self) -> String {
191 chrono::Local::now().format("%a %b %d").to_string()
192 }
193
194 /// Get custom datetime format (\D{format})
195 fn get_custom_datetime(&self, format: &str) -> String {
196 chrono::Local::now().format(format).to_string()
197 }
198 }
199
200 impl Default for RushPrompt {
201 fn default() -> Self {
202 Self::new()
203 }
204 }
205
206 impl Prompt for RushPrompt {
207 fn render_prompt_left(&self) -> Cow<'_, str> {
208 // Check for PS1 environment variable
209 if let Ok(ps1) = env::var("PS1") {
210 Cow::Owned(self.expand_ps1(&ps1))
211 } else {
212 // Default prompt: path〉 (fish-style)
213 Cow::Owned(format!("{}〉", self.get_working_dir(false)))
214 }
215 }
216
217 fn render_prompt_right(&self) -> Cow<'_, str> {
218 // Check for PS1_RIGHT environment variable (custom extension)
219 if let Ok(ps1_right) = env::var("PS1_RIGHT") {
220 Cow::Owned(self.expand_ps1(&ps1_right))
221 } else {
222 // Default right prompt: date and time
223 Cow::Owned(chrono::Local::now().format("%m/%d/%Y %I:%M:%S %p").to_string())
224 }
225 }
226
227 fn render_prompt_indicator(&self, _edit_mode: reedline::PromptEditMode) -> Cow<'_, str> {
228 Cow::Borrowed("")
229 }
230
231 fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> {
232 Cow::Borrowed("> ")
233 }
234
235 fn render_prompt_history_search_indicator(
236 &self,
237 _history_search: reedline::PromptHistorySearch,
238 ) -> Cow<'_, str> {
239 Cow::Borrowed("(search) ")
240 }
241
242 fn get_prompt_color(&self) -> reedline::Color {
243 reedline::Color::Reset
244 }
245
246 fn get_prompt_multiline_color(&self) -> nu_ansi_term::Color {
247 nu_ansi_term::Color::LightGray
248 }
249
250 fn get_indicator_color(&self) -> reedline::Color {
251 reedline::Color::Reset
252 }
253
254 fn get_prompt_right_color(&self) -> reedline::Color {
255 reedline::Color::Reset
256 }
257 }
258