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}