praca 0.1.3

The praça session-orchestration substrate for the mado/tear terminal — automation-first: sessions are auto-named + auto-bound to projects, cd auto-attaches the project's session, the picker is the fallback. Pure typed logic: project-root detection, frecency ranking, project↔session bindings, a fuzzy/frecency session index, and the cd-driven attach decision engine. No I/O, no daemon wiring, all time injected.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
//! The latent half of the session model: a [`SessionDefinition`] — the
//! durable *shape* of a session, from which live incarnations are
//! instantiated.
//!
//! This is the typed payload the pressure-test found missing. Before, a
//! `SessionRecord` stored only metadata (no layout, no panes, no
//! commands), [`crate::record::SessionState::Templated`] was an empty
//! enum arm with nothing behind it, and the ad-hoc construction path was
//! a silent third way to make a session. Here:
//!
//! * a definition CARRIES its plan — [`tear_types::WindowPlan`]s over
//!   [`tear_types::PaneSlot`]s + a [`tear_types::SpawnSpec`] per slot — so
//!   there is always something to instantiate (illegal state #3/#4);
//! * its identity is the durable [`DefinitionId`] (illegal state #1), and
//! * its provenance is a typed [`SessionOrigin`], not a hidden code path
//!   (illegal state #9).
//!
//! The interpreter that turns a definition into a [`tear_types::LiveSession`]
//! lives in [`crate::instantiate`] (M1b).

use std::collections::BTreeMap;
use std::path::PathBuf;

use serde::{Deserialize, Serialize};
use tear_types::{
    DefinitionId, LayoutPlan, PaneSlot, PlanError, SpawnSpec, TearSession, WindowPlan,
};

use crate::index::Searchable;
use crate::record::{display_name_for, identity_for, NameStyle, ThemeMirror};

/// Where a session definition came from — a typed third arm, so the
/// ad-hoc path is exhaustively matched rather than a silent `for_adhoc`
/// branch (pressure-test illegal state #9).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum SessionOrigin {
    /// Born from `cd`-ing into a project — the path-stable case. The
    /// definition's identity is the project root's stable seed.
    Project,
    /// An ad-hoc themed session (the `for_adhoc` path): a random name
    /// drawn from one theme register, keyed on `seed`.
    Adhoc {
        /// The theme register the random name is drawn from.
        theme: ThemeMirror,
        /// The seed the random name + identity derive from.
        seed: u64,
    },
    /// Authored from a `(defsession …)` blueprint — the triplet's
    /// authoring leg (lands when the `(defsession)` parser does).
    Authored,
}

/// Why a [`SessionDefinition`] failed [`SessionDefinition::validate`].
/// Every variant names a definition that could not be instantiated
/// faithfully, so a malformed definition is caught before it spawns
/// anything.
#[derive(Clone, Debug, PartialEq)]
pub enum DefinitionError {
    /// The definition has no windows — nothing to instantiate.
    NoWindows,
    /// One window's layout plan is structurally invalid.
    Layout(PlanError),
    /// A slot in some window's layout has no [`SpawnSpec`] — the
    /// interpreter would not know what to spawn there.
    MissingSpec(PaneSlot),
    /// A window's `active_slot` is not one of that window's layout slots.
    ActiveSlotNotInWindow(PaneSlot),
}

/// The durable shape of a session: identity, provenance, naming, the
/// project it belongs to, and the PLAN (windows + per-pane spawn specs)
/// the interpreter realizes into live panes.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SessionDefinition {
    /// Durable, project-stable identity (illegal state #1). Equal to the
    /// project's `name_seed` for `Project`-origin definitions.
    pub def_id: DefinitionId,
    /// Typed provenance (illegal state #9).
    pub origin: SessionOrigin,
    /// Atlas seed for the display name (mirrors `SessionRecord.name_seed`).
    pub name_seed: u64,
    /// Render style for the name.
    pub name_style: NameStyle,
    /// Theme the random name is drawn from (`None` = whole-pool).
    #[serde(default)]
    pub theme: Option<ThemeMirror>,
    /// Operator-chosen name overriding the emoji identity.
    #[serde(default)]
    pub custom_name: Option<String>,
    /// The project this definition belongs to.
    pub project_root: PathBuf,
    /// The window plans — at least one (enforced by [`Self::validate`]).
    pub windows: Vec<WindowPlan>,
    /// The spawn spec for every slot referenced by the window plans.
    pub pane_specs: BTreeMap<PaneSlot, SpawnSpec>,
    /// Frecency: total visits (mirrors the record's field).
    #[serde(default)]
    pub visits: u32,
    /// Frecency: unix-seconds of last touch (injected, never clock-read).
    #[serde(default)]
    pub last_seen: u64,
    /// Free-form operator tags.
    #[serde(default)]
    pub tags: Vec<String>,
}

