use std::path::PathBuf;
use clap::{ArgGroup, Parser, Subcommand};
#[derive(Debug, Parser)]
#[command(
name = "render-session",
about = "render-session: MCP server + HTTP viewer for AI session output",
version
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Debug, Subcommand)]
enum Commands {
Mcp,
Serve {
#[arg(long, default_value = "8000")]
port: u16,
#[arg(long)]
dir: String,
#[arg(long)]
watch_tick: Option<u64>,
#[arg(long, value_enum)]
lifecycle: Option<ViewerLifecycleCli>,
},
Gen {
#[arg(long)]
project: String,
},
Report {
#[arg(long)]
dir: String,
#[arg(long)]
title: String,
},
Capture {
#[arg(long)]
project: std::path::PathBuf,
#[arg(long, default_value = "both")]
mode: String,
#[arg(long)]
slug: Option<String>,
#[arg(long, default_value_t = 5)]
n: usize,
#[arg(long, default_value_t = false)]
all: bool,
},
Config {
#[command(subcommand)]
subcommand: ConfigSubcommand,
},
Info {
#[arg(long)]
project: Option<PathBuf>,
#[arg(long, default_value = "table")]
format: Option<String>,
},
Viewer {
#[command(subcommand)]
subcommand: ViewerSubcommand,
},
}
#[derive(Debug, Clone, clap::ValueEnum)]
enum ViewerLifecycleCli {
Detached,
Session,
Launchd,
}
impl From<ViewerLifecycleCli> for render_session_core::config::ViewerLifecycle {
fn from(cli: ViewerLifecycleCli) -> Self {
match cli {
ViewerLifecycleCli::Detached => render_session_core::config::ViewerLifecycle::Detached,
ViewerLifecycleCli::Session => render_session_core::config::ViewerLifecycle::Session,
ViewerLifecycleCli::Launchd => render_session_core::config::ViewerLifecycle::Launchd,
}
}
}
#[derive(Debug, Subcommand)]
enum ViewerSubcommand {
List,
#[command(group(
ArgGroup::new("target")
.required(true)
.args(["pid", "all", "dir"])
))]
Kill {
#[arg(long)]
pid: Option<u32>,
#[arg(long, default_value_t = false)]
all: bool,
#[arg(long)]
dir: Option<PathBuf>,
},
Plist {
#[arg(long)]
dir: PathBuf,
},
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt::Subscriber::builder()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let cli = Cli::parse();
match cli.command {
Commands::Mcp => run_mcp().await,
Commands::Serve {
port,
dir,
watch_tick,
lifecycle,
} => {
let resolved_lifecycle = resolve_lifecycle(lifecycle, &dir);
run_serve(port, dir, watch_tick, resolved_lifecycle).await
}
Commands::Gen { project } => run_gen(project).await,
Commands::Report { dir, title } => run_report(dir, title).await,
Commands::Capture {
project,
mode,
slug,
n,
all,
} => run_capture(project, mode, slug, n, all).await,
Commands::Config { subcommand } => run_config(subcommand).await,
Commands::Info { project, format } => run_info(project, format).await,
Commands::Viewer { subcommand } => run_viewer(subcommand).await,
}
}
async fn run_mcp() -> anyhow::Result<()> {
let exe_dir = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.to_path_buf()));
let mcp_path = exe_dir
.map(|d| d.join("render-session-mcp"))
.filter(|p| p.exists())
.unwrap_or_else(|| std::path::PathBuf::from("render-session-mcp"));
let status = tokio::process::Command::new(&mcp_path)
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.status()
.await
.map_err(|e| {
tracing::error!(
error = %e,
path = %mcp_path.display(),
"failed to spawn render-session-mcp"
);
e
})?;
if !status.success() {
let code = status.code().unwrap_or(-1);
tracing::error!(exit_code = code, "render-session-mcp exited non-zero");
anyhow::bail!("render-session-mcp exited with {}", status);
}
Ok(())
}
fn resolve_lifecycle(
cli_flag: Option<ViewerLifecycleCli>,
dir: &str,
) -> render_session_core::config::ViewerLifecycle {
use render_session_core::config::ViewerLifecycle;
if let Some(flag) = cli_flag {
return flag.into();
}
let project_root = std::path::Path::new(dir);
match render_session_core::config::load(project_root) {
Ok(loaded) => loaded.config.viewer.lifecycle,
Err(e) => {
tracing::warn!(
error = %e,
dir = %dir,
"resolve_lifecycle: config load failed, using default (Detached)"
);
ViewerLifecycle::default()
}
}
}
async fn run_viewer(sub: ViewerSubcommand) -> anyhow::Result<()> {
match sub {
ViewerSubcommand::List => run_viewer_list().await,
ViewerSubcommand::Kill { pid, all, dir } => run_viewer_kill(pid, all, dir).await,
ViewerSubcommand::Plist { dir } => run_viewer_plist(dir).await,
}
}
async fn run_viewer_list() -> anyhow::Result<()> {
use anyhow::Context as _;
use render_session_core::lease::LeaseStore;
let store_path = render_session_core::lease::store_path()
.context("viewer list: failed to resolve lease store path")?;
let store = LeaseStore::load(store_path)
.await
.context("viewer list: failed to load lease store")?;
let entries = store.list();
if entries.is_empty() {
println!("(no active viewer leases)");
return Ok(());
}
println!(
"{:<8} {:<6} {:<50} {:<22} TICK",
"PID", "PORT", "DIR", "STARTED_AT"
);
println!("{}", "-".repeat(100));
for e in entries {
let tick_str = e
.tick
.map(|t| t.to_string())
.unwrap_or_else(|| "-".to_string());
println!(
"{:<8} {:<6} {:<50} {:<22} {}",
e.pid,
e.port,
e.dir.display(),
e.started_at,
tick_str
);
}
Ok(())
}
async fn run_viewer_kill(pid: Option<u32>, all: bool, dir: Option<PathBuf>) -> anyhow::Result<()> {
use anyhow::Context as _;
use render_session_core::lease::LeaseStore;
let store_path = render_session_core::lease::store_path()
.context("viewer kill: failed to resolve lease store path")?;
let mut store = LeaseStore::load(store_path)
.await
.context("viewer kill: failed to load lease store")?;
if all {
let ports: Vec<u16> = store.list().iter().map(|e| e.port).collect();
if ports.is_empty() {
println!("(no active viewer leases to kill)");
return Ok(());
}
for port in ports {
store.release(port).await.map_err(|e| {
tracing::error!(error = %e, port, "viewer kill --all: release failed");
e
})?;
println!("killed viewer on port {port}");
}
store.prune_dead();
} else if let Some(target_pid) = pid {
let port = store
.list()
.iter()
.find(|e| e.pid == target_pid)
.map(|e| e.port)
.ok_or_else(|| {
tracing::warn!(target_pid, "viewer kill: no lease entry found for pid");
anyhow::anyhow!("no active lease found for pid {target_pid}")
})?;
store.release(port).await.map_err(|e| {
tracing::error!(error = %e, port, target_pid, "viewer kill: release failed");
anyhow::anyhow!("kill pid {target_pid}: {e}")
})?;
println!("killed viewer pid={target_pid} port={port}");
} else if let Some(target_dir) = dir {
let ports: Vec<u16> = store
.list()
.iter()
.filter(|e| e.dir == target_dir)
.map(|e| e.port)
.collect();
if ports.is_empty() {
println!(
"(no active viewer lease found for {})",
target_dir.display()
);
return Ok(());
}
for port in ports {
store.release(port).await.map_err(|e| {
tracing::error!(error = %e, port, dir = %target_dir.display(), "viewer kill --dir: release failed");
anyhow::anyhow!("kill --dir {}: {e}", target_dir.display())
})?;
println!("killed viewer on port {port}");
}
}
Ok(())
}
async fn run_viewer_plist(dir: PathBuf) -> anyhow::Result<()> {
#[cfg(not(target_os = "macos"))]
{
let _ = dir;
eprintln!("viewer plist subcommand is macOS-only");
std::process::exit(2);
}
#[cfg(target_os = "macos")]
{
use anyhow::Context as _;
let exe_dir = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.to_path_buf()));
let exe = exe_dir
.map(|d| d.join("render-session"))
.unwrap_or_else(|| std::path::PathBuf::from("render-session"));
let loaded = render_session_core::config::load(&dir)
.context("viewer plist: failed to load config")?;
let data_root = render_session_core::config::data_root(&loaded, &dir);
let stdout_path = data_root.join("viewer.stdout.log");
let stderr_path = data_root.join("viewer.stderr.log");
let label = format!(
"com.render-session.viewer.{}",
dir.file_name()
.and_then(|n| n.to_str())
.unwrap_or("project")
);
let args = vec![
"serve".to_string(),
"--lifecycle".to_string(),
"launchd".to_string(),
"--dir".to_string(),
dir.to_string_lossy().to_string(),
];
let plist = render_session_core::launchd::render_plist(
&label,
&exe,
&args,
&stdout_path,
&stderr_path,
);
print!("{plist}");
Ok(())
}
}
async fn run_serve(
port: u16,
dir: String,
watch_tick: Option<u64>,
lifecycle: render_session_core::config::ViewerLifecycle,
) -> anyhow::Result<()> {
use anyhow::Context as _;
use render_session_core::config::ViewerLifecycle;
if lifecycle == ViewerLifecycle::Session {
tokio::spawn(render_session_core::child::watch_parent_death());
}
if lifecycle == ViewerLifecycle::Detached {
let project_root = std::path::PathBuf::from(&dir);
let pid = std::process::id();
let pid_file_path = match render_session_core::config::load(&project_root) {
Ok(loaded) => render_session_core::config::viewer_pid_file_path(&loaded, &project_root),
Err(e) => {
tracing::warn!(
error = %e,
project = %project_root.display(),
"run_serve: config load failed; using default viewer.json path"
);
project_root.join(".claude/site").join("viewer.json")
}
};
let entry = render_session_core::viewer::PidFileEntry {
pid,
port,
started_at: {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format_unix_utc(secs)
},
tick: watch_tick,
};
tokio::task::spawn_blocking(move || {
render_session_core::viewer::write_atomic(&pid_file_path, &entry)
})
.await
.context("run_serve: viewer.json self-write task panicked")?
.context("run_serve: viewer.json self-write failed")?;
tracing::info!(
pid,
port,
"run_serve: wrote viewer.json (detached lifecycle)"
);
}
let watch_tick_dur = watch_tick.map(|t| {
let clamped = if t == 0 {
tracing::warn!("watch_tick=0 not allowed, clamping to 1");
1
} else {
t
};
std::time::Duration::from_secs(clamped)
});
render_session_core::serve::run(port, std::path::PathBuf::from(dir), watch_tick_dur).await?;
Ok(())
}
fn format_unix_utc(secs: u64) -> String {
let sec_of_day = secs % 86400;
let s = sec_of_day % 60;
let m = (sec_of_day / 60) % 60;
let h = sec_of_day / 3600;
let z = (secs / 86400) as i64;
let z = z + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = (z - era * 146097) as u64; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let day = doy - (153 * mp + 2) / 5 + 1; let month = if mp < 10 { mp + 3 } else { mp - 9 }; let year = y + if month <= 2 { 1 } else { 0 };
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
year, month, day, h, m, s
)
}
async fn run_gen(project: String) -> anyhow::Result<()> {
use anyhow::Context as _;
let project_path = std::path::PathBuf::from(&project);
let loaded =
render_session_core::config::load(&project_path).context("gen: failed to load config")?;
let site_shell = render_session_core::config::site_shell(&loaded, &project_path);
let list_path =
render_session_core::gen::emit_list_json(&loaded.config, &project_path, &site_shell)
.map_err(|e| {
tracing::error!(error = %e, project = %project, "emit_list_json failed");
e
})?;
tracing::info!(file = %list_path.display(), "list.json emitted");
println!("{}", list_path.display());
let rules_path =
render_session_core::gen::emit_rules_md(&loaded.config, &project_path, &site_shell)
.map_err(|e| {
tracing::error!(error = %e, project = %project, "emit_rules_md failed");
e
})?;
tracing::info!(file = %rules_path.display(), "render-lanes.md emitted");
println!("{}", rules_path.display());
Ok(())
}
async fn run_report(dir: String, title: String) -> anyhow::Result<()> {
use tokio::io::AsyncReadExt as _;
let mut body = String::new();
tokio::io::stdin()
.read_to_string(&mut body)
.await
.map_err(|e| {
tracing::error!(error = %e, "failed to read stdin for report");
e
})?;
let project_dir = std::path::Path::new(&dir);
let target_dir = render_session_core::config::category_dir_or_default(project_dir, "reports");
let path = render_session_core::writer::write_report(&target_dir, &title, &body)
.await
.map_err(|e| {
tracing::error!(error = %e, dir = %dir, title = %title, "write_report failed");
e
})?;
println!("{}", path.display());
Ok(())
}
async fn run_capture(
project: std::path::PathBuf,
mode: String,
slug: Option<String>,
n: usize,
all: bool,
) -> anyhow::Result<()> {
let claude_slug = match slug {
Some(s) => s,
None => derive_slug(&project).map_err(|e| {
tracing::error!(project = %project.display(), error = %e, "derive_slug failed");
e
})?,
};
let config = match render_session_core::config::load(&project) {
Ok(loaded) => loaded.config,
Err(e) => {
tracing::warn!(
project = %project.display(),
error = %e,
"capture: config load failed, using default"
);
render_session_core::config::Config::default()
}
};
let filter_chain_cfg = config
.categories
.get("recent")
.and_then(|c| c.filter.as_ref());
let filter_registry: Option<render_session_core::filters::FilterRegistry> =
filter_chain_cfg.map(|_| render_session_core::filters::FilterRegistry::with_builtins());
let config_filter_arg: Option<(
&render_session_core::filters::FilterRegistry,
&render_session_core::filters::FilterChainConfig,
)> = match (filter_registry.as_ref(), filter_chain_cfg) {
(Some(reg), Some(cfg)) => Some((reg, cfg)),
_ => None,
};
let override_registry;
let override_chain;
let filter_arg: Option<(
&render_session_core::filters::FilterRegistry,
&render_session_core::filters::FilterChainConfig,
)> = if all {
override_registry = render_session_core::filters::FilterRegistry::with_builtins();
override_chain = render_session_core::filters::FilterChainConfig {
steps: vec![],
mode: render_session_core::filters::FilterMode::All,
};
Some((&override_registry, &override_chain))
} else {
config_filter_arg
};
match mode.as_str() {
"session" => {
if let Some(p) =
render_session_core::sources::session::capture_session(&project, &claude_slug)
.map_err(|e| {
tracing::error!(error = %e, "capture_session failed");
e
})?
{
println!("{}", p.display());
}
}
"recent" => {
for p in render_session_core::sources::session::capture_recent(
&project,
&claude_slug,
n,
filter_arg,
)
.map_err(|e| {
tracing::error!(error = %e, "capture_recent failed");
e
})? {
println!("{}", p.display());
}
}
"both" => {
if let Some(p) =
render_session_core::sources::session::capture_session(&project, &claude_slug)
.map_err(|e| {
tracing::error!(error = %e, "capture_session failed");
e
})?
{
println!("{}", p.display());
}
for p in render_session_core::sources::session::capture_recent(
&project,
&claude_slug,
n,
filter_arg,
)
.map_err(|e| {
tracing::error!(error = %e, "capture_recent failed");
e
})? {
println!("{}", p.display());
}
}
other => {
anyhow::bail!("invalid mode {:?}, expected session|recent|both", other);
}
}
Ok(())
}
async fn run_info(project: Option<PathBuf>, format: Option<String>) -> anyhow::Result<()> {
use anyhow::Context as _;
use render_session_core::info::build_info_report;
use render_session_core::lease::LeaseStore;
let fmt = format.as_deref().unwrap_or("table");
if fmt != "json" && fmt != "table" {
anyhow::bail!("invalid format {:?}, expected json|table", fmt);
}
let project_root = resolve_project_dir(project);
let store_path = render_session_core::lease::store_path()
.context("info: failed to resolve lease store path")?;
let store = LeaseStore::load(store_path)
.await
.context("info: failed to load lease store")?;
let report = build_info_report(&project_root, env!("CARGO_PKG_VERSION"), &store)
.context("info: failed to build report")?;
if fmt == "json" {
println!(
"{}",
serde_json::to_string_pretty(&report).context("info: JSON serialize")?
);
} else {
print!("{}", format_info_table(&report));
}
Ok(())
}
fn format_info_table(report: &render_session_core::info::InfoReport) -> String {
let mut out = String::new();
out.push_str(&format!("Project: {}", report.project_root.display()));
out.push('\n');
out.push('\n');
out.push_str(&format!("Version: {}", report.binary_version));
out.push('\n');
out.push('\n');
out.push_str("Mounts:\n");
for m in &report.mounts {
out.push_str(&format!(
" {}: {} (source: {}, exists: {}, files: {})\n",
m.name,
m.dir.display(),
m.source_layer,
m.exists,
m.files_count
));
}
out.push('\n');
out.push_str("Viewer:\n");
match &report.viewer {
Some(v) => {
out.push_str(&format!(
" pid={} port={} dir={} started_at={}\n",
v.pid,
v.port,
v.dir.display(),
v.started_at
));
}
None => {
out.push_str(" (none)\n");
}
}
out
}
fn derive_slug(project: &std::path::Path) -> anyhow::Result<String> {
let canonical = project.canonicalize().map_err(|e| {
tracing::error!(project = %project.display(), error = %e, "canonicalize failed");
e
})?;
Ok(canonical.to_string_lossy().replace('/', "-"))
}
#[derive(Debug, Subcommand)]
enum ConfigSubcommand {
Show {
#[arg(long)]
project: PathBuf,
#[arg(long, default_value_t = false)]
origin: bool,
},
Info {
#[arg(long)]
project: Option<PathBuf>,
},
Doctor {
#[arg(long)]
project: Option<PathBuf>,
},
}
struct DoctorCheck {
name: String,
ok: bool,
message: Option<String>,
}
async fn run_config(sub: ConfigSubcommand) -> anyhow::Result<()> {
match sub {
ConfigSubcommand::Show { project, origin } => handle_config_show(project, origin).await,
ConfigSubcommand::Info { project } => handle_config_info(project).await,
ConfigSubcommand::Doctor { project } => handle_config_doctor(project).await,
}
}
async fn handle_config_show(project: PathBuf, origin: bool) -> anyhow::Result<()> {
use anyhow::Context as _;
let loaded = render_session_core::config::load(&project)
.context("config show: failed to load config")?;
let yaml = serde_yaml::to_string(&loaded.config)
.context("config show: failed to serialize config as YAML")?;
if !origin {
print!("{yaml}");
return Ok(());
}
let value: serde_yaml::Value =
serde_yaml::from_str(&yaml).context("config show: failed to re-parse YAML for origin")?;
let keys = flatten_keys(&value, "");
println!("# Effective configuration (with provenance)");
print!("{yaml}");
if !keys.is_empty() {
println!("# --- origin annotations ---");
for key in &keys {
let origin_label = match loaded.origin_of(key) {
Some(src) => format!("{src}"),
None => "unknown".to_string(),
};
println!("# {key}: {origin_label}");
}
}
Ok(())
}
async fn handle_config_info(project: Option<PathBuf>) -> anyhow::Result<()> {
use anyhow::Context as _;
let project = resolve_project_dir(project);
let loaded = render_session_core::config::load(&project)
.context("config info: failed to load config")?;
println!("Layer paths:");
for (kind, path, status) in loaded.layer_paths() {
println!(" {kind:?}: {} [{status:?}]", path.display());
}
println!("\nEnv overrides:");
let overrides = loaded.env_overrides();
if overrides.is_empty() {
println!(" (none)");
} else {
for (name, value) in overrides {
println!(" {name} = ***** ({} chars)", value.len());
}
}
Ok(())
}
async fn handle_config_doctor(project: Option<PathBuf>) -> anyhow::Result<()> {
use anyhow::Context as _;
use render_session_core::config::LayerStatus;
let project = resolve_project_dir(project);
let loaded = render_session_core::config::load(&project)
.context("config doctor: failed to load config")?;
let mut checks: Vec<DoctorCheck> = Vec::new();
for (kind, path, status) in loaded.layer_paths() {
let name = format!("{kind:?} layer");
let (ok, message) = match status {
LayerStatus::Loaded => (true, format!("found: {}", path.display())),
LayerStatus::Absent => {
(true, format!("absent (optional): {}", path.display()))
}
LayerStatus::Failed(err) => (false, format!("parse error: {err}")),
};
checks.push(DoctorCheck {
name,
ok,
message: Some(message),
});
}
if let Ok(cfg_path) = std::env::var("RENDER_SESSION_CONFIG") {
if !cfg_path.is_empty() {
let p = std::path::Path::new(&cfg_path);
let (ok, msg) = if p.exists() {
(
true,
format!("RENDER_SESSION_CONFIG path exists: {cfg_path}"),
)
} else {
(
false,
format!("RENDER_SESSION_CONFIG path not found: {cfg_path}"),
)
};
checks.push(DoctorCheck {
name: "RENDER_SESSION_CONFIG path".to_string(),
ok,
message: Some(msg),
});
}
}
let override_count = loaded.env_overrides().len();
if override_count > 0 {
checks.push(DoctorCheck {
name: "env overrides parse".to_string(),
ok: true,
message: Some(format!(
"{override_count} active override(s) parsed successfully"
)),
});
}
let mut fail_count = 0usize;
for check in &checks {
let symbol = if check.ok { "OK" } else { "FAIL" };
let detail = check.message.as_deref().unwrap_or("no detail");
println!("[{symbol}] {}: {detail}", check.name);
if !check.ok {
fail_count += 1;
}
}
if fail_count > 0 {
tracing::warn!(fail_count, "config doctor found issue(s)");
println!("\ndoctor: {fail_count} issue(s) found");
std::process::exit(1);
} else {
println!("\ndoctor: all checks passed");
}
Ok(())
}
fn resolve_project_dir(project: Option<PathBuf>) -> PathBuf {
project.unwrap_or_else(|| {
std::env::current_dir().unwrap_or_else(|e| {
tracing::warn!(error = %e, "current_dir() failed, falling back to '.'");
PathBuf::from(".")
})
})
}
fn flatten_keys(v: &serde_yaml::Value, prefix: &str) -> Vec<String> {
let mut out = Vec::new();
if let serde_yaml::Value::Mapping(map) = v {
for (k, val) in map {
if let Some(key) = k.as_str() {
let full = if prefix.is_empty() {
key.to_string()
} else {
format!("{prefix}.{key}")
};
match val {
serde_yaml::Value::Mapping(_) => out.extend(flatten_keys(val, &full)),
_ => out.push(full),
}
}
}
}
out
}
#[cfg(test)]
mod tests {
use render_session_core::info::{InfoReport, MountInfo, ViewerStatus};
use std::path::PathBuf;
#[test]
fn test_format_info_table_sections() {
let report = InfoReport {
binary_version: "0.3.0".to_string(),
project_root: PathBuf::from("/tmp/test-project"),
data_root: PathBuf::from("/tmp/test-project/.claude/site"),
data_root_source_layer: "default".to_string(),
mounts: vec![MountInfo {
name: "recent".to_string(),
enabled: true,
dir: PathBuf::from("/tmp/test-project/render-site/recent"),
exists: true,
files_count: 3,
source_layer: "default".to_string(),
}],
viewer: Some(ViewerStatus {
pid: 12345,
port: 8000,
dir: PathBuf::from("/tmp/test-project"),
started_at: "2026-01-01T00:00:00Z".to_string(),
}),
};
let output = super::format_info_table(&report);
assert!(
output.contains("Project: /tmp/test-project"),
"missing Project section"
);
assert!(output.contains("Version: 0.3.0"), "missing Version section");
assert!(output.contains("Mounts:"), "missing Mounts section");
assert!(output.contains("recent:"), "missing recent mount");
assert!(output.contains("source: default"), "missing source_layer");
assert!(output.contains("files: 3"), "missing files_count");
assert!(output.contains("Viewer:"), "missing Viewer section");
assert!(output.contains("pid=12345"), "missing pid");
assert!(output.contains("port=8000"), "missing port");
}
#[test]
fn test_format_info_table_no_viewer() {
let report = InfoReport {
binary_version: "0.3.0".to_string(),
project_root: PathBuf::from("/tmp/no-viewer"),
data_root: PathBuf::from("/tmp/no-viewer/.claude/site"),
data_root_source_layer: "default".to_string(),
mounts: vec![],
viewer: None,
};
let output = super::format_info_table(&report);
assert!(
output.contains("(none)"),
"expected (none) for absent viewer"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_mcp_commands_status_await() {
let true_bin = if cfg!(target_os = "macos") || cfg!(target_os = "linux") {
"true"
} else {
"cmd"
};
let status = tokio::process::Command::new(true_bin)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.await
.expect("spawn `true`");
assert!(status.success(), "`true` must exit with success");
let child = tokio::process::Command::new("sleep")
.arg("30")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.kill_on_drop(false)
.spawn()
.expect("spawn `sleep 30`");
let pid = child.id().expect("child has pid") as libc::pid_t;
drop(child);
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
#[allow(unsafe_code)]
let probe = unsafe { libc::kill(pid, 0) };
assert_eq!(
probe, 0,
"child should still be alive after handle drop (kill_on_drop=false)"
);
#[allow(unsafe_code)]
unsafe {
libc::kill(pid, libc::SIGTERM);
}
}
#[test]
fn test_format_unix_utc_epoch() {
let s = super::format_unix_utc(0);
assert_eq!(
s, "1970-01-01T00:00:00Z",
"epoch timestamp must format correctly"
);
}
#[test]
fn test_format_unix_utc_known_date() {
let s = super::format_unix_utc(1_704_067_200);
assert_eq!(
s, "2024-01-01T00:00:00Z",
"known timestamp 1704067200 must format as 2024-01-01T00:00:00Z"
);
}
#[test]
fn test_format_unix_utc_leap_year() {
let s = super::format_unix_utc(951_782_400);
assert_eq!(
s, "2000-02-29T00:00:00Z",
"leap day 2000-02-29 must format correctly"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn run_serve_writes_viewer_json_on_detached_start() {
use render_session_core::config::ViewerLifecycle;
use render_session_core::viewer::{read_alive, write_atomic, PidFileEntry};
let tmp_dir =
std::env::temp_dir().join(format!("render-session-test-{}", std::process::id()));
std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
let pid_file_path = tmp_dir.join("viewer.json");
let pid = std::process::id();
let entry = PidFileEntry {
pid,
port: 18200,
started_at: super::format_unix_utc(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
),
tick: None,
};
tokio::task::spawn_blocking({
let p = pid_file_path.clone();
let e = entry.clone();
move || write_atomic(&p, &e)
})
.await
.expect("spawn_blocking join")
.expect("write_atomic");
assert_eq!(ViewerLifecycle::default(), ViewerLifecycle::Detached);
let result = read_alive(&pid_file_path).expect("read_alive");
assert_eq!(
result,
Some(entry),
"viewer.json must contain the written entry with current PID"
);
}
#[test]
fn run_serve_skips_watch_parent_death_for_detached_lifecycle() {
use render_session_core::config::ViewerLifecycle;
let detached = ViewerLifecycle::Detached;
let session = ViewerLifecycle::Session;
let launchd = ViewerLifecycle::Launchd;
assert!(
!(detached != ViewerLifecycle::Detached),
"Detached lifecycle must skip watch_parent_death"
);
assert!(
session == ViewerLifecycle::Session,
"Session lifecycle must trigger watch_parent_death"
);
assert!(
launchd != ViewerLifecycle::Session,
"Launchd lifecycle must NOT trigger watch_parent_death"
);
}
}