Skip to main content

nmbrs_runtime/
session.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Session: the root context for a workload run.
5//!
6//! A session has a human-readable ID, a directory for all diagnostic
7//! artifacts (metrics, logs, flamegraphs), and is the root of the
8//! component tree for metrics labeling.
9//!
10//! Session ID format: `{scenario}_{YYYYMMDD_HHmmss}`
11//! Session directory: `logs/{session_id}/`
12//!
13//! All files from a run live under the session directory:
14//! - `metrics.db` — SQLite metrics
15//! - `flamegraph.svg` — profiler output
16//! - `session.log` — diagnostic log (future)
17
18use std::path::{Path, PathBuf};
19use std::sync::{Arc, Mutex, RwLock};
20
21use nmbrs_metrics::component::{Component, ComponentState, attach};
22use nmbrs_metrics::labels::Labels;
23use nmbrs_metrics::metrics_query::MetricsQuery;
24
25/// SRD-77 — one invocation of `nmbrs <verb>` within a
26/// [`Session`]. The session is the persistent container;
27/// each execution is the unit of "what was attempted at
28/// this point in time, with what workload version, and how
29/// did it dispose?" Every per-phase / per-metric row carries
30/// the owning execution's [`Self::exec_id`] as a dimensional
31/// label so cross-execution queries can scope cleanly.
32///
33/// Until the SRD-77 `refine` verb lands the per-session
34/// monotonic registry, every session has exactly one
35/// execution with `exec_id = 1`. The shape exists now so the
36/// SRD-76 storage layer (`phase_outcomes` / `phase_errors`)
37/// and the component tree's root labels (`session`,
38/// `exec_id`) honour the eventual SRD-77 contract from day
39/// one — no later schema migration is required.
40#[derive(Clone)]
41pub struct Execution {
42    /// Monotonic per-session sequence (`1, 2, 3, ...`).
43    /// SRD-77's `refine` verb bumps it; SRD-88's concurrent
44    /// harness allocates a distinct id per in-flight execution.
45    pub exec_id: u64,
46    /// Which verb launched this execution: `"run"` /
47    /// `"resume"` / `"refine"`. Operator-visible via the SRD-77
48    /// `executions` table and replay header.
49    pub verb: &'static str,
50    /// Wall-clock nanos-since-epoch at execution start.
51    pub started_at_nanos: i64,
52    /// The workload's bare stem (the `workload=` dimensional
53    /// label and the `executions.workload` column for THIS
54    /// execution). Per SRD-88 this is execution-tier identity,
55    /// not session-tier: N executions sharing one session each
56    /// carry their own.
57    pub workload: String,
58    /// Scenario name for this execution (metadata, not a
59    /// dimensional label).
60    pub scenario: String,
61    /// This execution's component — a child of the session
62    /// component (SRD-88 §2). Carries the `exec_id` + `workload`
63    /// labels; phase/activity components attach under it so
64    /// every metric inherits this execution's identity. The
65    /// session component above it carries only `session=<id>`
66    /// and is shared by every concurrent execution.
67    pub component: Arc<RwLock<Component>>,
68}
69
70impl Execution {
71    /// Start an execution under `session`: derive its
72    /// [`Execution::component`] as a child of the session
73    /// component, labelled with this execution's `exec_id` +
74    /// `workload` stem (SRD-88 §2 — the session is the shared
75    /// common root, each execution derives from it). `exec_id`
76    /// is `1` for a fresh `run`/`resume`, the refine plan's
77    /// `next_exec_id` for `refine`, or a distinct allocated id
78    /// for a concurrent execution.
79    pub fn start(
80        session: &Session,
81        workload: &str,
82        scenario: &str,
83        verb: &'static str,
84        exec_id: u64,
85    ) -> Self {
86        let workload_stem = Path::new(workload)
87            .file_stem()
88            .and_then(|s| s.to_str())
89            .unwrap_or("workload");
90        // Execution-tier labels: `exec_id` + `workload`. The
91        // parent session component already carries `session`;
92        // `attach` composes the two so descendant metrics see
93        // the full `{session, exec_id, workload}` set, exactly
94        // as the pre-SRD-88 single-tier root did.
95        let component = Arc::new(RwLock::new(Component::new(
96            Labels::of("exec_id", exec_id.to_string()).with("workload", workload_stem),
97            std::collections::HashMap::new(),
98        )));
99        attach(&session.component, &component);
100        component
101            .write()
102            .unwrap_or_else(|e| e.into_inner())
103            .set_state(ComponentState::Running);
104        Self {
105            exec_id,
106            verb,
107            started_at_nanos: std::time::SystemTime::now()
108                .duration_since(std::time::UNIX_EPOCH)
109                .map(|d| d.as_nanos() as i64)
110                .unwrap_or(0),
111            workload: workload_stem.to_string(),
112            scenario: scenario.to_string(),
113            component,
114        }
115    }
116}
117
118impl std::fmt::Debug for Execution {
119    /// Identity fields only — the component is an `Arc<RwLock<…>>`
120    /// into the live tree and not meaningfully `Debug`-printable.
121    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        f.debug_struct("Execution")
123            .field("exec_id", &self.exec_id)
124            .field("verb", &self.verb)
125            .field("started_at_nanos", &self.started_at_nanos)
126            .field("workload", &self.workload)
127            .field("scenario", &self.scenario)
128            .finish_non_exhaustive()
129    }
130}
131
132/// A workload run session.
133///
134/// The session is the root of the component tree and — once the
135/// runner has installed one — holds the shared [`MetricsQuery`] that
136/// every in-process reader (TUI, summary, Polydat metric nodes) reads
137/// through. See SRD-42 §"MetricsQuery — the unified read interface".
138///
139/// Per SRD-77, the session is a persistent container; the
140/// [`Execution`] carried in [`Self::execution`] is the unit
141/// of in-flight work. Both ride on the component tree as the
142/// `session` + `exec_id` dimensional labels so every metric
143/// the descendant components emit inherits them.
144pub struct Session {
145    /// Session identifier (the session-directory basename).
146    /// Surfaces as the `session` dimensional label on every
147    /// per-component metric via [`Self::component`].
148    pub id: String,
149    /// Output directory for diagnostic artifacts (metrics, logs, flamegraphs).
150    /// Located at `logs/{id}/`. Not the working directory.
151    pub output_dir: PathBuf,
152    /// Session root component — **one per process** (SRD-88 §2).
153    /// Owns the `session=<id>` dimensional label and nothing
154    /// else; per-execution identity (`exec_id`, `workload`)
155    /// lives on the [`Execution::component`] children that hang
156    /// under it. Each label name is owned by exactly one tier
157    /// and never redeclared below it.
158    pub component: Arc<RwLock<Component>>,
159    /// Shared `MetricsQuery` handle — installed by the runner once the
160    /// cadence reporter is built. `None` before the runner wires it.
161    pub metrics_query: Mutex<Option<Arc<MetricsQuery>>>,
162}
163
164/// Translate a CLI flag name (`--session-path`) into its
165/// canonical `NMBRS_`-prefixed env-var name (`NMBRS_SESSION_PATH`).
166/// Per SRD-04, every CLI flag automatically has an env-var
167/// equivalent following this convention.
168pub fn flag_env_name(flag: &str) -> String {
169    let stem = flag.trim_start_matches("--");
170    format!("NMBRS_{}", stem.replace('-', "_").to_ascii_uppercase())
171}
172
173/// Resolve a flag value from CLI args + its `NMBRS_`-prefixed
174/// env var. Returns `None` if neither is set. **Exits with
175/// status 2** if BOTH are set — configuration conflict; we
176/// refuse to silently disambiguate.
177///
178/// Per SRD-04 the env-var name is automatically derived from
179/// the flag name (`--foo-bar` → `NMBRS_FOO_BAR`).
180pub fn resolve_flag(args: &[String], flag: &str) -> Option<String> {
181    let cli = {
182        let eq_prefix = format!("{flag}=");
183        let mut iter = args.iter();
184        let mut found = None;
185        while let Some(arg) = iter.next() {
186            if let Some(rest) = arg.strip_prefix(&eq_prefix) {
187                found = Some(rest.to_string());
188                break;
189            }
190            if arg == flag {
191                found = iter.next().cloned();
192                break;
193            }
194        }
195        found
196    };
197    let env_name = flag_env_name(flag);
198    let env = std::env::var(&env_name)
199        .ok()
200        .filter(|v| !v.trim().is_empty());
201    match (cli, env) {
202        (Some(_), Some(_)) => {
203            eprintln!(
204                "error: configuration conflict — both `{flag}` (CLI) and \
205                 `{env_name}` (env) are set. Pick one. Per SRD-04, every \
206                 CLI flag has an env equivalent prefixed with `NMBRS_`; \
207                 specifying both at once is a hard error so the operator \
208                 sees their inputs are fighting."
209            );
210            std::process::exit(2);
211        }
212        (Some(v), None) | (None, Some(v)) => Some(v),
213        (None, None) => None,
214    }
215}
216
217/// Classify a session-path value: does it look like a
218/// `key=value` workload-param token (e.g. `scenario=foo`)
219/// rather than a real filesystem path? The umbrella
220/// `--session <kv>` parser splits only on `:`, so a
221/// `=`-shaped token slipping into the path slot would silently
222/// materialise directories like `<cwd>/scenario=foo/...`.
223///
224/// Heuristic: the head of `head=tail` matches the workload-
225/// param ABNF (alphanumeric / underscore / hyphen, no slash).
226/// A real path like `/var/tmp/k=v` keeps a leading slash in
227/// the head and passes through unchanged. Leading `./` or
228/// `../` likewise excludes the head from the param-shape
229/// check (the head contains a `/`).
230///
231/// Returns `Err(<error message>)` when the value should be
232/// rejected; `Ok(())` when it's safe to use as a session
233/// path.
234pub fn check_session_path(p: &str, source: &str) -> Result<(), String> {
235    if let Some((head, _)) = p.split_once('=') {
236        let head_looks_like_param = !head.is_empty()
237            && head
238                .chars()
239                .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
240            && !head.contains('/');
241        if head_looks_like_param {
242            return Err(format!(
243                "session path from {source} is '{p}' — that looks \
244                 like a `key=value` workload param, not a path. The umbrella \
245                 `--session <kv>` parser splits only on `:`, so this would \
246                 silently create a `<cwd>/{p}/…` directory tree. \
247                 Did you mean: `--session-path <path>` (with `{p}` as a \
248                 separate workload arg), or `--session path:<path>` (umbrella \
249                 form, `:` as the kv separator)?"
250            ));
251        }
252    }
253    Ok(())
254}
255
256/// Wrapper around [`check_session_path`] that prints to
257/// stderr and exits with code 2 on rejection. Used at every
258/// entry point that sets `session_path` from CLI / env input:
259/// [`parse_session_kv`] (bare-token branch and `path:`/`dir:`
260/// keys), [`resolve_session_dir`] for `--session-path`, and
261/// the legacy `SESSION_DIRECTORY` env fallback. Same exit-
262/// shape as the existing `--session` configuration-conflict
263/// error in [`resolve_flag`].
264pub(crate) fn validate_session_path_or_exit(p: &str, source: &str) {
265    if let Err(msg) = check_session_path(p, source) {
266        eprintln!("error: {msg}");
267        std::process::exit(2);
268    }
269}
270
271/// Legacy env-var name for `--session-path`. Pre-SRD-04
272/// shipping name that some operators may have in their shell
273/// config; honored as a deprecated fallback below
274/// `NMBRS_SESSION_PATH` and warns when read.
275pub const SESSION_DIRECTORY_ENV: &str = "SESSION_DIRECTORY";
276
277/// Token within a session-dir path that's replaced with the
278/// auto-generated session id at write time. Lets users template
279/// per-run directories without changing the path between runs:
280/// `--session-dir /data/sessions/SESSION_run` →
281/// `/data/sessions/default_20260101_120000_run`.
282pub const SESSION_TOKEN: &str = "SESSION";
283
284/// Policy when a session about to be created lands on an
285/// existing, non-empty session directory.
286///
287/// Defaults to `Error` so accidental reuse can never silently
288/// destroy a prior session's artifacts.
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
290pub enum SessionReuse {
291    /// Refuse to start. Exit with a clear message naming the
292    /// existing path and the available reuse-mode flags.
293    /// Default when `--session-reuse` is not specified.
294    #[default]
295    Error,
296    /// Wipe the existing artifacts (`metrics.db`, `session.log`,
297    /// `checkpoint.jsonl`, `summary.md`, etc.) and start fresh.
298    /// The dir itself stays; only its contents are cleared.
299    Restart,
300    /// Continue with the existing session — equivalent to
301    /// `--resume <session>` against this directory. Surfaces a
302    /// hint if the operator probably wanted Restart.
303    Resume,
304}
305
306impl SessionReuse {
307    /// Parse from a string value (CLI flag or env var).
308    /// Accepts `error` / `restart` / `resume`, case-insensitive.
309    pub fn parse(s: &str) -> Result<Self, String> {
310        match s.trim().to_ascii_lowercase().as_str() {
311            "error" | "fail" | "abort" => Ok(Self::Error),
312            "restart" | "wipe" | "clean" => Ok(Self::Restart),
313            "resume" | "continue" => Ok(Self::Resume),
314            other => Err(format!(
315                "session-reuse: expected one of 'error' | 'restart' | 'resume', got '{other}'",
316            )),
317        }
318    }
319}
320
321/// Where the default session directory lives when the user hasn't
322/// passed `--session-path`. Three cases:
323///
324/// * **Normal invocation** (installed binary, user shell): the
325///   session lands at `<cwd>/logs/<id>`. This is the documented,
326///   long-standing user-facing behavior.
327///
328/// * **In-process tests that pre-sandboxed cwd** (e.g.
329///   `checkpoint_resume_staircase` `in_dir(tmp, runner::run)`):
330///   `<cwd>/logs/<id>` is already inside the test's tempdir
331///   sandbox, so leave it alone — the test reads back from
332///   `<cwd>/logs/latest` and would be broken by a redirect.
333///
334/// * **Cargo-spawned invocation where cwd lands inside the
335///   workspace** (`cargo test` integration tests that spawn
336///   `nmbrs` as a subprocess without `--session-path`, plus
337///   `cargo run --bin nmbrs` runs from the workspace root):
338///   the default would otherwise be the user-visible
339///   `<workspace>/logs/<id>`, and the `session-keep` rotation
340///   would evict real run sessions. Redirect into
341///   `$TMPDIR/nmbrs-sessions/<id>` — `.cargo/config.toml`
342///   already points `TMPDIR` at `<workspace>/target/test-tmp`,
343///   so cargo-spawned sessions live in `target/test-tmp/`
344///   alongside other test artifacts. See
345///   `feedback_tests_no_project_root`.
346///
347/// Tests that want to read back their session contents must
348/// still pass `--session-path` to a known path — this
349/// fallback is the blast-radius limiter, not a substitute for
350/// explicit sandboxing.
351pub fn default_session_dir(id: &str) -> PathBuf {
352    default_sessions_root().join(id)
353}
354
355/// Resolve the parent directory the runtime treats as the session
356/// root when `--session-path` is absent. See [`default_session_dir`]
357/// for the three-case logic.
358/// `<root>/latest` — the symlink the runtime maintains to point
359/// at the most recent session dir. Read-side commands
360/// (`nmbrs report`, `nmbrs plot`, `nmbrs replay`, …) default to
361/// reading through this path so "show me my latest run" is the
362/// natural no-arg invocation.
363pub fn latest_session_dir() -> PathBuf {
364    default_sessions_root().join("latest")
365}
366
367/// `<root>/latest/metrics.db` — the sqlite db of the most recent
368/// session. Centralised here so every read-side command names
369/// the path one way.
370pub fn latest_metrics_db() -> PathBuf {
371    latest_session_dir().join("metrics.db")
372}
373
374/// `<root>/latest/session.log` — the diagnostic log of the most
375/// recent session. `nmbrs attach` / log tooling reads from here.
376pub fn latest_session_log() -> PathBuf {
377    latest_session_dir().join("session.log")
378}
379
380/// `<root>/latest/checkpoint.jsonl` — the SRD-44 event log of
381/// the most recent session. The resume planner reads from here.
382pub fn latest_checkpoint_jsonl() -> PathBuf {
383    latest_session_dir().join("checkpoint.jsonl")
384}
385
386/// `<root>/<name>` — a named session dir under the sessions root.
387/// Used by `--session=<name>` resolvers in read-side commands so
388/// the path doesn't get spelled out at every call site.
389pub fn session_dir_named(name: &str) -> PathBuf {
390    default_sessions_root().join(name)
391}
392
393/// Whether the CLI args request a dry-run (any `dryrun=<mode>` with a
394/// non-empty, non-false value). A dry-run is resume-INERT: it must NOT
395/// claim the `latest` symlink and must NOT write a checkpoint, so a
396/// later `--resume-latest` can never pick a dry-run up and resume from
397/// its (placeholder / short-circuited) phases (SRD-44). Detected from
398/// raw args so both `Session::new_with_args` (the `latest` claim) and
399/// `SessionHost::setup` (the checkpoint writer) gate on one signal.
400pub fn args_request_dryrun(args: &[String]) -> bool {
401    args.iter().any(|a| {
402        let a = a.strip_prefix("--").unwrap_or(a);
403        a.strip_prefix("dryrun=")
404            .map(|v| {
405                let v = v.trim();
406                !v.is_empty() && v != "false" && v != "0"
407            })
408            .unwrap_or(false)
409    })
410}
411
412/// Create a symlink at `link` pointing at `target` (which may be
413/// relative to `link`'s parent dir). On Unix this is plain
414/// `symlink`; Windows separates file and directory symlinks, so
415/// resolve the target and pick the matching flavor. Windows
416/// symlink creation needs Developer Mode (or admin) — callers
417/// treat failure as best-effort, same as on Unix.
418pub(crate) fn symlink_any(target: &Path, link: &Path) -> std::io::Result<()> {
419    #[cfg(unix)]
420    {
421        std::os::unix::fs::symlink(target, link)
422    }
423    #[cfg(windows)]
424    {
425        // Callers clear a previous link with `remove_file`, which
426        // on Windows cannot delete a DIRECTORY symlink or junction
427        // (those are directory entries) — clean up leftovers here
428        // so re-pointing works.
429        if let Ok(md) = std::fs::symlink_metadata(link)
430            && (md.file_type().is_symlink() || md.file_type().is_dir())
431        {
432            let _ = std::fs::remove_file(link);
433            let _ = std::fs::remove_dir(link);
434        }
435        let resolved = match link.parent() {
436            Some(p) => p.join(target),
437            None => target.to_path_buf(),
438        };
439        if resolved.is_dir() {
440            // Real symlinks need Developer Mode / admin; a
441            // directory junction needs no privilege and reads back
442            // through `fs::read_link` just the same (with an
443            // absolute target — consumers here already resolve
444            // both shapes). Junction targets must be absolute.
445            std::os::windows::fs::symlink_dir(target, link).or_else(|_| {
446                let abs = std::path::absolute(&resolved)?;
447                junction::create(&abs, link)
448            })
449        } else {
450            // File fallback: a same-volume hard link. Appends to
451            // the original show up through it (same file), and the
452            // artifact links are recreated at each session
453            // start / artifact production, which bounds staleness.
454            std::os::windows::fs::symlink_file(target, link)
455                .or_else(|_| std::fs::hard_link(&resolved, link))
456        }
457    }
458}
459
460/// Point `<sessions-root>/latest` at `session_dir`, but only when
461/// the dir lives under the sessions root — a path the user
462/// redirected elsewhere (`--session-path /tmp/x`) is left alone,
463/// same guard as the startup hook. Best-effort: symlink failures
464/// warn rather than abort.
465pub fn point_latest_at(session_dir: &Path) {
466    let root = default_sessions_root();
467    if !target_is_under(&root, session_dir) {
468        return;
469    }
470    if std::fs::create_dir_all(&root).is_err() {
471        return;
472    }
473    let latest = root.join("latest");
474    let relative_target = relative_symlink_target(&latest, session_dir);
475    let _ = std::fs::remove_file(&latest);
476    let _ = symlink_any(&relative_target, &latest);
477}
478
479/// Initialize a NEW, empty session: create its directory, the
480/// `metrics.db` schema, and the invariant `session` metadata — but
481/// record NO execution (nothing has run). Points `latest` at it.
482/// A later `run`/`refine` attaches the first execution.
483///
484/// `explicit_path` overrides the default `<sessions-root>/<id>`
485/// location. `reuse` governs an already-populated directory.
486pub fn init_empty_session(
487    id: &str,
488    explicit_path: Option<&Path>,
489    reuse: SessionReuse,
490) -> Result<PathBuf, String> {
491    let dir = match explicit_path {
492        Some(p) => p.to_path_buf(),
493        None => default_session_dir(id),
494    };
495    let metrics_db = dir.join("metrics.db");
496    if metrics_db.exists() {
497        match reuse {
498            SessionReuse::Error => {
499                return Err(format!(
500                    "session '{id}' already exists at {} — pass session-reuse=restart to \
501                 overwrite, session-reuse=resume to keep it, or choose another name",
502                    dir.display(),
503                ));
504            }
505            SessionReuse::Restart => {
506                let _ = std::fs::remove_file(&metrics_db);
507            }
508            SessionReuse::Resume => return Ok(dir),
509        }
510    }
511    std::fs::create_dir_all(&dir)
512        .map_err(|e| format!("create session dir {}: {e}", dir.display()))?;
513    {
514        // `SqliteReporter::new` creates the schema; writing the
515        // invariant `session` key seeds session_metadata. Dropping
516        // the reporter flushes (writes auto-commit).
517        let mut reporter = nmbrs_metrics::reporters::sqlite::SqliteReporter::new(&metrics_db)
518            .map_err(|e| format!("create metrics.db at {}: {e}", metrics_db.display()))?;
519        reporter.set_metadata("session", id);
520    }
521    point_latest_at(&dir);
522    Ok(dir)
523}
524
525pub fn default_sessions_root() -> PathBuf {
526    if cwd_is_workspace_dir() {
527        // Cargo-spawned invocation, cwd is a cargo workspace
528        // dir (`Cargo.toml` present) — the cwd-relative default
529        // would write into the user-visible `<workspace>/sessions/`.
530        // Redirect to TMPDIR (per `.cargo/config.toml`, this is
531        // `<workspace>/target/test-tmp/`), under an
532        // `nmbrs-sessions/` infix so siblings tempfiles stay
533        // segregated.
534        std::env::temp_dir().join("nmbrs-sessions")
535    } else {
536        // SRD-77: `sessions/` is the per-cwd default. The
537        // directory holds session state (metrics.db,
538        // session.log, checkpoints, reports), not just logs.
539        // Pre-SRD-77 builds wrote to `logs/`; the migration is
540        // flag-day (no auto-rename — operators run a one-shot
541        // `mv logs sessions` if they care about retention).
542        PathBuf::from("sessions")
543    }
544}
545
546/// `true` when both (a) we're running under cargo
547/// (`CARGO_MANIFEST_DIR` env present, inherited from the cargo
548/// parent through `std::process::Command`'s default env-inherit)
549/// and (b) the current working directory has a `Cargo.toml` at
550/// its root (workspace member or workspace root). Tests that
551/// sandbox cwd into a tempdir won't satisfy (b) — the redirect
552/// stays off for those.
553fn cwd_is_workspace_dir() -> bool {
554    if std::env::var_os("CARGO_MANIFEST_DIR").is_none() {
555        return false;
556    }
557    std::env::current_dir()
558        .map(|cwd| cwd.join("Cargo.toml").is_file())
559        .unwrap_or(false)
560}
561
562/// Resolved session inputs from CLI / env. Built by
563/// [`resolve_session_dir`] from one of:
564///
565/// - The umbrella `--session <kv-list>` flag (a
566///   comma-separated list of `key:value` pairs and bare
567///   shortcuts).
568/// - Per-key long-form flags (`--session-name`,
569///   `--session-path`, `--session-reuse`, `--session-keep`,
570///   `--session-shelflife`).
571/// - `SESSION_DIRECTORY` env var (equivalent to
572///   `--session-path`).
573///
574/// When both forms are present the long-form flag wins; the
575/// umbrella flag is shorthand. Within the umbrella value the
576/// last-wins rule applies if a key is repeated.
577#[derive(Debug, Clone, Default)]
578pub struct SessionDirSpec {
579    /// `name` — session id (basename of the session directory).
580    /// Used in metric labels and as the resolved value of the
581    /// `SESSION` token in `path`. When unset, defaults to the
582    /// auto-generated `<scenario>_<timestamp>` form.
583    pub session_name: Option<String>,
584    /// `path` — full directory path. Optional `SESSION` token
585    /// inside the path is replaced with `session_name` at write
586    /// time. When unset, the path is `logs/<session_name>/`.
587    pub session_path: Option<String>,
588    /// `reuse` — policy when the resolved directory already
589    /// contains prior session artifacts. Default `Error`.
590    pub reuse: SessionReuse,
591    /// `keep` — number of session directories retained under
592    /// the parent at startup. Default 10. `0` disables.
593    pub session_keep: usize,
594    /// `shelflife` — max age of a session directory before
595    /// purge at startup. Default 4 weeks. `0` disables.
596    pub session_shelflife: std::time::Duration,
597    /// SRD-106 — the umbrella bare token `new` (`--session new`):
598    /// force a fresh auto-named session. Its only consumer is the
599    /// `stick_session` resolution rung, which it defeats; with no
600    /// stick in play a fresh session is already the default, so
601    /// the token is a harmless no-op there.
602    pub force_new: bool,
603}
604
605impl SessionDirSpec {
606    /// `true` when no path/name flag is set — caller falls back
607    /// to the default `logs/<auto_id>/` behavior. (`reuse`,
608    /// `keep`, `shelflife` are always present at their defaults
609    /// and don't count toward "is this empty?".)
610    pub fn is_empty(&self) -> bool {
611        self.session_name.is_none() && self.session_path.is_none()
612    }
613
614    /// Resolve to a concrete `(output_dir, session_id)`.
615    ///
616    /// - `auto_id` is the fallback session name when
617    ///   `session_name` is unset.
618    /// - The resolved id = `session_name.unwrap_or(auto_id)`.
619    /// - The resolved path = `session_path` (with `SESSION`
620    ///   token replaced by the resolved id) if set, else
621    ///   `logs/<id>/`.
622    ///
623    /// Returns `None` only when the spec is empty and the
624    /// caller should use defaults; otherwise `Some` with the
625    /// resolved values.
626    pub fn resolve(&self, auto_id: &str) -> Option<(PathBuf, String)> {
627        if self.is_empty() {
628            return None;
629        }
630        let id = self
631            .session_name
632            .clone()
633            .unwrap_or_else(|| auto_id.to_string());
634        let path = match &self.session_path {
635            Some(p) => PathBuf::from(p.replace(SESSION_TOKEN, &id)),
636            None => default_session_dir(&id),
637        };
638        // Re-derive id from basename so a path-only spec
639        // (`--session-path /tmp/foo`) still yields id="foo".
640        let id = path
641            .file_name()
642            .and_then(|s| s.to_str())
643            .map(String::from)
644            .unwrap_or(id);
645        Some((path, id))
646    }
647
648    /// `true` if the resolved path needs the auto-id (i.e.
649    /// neither `session_name` nor a fully-concrete
650    /// `session_path` is set). Used at startup to decide
651    /// whether `logs/latest` can be wired immediately or must
652    /// wait for session creation.
653    pub fn needs_auto_id(&self) -> bool {
654        if self.session_name.is_some() {
655            return false;
656        }
657        match self.session_path.as_deref() {
658            Some(p) => p.contains(SESSION_TOKEN),
659            None => true,
660        }
661    }
662}
663
664/// Parse the umbrella `--session <kv-list>` argument into a
665/// [`SessionDirSpec`]. The list is comma-separated; each item
666/// is either a bare shortcut (`restart`, `resume`, `error`)
667/// or a `key:value` pair.
668///
669/// Recognised keys (and their long-form flag equivalents):
670///
671/// | Key            | Long-form flag             |
672/// | -------------- | -------------------------- |
673/// | `name`         | `--session-name`           |
674/// | `path` / `dir` | `--session-path`           |
675/// | `reuse`        | `--session-reuse`          |
676/// | `keep`         | `--session-keep`           |
677/// | `shelflife`    | `--session-shelflife`      |
678///
679/// Bare shortcuts:
680///
681/// | Token     | Equivalent     |
682/// | --------- | -------------- |
683/// | `restart` | `reuse:restart`|
684/// | `resume`  | `reuse:resume` |
685/// | `error`   | `reuse:error`  |
686/// | `new`     | force a fresh auto-named session (defeats `stick_session`) |
687///
688/// Whitespace around items + key/value separators is trimmed.
689/// Unknown keys produce a `Warn` log; the rest of the spec is
690/// kept (so a typo doesn't kill the run).
691pub fn parse_session_kv(s: &str) -> SessionDirSpec {
692    let mut spec = SessionDirSpec {
693        session_keep: DEFAULT_SESSIONS_MAX,
694        session_shelflife: DEFAULT_SESSIONS_SHELFLIFE,
695        ..SessionDirSpec::default()
696    };
697    for raw_item in s.split(',') {
698        let item = raw_item.trim();
699        if item.is_empty() {
700            continue;
701        }
702        // Bare shortcut?
703        match item {
704            "restart" => {
705                spec.reuse = SessionReuse::Restart;
706                continue;
707            }
708            "resume" => {
709                spec.reuse = SessionReuse::Resume;
710                continue;
711            }
712            "error" => {
713                spec.reuse = SessionReuse::Error;
714                continue;
715            }
716            // SRD-106 — `--session new`: force a fresh auto-named
717            // session, defeating a workload's `stick_session`.
718            // Intercepted here so the name heuristic below never
719            // reads it as `name:new`.
720            "new" => {
721                spec.force_new = true;
722                continue;
723            }
724            _ => {}
725        }
726        // A Windows drive-letter path (`C:\…` or `C:/…`) would
727        // split at its drive colon and read as unknown key "C" —
728        // recognize it as a bare path token before the key:value
729        // parse gets a look.
730        let drive_letter_path = item.len() >= 3
731            && item.as_bytes()[0].is_ascii_alphabetic()
732            && item.as_bytes()[1] == b':'
733            && matches!(item.as_bytes()[2], b'\\' | b'/');
734        // key:value pair takes precedence over the
735        // bare-token heuristics so `dir:/tmp/x` etc.
736        // disambiguate cleanly.
737        let kv = if drive_letter_path {
738            None
739        } else {
740            item.split_once(':')
741        };
742        let (key, value) = match kv {
743            Some((k, v)) => (k.trim(), v.trim()),
744            None => {
745                // Bare token without `:`. Two heuristics:
746                //   - contains a path separator OR resolves to an
747                //     existing directory → treat as `path:<value>`.
748                //     Lets operators write
749                //     `--session logs/fulltest_2026...` without
750                //     remembering the `path:` prefix.
751                //   - otherwise → session name (SRD-04
752                //     most-specific-name rule).
753                if drive_letter_path
754                    || item.contains('/')
755                    || (cfg!(windows) && item.contains('\\'))
756                    || std::path::Path::new(item).is_dir()
757                {
758                    validate_session_path_or_exit(item, "umbrella `--session <bare-token>`");
759                    spec.session_path = Some(item.to_string());
760                } else {
761                    spec.session_name = Some(item.to_string());
762                }
763                continue;
764            }
765        };
766        match key {
767            "name" => spec.session_name = Some(value.to_string()),
768            "path" | "dir" => {
769                validate_session_path_or_exit(value, "umbrella `--session path:<v>`");
770                spec.session_path = Some(value.to_string());
771            }
772            "reuse" => match SessionReuse::parse(value) {
773                Ok(r) => spec.reuse = r,
774                Err(e) => crate::observer::log(
775                    crate::observer::LogLevel::Warn,
776                    &format!("--session: {e}"),
777                ),
778            },
779            "keep" => match value.parse::<usize>() {
780                Ok(n) => spec.session_keep = n,
781                Err(_) => crate::observer::log(
782                    crate::observer::LogLevel::Warn,
783                    &format!("--session: keep:{value:?} is not a non-negative integer"),
784                ),
785            },
786            "shelflife" => match parse_duration(value) {
787                Ok(d) => spec.session_shelflife = d,
788                Err(e) => crate::observer::log(
789                    crate::observer::LogLevel::Warn,
790                    &format!("--session: shelflife: {e}"),
791                ),
792            },
793            other => crate::observer::log(
794                crate::observer::LogLevel::Warn,
795                &format!(
796                    "--session: unknown key {other:?} (recognised: name, path, dir, reuse, keep, shelflife)"
797                ),
798            ),
799        }
800    }
801    spec
802}
803
804/// Parse the umbrella `--session <kv>` flag + per-key
805/// long-form flags + env vars into a [`SessionDirSpec`].
806///
807/// **Umbrella form** — `--session <kv-list>` where `<kv-list>`
808/// is comma-separated `key:value` pairs and bare shortcuts.
809/// See [`parse_session_kv`] for the full key list.
810///
811/// **Long-form flags** — same keys, individually:
812/// `--session-name`, `--session-path`, `--session-reuse`,
813/// `--session-keep`, `--session-shelflife`. Both `=value` and
814/// space-separated `<flag> <value>` shapes are accepted.
815///
816/// **Env vars:**
817/// - `SESSION_DIRECTORY` → `--session-path`
818/// - `SESSIONS_MAX` → `--session-keep`
819/// - `SESSIONS_SHELFLIFE` → `--session-shelflife`
820///
821/// **Precedence** (highest first):
822/// 1. Long-form per-key flag.
823/// 2. Umbrella `--session` value (parsed left-to-right; later
824///    keys override earlier ones).
825/// 3. Env var equivalents.
826/// 4. Default.
827/// Resolve a flag written either canonically or under a documented alias.
828///
829/// Several spellings appeared only in the docs and were parsed nowhere, so an
830/// invocation copied from the guide was read as an unknown flag and ignored — the
831/// setting silently stayed at its default. The canonical spelling wins when both
832/// appear; a differing alias value is reported rather than dropped, since two
833/// disagreeing values on one command line means the operator expected something
834/// this cannot deliver.
835fn aliased_flag(args: &[String], canonical: &str, alias: &str) -> Option<String> {
836    let canon = resolve_flag(args, canonical);
837    let aliased = resolve_flag(args, alias);
838    match (&canon, &aliased) {
839        (Some(c), Some(a)) if c != a => crate::observer::log(
840            crate::observer::LogLevel::Warn,
841            &format!("{canonical}={c} and {alias}={a} disagree; using {canonical}={c}"),
842        ),
843        _ => {}
844    }
845    canon.or(aliased)
846}
847
848pub fn resolve_session_dir(args: &[String]) -> SessionDirSpec {
849    // Each flag goes through `resolve_flag` which checks both
850    // CLI and the auto-derived `NMBRS_<FLAG>` env var. Setting
851    // both is a hard error.
852
853    // Umbrella --session / NMBRS_SESSION first, so long-form
854    // and per-key env can override individual fields.
855    // Accept the bare `session=<spec>` spelling as well as `--session=<spec>`.
856    // Read-side commands already take bare `workload=<file>`, so an operator
857    // reasonably writes `session=<dir>` beside it — and before this, that form was
858    // consumed as an unrecognised key=value and SILENTLY ignored, so the command
859    // reported on `sessions/latest` while naming a different directory. A wrong
860    // answer that looks right is the worst outcome available here.
861    let bare_session = args
862        .iter()
863        .find_map(|a| a.strip_prefix("session="))
864        .map(|v| v.to_string());
865    let mut spec = match resolve_flag(args, "--session").or(bare_session) {
866        Some(kv) => parse_session_kv(&kv),
867        None => SessionDirSpec {
868            session_keep: DEFAULT_SESSIONS_MAX,
869            session_shelflife: DEFAULT_SESSIONS_SHELFLIFE,
870            ..SessionDirSpec::default()
871        },
872    };
873
874    // Long-form per-key flags (each with NMBRS_ env equivalent).
875    if let Some(v) = resolve_flag(args, "--session-name") {
876        spec.session_name = Some(v);
877    }
878    // `--session-dir` is an alias for `--session-path`. `Session::new_with_args`'s
879    // own doc, and the user guide's flag table, both named `--session-dir` as the
880    // way to set the directory — but nothing parsed it, so it was silently ignored
881    // and the session landed at the default path. (`SESSION_DIRECTORY`, the legacy
882    // env spelling the guide pairs with it, WAS honoured, which made the gap look
883    // like an env-only feature.)
884    if let Some(v) = aliased_flag(args, "--session-path", "--session-dir") {
885        validate_session_path_or_exit(&v, "`--session-path` flag (or NMBRS_SESSION_PATH env)");
886        spec.session_path = Some(v);
887    }
888    if let Some(v) = resolve_flag(args, "--session-reuse")
889        && let Ok(r) = SessionReuse::parse(&v)
890    {
891        spec.reuse = r;
892    }
893    // Retention. `--session-keep` / `--session-shelflife` are canonical: they match
894    // the rest of the `--session-*` family and the umbrella's `keep:` / `shelflife:`
895    // sub-keys. The `--sessions-max` / `--sessions-shelflife` spellings are accepted
896    // as aliases because the user guide documented ONLY those, so every invocation
897    // written against it — `nmbrs run … --sessions-max=5` — was parsed as an unknown
898    // flag and silently ignored, leaving retention at its default while the operator
899    // believed they had changed it.
900    if let Some(v) = aliased_flag(args, "--session-keep", "--sessions-max")
901        && let Ok(n) = v.trim().parse::<usize>()
902    {
903        spec.session_keep = n;
904    }
905    if let Some(v) = aliased_flag(args, "--session-shelflife", "--sessions-shelflife")
906        && let Ok(d) = parse_duration(&v)
907    {
908        spec.session_shelflife = d;
909    }
910
911    // Legacy env: SESSION_DIRECTORY is the pre-SRD-04 name for
912    // NMBRS_SESSION_PATH. Honor it for back-compat with one
913    // deprecation warning. Skip silently if NMBRS_SESSION_PATH
914    // already won.
915    if spec.session_path.is_none()
916        && let Ok(v) = std::env::var(SESSION_DIRECTORY_ENV)
917        && !v.trim().is_empty()
918    {
919        crate::observer::log(
920            crate::observer::LogLevel::Warn,
921            "SESSION_DIRECTORY is deprecated; use NMBRS_SESSION_PATH (SRD-04 NMBRS_-prefix convention).",
922        );
923        validate_session_path_or_exit(&v, "legacy `SESSION_DIRECTORY` env");
924        spec.session_path = Some(v);
925    }
926
927    spec
928}
929
930/// Resolve the `--session` / `--session-path` / `--session-name`
931/// arguments to a session directory path that `metrics.db`
932/// would live under. Used by every read-side tool (`nmbrs plot`,
933/// `nmbrs report`, `nmbrs metrics ...`, completion) so the same
934/// flag means the same thing everywhere.
935///
936/// Returns `None` when no session flag is on the line — the
937/// caller falls back to its own default (typically
938/// `logs/latest`).
939///
940/// Never mutates the filesystem: no symlink rewrite, no purge. A pure path
941/// computation, because read-side tools must not have side effects on the active
942/// session symlink. This is now the ONLY way `--session` reaches a read command —
943/// the startup hook used to additionally repoint `sessions/latest` at the named
944/// session, which is exactly the side effect this doc warned against. See
945/// [`purge_stale_sessions_at_startup`].
946pub fn read_session_dir(args: &[String]) -> Option<PathBuf> {
947    let spec = resolve_session_dir(args);
948    if spec.is_empty() || spec.needs_auto_id() {
949        return None;
950    }
951    spec.resolve("").map(|(p, _)| p)
952}
953
954/// Active-session resolver consolidating the patterns used by
955/// `replay.rs` / `summary.rs` / `report_cmd.rs` /
956/// `metricsql_cmd.rs` / `plot_metrics.rs` / `completion.rs`.
957///
958/// Resolves to an existing session directory in this order:
959///
960/// 1. `--session` / `--session-path` / `--session-name` from
961///    `args` (via [`read_session_dir`]).
962/// 2. The `logs/latest` symlink, when it exists and points at a
963///    session directory with the expected artifacts
964///    (`metrics.db` or `session.log`).
965/// 3. `Err` with a remediation message naming the flags that
966///    would have worked.
967///
968/// Read-only — no filesystem mutation. Use this anywhere a
969/// post-run command needs to operate on an existing session.
970///
971/// **Why a separate function from [`read_session_dir`].**
972/// `read_session_dir` returns `Option` (no opinion on what to do
973/// when nothing's set); `resolve_active` makes that the call
974/// site's failure mode, with a single canonical error message.
975pub fn resolve_active(args: &[String]) -> Result<PathBuf, String> {
976    if let Some(p) = read_session_dir(args) {
977        if !p.exists() {
978            return Err(format!(
979                "session directory '{}' does not exist",
980                p.display(),
981            ));
982        }
983        return Ok(p);
984    }
985    let latest = latest_session_dir();
986    if latest.exists() {
987        // Resolve through the symlink so callers get a stable
988        // path that won't change underneath them mid-run.
989        let resolved = std::fs::canonicalize(&latest).unwrap_or(latest.clone());
990        return Ok(resolved);
991    }
992    Err("no active session — run a workload first, or pass \
993         `--session <name>` / `--session-path <dir>` to point \
994         at an existing one"
995        .to_string())
996}
997
998/// Default for `--session-keep` (alias `--sessions-max`): keep the
999/// 10 most-recent sessions, purging older ones at the start of the
1000/// next session-creating command.
1001pub const DEFAULT_SESSIONS_MAX: usize = 10;
1002
1003/// Default for `--session-shelflife` (alias `--sessions-shelflife`):
1004/// 4 weeks. Sessions older than this are purged regardless of the
1005/// `--session-keep` cap.
1006pub const DEFAULT_SESSIONS_SHELFLIFE: std::time::Duration =
1007    std::time::Duration::from_secs(60 * 60 * 24 * 7 * 4);
1008
1009/// Parse a duration suffix-string. Accepts:
1010/// - `<n>s` — seconds
1011/// - `<n>m` — minutes
1012/// - `<n>h` — hours
1013/// - `<n>d` — days
1014/// - `<n>w` — weeks
1015/// - bare integer — seconds (back-compat with raw numeric input)
1016///
1017/// Whitespace is trimmed. `0` (any unit) disables the cap.
1018pub fn parse_duration(s: &str) -> Result<std::time::Duration, String> {
1019    let s = s.trim();
1020    if s.is_empty() {
1021        return Err("empty duration".into());
1022    }
1023    let (num_part, unit_seconds) = if let Some(n) = s.strip_suffix('w') {
1024        (n, 60 * 60 * 24 * 7)
1025    } else if let Some(n) = s.strip_suffix('d') {
1026        (n, 60 * 60 * 24)
1027    } else if let Some(n) = s.strip_suffix('h') {
1028        (n, 60 * 60)
1029    } else if let Some(n) = s.strip_suffix('m') {
1030        (n, 60)
1031    } else if let Some(n) = s.strip_suffix('s') {
1032        (n, 1)
1033    } else {
1034        (s, 1) // bare number = seconds
1035    };
1036    let n: u64 = num_part.trim().parse().map_err(|_| {
1037        format!("duration: '{s}' is not a valid number with optional s/m/h/d/w suffix",)
1038    })?;
1039    Ok(std::time::Duration::from_secs(n * unit_seconds))
1040}
1041
1042/// Return `true` when `dir` exists AND contains artifacts that
1043/// indicate a prior session's state (metrics db, session log,
1044/// or checkpoint). Used by [`Session::new_with_args`] to decide
1045/// whether the reuse-policy check applies.
1046pub fn session_dir_has_prior_artifacts(dir: &Path) -> bool {
1047    if !dir.exists() {
1048        return false;
1049    }
1050    for marker in ["metrics.db", "session.log", "checkpoint.jsonl"] {
1051        if dir.join(marker).exists() {
1052            return true;
1053        }
1054    }
1055    false
1056}
1057
1058/// Count directory entries under `parent` (excluding
1059/// symlinks, files, and the `latest` symlink target). Used
1060/// for end-of-run keep-cap forecasting.
1061pub fn count_session_dirs(parent: &Path) -> usize {
1062    let Ok(rd) = std::fs::read_dir(parent) else {
1063        return 0;
1064    };
1065    rd.filter_map(|e| e.ok())
1066        .filter(|e| {
1067            std::fs::symlink_metadata(e.path())
1068                .map(|m| !m.file_type().is_symlink() && m.file_type().is_dir())
1069                .unwrap_or(false)
1070        })
1071        .count()
1072}
1073
1074/// Forecast how many session directories the **next** new
1075/// session would auto-purge under `parent` given the current
1076/// keep cap. Returns `0` when no purge would happen (or when
1077/// `keep_cap == 0`, which disables the cap).
1078///
1079/// Logged at INFO level by the end-of-run notice guard so
1080/// operators see the imminent cleanup before it happens, with
1081/// instructions for disabling it.
1082pub fn forecast_keep_purge(parent: &Path, keep_cap: usize) -> usize {
1083    if keep_cap == 0 {
1084        return 0;
1085    }
1086    let current = count_session_dirs(parent);
1087    // After +1 new session, total would be current+1. Anything
1088    // past keep_cap gets purged.
1089    (current + 1).saturating_sub(keep_cap)
1090}
1091
1092/// `true` if `path` carries one of the signature artifacts an
1093/// nmbrs run writes — the gate the purge logic uses to avoid
1094/// destroying unrelated directories that happen to share a
1095/// parent with an explicit `--session-path`. Any one of
1096/// `metrics.db`, `session.log`, or `checkpoint.jsonl` is
1097/// enough; the runtime writes at least one of them per
1098/// session, so the test is robust across early-aborted runs.
1099fn looks_like_session_dir(path: &Path) -> bool {
1100    const SIGNATURES: &[&str] = &["metrics.db", "session.log", "checkpoint.jsonl"];
1101    SIGNATURES.iter().any(|s| path.join(s).exists())
1102}
1103
1104/// Purge stale session directories under `parent` according to
1105/// `max_sessions` (keep the latest N) and `shelflife` (drop
1106/// anything older than this).
1107///
1108/// Skipped: the `latest` symlink and whatever it points at
1109/// (the active session). Non-directory entries (loose files
1110/// like a stray `summary.md`) are left alone.
1111///
1112/// Errors during enumeration / removal are logged at Warn
1113/// (this is a best-effort housekeeping pass; failure shouldn't
1114/// abort startup).
1115pub fn purge_stale_sessions(parent: &Path, max_sessions: usize, shelflife: std::time::Duration) {
1116    if !parent.exists() {
1117        return;
1118    }
1119    // Resolve the latest-symlink's target so we never delete
1120    // the active session out from under a running operator.
1121    let latest_target: Option<PathBuf> = std::fs::read_link(parent.join("latest"))
1122        .ok()
1123        .map(|t| if t.is_absolute() { t } else { parent.join(t) });
1124
1125    // Collect (path, mtime) for every subdirectory.
1126    let mut entries: Vec<(PathBuf, std::time::SystemTime)> = match std::fs::read_dir(parent) {
1127        Ok(rd) => rd,
1128        Err(e) => {
1129            crate::observer::log(
1130                crate::observer::LogLevel::Warn,
1131                &format!(
1132                    "warning: session cleanup: failed to read {}: {e}",
1133                    parent.display(),
1134                ),
1135            );
1136            return;
1137        }
1138    }
1139    .filter_map(|entry| entry.ok())
1140    .filter_map(|entry| {
1141        let path = entry.path();
1142        // Skip symlinks (logs/latest) — only cleanup real
1143        // directories.
1144        let md = std::fs::symlink_metadata(&path).ok()?;
1145        if md.file_type().is_symlink() || !md.file_type().is_dir() {
1146            return None;
1147        }
1148        // Don't delete the active session.
1149        if let Some(target) = latest_target.as_ref()
1150            && path == *target
1151        {
1152            return None;
1153        }
1154        // Only consider directories that *look like* nmbrs
1155        // sessions — i.e. carry one of the signature
1156        // artifacts the runtime writes. Without this, an
1157        // explicit `--session-path /tmp/foo` would set
1158        // `cleanup_parent = /tmp` and drag arbitrary
1159        // unrelated dirs (snap.rootfs_*, systemd-private-*,
1160        // …) into the purge set.
1161        if !looks_like_session_dir(&path) {
1162            return None;
1163        }
1164        let mtime = md.modified().ok()?;
1165        Some((path, mtime))
1166    })
1167    .collect();
1168
1169    if entries.is_empty() {
1170        return;
1171    }
1172
1173    // Sort newest-first for the max-cap pass.
1174    entries.sort_by(|a, b| b.1.cmp(&a.1));
1175
1176    let now = std::time::SystemTime::now();
1177    let mut to_purge: Vec<&PathBuf> = Vec::new();
1178
1179    // Cap by count: anything past the max is purged.
1180    if max_sessions > 0 && entries.len() > max_sessions {
1181        for (p, _) in &entries[max_sessions..] {
1182            to_purge.push(p);
1183        }
1184    }
1185
1186    // Cap by age: anything older than now - shelflife is purged.
1187    if !shelflife.is_zero() {
1188        for (p, mtime) in &entries[..entries.len().min(if max_sessions == 0 {
1189            usize::MAX
1190        } else {
1191            max_sessions
1192        })] {
1193            if let Ok(age) = now.duration_since(*mtime)
1194                && age > shelflife
1195                && !to_purge.contains(&p)
1196            {
1197                to_purge.push(p);
1198            }
1199        }
1200    }
1201
1202    for path in to_purge {
1203        if let Err(e) = std::fs::remove_dir_all(path) {
1204            crate::observer::log(
1205                crate::observer::LogLevel::Warn,
1206                &format!(
1207                    "warning: session cleanup: failed to remove {}: {e}",
1208                    path.display(),
1209                ),
1210            );
1211        }
1212    }
1213}
1214
1215/// Run session-lifecycle cleanup at binary startup, honouring
1216/// `--session-keep` / `--session-shelflife`.
1217///
1218/// # What this used to also do, and why it no longer does
1219///
1220/// It used to REPOINT the `sessions/latest` symlink at whatever `--session`
1221/// named, on the theory that read-side commands defaulting to
1222/// `sessions/latest/metrics.db` would then target the right session for free.
1223/// That made `--session` work by mutating shared state: a read-only
1224/// `nmbrs table … --session=sessions/foo` left `latest` pointing at `foo`
1225/// afterwards, so a later bare `nmbrs report` — or a `--resume-latest` — silently
1226/// operated on `foo` instead of the newest real run. It also only worked for
1227/// sessions living under `sessions/`, so `--session=/tmp/x` behaved differently
1228/// from `--session=sessions/x` for no reason a caller could see.
1229///
1230/// Every command that legitimately OWNS `latest` claims it itself —
1231/// [`Session::new_with_args`] for a fresh run, [`Session::reattach`] for a
1232/// resume, [`init_empty_session`] for `session init` — so nothing was relying on
1233/// this to write the link. The read side now resolves `--session` locally
1234/// ([`read_session_dir`], and the per-command `resolve_db` helpers), which works
1235/// for any path and leaves `latest` alone.
1236///
1237/// A dry-run's deliberate refusal to claim `latest` is what made the cost of the
1238/// old behaviour concrete: see `args_request_dryrun` and SRD-44.
1239///
1240/// # Why this only runs for session-CREATING commands
1241///
1242/// The cleanup DELETES session directories (keeping the `--session-keep` most
1243/// recent, and dropping anything past `--session-shelflife`). It used to run on
1244/// every invocation, so a read-only `nmbrs table …` could destroy old sessions —
1245/// the destructive counterpart of the symlink rewrite described above. Retiring
1246/// old sessions belongs where sessions are ADDED, which is the only moment the
1247/// count grows; observing a session must never delete one.
1248///
1249/// `creating_session` comes from the caller, which knows the subcommand. Passing
1250/// `false` for an unrecognised command fails safe: cleanup is skipped, and the
1251/// next writing command performs it.
1252///
1253/// Failures log Warn and return; cleanup is best-effort.
1254pub fn purge_stale_sessions_at_startup(args: &[String], creating_session: bool) {
1255    if !creating_session {
1256        return;
1257    }
1258    let spec = resolve_session_dir(args);
1259
1260    // Lifecycle cleanup runs unconditionally — it consults
1261    // `--session-keep` / `--session-shelflife` (with defaults).
1262    // Targets the parent dir: `--logs-dir` if specified, else
1263    // `logs/` under cwd. When `--session-dir` is explicit, its
1264    // *parent* directory is the cleanup target.
1265    let cleanup_parent = if let Some(sd) = spec.session_path.as_ref() {
1266        let resolved = sd.replace(SESSION_TOKEN, "");
1267        PathBuf::from(resolved)
1268            .parent()
1269            .map(|p| p.to_path_buf())
1270            .unwrap_or_else(default_sessions_root)
1271    } else {
1272        default_sessions_root()
1273    };
1274    purge_stale_sessions(&cleanup_parent, spec.session_keep, spec.session_shelflife);
1275}
1276
1277/// True when `target`'s absolute path is `logs_dir`'s absolute
1278/// path or lies below it. Both paths are resolved against the
1279/// current cwd if relative; canonicalize is avoided so this works
1280/// for not-yet-created targets.
1281pub(crate) fn target_is_under(logs_dir: &Path, target: &Path) -> bool {
1282    let cwd = std::env::current_dir().ok();
1283    let abs = |p: &Path| -> Option<PathBuf> {
1284        if p.is_absolute() {
1285            Some(p.to_path_buf())
1286        } else {
1287            cwd.as_ref().map(|c| c.join(p))
1288        }
1289    };
1290    match (abs(logs_dir), abs(target)) {
1291        (Some(l), Some(t)) => t.starts_with(&l),
1292        _ => false,
1293    }
1294}
1295
1296/// Compute a target string for a symlink at `link_path` that
1297/// addresses `target` via a path relative to the link's parent
1298/// directory. Falls back to the absolute target when neither
1299/// path can be canonicalised (e.g. target doesn't exist yet,
1300/// which is normal for `logs/latest -> <id>` at session-create
1301/// time).
1302///
1303/// Examples:
1304///   `logs/latest`, `logs/foo_20260504/`        → `foo_20260504`
1305///   `logs/latest`, `target/test-tmp/sandbox/`  → `../target/test-tmp/sandbox`
1306///   `logs/latest`, `/tmp/explore/`             → `/tmp/explore`  (no common root)
1307pub(crate) fn relative_symlink_target(link_path: &Path, target: &Path) -> PathBuf {
1308    let link_parent = link_path.parent().unwrap_or_else(|| Path::new("."));
1309    // Resolve both sides to absolute paths to compute a relative
1310    // route. `canonicalize` would also follow symlinks; we want
1311    // logical absolutes so we use cwd-prefixing for the
1312    // not-yet-existing target case.
1313    let cwd = std::env::current_dir().ok();
1314    let abs = |p: &Path| -> Option<PathBuf> {
1315        if p.is_absolute() {
1316            Some(p.to_path_buf())
1317        } else {
1318            cwd.as_ref().map(|c| c.join(p))
1319        }
1320    };
1321    let (Some(link_abs), Some(tgt_abs)) = (abs(link_parent), abs(target)) else {
1322        return target.to_path_buf();
1323    };
1324    let link_comps: Vec<_> = link_abs.components().collect();
1325    let tgt_comps: Vec<_> = tgt_abs.components().collect();
1326    let common = link_comps
1327        .iter()
1328        .zip(tgt_comps.iter())
1329        .take_while(|(a, b)| a == b)
1330        .count();
1331    if common == 0 {
1332        // Different roots (e.g. `/home/...` vs `/tmp/...`) —
1333        // can't express as a relative path without more `..`s
1334        // than is sensible. Fall back to absolute.
1335        return tgt_abs;
1336    }
1337    let ups = link_comps.len() - common;
1338    let mut rel = PathBuf::new();
1339    for _ in 0..ups {
1340        rel.push("..");
1341    }
1342    for c in &tgt_comps[common..] {
1343        rel.push(c.as_os_str());
1344    }
1345    if rel.as_os_str().is_empty() {
1346        rel.push(".");
1347    }
1348    rel
1349}
1350
1351impl Session {
1352    /// Create a new session. Picks the output directory in this
1353    /// priority order:
1354    ///
1355    /// 1. `--session-dir <path>` CLI flag (or
1356    ///    `SESSION_DIRECTORY` env var, equivalent). `SESSION`
1357    ///    token in the path is replaced with the auto-generated
1358    ///    `{session_name}_{timestamp}` name. The basename becomes
1359    ///    the session id.
1360    /// 2. `--logs-dir <parent>` and/or `--session <name>` CLI
1361    ///    flags. Compose into `<parent>/<name>` (defaulting to
1362    ///    `logs/<auto-id>` for any unspecified component).
1363    /// 3. Default — `logs/{session_name}_{timestamp}/`.
1364    ///
1365    /// `session_name` is the auto-id stem (the single-run caller
1366    /// passes the scenario name; a concurrent SRD-88 host passes
1367    /// a session-level name). `args` is the raw CLI args slice —
1368    /// pass an empty slice to get env-only resolution. The
1369    /// session carries only `session=<id>` identity; per-execution
1370    /// workload/scenario live on the [`Execution`] tier.
1371    pub fn new_with_args(session_name: &str, args: &[String]) -> Self {
1372        let timestamp = format_timestamp();
1373        let auto_id = format!("{session_name}_{timestamp}");
1374
1375        let spec = resolve_session_dir(args);
1376        let (output_dir, id) = spec
1377            .resolve(&auto_id)
1378            .unwrap_or_else(|| (default_session_dir(&auto_id), auto_id.clone()));
1379
1380        // Reuse-policy check. Only fires when the resolved
1381        // directory already holds prior session artifacts. The
1382        // resume path enters via `Session::resume`, not here, so
1383        // any pre-existing artifacts at this entry point are a
1384        // collision the operator must explicitly resolve.
1385        if session_dir_has_prior_artifacts(&output_dir) {
1386            match spec.reuse {
1387                SessionReuse::Error => {
1388                    eprintln!(
1389                        "error: session directory {} already contains artifacts \
1390                         (metrics.db / session.log / checkpoint.jsonl).\n  \
1391                         Pick a reuse policy:\n    \
1392                         --session-reuse=restart  (wipe artifacts, fresh run)\n    \
1393                         --session-reuse=resume   (continue with the prior session — \
1394                         equivalent to --resume <id>)\n  \
1395                         Or pick a different path via --session, --logs-dir, \
1396                         --session-dir, or SESSION_DIRECTORY env.",
1397                        output_dir.display(),
1398                    );
1399                    std::process::exit(2);
1400                }
1401                SessionReuse::Restart => {
1402                    for marker in [
1403                        "metrics.db",
1404                        "session.log",
1405                        "checkpoint.jsonl",
1406                        "checkpoint.lock",
1407                        "summary.md",
1408                        "summary.txt",
1409                        "summary.json",
1410                        "tui.dump",
1411                        "flamegraph.svg",
1412                        "flamegraph-perf.svg",
1413                        "flamegraph-perf.md",
1414                    ] {
1415                        let _ = std::fs::remove_file(output_dir.join(marker));
1416                    }
1417                    crate::observer::log(
1418                        crate::observer::LogLevel::Warn,
1419                        &format!(
1420                            "session-reuse=restart: wiped prior artifacts in {}",
1421                            output_dir.display(),
1422                        ),
1423                    );
1424                }
1425                SessionReuse::Resume => {
1426                    eprintln!(
1427                        "error: --session-reuse=resume requires --resume to actually \
1428                         continue the prior session. Add --resume (or --resume-latest) \
1429                         to your command line. Path: {}",
1430                        output_dir.display(),
1431                    );
1432                    std::process::exit(2);
1433                }
1434            }
1435        }
1436
1437        // Create the output directory (and any missing parents)
1438        if let Err(e) = std::fs::create_dir_all(&output_dir) {
1439            crate::observer::log(
1440                crate::observer::LogLevel::Warn,
1441                &format!(
1442                    "warning: failed to create session output directory {}: {e}",
1443                    output_dir.display()
1444                ),
1445            );
1446        }
1447
1448        // Refresh `logs/latest` → this session, then *clear* every
1449        // per-artifact convenience symlink from the previous
1450        // session. Optional artifacts (flamegraphs, summary,
1451        // tui.dump) get their convenience link only when their
1452        // writer actually produces the file — eager pre-creation
1453        // would leave a dangling link any time the run skips that
1454        // artifact (e.g. no `profiler=` on the CLI). The two
1455        // guaranteed-written artifacts (`session.log` / `metrics.db`)
1456        // are linked here so live tooling can `tail -f
1457        // logs/session.log` or open `logs/metrics.db` without
1458        // chasing the timestamped session id.
1459        let logs = default_sessions_root();
1460        // Only touch `logs/` when the session output dir is under
1461        // it. An explicit `--session-path /tmp/x` (or any redirect
1462        // outside `logs/`) is treated as opt-out:
1463        // - The mkdir below would otherwise stamp a stray
1464        //   `<cwd>/logs/` directory in test sandboxes / CI
1465        //   working trees that don't want it (the
1466        //   `feedback_tests_no_project_root` rule), and
1467        // - `logs/latest` shouldn't get hijacked by test fixtures
1468        //   or one-off `--session-path` runs.
1469        // A dry-run is resume-inert: it must not hijack `latest` (nor
1470        // the per-artifact convenience links). Leaving `latest` pointing
1471        // at the prior REAL run is exactly what lets a subsequent
1472        // `--resume-latest` resume that run instead of the throwaway
1473        // dry-run. See `args_request_dryrun` / SRD-44.
1474        if target_is_under(&logs, &output_dir) && !args_request_dryrun(args) {
1475            let _ = std::fs::create_dir_all(&logs);
1476            let latest = logs.join("latest");
1477            let _ = std::fs::remove_file(&latest);
1478            let _ = symlink_any(&latest_symlink_target(&output_dir, &logs, &id), &latest);
1479            for stale in [
1480                "summary.md",
1481                "flamegraph.svg",
1482                "flamegraph-perf.svg",
1483                "flamegraph-perf.md",
1484                "tui.dump",
1485            ] {
1486                let _ = std::fs::remove_file(logs.join(stale));
1487            }
1488            for artifact in ["session.log", "metrics.db"] {
1489                let link = logs.join(artifact);
1490                let _ = std::fs::remove_file(&link);
1491                // Relative target routes through the `latest`
1492                // symlink so swapping sessions updates every artifact
1493                // link in a single `latest` update.
1494                let target = PathBuf::from("latest").join(artifact);
1495                let _ = symlink_any(&target, &link);
1496            }
1497        }
1498
1499        // The session component owns exactly one dimensional
1500        // label: `session=<id>`. Per SRD-88 §2 this is the shared
1501        // common root — one per process — and per the label-
1502        // ownership invariant it never carries `exec_id` /
1503        // `workload`; those are declared once, on each
1504        // [`Execution::component`] child, and composed in by the
1505        // component tree. Descendant metrics still see the full
1506        // `{session, exec_id, workload}` set, but every name is
1507        // owned by exactly one tier.
1508        let component =
1509            Component::root(Labels::of("session", &id), std::collections::HashMap::new());
1510        // Install the session root as the resolver backing for
1511        // Polydat runtime-context nodes (`control(...)`, `rate()`,
1512        // `concurrency()`, etc.). See SRD 12 §"Runtime context
1513        // nodes" and polydat/src/nodes/runtime_context.rs.
1514        crate::polydat_nodes::runtime_context::set_session_root(component.clone());
1515
1516        Self {
1517            id,
1518            output_dir,
1519            component,
1520            metrics_query: Mutex::new(None),
1521        }
1522    }
1523
1524    /// Backward-compat shim for callers that don't have CLI
1525    /// args handy. Equivalent to
1526    /// `Session::new_with_args(session_name, &[])` — resolves
1527    /// session-dir from `SESSION_DIRECTORY` env only.
1528    pub fn new(session_name: &str) -> Self {
1529        Self::new_with_args(session_name, &[])
1530    }
1531
1532    /// Re-attach to a prior session — reuse its directory, id,
1533    /// and (consequently) its `metrics.db` so the attaching
1534    /// invocation appends to the same metrics history rather
1535    /// than starting fresh in a new dir. Per SRD-44 §"Wholesale
1536    /// metrics-purge", phases that re-run need their prior sample
1537    /// rows purged in-place; that requires writing to the same
1538    /// db, which requires reusing the same session dir. The id is
1539    /// read from the directory's basename, so it preserves the
1540    /// original timestamp suffix.
1541    ///
1542    /// Backs both `nmbrs resume` and `nmbrs refine`: the two differ
1543    /// only in the [`Execution`] the caller starts under this
1544    /// session (its `verb` and `exec_id`), which is now an
1545    /// execution-tier concern (SRD-88) — not a session-tier one.
1546    /// `session_name` is the fallback id stem used only when the
1547    /// directory basename can't be read.
1548    pub fn reattach(prior_session_dir: PathBuf, session_name: &str) -> Self {
1549        let id = prior_session_dir
1550            .file_name()
1551            .and_then(|s| s.to_str())
1552            .map(String::from)
1553            .unwrap_or_else(|| {
1554                // Fallback: synthesize a fresh id from the
1555                // session name + timestamp. Shouldn't fire in
1556                // practice (we resolved the dir to a real session
1557                // before calling here).
1558                format!("{session_name}_{}", format_timestamp())
1559            });
1560
1561        // Re-establish the convenience symlinks under `logs/` so
1562        // `tail -f logs/session.log` and `sqlite3 logs/metrics.db`
1563        // resolve to this session's artifacts. Same shape as
1564        // Session::new — a no-op when the symlinks already point
1565        // here from a prior run. Skipped when the session lives
1566        // outside `logs/` (see Session::new for rationale: one-off
1567        // external session dirs shouldn't hijack `logs/latest`).
1568        let logs = default_sessions_root();
1569        if target_is_under(&logs, &prior_session_dir) {
1570            let latest = logs.join("latest");
1571            let _ = std::fs::remove_file(&latest);
1572            let _ = symlink_any(Path::new(&id), &latest);
1573            for artifact in ["session.log", "metrics.db"] {
1574                let link = logs.join(artifact);
1575                let _ = std::fs::remove_file(&link);
1576                let target = PathBuf::from("latest").join(artifact);
1577                let _ = symlink_any(&target, &link);
1578            }
1579        }
1580
1581        // Session component carries `session=<id>` only — the
1582        // re-attached execution(s) declare `exec_id` / `workload`
1583        // on their own [`Execution::component`] children, exactly
1584        // as a fresh session does (see `new_with_args`).
1585        let component =
1586            Component::root(Labels::of("session", &id), std::collections::HashMap::new());
1587        crate::polydat_nodes::runtime_context::set_session_root(component.clone());
1588
1589        Self {
1590            id,
1591            output_dir: prior_session_dir,
1592            component,
1593            metrics_query: Mutex::new(None),
1594        }
1595    }
1596
1597    /// Create a `logs/<artifact>` convenience symlink that points
1598    /// (through `logs/latest`) at the named artifact in the
1599    /// current session's output dir. Idempotent — replaces any
1600    /// existing link with the same name. Call from the writer
1601    /// at the moment the artifact has actually been produced, so
1602    /// the convenience link never dangles.
1603    pub fn link_artifact(name: &str) {
1604        let logs = default_sessions_root();
1605        let link = logs.join(name);
1606        let _ = std::fs::remove_file(&link);
1607        let target = PathBuf::from("latest").join(name);
1608        let _ = symlink_any(&target, &link);
1609    }
1610
1611    /// Install the shared [`MetricsQuery`] handle. Called once by the
1612    /// runner after it has planned the cadence tree and built the
1613    /// cadence reporter. Panics if called twice.
1614    pub fn set_metrics_query(&self, query: Arc<MetricsQuery>) {
1615        let mut slot = self.metrics_query.lock().unwrap_or_else(|e| e.into_inner());
1616        assert!(slot.is_none(), "session metrics_query already installed");
1617        *slot = Some(query);
1618    }
1619
1620    /// Borrow the installed [`MetricsQuery`]. Returns `None` before
1621    /// the runner wires it.
1622    pub fn metrics_query(&self) -> Option<Arc<MetricsQuery>> {
1623        self.metrics_query
1624            .lock()
1625            .unwrap_or_else(|e| e.into_inner())
1626            .clone()
1627    }
1628
1629    /// Path to the SQLite metrics file for this session.
1630    pub fn metrics_path(&self) -> PathBuf {
1631        self.output_dir.join("metrics.db")
1632    }
1633
1634    /// Path for a profiler output file.
1635    pub fn profiler_path(&self, suffix: &str) -> PathBuf {
1636        self.output_dir.join(format!("flamegraph{suffix}.svg"))
1637    }
1638
1639    /// Path for an arbitrary session artifact.
1640    pub fn artifact_path(&self, filename: &str) -> PathBuf {
1641        self.output_dir.join(filename)
1642    }
1643}
1644
1645/// Format the current time as `YYYYMMDD_HHmmss`.
1646/// Compute the symlink target string for `logs/latest`,
1647/// always relative. For sessions under `logs/<id>/` the link
1648/// resolves to a bare `{id}`; sessions outside `logs/` get an
1649/// `../...` route up to a common ancestor. Relative targets
1650/// keep the link portable across directory moves and readable
1651/// in `ls -la` output.
1652fn latest_symlink_target(output_dir: &Path, logs: &Path, id: &str) -> PathBuf {
1653    if output_dir.parent() == Some(logs) {
1654        return PathBuf::from(id);
1655    }
1656    let latest = logs.join("latest");
1657    relative_symlink_target(&latest, output_dir)
1658}
1659
1660/// UTC datetime components for session-name templating, derived
1661/// from the same manual Gregorian math as [`format_timestamp`] (no
1662/// chrono dependency). Returns `(year, month, day, hour, minute,
1663/// second, epoch_millis, epoch_seconds)`.
1664pub fn utc_datetime_fields() -> (u64, u64, u64, u64, u64, u64, u128, u64) {
1665    let now = std::time::SystemTime::now()
1666        .duration_since(std::time::UNIX_EPOCH)
1667        .unwrap_or_default();
1668    let secs = now.as_secs();
1669    let millis = now.as_millis();
1670    let day_count = secs / 86400;
1671    let t = secs % 86400;
1672    let (year, month, day) = days_to_ymd(day_count);
1673    (
1674        year,
1675        month,
1676        day,
1677        t / 3600,
1678        (t % 3600) / 60,
1679        t % 60,
1680        millis,
1681        secs,
1682    )
1683}
1684
1685fn format_timestamp() -> String {
1686    let secs = std::time::SystemTime::now()
1687        .duration_since(std::time::UNIX_EPOCH)
1688        .unwrap()
1689        .as_secs();
1690    // Convert epoch seconds to date/time components.
1691    // Simple implementation without chrono dependency.
1692    let days = secs / 86400;
1693    let time = secs % 86400;
1694    let hours = time / 3600;
1695    let minutes = (time % 3600) / 60;
1696    let seconds = time % 60;
1697
1698    // Days since epoch to Y/M/D (simplified Gregorian)
1699    let (year, month, day) = days_to_ymd(days);
1700
1701    format!("{year:04}{month:02}{day:02}_{hours:02}{minutes:02}{seconds:02}")
1702}
1703
1704/// Current wall-clock time as `YYYY-MM-DD HH:MM:SS.mmm` (UTC).
1705/// Used by the session log writer for human-readable line timestamps.
1706pub fn now_log_timestamp() -> String {
1707    format_log_timestamp(std::time::SystemTime::now())
1708}
1709
1710/// Format a specific `SystemTime` in the same shape as
1711/// [`now_log_timestamp`]. Used by the failure-dump path
1712/// in `nmbrs-tui::observer` to render per-LogEntry
1713/// timestamps captured at log-emit time.
1714pub fn format_log_timestamp(t: std::time::SystemTime) -> String {
1715    let dur = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
1716    let secs = dur.as_secs();
1717    let millis = dur.subsec_millis();
1718    let days = secs / 86400;
1719    let time = secs % 86400;
1720    let hours = time / 3600;
1721    let minutes = (time % 3600) / 60;
1722    let seconds = time % 60;
1723    let (year, month, day) = days_to_ymd(days);
1724    format!("{year:04}-{month:02}-{day:02} {hours:02}:{minutes:02}:{seconds:02}.{millis:03}")
1725}
1726
1727/// Convert days since Unix epoch to (year, month, day).
1728/// Format Unix seconds as `MM-DD HH:MM:SS` (UTC).
1729///
1730/// Exposed because report tables now show WHEN a series started and last moved,
1731/// and the epoch value on its own is unreadable. Shares `days_to_ymd` with the
1732/// session-id formatter rather than restating the calendar arithmetic; the year
1733/// is omitted because these columns compare moments inside one run, where the
1734/// month and day are already the widest useful distinction.
1735pub fn format_utc_short(epoch_seconds: f64) -> String {
1736    if !epoch_seconds.is_finite() || epoch_seconds <= 0.0 {
1737        return "-".to_string();
1738    }
1739    let secs = epoch_seconds as u64;
1740    let (_, month, day) = days_to_ymd(secs / 86400);
1741    let t = secs % 86400;
1742    format!(
1743        "{month:02}-{day:02} {:02}:{:02}:{:02}",
1744        t / 3600,
1745        (t % 3600) / 60,
1746        t % 60
1747    )
1748}
1749
1750fn days_to_ymd(days: u64) -> (u64, u64, u64) {
1751    // Algorithm from Howard Hinnant's date library
1752    let z = days + 719468;
1753    let era = z / 146097;
1754    let doe = z - era * 146097;
1755    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
1756    let y = yoe + era * 400;
1757    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1758    let mp = (5 * doy + 2) / 153;
1759    let d = doy - (153 * mp + 2) / 5 + 1;
1760    let m = if mp < 10 { mp + 3 } else { mp - 9 };
1761    let y = if m <= 2 { y + 1 } else { y };
1762    (y, m, d)
1763}
1764
1765/// Resolve an operator-supplied relative path INSIDE `base`, refusing
1766/// anything that could name a location elsewhere: an absolute path, a
1767/// `..` component, an empty path, or a Windows drive/prefix. Output
1768/// paths that a workload file can set (`metrics-log`, `trace_log`)
1769/// route through here, so a shared workload can only ever write into
1770/// the session's own directory tree.
1771pub fn confine_to_dir(base: &Path, rel: &str) -> Result<PathBuf, String> {
1772    use std::path::Component;
1773    let rel_path = Path::new(rel.trim());
1774    if rel.trim().is_empty() {
1775        return Err("empty path".to_string());
1776    }
1777    for comp in rel_path.components() {
1778        match comp {
1779            Component::Normal(_) | Component::CurDir => {}
1780            Component::ParentDir => {
1781                return Err(format!(
1782                    "'{rel}' escapes the session directory (`..` is not allowed)"
1783                ));
1784            }
1785            Component::RootDir | Component::Prefix(_) => {
1786                return Err(format!(
1787                    "'{rel}' is absolute; only paths relative to the session directory are accepted here"
1788                ));
1789            }
1790        }
1791    }
1792    Ok(base.join(rel_path))
1793}
1794
1795#[cfg(test)]
1796mod tests {
1797    use super::*;
1798
1799    #[test]
1800    fn timestamp_format() {
1801        let ts = format_timestamp();
1802        // Should be 15 chars: YYYYMMDD_HHmmss
1803        assert_eq!(ts.len(), 15, "timestamp: {ts}");
1804        assert!(
1805            ts.contains('_'),
1806            "timestamp should contain underscore: {ts}"
1807        );
1808    }
1809
1810    /// Serialize all tests that mutate process-global state
1811    /// (SESSION_DIRECTORY env var, cwd-relative `logs/` writes,
1812    /// etc.). Cargo runs tests in parallel by default; tests
1813    /// touching shared state must hold this lock for the
1814    /// duration of the operation.
1815    fn env_test_lock() -> std::sync::MutexGuard<'static, ()> {
1816        use std::sync::Mutex;
1817        static LOCK: Mutex<()> = Mutex::new(());
1818        LOCK.lock().unwrap_or_else(|e| e.into_inner())
1819    }
1820
1821    #[test]
1822    fn dryrun_detection_from_args() {
1823        let yes =
1824            |a: &[&str]| args_request_dryrun(&a.iter().map(|s| s.to_string()).collect::<Vec<_>>());
1825        assert!(yes(&["run", "workload=w", "dryrun=op"]));
1826        assert!(yes(&["run", "dryrun=phase,wiring"]));
1827        assert!(yes(&["run", "--dryrun=structure"]));
1828        // A real run — no dryrun signal.
1829        assert!(!yes(&["run", "workload=w", "scenario=incremental"]));
1830        // Explicit falsey values are not a dry-run.
1831        assert!(!yes(&["run", "dryrun=false"]));
1832        assert!(!yes(&["run", "dryrun=0"]));
1833        assert!(!yes(&["run", "dryrun="]));
1834    }
1835
1836    #[test]
1837    fn session_id_format() {
1838        // Session construction installs the process-global session root as a
1839        // side effect (see `new_with_args`), racing every control-node test
1840        // that reads through it. Hold the same guard those tests hold.
1841        let _root_guard = crate::polydat_nodes::runtime_context::session_root_test_guard();
1842        let _g = env_test_lock();
1843        unsafe {
1844            std::env::remove_var(SESSION_DIRECTORY_ENV);
1845        }
1846        let session = Session::new("fknn_rampup");
1847        assert!(session.id.starts_with("fknn_rampup_"), "id: {}", session.id);
1848        let expected_root = default_sessions_root();
1849        assert!(
1850            session.output_dir.starts_with(&expected_root),
1851            "output_dir {} should start with {}",
1852            session.output_dir.display(),
1853            expected_root.display()
1854        );
1855    }
1856
1857    #[test]
1858    fn session_paths() {
1859        // Session construction installs the process-global session root as a
1860        // side effect (see `new_with_args`), racing every control-node test
1861        // that reads through it. Hold the same guard those tests hold.
1862        let _root_guard = crate::polydat_nodes::runtime_context::session_root_test_guard();
1863        let _g = env_test_lock();
1864        unsafe {
1865            std::env::remove_var(SESSION_DIRECTORY_ENV);
1866        }
1867        let session = Session::new("smoke");
1868        assert!(session.metrics_path().ends_with("metrics.db"));
1869        assert!(session.profiler_path("").ends_with("flamegraph.svg"));
1870        assert!(
1871            session
1872                .profiler_path("-perf")
1873                .ends_with("flamegraph-perf.svg")
1874        );
1875    }
1876
1877    /// Clear every env var that `resolve_session_dir` reads, so a
1878    /// `spec_*` test sees CLI-only inputs. Without this, a
1879    /// concurrent test that sets `NMBRS_SESSION_NAME` (or any
1880    /// peer var) collides with this test's CLI flag, hits the
1881    /// "configuration conflict" path in `resolve_flag`, and
1882    /// calls `process::exit(2)` — killing the entire test
1883    /// process and surfacing as an unrelated test failure.
1884    /// Must be called under [`env_test_lock`] so the cleanup
1885    /// holds for the duration of the spec resolution.
1886    fn clear_session_env() {
1887        // SAFETY: under env_test_lock, no other test thread is
1888        // touching these vars.
1889        unsafe {
1890            std::env::remove_var("NMBRS_SESSION");
1891            std::env::remove_var("NMBRS_SESSION_NAME");
1892            std::env::remove_var("NMBRS_SESSION_PATH");
1893            std::env::remove_var("NMBRS_SESSION_REUSE");
1894            std::env::remove_var("NMBRS_SESSION_KEEP");
1895            std::env::remove_var("NMBRS_SESSION_SHELFLIFE");
1896            std::env::remove_var(SESSION_DIRECTORY_ENV);
1897        }
1898    }
1899
1900    #[test]
1901    fn spec_session_path_flag_yields_basename_id() {
1902        let _g = env_test_lock();
1903        clear_session_env();
1904        let args = vec!["--session-path=/tmp/explicit".into()];
1905        let spec = resolve_session_dir(&args);
1906        let (path, id) = spec.resolve("auto-id").unwrap();
1907        assert_eq!(path.to_str(), Some("/tmp/explicit"));
1908        assert_eq!(id, "explicit");
1909    }
1910
1911    /// Flags that the docs named but nothing parsed.
1912    ///
1913    /// The user guide's quick reference listed `--sessions-max`,
1914    /// `--sessions-shelflife` and `--session-dir`; none were parsed, so an
1915    /// invocation copied from the guide was read as an unknown flag and IGNORED —
1916    /// retention silently stayed at 10 sessions / 4 weeks, and the session landed
1917    /// at the default path. They are aliases now, with the `--session-*` family
1918    /// spellings canonical.
1919    #[test]
1920    fn documented_flag_aliases_are_honoured() {
1921        let _g = env_test_lock();
1922        clear_session_env();
1923
1924        let spec = resolve_session_dir(&["--sessions-max=5".to_string()]);
1925        assert_eq!(
1926            spec.session_keep, 5,
1927            "`--sessions-max` must set the keep cap"
1928        );
1929
1930        let spec = resolve_session_dir(&["--sessions-shelflife=2w".to_string()]);
1931        assert_eq!(
1932            spec.session_shelflife,
1933            std::time::Duration::from_secs(14 * 24 * 3600),
1934            "`--sessions-shelflife` must set the retention window"
1935        );
1936
1937        let spec = resolve_session_dir(&["--session-dir=/tmp/explicit".to_string()]);
1938        let (path, id) = spec.resolve("auto").unwrap();
1939        assert_eq!(
1940            path.to_str(),
1941            Some("/tmp/explicit"),
1942            "`--session-dir` must set the session path"
1943        );
1944        assert_eq!(id, "explicit");
1945
1946        // The canonical spellings keep working, and win when both are given.
1947        let spec = resolve_session_dir(&["--session-keep=7".to_string()]);
1948        assert_eq!(spec.session_keep, 7);
1949        let spec = resolve_session_dir(&[
1950            "--session-keep=7".to_string(),
1951            "--sessions-max=99".to_string(),
1952        ]);
1953        assert_eq!(
1954            spec.session_keep, 7,
1955            "the canonical spelling must win over the alias"
1956        );
1957    }
1958
1959    #[test]
1960    fn bare_session_kv_resolves_like_the_dash_flag() {
1961        // Read-side commands take bare `workload=<file>`, so an operator writes
1962        // `session=<dir>` beside it. Before this was accepted, that token was
1963        // consumed as an unrecognised key=value and IGNORED — the command
1964        // reported on `sessions/latest` while naming a different directory.
1965        let _g = env_test_lock();
1966        clear_session_env();
1967        let bare = resolve_session_dir(&["session=/tmp/explicit".to_string()])
1968            .resolve("auto-id")
1969            .unwrap();
1970        let dashed = resolve_session_dir(&["--session=/tmp/explicit".to_string()])
1971            .resolve("auto-id")
1972            .unwrap();
1973        assert_eq!(bare, dashed, "both spellings must name the same session");
1974        assert_eq!(bare.0.to_str(), Some("/tmp/explicit"));
1975    }
1976
1977    #[test]
1978    fn dash_session_wins_over_bare_session() {
1979        // Explicit flag beats the bare param spelling, so a wrapper script that
1980        // appends `--session=` can override a workload-supplied `session=`.
1981        let _g = env_test_lock();
1982        clear_session_env();
1983        let args = vec![
1984            "session=/tmp/from-param".to_string(),
1985            "--session=/tmp/from-flag".to_string(),
1986        ];
1987        let (path, _) = resolve_session_dir(&args).resolve("auto-id").unwrap();
1988        assert_eq!(path.to_str(), Some("/tmp/from-flag"));
1989    }
1990
1991    #[test]
1992    fn spec_session_name_only_yields_default_logs_dir() {
1993        let _g = env_test_lock();
1994        clear_session_env();
1995        let args = vec!["--session-name=alpha".into()];
1996        let (path, id) = resolve_session_dir(&args).resolve("autogen").unwrap();
1997        assert_eq!(path, default_sessions_root().join("alpha"));
1998        assert_eq!(id, "alpha");
1999    }
2000
2001    #[test]
2002    fn spec_session_path_token_replaced_with_name() {
2003        let _g = env_test_lock();
2004        clear_session_env();
2005        let args = vec![
2006            "--session-path=/data/SESSION_run".into(),
2007            "--session-name=alpha".into(),
2008        ];
2009        let (path, id) = resolve_session_dir(&args).resolve("autogen").unwrap();
2010        assert_eq!(path.to_str(), Some("/data/alpha_run"));
2011        assert_eq!(id, "alpha_run");
2012    }
2013
2014    #[test]
2015    fn spec_session_path_token_falls_back_to_auto_id_when_no_name() {
2016        let _g = env_test_lock();
2017        clear_session_env();
2018        let args = vec!["--session-path=/data/SESSION_run".into()];
2019        let (path, id) = resolve_session_dir(&args).resolve("autogen").unwrap();
2020        assert_eq!(path.to_str(), Some("/data/autogen_run"));
2021        assert_eq!(id, "autogen_run");
2022    }
2023
2024    #[test]
2025    fn spec_space_form_session_path_flag() {
2026        let _g = env_test_lock();
2027        clear_session_env();
2028        let args = vec!["--session-path".into(), "/data/path".into()];
2029        let (path, _) = resolve_session_dir(&args).resolve("auto").unwrap();
2030        assert_eq!(path.to_str(), Some("/data/path"));
2031    }
2032
2033    #[test]
2034    fn spec_falls_back_to_env() {
2035        let _g = env_test_lock();
2036        let prior = std::env::var(SESSION_DIRECTORY_ENV).ok();
2037        unsafe {
2038            std::env::set_var(SESSION_DIRECTORY_ENV, "/from/env");
2039        }
2040        let spec = resolve_session_dir(&[]);
2041        match prior {
2042            Some(v) => unsafe {
2043                std::env::set_var(SESSION_DIRECTORY_ENV, v);
2044            },
2045            None => unsafe {
2046                std::env::remove_var(SESSION_DIRECTORY_ENV);
2047            },
2048        }
2049        let (path, _) = spec.resolve("auto").unwrap();
2050        assert_eq!(path.to_str(), Some("/from/env"));
2051    }
2052
2053    #[test]
2054    fn spec_cli_flag_overrides_env() {
2055        let _g = env_test_lock();
2056        let prior = std::env::var(SESSION_DIRECTORY_ENV).ok();
2057        unsafe {
2058            std::env::set_var(SESSION_DIRECTORY_ENV, "/from/env");
2059        }
2060        let args = vec!["--session-path=/from/cli".into()];
2061        let spec = resolve_session_dir(&args);
2062        match prior {
2063            Some(v) => unsafe {
2064                std::env::set_var(SESSION_DIRECTORY_ENV, v);
2065            },
2066            None => unsafe {
2067                std::env::remove_var(SESSION_DIRECTORY_ENV);
2068            },
2069        }
2070        let (path, _) = spec.resolve("auto").unwrap();
2071        assert_eq!(
2072            path.to_str(),
2073            Some("/from/cli"),
2074            "CLI --session-path must win over SESSION_DIRECTORY env"
2075        );
2076    }
2077
2078    #[test]
2079    fn spec_empty_returns_no_resolution() {
2080        let _g = env_test_lock();
2081        let prior = std::env::var(SESSION_DIRECTORY_ENV).ok();
2082        unsafe {
2083            std::env::remove_var(SESSION_DIRECTORY_ENV);
2084        }
2085        let spec = resolve_session_dir(&[]);
2086        if let Some(v) = prior {
2087            unsafe {
2088                std::env::set_var(SESSION_DIRECTORY_ENV, v);
2089            }
2090        }
2091        assert!(spec.is_empty());
2092        assert!(spec.resolve("auto").is_none());
2093    }
2094
2095    #[test]
2096    fn spec_needs_auto_id_when_token_present() {
2097        let _g = env_test_lock();
2098        clear_session_env();
2099        let args = vec!["--session-path=/data/SESSION_x".into()];
2100        assert!(resolve_session_dir(&args).needs_auto_id());
2101    }
2102
2103    #[test]
2104    fn spec_does_not_need_auto_id_when_path_is_concrete() {
2105        let _g = env_test_lock();
2106        clear_session_env();
2107        let args = vec!["--session-path=/data/specific".into()];
2108        assert!(!resolve_session_dir(&args).needs_auto_id());
2109    }
2110
2111    #[test]
2112    fn spec_does_not_need_auto_id_with_explicit_name() {
2113        let _g = env_test_lock();
2114        clear_session_env();
2115        let args = vec![
2116            "--session-path=/data/SESSION_x".into(),
2117            "--session-name=alpha".into(),
2118        ];
2119        assert!(!resolve_session_dir(&args).needs_auto_id());
2120    }
2121
2122    // -----------------------------------------------------------
2123    // Umbrella --session kv-list parsing
2124    // -----------------------------------------------------------
2125
2126    #[test]
2127    fn umbrella_dir_shortcut_sets_path() {
2128        let _g = env_test_lock();
2129        clear_session_env();
2130        let args = vec!["--session=dir:asldkfjsldfj".into()];
2131        let (path, id) = resolve_session_dir(&args).resolve("auto").unwrap();
2132        assert_eq!(path.to_str(), Some("asldkfjsldfj"));
2133        assert_eq!(id, "asldkfjsldfj", "session id is the basename of the path");
2134    }
2135
2136    #[test]
2137    fn umbrella_dir_with_subpath_yields_basename_id() {
2138        let _g = env_test_lock();
2139        clear_session_env();
2140        let args = vec!["--session=dir:l2k3j4/drr".into()];
2141        let (path, id) = resolve_session_dir(&args).resolve("auto").unwrap();
2142        assert_eq!(path.to_str(), Some("l2k3j4/drr"));
2143        assert_eq!(id, "drr");
2144    }
2145
2146    #[test]
2147    fn umbrella_full_kv_list() {
2148        let _g = env_test_lock();
2149        clear_session_env();
2150        let args =
2151            vec!["--session=keep:42,name:sessname42,path:sessions/dir/SESSION,reuse:resume".into()];
2152        let spec = resolve_session_dir(&args);
2153        assert_eq!(spec.session_name.as_deref(), Some("sessname42"));
2154        assert_eq!(spec.session_path.as_deref(), Some("sessions/dir/SESSION"));
2155        assert_eq!(spec.reuse, SessionReuse::Resume);
2156        assert_eq!(spec.session_keep, 42);
2157        let (path, id) = spec.resolve("autogen").unwrap();
2158        assert_eq!(path.to_str(), Some("sessions/dir/sessname42"));
2159        assert_eq!(id, "sessname42");
2160    }
2161
2162    #[test]
2163    fn umbrella_bare_restart_token_sets_reuse() {
2164        let _g = env_test_lock();
2165        clear_session_env();
2166        let args = vec!["--session=restart,dir:/tmp/x".into()];
2167        let spec = resolve_session_dir(&args);
2168        assert_eq!(spec.reuse, SessionReuse::Restart);
2169        assert_eq!(spec.session_path.as_deref(), Some("/tmp/x"));
2170    }
2171
2172    #[test]
2173    fn umbrella_bare_resume_token_sets_reuse() {
2174        let _g = env_test_lock();
2175        clear_session_env();
2176        let args = vec!["--session=resume,name:foo".into()];
2177        let spec = resolve_session_dir(&args);
2178        assert_eq!(spec.reuse, SessionReuse::Resume);
2179        assert_eq!(spec.session_name.as_deref(), Some("foo"));
2180    }
2181
2182    #[test]
2183    fn umbrella_long_form_overrides_umbrella() {
2184        let _g = env_test_lock();
2185        clear_session_env();
2186        let args = vec![
2187            "--session=name:from-umbrella".into(),
2188            "--session-name=from-longform".into(),
2189        ];
2190        let spec = resolve_session_dir(&args);
2191        assert_eq!(spec.session_name.as_deref(), Some("from-longform"));
2192    }
2193
2194    #[test]
2195    fn umbrella_unknown_key_logs_warn_but_keeps_rest() {
2196        let _g = env_test_lock();
2197        clear_session_env();
2198        let args = vec!["--session=name:foo,what:nope,reuse:restart".into()];
2199        let spec = resolve_session_dir(&args);
2200        // Unknown 'what:nope' was logged + skipped; recognized
2201        // entries still applied.
2202        assert_eq!(spec.session_name.as_deref(), Some("foo"));
2203        assert_eq!(spec.reuse, SessionReuse::Restart);
2204    }
2205
2206    // ── check_session_path: catch the `scenario=foo`-as-path footgun ──
2207
2208    #[test]
2209    fn check_session_path_rejects_workload_param_shape() {
2210        // The classic recurring footgun: `--session-path scenario=foo`
2211        // would silently create `<cwd>/scenario=foo/...` because the
2212        // umbrella parser splits only on `:`.
2213        for bad in &[
2214            "scenario=foo",
2215            "scenario=/tmp/foo",
2216            "scenario=target",
2217            "scenario=target/test-tmp/x",
2218            "k=v",
2219            "key_with_underscore=value",
2220            "kebab-case=value",
2221        ] {
2222            assert!(
2223                check_session_path(bad, "test").is_err(),
2224                "should reject '{bad}'"
2225            );
2226        }
2227    }
2228
2229    // ── SRD-77 session-path helpers ──────────────────────
2230    // Pin the structural composition: every helper builds on
2231    // `default_sessions_root()` so a future env-aware redirect
2232    // (cargo tmp, --session-path override, etc.) propagates
2233    // cleanly without each helper carrying its own literal.
2234
2235    #[test]
2236    fn latest_session_dir_is_under_default_sessions_root() {
2237        let root = default_sessions_root();
2238        let latest = latest_session_dir();
2239        assert!(
2240            latest.starts_with(&root),
2241            "latest_session_dir MUST live under default_sessions_root \
2242             so the env-aware redirect (cargo tmp, etc.) covers it; \
2243             root={root:?}, latest={latest:?}"
2244        );
2245        assert_eq!(latest.file_name().and_then(|s| s.to_str()), Some("latest"));
2246    }
2247
2248    #[test]
2249    fn latest_metrics_db_is_under_latest_session_dir() {
2250        let metrics = latest_metrics_db();
2251        let latest = latest_session_dir();
2252        assert!(
2253            metrics.starts_with(&latest),
2254            "latest_metrics_db MUST live under latest_session_dir; \
2255             metrics={metrics:?}, latest={latest:?}"
2256        );
2257        assert_eq!(
2258            metrics.file_name().and_then(|s| s.to_str()),
2259            Some("metrics.db")
2260        );
2261    }
2262
2263    #[test]
2264    fn latest_session_log_is_under_latest_session_dir() {
2265        let log = latest_session_log();
2266        let latest = latest_session_dir();
2267        assert!(log.starts_with(&latest));
2268        assert_eq!(
2269            log.file_name().and_then(|s| s.to_str()),
2270            Some("session.log")
2271        );
2272    }
2273
2274    #[test]
2275    fn latest_checkpoint_jsonl_is_under_latest_session_dir() {
2276        let ckpt = latest_checkpoint_jsonl();
2277        let latest = latest_session_dir();
2278        assert!(ckpt.starts_with(&latest));
2279        assert_eq!(
2280            ckpt.file_name().and_then(|s| s.to_str()),
2281            Some("checkpoint.jsonl")
2282        );
2283    }
2284
2285    #[test]
2286    fn session_dir_named_is_a_sibling_of_latest() {
2287        let root = default_sessions_root();
2288        let named = session_dir_named("default_20260601_120000");
2289        assert!(named.starts_with(&root));
2290        assert_eq!(
2291            named.file_name().and_then(|s| s.to_str()),
2292            Some("default_20260601_120000"),
2293        );
2294    }
2295
2296    #[test]
2297    fn check_session_path_accepts_real_paths() {
2298        for good in &[
2299            "/tmp/foo",
2300            "/tmp/foo/bar",
2301            "logs/session_2026",
2302            "./local/x",
2303            "../sibling/y",
2304            "relative/path",
2305            "/var/run/x=y",     // `=` inside path, not at the head
2306            "C:/Windows/maybe", // exotic but harmless
2307            "logs/SESSION/x",
2308        ] {
2309            assert!(
2310                check_session_path(good, "test").is_ok(),
2311                "should accept '{good}'"
2312            );
2313        }
2314    }
2315
2316    #[test]
2317    fn check_session_path_message_names_remediation() {
2318        // The error must point the user at the right flag form,
2319        // not just say "bad path".
2320        let err = check_session_path("scenario=foo", "test").unwrap_err();
2321        assert!(err.contains("--session-path"), "missing flag hint: {err}");
2322        assert!(
2323            err.contains(":") && err.contains("kv separator"),
2324            "missing umbrella-form hint: {err}"
2325        );
2326    }
2327
2328    #[test]
2329    fn check_session_path_empty_head_passes() {
2330        // `=value` (empty head) is unusual but doesn't match the
2331        // param shape — let it through; downstream path APIs will
2332        // reject it on their own terms.
2333        assert!(check_session_path("=foo", "test").is_ok());
2334    }
2335
2336    #[test]
2337    fn flag_env_name_canonicalisation() {
2338        assert_eq!(flag_env_name("--session"), "NMBRS_SESSION");
2339        assert_eq!(flag_env_name("--session-name"), "NMBRS_SESSION_NAME");
2340        assert_eq!(flag_env_name("--session-path"), "NMBRS_SESSION_PATH");
2341        assert_eq!(flag_env_name("--multi-word-flag"), "NMBRS_MULTI_WORD_FLAG");
2342    }
2343
2344    #[test]
2345    fn resolve_flag_picks_cli_when_only_cli_set() {
2346        let _g = env_test_lock();
2347        unsafe {
2348            std::env::remove_var("NMBRS_SESSION_NAME");
2349        }
2350        let args = vec!["--session-name=foo".into()];
2351        assert_eq!(
2352            resolve_flag(&args, "--session-name").as_deref(),
2353            Some("foo")
2354        );
2355    }
2356
2357    #[test]
2358    fn resolve_flag_picks_env_when_only_env_set() {
2359        let _g = env_test_lock();
2360        unsafe {
2361            std::env::set_var("NMBRS_SESSION_NAME", "bar");
2362        }
2363        let v = resolve_flag(&[], "--session-name");
2364        unsafe {
2365            std::env::remove_var("NMBRS_SESSION_NAME");
2366        }
2367        assert_eq!(v.as_deref(), Some("bar"));
2368    }
2369
2370    #[test]
2371    fn forecast_keep_purge_counts_excess() {
2372        let parent =
2373            std::env::temp_dir().join(format!("nmbrs-forecast-{}", crate::scratch_suffix()));
2374        std::fs::create_dir_all(&parent).unwrap();
2375
2376        // 5 dirs present, keep=10 → next run wouldn't purge.
2377        for i in 0..5 {
2378            std::fs::create_dir(parent.join(format!("s{i}"))).unwrap();
2379        }
2380        assert_eq!(forecast_keep_purge(&parent, 10), 0);
2381
2382        // 5 dirs present, keep=5 → next run would purge 1.
2383        assert_eq!(forecast_keep_purge(&parent, 5), 1);
2384
2385        // 5 dirs present, keep=3 → next run would purge 3.
2386        assert_eq!(forecast_keep_purge(&parent, 3), 3);
2387
2388        // keep=0 disables.
2389        assert_eq!(forecast_keep_purge(&parent, 0), 0);
2390
2391        let _ = std::fs::remove_dir_all(&parent);
2392    }
2393
2394    #[test]
2395    fn resolve_flag_returns_none_when_neither_set() {
2396        let _g = env_test_lock();
2397        unsafe {
2398            std::env::remove_var("NMBRS_SESSION_NAME");
2399        }
2400        assert!(resolve_flag(&[], "--session-name").is_none());
2401    }
2402    // Note: the conflict-error path (both CLI and env set)
2403    // calls process::exit(2). Testing it requires spawning a
2404    // subprocess; left to the e2e level.
2405
2406    #[test]
2407    fn parse_duration_units() {
2408        use std::time::Duration;
2409        assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
2410        assert_eq!(parse_duration("5m").unwrap(), Duration::from_secs(300));
2411        assert_eq!(parse_duration("2h").unwrap(), Duration::from_secs(7200));
2412        assert_eq!(parse_duration("3d").unwrap(), Duration::from_secs(259200));
2413        assert_eq!(parse_duration("4w").unwrap(), Duration::from_secs(2419200));
2414        // Bare integer = seconds.
2415        assert_eq!(parse_duration("60").unwrap(), Duration::from_secs(60));
2416    }
2417
2418    #[test]
2419    fn parse_duration_rejects_garbage() {
2420        assert!(parse_duration("").is_err());
2421        assert!(parse_duration("not-a-number").is_err());
2422        assert!(parse_duration("abc4w").is_err());
2423    }
2424
2425    #[test]
2426    fn purge_keeps_latest_n_sessions() {
2427        let parent =
2428            std::env::temp_dir().join(format!("nmbrs-purge-test-{}", crate::scratch_suffix()));
2429        std::fs::create_dir_all(&parent).unwrap();
2430
2431        // Create 5 dirs with staggered mtimes.
2432        for i in 0..5 {
2433            let d = parent.join(format!("sess_{i}"));
2434            std::fs::create_dir(&d).unwrap();
2435            let now = std::time::SystemTime::now() - std::time::Duration::from_secs(60 * (5 - i));
2436            // Touch via filetime-style: re-create a marker so mtime
2437            // reflects roughly the right ordering.
2438            std::fs::write(d.join("metrics.db"), "x").unwrap();
2439            // Set the dir's modified time via a fresh file write; OS
2440            // updates mtime as side effect. For test stability, sort
2441            // order will follow the creation order, which is
2442            // newest-last.
2443            let _ = now;
2444        }
2445
2446        // Newest 2 should survive after purge with max=2.
2447        purge_stale_sessions(&parent, 2, std::time::Duration::ZERO);
2448
2449        let surviving: Vec<_> = std::fs::read_dir(&parent)
2450            .unwrap()
2451            .filter_map(|e| e.ok())
2452            .map(|e| e.path())
2453            .filter(|p| p.is_dir())
2454            .collect();
2455        assert!(
2456            surviving.len() <= 2,
2457            "expected ≤2 survivors, got {}: {:?}",
2458            surviving.len(),
2459            surviving
2460        );
2461
2462        // Cleanup
2463        let _ = std::fs::remove_dir_all(&parent);
2464    }
2465
2466    #[test]
2467    fn purge_skips_logs_latest_symlink() {
2468        let parent =
2469            std::env::temp_dir().join(format!("nmbrs-purge-symlink-{}", crate::scratch_suffix()));
2470        std::fs::create_dir_all(&parent).unwrap();
2471        let active = parent.join("active_session");
2472        std::fs::create_dir(&active).unwrap();
2473        std::fs::write(active.join("metrics.db"), "x").unwrap();
2474        // Create logs/latest symlink → active. Windows only allows
2475        // symlink creation with Developer Mode / admin — skip the
2476        // test rather than fail when the link can't exist at all.
2477        if symlink_any(&active, &parent.join("latest")).is_err() {
2478            eprintln!("skipping: cannot create symlinks here");
2479            let _ = std::fs::remove_dir_all(&parent);
2480            return;
2481        }
2482
2483        // Purge with aggressive caps — `active` must not be deleted
2484        // because it's the symlink target.
2485        purge_stale_sessions(&parent, 0, std::time::Duration::from_secs(1));
2486
2487        assert!(
2488            active.exists(),
2489            "active session pointed-at by logs/latest must survive purge"
2490        );
2491        let _ = std::fs::remove_dir_all(&parent);
2492    }
2493}
2494
2495#[cfg(test)]
2496mod confine_tests {
2497    use super::confine_to_dir;
2498    use std::path::Path;
2499
2500    #[test]
2501    fn relative_paths_land_inside_the_base() {
2502        let base = Path::new("/sess");
2503        assert_eq!(
2504            confine_to_dir(base, "traces.jsonl").unwrap(),
2505            Path::new("/sess/traces.jsonl")
2506        );
2507        assert_eq!(
2508            confine_to_dir(base, "./sub/x.log").unwrap(),
2509            Path::new("/sess/./sub/x.log")
2510        );
2511    }
2512
2513    #[test]
2514    fn escapes_and_absolutes_are_refused() {
2515        let base = Path::new("/sess");
2516        assert!(confine_to_dir(base, "../x").unwrap_err().contains(".."));
2517        assert!(
2518            confine_to_dir(base, "a/../../x")
2519                .unwrap_err()
2520                .contains("..")
2521        );
2522        assert!(
2523            confine_to_dir(base, "/etc/passwd")
2524                .unwrap_err()
2525                .contains("absolute")
2526        );
2527        assert!(confine_to_dir(base, "").unwrap_err().contains("empty"));
2528    }
2529}