impl SessionDefinition {
    /// A single-window, single-pane project definition running `shell` —
    /// the common case (most sessions start as one shell in one project).
    /// The lone slot is `PaneSlot(0)`; identity is the project's stable seed.
    #[must_use]
    pub fn single_pane(
        project_root: impl Into<PathBuf>,
        shell: impl Into<String>,
        name_style: NameStyle,
        last_seen: u64,
    ) -> Self {
        let project_root = project_root.into();
        let def_id = DefinitionId::from_project(&project_root);
        let slot = PaneSlot(0);
        let mut pane_specs = BTreeMap::new();
        pane_specs.insert(slot, SpawnSpec::shell(slot, shell));
        Self {
            def_id,
            origin: SessionOrigin::Project,
            name_seed: def_id.0,
            name_style,
            theme: None,
            custom_name: None,
            project_root,
            windows: vec![WindowPlan::single("main", slot)],
            pane_specs,
            visits: 1,
            last_seen,
            tags: Vec::new(),
        }
    }

    /// Capture a RUNNING session into a reusable definition — the inverse
    /// of [`crate::instantiate`], built on `LayoutPlan::from_node`. Each
    /// window's live layout is dehydrated to a [`tear_types::LayoutPlan`]
    /// over fresh slots (globally unique across windows, via the shared
    /// `from_node_into` counter), and each pane becomes a
    /// [`tear_types::SpawnSpec`] carrying its shell/args/cwd/env/title so
    /// re-instantiating reproduces the layout. Identity + naming come from
    /// `project_root` (stable, like [`single_pane`](Self::single_pane));
    /// origin is [`SessionOrigin::Authored`] — the operator authored this
    /// preset by saving a live session.
    ///
    /// This is the "save-as-preset" morphism: `from_live` produces the
    /// definition; *persisting* it (a `PracaSnapshot.definitions` store) is
    /// the separate M2 leg. The captured definition is faithful by
    /// construction — it always [`validate`](Self::validate)s.
    #[must_use]
    pub fn from_live(
        session: &TearSession,
        project_root: impl Into<PathBuf>,
        last_seen: u64,
    ) -> Self {
        let project_root = project_root.into();
        let def_id = DefinitionId::from_project(&project_root);
        let mut next = 0u32;
        let mut pane_specs = BTreeMap::new();
        let mut windows = Vec::new();
        for win in session.windows.values() {
            // Dehydrate this window's live tree into the GLOBAL slot space.
            let mut slot_map = BTreeMap::new();
            let plan = LayoutPlan::from_node_into(&win.layout, &mut next, &mut slot_map);
            let mut active_slot = None;
            for (slot, pane_id) in &slot_map {
                let spec = match session.panes.get(pane_id) {
                    Some(p) => SpawnSpec {
                        slot: *slot,
                        shell: p.shell.clone(),
                        args: p.args.clone(),
                        cwd: p.cwd.clone(),
                        env: p.env.clone(),
                        title: p.title.clone(),
                        input_policy: p.input_policy,
                    },
                    // Pane vanished mid-capture — keep the slot, empty shell.
                    None => SpawnSpec::shell(*slot, String::new()),
                };
                pane_specs.insert(*slot, spec);
                if *pane_id == win.active_pane {
                    active_slot = Some(*slot);
                }
            }
            let active_slot = active_slot
                .or_else(|| slot_map.keys().next().copied())
                .unwrap_or(PaneSlot(0));
            windows.push(WindowPlan {
                name: win.name.clone(),
                layout: plan,
                active_slot,
            });
        }
        Self {
            def_id,
            origin: SessionOrigin::Authored,
            name_seed: def_id.0,
            name_style: NameStyle::Emoji,
            theme: None,
            custom_name: None,
            project_root,
            windows,
            pane_specs,
            visits: 1,
            last_seen,
            tags: Vec::new(),
        }
    }

