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
//! Rename a managed session — update `tmux_name` + the live tmux entity.
//!
//! Why: operators need to give a managed session a meaningful name, both from
//! the master list (`tm sessions rename <id-or-name> <new-name>`) and from
//! within a session (`tm sessions rename <new-name>`, resolved from
//! `$TM_MANAGED_SESSION_ID`). A managed session's identity name IS its
//! `tmux_name` (there is no separate label field), so a rename updates that
//! field AND renames the live tmux session so `tmux attach`/`list-sessions`
//! stay consistent with the record. Extracted into its own file (mirroring
//! `delete.rs`) so `manager.rs` stays under the 500-SLOC production cap.
//! What: [`validate_session_name`] (a pure, unit-testable name check) plus an
//! inherent `impl SessionManager` block adding [`SessionManager::rename`], which
//! validates the name, guards against collisions (with another record OR a live
//! tmux session), renames the live tmux session when one exists, updates
//! `tmux_name`, and persists.
//! Test: `rename_*` in `super::rename_tests`.
use chrono::Utc;
use super::manager::{ManagedError, SessionManager};
use super::record::{ManagedSessionId, SessionRecord};
/// Validate a proposed session name, returning the trimmed value or a message.
///
/// Why: a tmux session name cannot be empty or contain whitespace, and tmux
/// treats `.`/`:` as reserved target separators — rejecting a bad name up front
/// with an actionable message beats a confusing downstream tmux failure.
/// What: trims `name`, then rejects an empty value, one over 64 chars, or any
/// character outside `[A-Za-z0-9_-]` (the alphabet the existing
/// `tm-<leaf>-NN`/`tm-<adjective>-<noun>` names already use). Returns the
/// trimmed, validated name on success.
/// Test: `validate_session_name_*` in `super::rename_tests`.
pub(crate) fn validate_session_name(name: &str) -> Result<String, String> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err("session name must not be empty".to_string());
}
if trimmed.chars().count() > 64 {
return Err(format!(
"session name '{trimmed}' is too long (max 64 characters)"
));
}
if !trimmed
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err(format!(
"session name '{trimmed}' has invalid characters — \
use letters, digits, '-', and '_' only"
));
}
Ok(trimmed.to_string())
}
impl SessionManager {
/// Rename a managed session — set its `tmux_name` and rename the live tmux
/// session to match.
///
/// Why: a managed session's user-facing identity IS its `tmux_name`;
/// renaming must therefore update the record AND the live tmux entity in
/// lock-step so a later `tmux attach`/`ls` never shows a stale name. A
/// stopped session has no live tmux, so only its record changes.
///
/// What: validates `new_name` via [`validate_session_name`] (an invalid
/// name surfaces as [`ManagedError::InvalidState`]). Looks up the record (a
/// missing id surfaces as [`ManagedError::SessionNotFound`]). A rename to
/// the SAME name is a no-op that returns the record unchanged. A terminal
/// record (`Decommissioned`/`Deleted`) is refused with `InvalidState`.
/// Refuses — with [`ManagedError::NameCollision`] — a name already taken by
/// another NON-terminal managed record or by a live tmux session (a terminal
/// tombstone's name is reusable). Otherwise, when a live tmux session backs
/// the record it is renamed via
/// [`ManagedTmuxDriver::rename_session`](super::driver::ManagedTmuxDriver::rename_session)
/// FIRST (re-verifying `tmux_name` immediately beforehand to catch a
/// concurrent rename), then the record's `tmux_name` is updated,
/// `last_activity_at` is stamped, and the record is persisted. If the store
/// write fails AFTER the tmux rename, the tmux rename is rolled back so the
/// live session and the record never desync; a failed rollback surfaces an
/// explicit manual-recovery error. Returns the updated [`SessionRecord`].
/// Test: `rename_updates_name_and_persists`,
/// `rename_same_name_is_noop`, `rename_rejects_collision_with_record`,
/// `rename_rejects_collision_with_live_tmux`,
/// `rename_rejects_invalid_name`, `rename_rejects_terminal_record`,
/// `rename_renames_live_tmux_session`,
/// `rename_reuses_name_freed_by_a_deleted_record` in `super::rename_tests`.
pub async fn rename(
&self,
id: &ManagedSessionId,
new_name: &str,
) -> Result<SessionRecord, ManagedError> {
let new_name = validate_session_name(new_name)
.map_err(|msg| ManagedError::InvalidState(id.to_string(), msg))?;
let record = self.get(id).await?;
if record.tmux_name == new_name {
// Renaming to the current name is a no-op — nothing to persist.
return Ok(record);
}
if record.state.is_terminal() {
return Err(ManagedError::InvalidState(
id.to_string(),
format!(
"session is {} — a terminal (decommissioned/deleted) record cannot be renamed",
record.state
),
));
}
// Collision guard: no OTHER *live* managed record may already carry the
// name. A TERMINAL tombstone's `tmux_name` does NOT block reuse (it is
// gone for good) — so a name freed by delete/decommission is
// immediately reusable; only `prune` still holds the on-disk record.
if self
.list()
.await
.into_iter()
.any(|r| r.id != *id && r.tmux_name == new_name && !r.state.is_terminal())
{
return Err(ManagedError::NameCollision(new_name));
}
// …and no live tmux session (managed or foreign) may hold it either.
if self.tmux.session_exists(&new_name) {
return Err(ManagedError::NameCollision(new_name));
}
let old_name = record.tmux_name.clone();
let tmux_live = self.tmux.session_exists(&old_name);
// Rename the live tmux session FIRST when one backs the record: a tmux
// failure aborts here, before the store is mutated. A stopped session
// has no live tmux to rename — only the record changes.
//
// Concurrency (get→check→act is not atomic): another rename could have
// changed this record's `tmux_name` between our read and now. Re-verify
// right before the destructive tmux rename and abort on mismatch, so we
// never rename a tmux session that no longer belongs to this record.
if tmux_live {
let current = self.get(id).await?;
if current.tmux_name != old_name {
return Err(ManagedError::InvalidState(
id.to_string(),
format!(
"session was renamed concurrently (now '{}', expected '{old_name}') — \
retry the rename",
current.tmux_name
),
));
}
self.tmux.rename_session(&old_name, &new_name)?;
}
let mut updated = record;
updated.tmux_name = new_name.clone();
updated.last_activity_at = Some(Utc::now());
if let Err(e) = self.store.write().await.upsert(updated.clone()).await {
// Compensation: the tmux session was already renamed but the store
// write failed, so the live tmux name and the (unchanged) record
// would desync. Roll the tmux rename back; if THAT also fails, surface
// an explicit manual-recovery error rather than leaving a silent split.
if tmux_live && let Err(rollback) = self.tmux.rename_session(&new_name, &old_name) {
return Err(ManagedError::InvalidState(
id.to_string(),
format!(
"rename half-applied: tmux is now '{new_name}' but the store still \
records '{old_name}', and the rollback failed ({rollback}) — manually \
run `tmux rename-session -t {new_name} {old_name}` (store error: {e})"
),
));
}
return Err(e.into());
}
Ok(updated)
}
}