framewatch 0.6.0

Event-driven, change-triggered window capture that emits timestamped screenshots + metadata for AI agents.
//! Session identity, paths, and the `session.json` manifest.

use crate::config::{Config, RoiHint, Target};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

/// Build the session id `"%Y-%m-%dT%H-%M-%S_<exe-stem>"`.
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)
}

/// Reduce an exe / target string to a filesystem-friendly 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()
    }
}

/// Derive a hint for the session id from a [`Target`].
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}"),
    }
}

/// Resolved on-disk locations for a session.
#[derive(Debug, Clone)]
pub struct Session {
    /// Session identifier.
    pub id: String,
    /// The session directory (`<out_dir>/<id>`).
    pub dir: PathBuf,
    /// When the session started.
    pub started_at: DateTime<Utc>,
}

impl Session {
    /// Create a session rooted under `out_dir`, deriving the id from `started_at` and `exe_hint`.
    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,
        }
    }

    /// The `frames/` subdirectory.
    pub fn frames_dir(&self) -> PathBuf {
        self.dir.join("frames")
    }

    /// The `timeline.jsonl` path.
    pub fn timeline_path(&self) -> PathBuf {
        self.dir.join("timeline.jsonl")
    }

    /// The `session.json` path.
    pub fn manifest_path(&self) -> PathBuf {
        self.dir.join("session.json")
    }

    /// The `README_FOR_AGENT.md` path.
    pub fn readme_path(&self) -> PathBuf {
        self.dir.join("README_FOR_AGENT.md")
    }
}

/// Target descriptor inside the manifest.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestTarget {
    /// Window title, if known.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub title: Option<String>,
    /// Executable basename, if known.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub exe: Option<String>,
    /// Window class, if known.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub class: Option<String>,
    /// How the target was selected (e.g. `"gui"`, `"cli"`, `"config"`).
    pub selected_via: String,
}

impl ManifestTarget {
    /// Build a descriptor from a [`Target`] and how it was selected. `class` is
    /// left `None` (it is only known once a window is resolved).
    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(),
        }
    }
}

/// A compact view of config knobs recorded in the manifest.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestConfig {
    /// Settle threshold (ms).
    pub settle_ms: u64,
    /// `[cols, rows]` tile grid.
    pub tile_grid: [u16; 2],
    /// Dedup hamming threshold.
    pub dedup_hamming: u32,
    /// Volatile sample throttle (ms).
    pub value_sample_ms: u64,
}

/// Running counts recorded in the manifest.
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
pub struct ManifestCounts {
    /// Frames observed by the engine.
    pub frames_observed: u64,
    /// Images written to disk.
    pub images_saved: u64,
    /// Events emitted.
    pub events: u64,
}

/// The `session.json` manifest, written at start and updated on shutdown.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionManifest {
    /// Session id.
    pub session_id: String,
    /// Tool name + version.
    pub tool: String,
    /// Target descriptor.
    pub target: ManifestTarget,
    /// Start timestamp.
    pub started_at: DateTime<Utc>,
    /// End timestamp (set on shutdown).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub ended_at: Option<DateTime<Utc>>,
    /// Recorded config knobs.
    pub config: ManifestConfig,
    /// ROI hints in effect.
    pub roi_hints: Vec<RoiHint>,
    /// Running counts.
    pub counts: ManifestCounts,
    /// Relative path to the timeline file.
    pub timeline: String,
}

impl SessionManifest {
    /// Build the initial manifest for a session.
    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"
        );
        // All-special / empty hints fall back to "window".
        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 "));
        // ByPid / ByHwnd carry neither title nor exe.
        let mt = ManifestTarget::from_target(&Target::ByPid(3), "gui");
        assert!(mt.title.is_none() && mt.exe.is_none());
    }
}