    /// The session's display name (custom name, else the themed emoji
    /// name). Shares the resolver with [`crate::record::SessionRecord`].
    #[must_use]
    pub fn display_name(&self) -> String {
        display_name_for(
            self.name_seed,
            self.name_style,
            self.theme,
            self.custom_name.as_deref(),
        )
    }

    /// The resolved emoji identity — shares the `identity_for` resolver
    /// with [`crate::record::SessionRecord`], so a definition and a record
    /// for the same seed render the same `🌊 tide`.
    #[must_use]
    pub fn identity(&self) -> ishou_tokens::SessionIdentity {
        identity_for(self.name_seed, self.theme)
    }

    /// The identity's word (`"tide"`) — the stable searchable token.
    #[must_use]
    pub fn name_word(&self) -> &'static str {
        self.identity().word
    }

    /// The identity's search keywords (`"wave"`/`"water"` for `🌊 tide`).
    #[must_use]
    pub fn keywords(&self) -> &'static [&'static str] {
        self.identity().keywords
    }

    /// Structural validation: ≥1 window, every window's layout valid,
    /// every referenced slot has a spawn spec, every `active_slot` belongs
    /// to its window. A definition that validates can be instantiated.
    pub fn validate(&self) -> Result<(), DefinitionError> {
        if self.windows.is_empty() {
            return Err(DefinitionError::NoWindows);
        }
        for w in &self.windows {
            w.layout.validate().map_err(DefinitionError::Layout)?;
            let slots = w.layout.slots();
            for slot in &slots {
                if !self.pane_specs.contains_key(slot) {
                    return Err(DefinitionError::MissingSpec(*slot));
                }
            }
            if !slots.contains(&w.active_slot) {
                return Err(DefinitionError::ActiveSlotNotInWindow(w.active_slot));
            }
        }
        Ok(())
    }

    /// Total slots across all windows.
    #[must_use]
    pub fn slot_count(&self) -> usize {
        self.windows.iter().map(|w| w.layout.slot_count()).sum()
    }
}

