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,
}
#[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 {
let (title, exe) = match &config.target {
Target::ByTitleRegex(t) => (Some(t.clone()), None),
Target::ByExe(e) => (None, Some(e.clone())),
Target::ByHwnd(_) | Target::ByPid(_) => (None, None),
};
Self {
session_id: session.id.clone(),
tool: format!("framewatch {}", env!("CARGO_PKG_VERSION")),
target: ManifestTarget {
title,
exe,
class: None,
selected_via: selected_via.to_string(),
},
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(),
}
}
}