use chrono::{NaiveDate, NaiveTime, Utc};
use std::path::{Path, PathBuf};
pub(crate) fn escape_xml_attr(value: &str) -> String {
value
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
pub(crate) fn file_stem_search_pattern(path: &str) -> String {
Path::new(path)
.file_stem()
.and_then(|s| s.to_str())
.map(str::trim)
.filter(|s| !s.is_empty() && s.chars().any(char::is_alphanumeric))
.unwrap_or("")
.to_string()
}
pub(crate) fn parent_dir_slash(path: &str) -> String {
Path::new(path)
.parent()
.and_then(|p| p.to_str())
.map_or_else(
|| "./".to_string(),
|p| {
let norm = p.replace('\\', "/");
let trimmed = norm.trim_end_matches('/');
if trimmed.is_empty() {
"./".to_string()
} else {
format!("{trimmed}/")
}
},
)
}
pub(crate) fn sessions_dir() -> Option<PathBuf> {
crate::core::data_dir::lean_ctx_data_dir()
.ok()
.map(|d| d.join("sessions"))
}
pub(crate) fn generate_session_id() -> String {
static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let now = Utc::now();
let ts = now.format("%Y%m%d-%H%M%S").to_string();
let micros = now.timestamp_subsec_micros();
let seq = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
format!("{ts}u{micros:06}p{}s{seq}", std::process::id())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct EngineSessionId<'a>(&'a str);
impl<'a> EngineSessionId<'a> {
pub(crate) fn as_str(self) -> &'a str {
self.0
}
}
pub(crate) fn parse_engine_session_id(id: &str) -> Option<EngineSessionId<'_>> {
let bytes = id.as_bytes();
if !(26..=44).contains(&bytes.len())
|| bytes[8] != b'-'
|| !matches!(bytes[15], b'-' | b'u')
|| bytes[22] != b'p'
{
return None;
}
if !bytes[..8].iter().all(u8::is_ascii_digit)
|| !bytes[9..15].iter().all(u8::is_ascii_digit)
|| !bytes[16..22].iter().all(u8::is_ascii_digit)
{
return None;
}
NaiveDate::parse_from_str(&id[..8], "%Y%m%d").ok()?;
let hour = id[9..11].parse::<u32>().ok()?;
let minute = id[11..13].parse::<u32>().ok()?;
let second = id[13..15].parse::<u32>().ok()?;
let micros = id[16..22].parse::<u32>().ok()?;
NaiveTime::from_hms_micro_opt(hour, minute, second, micros)?;
let (pid, sequence) = id[23..].split_once('s')?;
if pid.is_empty()
|| sequence.is_empty()
|| !pid.bytes().all(|byte| byte.is_ascii_digit())
|| !sequence.bytes().all(|byte| byte.is_ascii_digit())
|| pid.starts_with('0')
|| (sequence.len() > 1 && sequence.starts_with('0'))
|| !pid.parse::<u32>().is_ok_and(|value| value > 0)
|| sequence.parse::<u32>().is_err()
{
return None;
}
Some(EngineSessionId(id))
}
pub(crate) fn extract_cd_target(command: &str, base_cwd: &str) -> Option<String> {
let first_cmd = quote_aware_first_segment(command);
let first_cmd = first_cmd.trim();
if !first_cmd.starts_with("cd ") && first_cmd != "cd" {
return None;
}
let mut target = first_cmd.strip_prefix("cd")?.trim();
if target.is_empty() || target == "~" {
return dirs::home_dir().map(|h| h.to_string_lossy().to_string());
}
if target.starts_with("/d ") || target.starts_with("/D ") {
target = target[3..].trim();
}
let target = target.trim_matches('"').trim_matches('\'');
let path = std::path::Path::new(target);
if path.is_absolute() || is_windows_absolute(target) {
Some(target.to_string())
} else {
let base = std::path::Path::new(base_cwd);
let joined = base.join(target).to_string_lossy().to_string();
Some(joined.replace('\\', "/"))
}
}
fn is_windows_absolute(path: &str) -> bool {
let bytes = path.as_bytes();
bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& (bytes[2] == b'\\' || bytes[2] == b'/')
}
fn quote_aware_first_segment(command: &str) -> &str {
let bytes = command.as_bytes();
let len = bytes.len();
let mut i = 0;
let mut in_single = false;
let mut in_double = false;
while i < len {
let ch = bytes[i];
if in_single {
if ch == b'\'' {
in_single = false;
}
i += 1;
continue;
}
if in_double {
if ch == b'"' && (i == 0 || bytes[i - 1] != b'\\') {
in_double = false;
}
i += 1;
continue;
}
match ch {
b'\'' => {
in_single = true;
i += 1;
}
b'"' => {
in_double = true;
i += 1;
}
b';' | b'\n' | b'\r' | b'|' => return &command[..i],
b'&' if i + 1 < len && bytes[i + 1] == b'&' => return &command[..i],
_ => i += 1,
}
}
command
}
pub(crate) fn shorten_path(path: &str) -> String {
let parts: Vec<&str> = path.split('/').collect();
if parts.len() <= 2 {
return path.to_string();
}
let last_two: Vec<&str> = parts.iter().rev().take(2).copied().collect();
format!("…/{}/{}", last_two[1], last_two[0])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cd_simple_path() {
let r = extract_cd_target("cd /foo/bar", "/home");
assert_eq!(r, Some("/foo/bar".into()));
}
#[test]
fn cd_with_chain_operators() {
let r = extract_cd_target("cd /foo && ls", "/home");
assert_eq!(r, Some("/foo".into()));
}
#[test]
fn cd_quoted_path_with_spaces() {
let r = extract_cd_target(r#"cd "/path/with spaces/dir" && build"#, "/home");
assert_eq!(r, Some("/path/with spaces/dir".into()));
}
#[test]
fn cd_quoted_path_with_ampersand_inside() {
let r = extract_cd_target(r#"cd "/path/a&&b""#, "/home");
assert_eq!(r, Some("/path/a&&b".into()));
}
#[test]
fn cd_single_quoted_path_with_semicolon() {
let r = extract_cd_target("cd '/path/a;b' ; ls", "/home");
assert_eq!(r, Some("/path/a;b".into()));
}
#[test]
fn cd_windows_drive() {
let r = extract_cd_target(r"cd /d D:\Projects\app", r"C:\Users\test");
assert_eq!(r, Some(r"D:\Projects\app".into()));
}
#[test]
fn cd_windows_program_files() {
let r = extract_cd_target(
r#"cd /d "C:\Program Files\My Project" && cargo build"#,
r"C:\Users\test",
);
assert_eq!(r, Some(r"C:\Program Files\My Project".into()));
}
#[test]
fn windows_absolute_detection() {
assert!(is_windows_absolute(r"C:\Users\test"));
assert!(is_windows_absolute("D:/Projects/app"));
assert!(!is_windows_absolute("/unix/path"));
assert!(!is_windows_absolute("relative/path"));
}
#[test]
fn cd_relative_path() {
let r = extract_cd_target("cd src/lib", "/home/user/project");
assert_eq!(r, Some("/home/user/project/src/lib".into()));
}
#[test]
fn cd_bare() {
let r = extract_cd_target("cd", "/home/user");
assert!(r.is_some(), "bare cd should return home dir");
}
#[test]
fn not_a_cd_command() {
assert!(extract_cd_target("ls -la", "/home").is_none());
}
#[test]
fn session_id_is_qualified_by_process() {
let id = generate_session_id();
assert!(id.contains(&format!("p{}s", std::process::id())));
assert_eq!(id.as_bytes()[8], b'-');
assert!(id[..8].bytes().all(|byte| byte.is_ascii_digit()));
assert!(id[9..15].bytes().all(|byte| byte.is_ascii_digit()));
assert_eq!(id.as_bytes()[15], b'u');
assert!(parse_engine_session_id(&id).is_some());
assert!(!id.contains('/') && !id.contains('\\'));
}
#[test]
fn engine_session_id_parser_preserves_legacy_shape_without_accepting_free_text() {
assert!(parse_engine_session_id("20261004-123406-123456p43210s0").is_some());
assert!(parse_engine_session_id("20261004-123406u123456p43210s0").is_some());
for invalid in [
"20260230-123406-123456p43210s0",
"20261004-246006u123456p43210s0",
"20261004-123406u123456px43210s0",
"20261004-123406u123456p43210s",
"20261004-123406u123456p43210s0/other",
"20261004-123406u123456p043210s0",
"20261004-123406u123456p43210s00",
"list-boundary",
] {
assert!(parse_engine_session_id(invalid).is_none(), "{invalid}");
}
assert!(
parse_engine_session_id(&format!("20261004-123406u123456p{}1s0", "0".repeat(100)))
.is_none()
);
}
#[test]
fn cd_tilde() {
let r = extract_cd_target("cd ~", "/wherever");
assert!(r.is_some(), "cd ~ should return home dir");
}
}