use super::{PagerAction, PagerConfig, PagerContent, copy_to_clipboard, run};
use crate::config::{ConfigStatus, RSourceStatus, mask_home_path};
use crate::editor::prompt::get_r_version;
use crate::external::{formatter, rig};
use crate::repl::state::PromptRuntimeConfig;
use crossterm::event::{KeyCode, KeyModifiers};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use std::path::PathBuf;
pub fn display_session_info(
prompt_config: &PromptRuntimeConfig,
config_path: &Option<PathBuf>,
config_status: ConfigStatus,
r_history_path: &Option<PathBuf>,
shell_history_path: &Option<PathBuf>,
r_source_status: &RSourceStatus,
) {
let lines = generate_info_lines(
prompt_config,
config_path,
config_status,
r_history_path,
shell_history_path,
r_source_status,
);
let mut content = SessionInfoContent::new(lines);
let config = PagerConfig {
title: "Session Info",
footer_hint: "↑↓/jk scroll │ c copy │ q exit",
manage_alternate_screen: true,
};
if let Err(e) = run(&mut content, &config) {
eprintln!("Pager error: {}", e);
}
}
fn generate_info_lines(
prompt_config: &PromptRuntimeConfig,
config_path: &Option<PathBuf>,
config_status: ConfigStatus,
r_history_path: &Option<PathBuf>,
shell_history_path: &Option<PathBuf>,
r_source_status: &RSourceStatus,
) -> Vec<String> {
let mut lines = Vec::new();
lines.push("# Session Information".to_string());
lines.push(String::new());
lines.push(format!("arf version: {}", env!("CARGO_PKG_VERSION")));
lines.push(format!(
"OS: {} ({})",
std::env::consts::OS,
std::env::consts::ARCH
));
if let Some(path) = config_path {
if path.exists() {
match config_status {
ConfigStatus::Ok => {
lines.push(format!("Config file: {}", mask_home_path(path)));
}
ConfigStatus::ParseError => {
lines.push(format!(
"Config file: {} (parse error, using defaults)",
mask_home_path(path)
));
}
ConfigStatus::ReadError => {
lines.push(format!(
"Config file: {} (read error, using defaults)",
mask_home_path(path)
));
}
}
} else {
lines.push(format!(
"Config file: {} (not found, using defaults)",
mask_home_path(path)
));
}
} else {
lines.push("Config file: (using defaults)".to_string());
}
let r_version = get_r_version();
if r_version.is_empty() {
lines.push("R version: (not available)".to_string());
} else {
lines.push(format!("R version: {}", r_version));
}
if let Ok(r_home) = std::env::var("R_HOME") {
let r_home_path = std::path::Path::new(&r_home);
lines.push(format!("R_HOME: {}", mask_home_path(r_home_path)));
}
lines.push(format!("R source: {}", r_source_status.display()));
lines.push(String::new());
if rig::rig_available() {
let mut rig_line = "rig: installed".to_string();
if let Ok(versions) = rig::list_versions()
&& !versions.is_empty()
{
let version_list: Vec<_> = versions
.iter()
.map(|v| {
if v.default {
format!("{}*", v.name)
} else {
v.name.clone()
}
})
.collect();
rig_line.push_str(&format!(" ({})", version_list.join(", ")));
}
lines.push(rig_line);
} else {
lines.push("rig: not installed".to_string());
}
if formatter::is_formatter_available() {
lines.push("air: installed".to_string());
} else {
lines.push("air: not installed".to_string());
}
lines.push(String::new());
let mode = if prompt_config.is_shell_enabled() {
"Shell"
} else if prompt_config.is_reprex_enabled() {
"R (reprex)"
} else {
"R"
};
lines.push(format!("Current mode: {}", mode));
if prompt_config.is_reprex_enabled() {
let autoformat = if prompt_config.is_autoformat_enabled() {
"enabled"
} else {
"disabled"
};
lines.push(format!("Auto-format: {}", autoformat));
}
lines.push(String::new());
if let Some(path) = r_history_path {
lines.push(format!("R history: {}", mask_home_path(path)));
}
if let Some(path) = shell_history_path {
lines.push(format!("Shell history: {}", mask_home_path(path)));
}
lines.push(String::new());
let env_vars = [
("ARF_HISTORY_DIR", "ARF_HISTORY_DIR: "),
("R_LIBS", "R_LIBS: "),
("R_LIBS_USER", "R_LIBS_USER: "),
("R_LIBS_SITE", "R_LIBS_SITE: "),
("R_PROFILE", "R_PROFILE: "),
("R_ENVIRON", "R_ENVIRON: "),
];
let mut has_env = false;
for (var, label) in &env_vars {
if let Ok(value) = std::env::var(var) {
if !has_env {
lines.push("## Environment Variables".to_string());
lines.push(String::new());
has_env = true;
}
let masked_value = mask_env_value(&value);
lines.push(format!("{}{}", label, masked_value));
}
}
lines
}
fn mask_env_value(value: &str) -> String {
let separator = if cfg!(windows) { ';' } else { ':' };
let masked_parts: Vec<String> = value
.split(separator)
.map(|part| {
let path = std::path::Path::new(part);
mask_home_path(path)
})
.collect();
masked_parts.join(&separator.to_string())
}
struct SessionInfoContent {
lines: Vec<String>,
feedback_message: Option<String>,
}
impl SessionInfoContent {
fn new(lines: Vec<String>) -> Self {
Self {
lines,
feedback_message: None,
}
}
fn as_plain_text(&self) -> String {
self.lines.join("\n")
}
}
impl PagerContent for SessionInfoContent {
fn line_count(&self) -> usize {
self.lines.len()
}
fn render_line(&self, index: usize, _width: usize) -> Line<'static> {
let line = &self.lines[index];
style_info_line(line)
}
fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> Option<PagerAction> {
if code == KeyCode::Char('c') && modifiers == KeyModifiers::NONE {
let text = self.as_plain_text();
if copy_to_clipboard(&text).is_ok() {
self.feedback_message = Some("Copied session info to clipboard".to_string());
} else {
self.feedback_message = Some("Failed to copy".to_string());
}
return None; }
None
}
fn feedback_message(&self) -> Option<&str> {
self.feedback_message.as_deref()
}
fn clear_feedback(&mut self) {
self.feedback_message = None;
}
}
fn style_info_line(line: &str) -> Line<'static> {
if line.starts_with("# ") || line.starts_with("## ") {
return Line::from(Span::styled(
line.to_string(),
Style::default().add_modifier(Modifier::BOLD),
));
}
if let Some(colon_idx) = line.find(':')
&& !line.starts_with(' ')
{
let (key, value) = line.split_at(colon_idx);
return Line::from(vec![
Span::styled(key.to_string(), Style::default().fg(Color::Cyan)),
Span::raw(value.to_string()),
]);
}
Line::from(line.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mask_env_value_with_home() {
if let Some(home) = dirs::home_dir() {
let home_str = home.display().to_string();
let sep = std::path::MAIN_SEPARATOR;
let test_value = format!("{}{}R{}library", home_str, sep, sep);
let masked = mask_env_value(&test_value);
assert!(masked.starts_with("~"), "Should mask home dir: {}", masked);
assert!(masked.contains("R"), "Should contain R: {}", masked);
assert!(
masked.contains("library"),
"Should contain library: {}",
masked
);
}
}
#[test]
fn test_mask_env_value_without_home() {
let test_value = "/opt/R/library";
let expected = std::path::Path::new(test_value).display().to_string();
let masked = mask_env_value(test_value);
assert_eq!(masked, expected, "Should not change non-home paths");
}
#[test]
fn test_mask_env_value_multiple_paths() {
if let Some(home) = dirs::home_dir() {
let home_str = home.display().to_string();
let path_sep = std::path::MAIN_SEPARATOR;
let list_sep = if cfg!(windows) { ';' } else { ':' };
let test_value = format!(
"{}{}.R{}library{}{}{}other",
home_str, path_sep, path_sep, list_sep, home_str, path_sep
);
let masked = mask_env_value(&test_value);
assert!(
!masked.contains(&home_str),
"All home dirs should be masked: {}",
masked
);
assert!(masked.starts_with("~"), "Should start with ~: {}", masked);
}
}
#[test]
fn test_style_info_line_heading() {
let styled = style_info_line("# Session Information");
assert!(
styled.spans[0].style.add_modifier.contains(Modifier::BOLD),
"Heading should be bold"
);
}
#[test]
fn test_style_info_line_h2_heading() {
let styled = style_info_line("## Environment Variables");
assert!(
styled.spans[0].style.add_modifier.contains(Modifier::BOLD),
"H2 heading should be bold"
);
}
#[test]
fn test_style_info_line_key_value() {
let styled = style_info_line("arf version: 0.2.1");
assert_eq!(styled.spans[0].style.fg, Some(Color::Cyan));
assert_eq!(styled.spans[0].content, "arf version");
assert_eq!(styled.spans[1].content, ": 0.2.1");
}
#[test]
fn test_style_info_line_env_var() {
let styled = style_info_line("R_LIBS: /path/to/libs");
assert_eq!(styled.spans[0].style.fg, Some(Color::Cyan));
assert_eq!(styled.spans[0].content, "R_LIBS");
}
#[test]
fn test_style_info_line_empty() {
let styled = style_info_line("");
assert!(styled.spans.is_empty() || styled.spans[0].content.is_empty());
}
#[test]
fn test_style_info_line_plain() {
let styled = style_info_line("Some plain text without special formatting");
assert_eq!(styled.spans.len(), 1);
assert_eq!(
styled.spans[0].content,
"Some plain text without special formatting"
);
assert_eq!(styled.spans[0].style, Style::default());
}
#[test]
fn test_session_info_content_as_plain_text() {
let lines = vec![
"# Test".to_string(),
"key: value".to_string(),
"".to_string(),
];
let content = SessionInfoContent::new(lines);
let plain = content.as_plain_text();
assert_eq!(plain, "# Test\nkey: value\n");
}
#[test]
fn test_session_info_content_line_count() {
let lines = vec!["line1".to_string(), "line2".to_string()];
let content = SessionInfoContent::new(lines);
assert_eq!(content.line_count(), 2);
}
use crate::editor::prompt::PromptFormatter;
use crate::repl::state::PromptRuntimeConfig;
fn default_prompt_config() -> PromptRuntimeConfig {
PromptRuntimeConfig::builder(PromptFormatter::default(), "r> ", "+ ", "[bash] $ ").build()
}
#[test]
fn test_generate_info_lines_config_path_none() {
let config = default_prompt_config();
let lines = generate_info_lines(
&config,
&None,
ConfigStatus::Ok,
&None,
&None,
&RSourceStatus::Path,
);
let config_line = lines
.iter()
.find(|l| l.starts_with("Config file:"))
.unwrap();
assert!(
config_line.contains("using defaults"),
"None path should show defaults: {}",
config_line
);
}
#[test]
fn test_generate_info_lines_config_path_existing() {
let config = default_prompt_config();
let temp_file = tempfile::NamedTempFile::new().unwrap();
let path = temp_file.path().to_path_buf();
let masked = mask_home_path(&path);
let lines = generate_info_lines(
&config,
&Some(path),
ConfigStatus::Ok,
&None,
&None,
&RSourceStatus::Path,
);
let config_line = lines
.iter()
.find(|l| l.starts_with("Config file:"))
.unwrap();
assert!(
config_line.contains(&masked),
"Existing path should contain the file path: {}",
config_line
);
assert!(
!config_line.contains("not found"),
"Existing path should not say 'not found': {}",
config_line
);
assert!(
!config_line.contains("using defaults"),
"Existing path should not say 'using defaults': {}",
config_line
);
}
#[test]
fn test_generate_info_lines_config_path_nonexistent() {
let config = default_prompt_config();
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("nonexistent_config.toml");
let lines = generate_info_lines(
&config,
&Some(path),
ConfigStatus::Ok,
&None,
&None,
&RSourceStatus::Path,
);
let config_line = lines
.iter()
.find(|l| l.starts_with("Config file:"))
.unwrap();
assert!(
config_line.contains("not found"),
"Non-existing path should say 'not found': {}",
config_line
);
}
#[test]
fn test_generate_info_lines_config_parse_error() {
let config = default_prompt_config();
let temp_file = tempfile::NamedTempFile::new().unwrap();
let path = temp_file.path().to_path_buf();
let lines = generate_info_lines(
&config,
&Some(path),
ConfigStatus::ParseError,
&None,
&None,
&RSourceStatus::Path,
);
let config_line = lines
.iter()
.find(|l| l.starts_with("Config file:"))
.unwrap();
assert!(
config_line.contains("parse error"),
"Parse error should be shown: {}",
config_line
);
}
#[test]
fn test_generate_info_lines_config_read_error() {
let config = default_prompt_config();
let temp_file = tempfile::NamedTempFile::new().unwrap();
let path = temp_file.path().to_path_buf();
let lines = generate_info_lines(
&config,
&Some(path),
ConfigStatus::ReadError,
&None,
&None,
&RSourceStatus::Path,
);
let config_line = lines
.iter()
.find(|l| l.starts_with("Config file:"))
.unwrap();
assert!(
config_line.contains("read error"),
"Read error should be shown: {}",
config_line
);
}
}