use std::env;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use ninmu_runtime::{ContentBlock, MessageRole, Session, SessionStore};
pub(crate) const DEFAULT_HISTORY_LIMIT: usize = 20;
#[derive(Debug, Clone)]
pub(crate) struct SessionHandle {
pub(crate) id: String,
pub(crate) path: PathBuf,
}
#[derive(Debug, Clone)]
pub(crate) struct ManagedSessionSummary {
pub(crate) id: String,
pub(crate) path: PathBuf,
pub(crate) updated_at_ms: u64,
pub(crate) modified_epoch_millis: u128,
pub(crate) message_count: usize,
pub(crate) parent_session_id: Option<String>,
pub(crate) branch_name: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct PromptHistoryEntry {
pub(crate) timestamp_ms: u64,
pub(crate) text: String,
}
pub(crate) fn sessions_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
Ok(current_session_store()?.sessions_dir().to_path_buf())
}
pub(crate) fn current_session_store() -> Result<SessionStore, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
SessionStore::from_cwd(&cwd).map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
}
pub(crate) fn new_cli_session() -> Result<Session, Box<dyn std::error::Error>> {
Ok(Session::new().with_workspace_root(env::current_dir()?))
}
pub(crate) fn create_managed_session_handle(
session_id: &str,
) -> Result<SessionHandle, Box<dyn std::error::Error>> {
let handle = current_session_store()?.create_handle(session_id);
Ok(SessionHandle {
id: handle.id,
path: handle.path,
})
}
pub(crate) fn resolve_session_reference(
reference: &str,
) -> Result<SessionHandle, Box<dyn std::error::Error>> {
let handle = current_session_store()?
.resolve_reference(reference)
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
Ok(SessionHandle {
id: handle.id,
path: handle.path,
})
}
pub(crate) fn resolve_managed_session_path(
session_id: &str,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
current_session_store()?
.resolve_managed_path(session_id)
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
}
pub(crate) fn list_managed_sessions(
) -> Result<Vec<ManagedSessionSummary>, Box<dyn std::error::Error>> {
Ok(current_session_store()?
.list_sessions()
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?
.into_iter()
.map(|session| ManagedSessionSummary {
id: session.id,
path: session.path,
updated_at_ms: session.updated_at_ms,
modified_epoch_millis: session.modified_epoch_millis,
message_count: session.message_count,
parent_session_id: session.parent_session_id,
branch_name: session.branch_name,
})
.collect())
}
pub(crate) fn latest_managed_session() -> Result<ManagedSessionSummary, Box<dyn std::error::Error>>
{
let session = current_session_store()?
.latest_session()
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
Ok(ManagedSessionSummary {
id: session.id,
path: session.path,
updated_at_ms: session.updated_at_ms,
modified_epoch_millis: session.modified_epoch_millis,
message_count: session.message_count,
parent_session_id: session.parent_session_id,
branch_name: session.branch_name,
})
}
pub(crate) fn load_session_reference(
reference: &str,
) -> Result<(SessionHandle, Session), Box<dyn std::error::Error>> {
let loaded = current_session_store()?
.load_session(reference)
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
Ok((
SessionHandle {
id: loaded.handle.id,
path: loaded.handle.path,
},
loaded.session,
))
}
pub(crate) fn delete_managed_session(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
if !path.exists() {
return Err(format!("session file does not exist: {}", path.display()).into());
}
fs::remove_file(path)?;
Ok(())
}
pub(crate) fn confirm_session_deletion(session_id: &str) -> bool {
print!("Delete session '{session_id}'? This cannot be undone. [y/N]: ");
io::stdout().flush().unwrap_or(());
let mut answer = String::new();
if io::stdin().read_line(&mut answer).is_err() {
return false;
}
matches!(answer.trim(), "y" | "Y" | "yes" | "Yes" | "YES")
}
pub(crate) fn render_session_list(
active_session_id: &str,
) -> Result<String, Box<dyn std::error::Error>> {
let sessions = list_managed_sessions()?;
let mut lines = vec![
"Sessions".to_string(),
format!(" Directory {}", sessions_dir()?.display()),
];
if sessions.is_empty() {
lines.push(" No managed sessions saved yet.".to_string());
return Ok(lines.join("\n"));
}
for session in sessions {
let marker = if session.id == active_session_id {
"● current"
} else {
"○ saved"
};
let lineage = match (
session.branch_name.as_deref(),
session.parent_session_id.as_deref(),
) {
(Some(branch_name), Some(parent_session_id)) => {
format!(" branch={branch_name} from={parent_session_id}")
}
(None, Some(parent_session_id)) => format!(" from={parent_session_id}"),
(Some(branch_name), None) => format!(" branch={branch_name}"),
(None, None) => String::new(),
};
lines.push(format!(
" {id:<20} {marker:<10} msgs={msgs:<4} modified={modified}{lineage} path={path}",
id = session.id,
msgs = session.message_count,
modified = format_session_modified_age(session.modified_epoch_millis),
lineage = lineage,
path = session.path.display(),
));
}
Ok(lines.join("\n"))
}
pub(crate) fn format_session_modified_age(modified_epoch_millis: u128) -> String {
let now = std::time::SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map_or(modified_epoch_millis, |duration| duration.as_millis());
let delta_seconds = now
.saturating_sub(modified_epoch_millis)
.checked_div(1_000)
.unwrap_or_default();
match delta_seconds {
0..=4 => "just-now".to_string(),
5..=59 => format!("{delta_seconds}s-ago"),
60..=3_599 => format!("{}m-ago", delta_seconds / 60),
3_600..=86_399 => format!("{}h-ago", delta_seconds / 3_600),
_ => format!("{}d-ago", delta_seconds / 86_400),
}
}
pub(crate) fn write_session_clear_backup(
session: &Session,
session_path: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
let backup_path = session_clear_backup_path(session_path);
session.save_to_path(&backup_path)?;
Ok(backup_path)
}
pub(crate) fn session_clear_backup_path(session_path: &Path) -> PathBuf {
let timestamp = std::time::SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map_or(0, |duration| duration.as_millis());
let file_name = session_path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("session.jsonl");
session_path.with_file_name(format!("{file_name}.before-clear-{timestamp}.bak"))
}
pub(crate) fn parse_history_count(raw: Option<&str>) -> Result<usize, String> {
let Some(raw) = raw else {
return Ok(DEFAULT_HISTORY_LIMIT);
};
let parsed: usize = raw
.parse()
.map_err(|_| format!("history: invalid count '{raw}'. Expected a positive integer."))?;
if parsed == 0 {
return Err("history: count must be greater than 0.".to_string());
}
Ok(parsed)
}
pub(crate) fn format_history_timestamp(timestamp_ms: u64) -> String {
let secs = timestamp_ms / 1_000;
let subsec_ms = timestamp_ms % 1_000;
let days_since_epoch = secs / 86_400;
let seconds_of_day = secs % 86_400;
let hours = seconds_of_day / 3_600;
let minutes = (seconds_of_day % 3_600) / 60;
let seconds = seconds_of_day % 60;
let (year, month, day) = civil_from_days(i64::try_from(days_since_epoch).unwrap_or(0));
format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}.{subsec_ms:03}Z")
}
#[allow(
clippy::cast_sign_loss,
clippy::cast_possible_wrap,
clippy::cast_possible_truncation
)]
pub(crate) fn civil_from_days(days: i64) -> (i32, u32, u32) {
let z = days + 719_468;
let era = if z >= 0 {
z / 146_097
} else {
(z - 146_096) / 146_097
};
let doe = (z - era * 146_097) as u64; let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let y = y + i64::from(m <= 2);
(y as i32, m as u32, d as u32)
}
pub(crate) fn render_prompt_history_report(entries: &[PromptHistoryEntry], limit: usize) -> String {
if entries.is_empty() {
return "Prompt history\n Result no prompts recorded yet".to_string();
}
let total = entries.len();
let start = total.saturating_sub(limit);
let shown = &entries[start..];
let mut lines = vec![
"Prompt history".to_string(),
format!(" Total {total}"),
format!(" Showing {} most recent", shown.len()),
format!(" Reverse search Ctrl-R in the REPL"),
String::new(),
];
for (offset, entry) in shown.iter().enumerate() {
let absolute_index = start + offset + 1;
let timestamp = format_history_timestamp(entry.timestamp_ms);
let first_line = entry.text.lines().next().unwrap_or("").trim();
let display = if first_line.chars().count() > 80 {
let truncated: String = first_line.chars().take(77).collect();
format!("{truncated}...")
} else {
first_line.to_string()
};
lines.push(format!(" {absolute_index:>3}. [{timestamp}] {display}"));
}
lines.join("\n")
}
pub(crate) fn collect_session_prompt_history(session: &Session) -> Vec<PromptHistoryEntry> {
if !session.prompt_history.is_empty() {
return session
.prompt_history
.iter()
.map(|entry| PromptHistoryEntry {
timestamp_ms: entry.timestamp_ms,
text: entry.text.clone(),
})
.collect();
}
let timestamp_ms = session.updated_at_ms;
session
.messages
.iter()
.filter(|message| message.role == MessageRole::User)
.filter_map(|message| {
message.blocks.iter().find_map(|block| match block {
ContentBlock::Text { text } => Some(PromptHistoryEntry {
timestamp_ms,
text: text.clone(),
}),
_ => None,
})
})
.collect()
}