/// A latent definition ranks through the SAME scorer as a live record —
/// the seam that lets `Ctrl-S` show presets and running sessions in one
/// frecency+fuzzy order.
impl Searchable for SessionDefinition {
    fn custom_name(&self) -> Option<&str> {
        self.custom_name.as_deref()
    }
    fn name_word(&self) -> &'static str {
        SessionDefinition::name_word(self)
    }
    fn keywords(&self) -> &'static [&'static str] {
        SessionDefinition::keywords(self)
    }
    fn tags(&self) -> &[String] {
        &self.tags
    }
    fn path_str(&self) -> std::borrow::Cow<'_, str> {
        self.project_root.to_string_lossy()
    }
    fn visits(&self) -> u32 {
        self.visits
    }
    fn last_seen(&self) -> u64 {
        self.last_seen
    }
    fn rank_key(&self) -> u64 {
        self.def_id.0
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tear_types::SplitOrientation;

    #[test]
    fn single_pane_definition_validates() {
        let d = SessionDefinition::single_pane("/code/pleme-io/mado", "/bin/zsh", NameStyle::Emoji, 0);
        d.validate().unwrap();
        assert_eq!(d.slot_count(), 1);
        // Identity is the project seed (illegal state #1) — matches the id
        // a SessionRecord for the same project would carry.
        assert_eq!(d.def_id, DefinitionId::from_project(std::path::Path::new("/code/pleme-io/mado")));
        assert_eq!(d.name_seed, d.def_id.0);
    }

    #[test]
    fn validate_rejects_slot_without_spec() {
        let mut d = SessionDefinition::single_pane("/x", "/bin/zsh", NameStyle::Emoji, 0);
        // Add a window referencing slot 9 with no spec.
        d.windows.push(WindowPlan::single("orphan", PaneSlot(9)));
        assert_eq!(d.validate(), Err(DefinitionError::MissingSpec(PaneSlot(9))));
    }

    #[test]
    fn validate_rejects_active_slot_not_in_window() {
        let mut d = SessionDefinition::single_pane("/x", "/bin/zsh", NameStyle::Emoji, 0);
        d.pane_specs.insert(PaneSlot(1), SpawnSpec::shell(PaneSlot(1), "/bin/sh"));
        // Window over slot 0, but active_slot points at 1 (not in it).
        d.windows[0].active_slot = PaneSlot(1);
        assert_eq!(d.validate(), Err(DefinitionError::ActiveSlotNotInWindow(PaneSlot(1))));
    }

    #[test]
    fn multi_pane_definition_validates() {
        // Two-pane window: slot 0 | slot 1.
        let mut pane_specs = BTreeMap::new();
        pane_specs.insert(PaneSlot(0), SpawnSpec::shell(PaneSlot(0), "/bin/zsh"));
        pane_specs.insert(PaneSlot(1), SpawnSpec::shell(PaneSlot(1), "/bin/sh"));
        let d = SessionDefinition {
            def_id: DefinitionId::from_project(std::path::Path::new("/x")),
            origin: SessionOrigin::Project,
            name_seed: 0,
            name_style: NameStyle::Emoji,
            theme: None,
            custom_name: None,
            project_root: "/x".into(),
            windows: vec![WindowPlan {
                name: "work".into(),
                layout: LayoutPlan::split(
                    SplitOrientation::Vertical,
                    LayoutPlan::leaf(PaneSlot(0)),
                    LayoutPlan::leaf(PaneSlot(1)),
                ),
                active_slot: PaneSlot(0),
            }],
            pane_specs,
            visits: 1,
            last_seen: 0,
            tags: Vec::new(),
        };
        d.validate().unwrap();
        assert_eq!(d.slot_count(), 2);
    }

    #[test]
    fn origin_adhoc_is_a_typed_arm() {
        // The ad-hoc path is now an exhaustively-matchable value.
        let o = SessionOrigin::Adhoc { theme: ThemeMirror::Brazil, seed: 42 };
        match o {
            SessionOrigin::Project | SessionOrigin::Authored => panic!("wrong arm"),
            SessionOrigin::Adhoc { theme, seed } => {
                assert_eq!(theme, ThemeMirror::Brazil);
                assert_eq!(seed, 42);
            }
        }
    }

    #[test]
    fn definition_serde_round_trips() {
        let d = SessionDefinition::single_pane("/x", "/bin/zsh", NameStyle::Emoji, 7);
        let json = serde_json::to_string(&d).unwrap();
        let back: SessionDefinition = serde_json::from_str(&json).unwrap();
        assert_eq!(d, back);
    }

    #[test]
    fn from_live_captures_a_running_multi_pane_session() {
        use tear_core::inproc::InProcess;
        use tear_types::{Direction, MultiplexerControl};
        // Build a 2-pane running session, then capture it.
        let inproc = InProcess::new();
        let sid = inproc.new_session("cap", "/bin/sh").unwrap();
        let s0 = inproc.get_session(sid).unwrap();
        let wid = s0.active_window;
        let p0 = s0.windows[&wid].active_pane;
        inproc.split_pane(p0, Direction::Right, "/bin/sh").unwrap();
        let session = inproc.get_session(sid).unwrap();

        let def = SessionDefinition::from_live(&session, "/code/captured", 0);
        // A faithful capture validates + carries both panes' specs.
        def.validate().unwrap();
        assert_eq!(def.slot_count(), 2);
        assert_eq!(def.pane_specs.len(), 2);
        assert_eq!(def.windows.len(), 1);
        // Authored origin; identity from the project root.
        assert_eq!(def.origin, SessionOrigin::Authored);
        assert_eq!(
            def.def_id,
            DefinitionId::from_project(std::path::Path::new("/code/captured"))
        );
        // Every captured pane kept its shell.
        assert!(def.pane_specs.values().all(|s| s.shell == "/bin/sh"));
    }
}