use crate::config::{Config, RoiHint, Target};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
pub fn make_session_id(started_at: DateTime<Utc>, exe_hint: &str) -> String {
let stem = exe_stem(exe_hint);
format!("{}_{}", started_at.format("%Y-%m-%dT%H-%M-%S"), stem)
}
fn exe_stem(exe: &str) -> String {
let base = exe.rsplit(['/', '\\']).next().unwrap_or(exe);
let stem = base.strip_suffix(".exe").unwrap_or(base);
let cleaned: String = stem
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect();
let trimmed = cleaned.trim_matches('-');
if trimmed.is_empty() {
"window".to_string()
} else {
trimmed.to_string()
}
}
pub fn target_hint(target: &Target) -> String {
match target {
Target::ByExe(e) => e.clone(),
Target::ByTitleRegex(t) => t.clone(),
Target::ByHwnd(h) => format!("hwnd{h}"),
Target::ByPid(p) => format!("pid{p}"),
}
}
#[derive(Debug, Clone)]
pub struct Session {
pub id: String,
pub dir: PathBuf,
pub started_at: DateTime<Utc>,
}
impl Session {
pub fn new(out_dir: &Path, started_at: DateTime<Utc>, exe_hint: &str) -> Self {
let id = make_session_id(started_at, exe_hint);
let dir = out_dir.join(&id);
Self {
id,
dir,
started_at,
}
}
pub fn frames_dir(&self) -> PathBuf {
self.dir.join("frames")
}
pub fn timeline_path(&self) -> PathBuf {
self.dir.join("timeline.jsonl")
}
pub fn manifest_path(&self) -> PathBuf {
self.dir.join("session.json")
}
pub fn readme_path(&self) -> PathBuf {
self.dir.join("README_FOR_AGENT.md")
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestTarget {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exe: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub class: Option<String>,
pub selected_via: String,
}
impl ManifestTarget {
pub fn from_target(target: &Target, selected_via: &str) -> Self {
let (title, exe) = match target {
Target::ByTitleRegex(t) => (Some(t.clone()), None),
Target::ByExe(e) => (None, Some(e.clone())),
Target::ByHwnd(_) | Target::ByPid(_) => (None, None),
};
Self {
title,
exe,
class: None,
selected_via: selected_via.to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestConfig {
pub settle_ms: u64,
pub tile_grid: [u16; 2],
pub dedup_hamming: u32,
pub value_sample_ms: u64,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
pub struct ManifestCounts {
pub frames_observed: u64,
pub images_saved: u64,
pub events: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionManifest {
pub session_id: String,
pub tool: String,
pub target: ManifestTarget,
pub started_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ended_at: Option<DateTime<Utc>>,
pub config: ManifestConfig,
pub roi_hints: Vec<RoiHint>,
pub counts: ManifestCounts,
pub timeline: String,
}
impl SessionManifest {
pub fn new(session: &Session, config: &Config, selected_via: &str) -> Self {
Self {
session_id: session.id.clone(),
tool: format!("framewatch {}", env!("CARGO_PKG_VERSION")),
target: ManifestTarget::from_target(&config.target, selected_via),
started_at: session.started_at,
ended_at: None,
config: ManifestConfig {
settle_ms: config.settle_ms,
tile_grid: [config.cols(), config.rows()],
dedup_hamming: config.dedup_hamming,
value_sample_ms: config.value_sample_ms,
},
roi_hints: config.rois.clone(),
counts: ManifestCounts::default(),
timeline: "timeline.jsonl".to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
use std::path::Path;
fn t() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 6, 13, 15, 4, 5).unwrap()
}
#[test]
fn session_id_format_and_exe_stem_cleaning() {
assert_eq!(make_session_id(t(), "Code.exe"), "2026-06-13T15-04-05_Code");
assert_eq!(
make_session_id(t(), r"C:\path\My App.exe"),
"2026-06-13T15-04-05_My-App"
);
assert_eq!(make_session_id(t(), "***"), "2026-06-13T15-04-05_window");
assert_eq!(make_session_id(t(), ""), "2026-06-13T15-04-05_window");
}
#[test]
fn target_hint_for_each_variant() {
assert_eq!(target_hint(&Target::ByExe("a.exe".into())), "a.exe");
assert_eq!(target_hint(&Target::ByTitleRegex("T".into())), "T");
assert_eq!(target_hint(&Target::ByHwnd(5)), "hwnd5");
assert_eq!(target_hint(&Target::ByPid(9)), "pid9");
}
#[test]
fn session_paths_are_under_the_session_dir() {
let s = Session::new(Path::new("/out"), t(), "Code.exe");
assert_eq!(s.id, "2026-06-13T15-04-05_Code");
assert!(s.frames_dir().ends_with("frames"));
assert!(s.timeline_path().ends_with("timeline.jsonl"));
assert!(s.manifest_path().ends_with("session.json"));
assert!(s.readme_path().ends_with("README_FOR_AGENT.md"));
}
#[test]
fn manifest_records_target_and_tool() {
let cfg = Config::builder()
.target(Target::ByTitleRegex("My App".into()))
.build()
.unwrap();
let s = Session::new(Path::new("/out"), t(), "app");
let m = SessionManifest::new(&s, &cfg, "cli");
assert_eq!(m.target.title.as_deref(), Some("My App"));
assert!(m.target.exe.is_none());
assert_eq!(m.target.selected_via, "cli");
assert!(m.tool.starts_with("framewatch "));
let mt = ManifestTarget::from_target(&Target::ByPid(3), "gui");
assert!(mt.title.is_none() && mt.exe.is_none());
}
}