返回 CodeWhale
title.rs
根目录 / crates / tui / src / commands / groups / session / title.rs
1 //! `/title` command — portable handler over the session-control facet.
2 //!
3 //! Distinct from `/rename`: it sets the session *window/tab* title. The
4 //! handler owns the bare-report branch, the `off|clear|none` synonyms, and
5 //! the 100-character limit on the raw argument, sanitization, and exact
6 //! messages; the atomic set/clear delegates own persistence, publication,
7 //! and the redraw flag.
8
9 use super::CommandResult;
10 use codewhale_command_contract::facets::{CommandSessionControlContext, TitleSource};
11 use codewhale_command_contract::handler::{CommandContexts, CommandHandler};
12 use codewhale_command_contract::metadata::{
13 CommandInfo as ContractInfo, RegisterCommand as ContractRegisterCommand,
14 };
15
16 pub(in crate::commands) struct TitleCmd;
17
18 pub(in crate::commands) const CONTRACT_INFO: ContractInfo = ContractInfo {
19 name: "title",
20 aliases: &["tabtitle", "window-title"],
21 usage: "/title [new title|off]",
22 description_key: "cmd_title_description",
23 };
24
25 impl ContractRegisterCommand<CommandResult> for TitleCmd {
26 fn info() -> &'static ContractInfo {
27 &CONTRACT_INFO
28 }
29 fn handler() -> CommandHandler<CommandResult> {
30 CommandHandler::Contextual {
31 capabilities: codewhale_command_contract::handler::CommandCapabilities::SESSION_CONTROL,
32 handler: title_contextual,
33 }
34 }
35 }
36
37 pub(in crate::commands) fn title_contextual(
38 contexts: CommandContexts<'_>,
39 arg: Option<&str>,
40 ) -> CommandResult {
41 let mut parts = contexts.into_parts();
42 let Some(control) = parts.control.as_deref_mut() else {
43 return CommandResult::error("Command capability unavailable: session_control".to_string());
44 };
45 title_portable(control, arg)
46 }
47
48 pub(in crate::commands) fn title_portable(
49 control: &mut dyn CommandSessionControlContext,
50 arg: Option<&str>,
51 ) -> CommandResult {
52 let trimmed = arg.map(str::trim).filter(|s| !s.is_empty());
53 let Some(arg) = trimmed else {
54 let report = control.title_report();
55 let source = match report.source {
56 TitleSource::Session => " (session)",
57 TitleSource::ConfigDefault => " (config default)",
58 TitleSource::None => "",
59 };
60 return CommandResult::message(format!("Window title: [{}]{source}", report.effective));
61 };
62
63 if arg == "off" || arg == "clear" || arg == "none" {
64 return match control.clear_window_title() {
65 Ok(()) => CommandResult::message(
66 "Window title cleared (the config default still applies if set)",
67 ),
68 Err(error) => CommandResult::error(error),
69 };
70 }
71
72 if arg.chars().count() > super::MAX_TITLE_LEN {
73 return CommandResult::error(format!(
74 "Title too long (max {} characters)",
75 super::MAX_TITLE_LEN
76 ));
77 }
78
79 let sanitized = control.sanitize_session_title(arg);
80 let title = sanitized.trim();
81 if title.is_empty() {
82 return CommandResult::error(
83 "Title cannot be empty; use /title off to clear a session title",
84 );
85 }
86
87 match control.set_window_title(title.to_string()) {
88 Ok(()) => CommandResult::message(format!(
89 "Window title set to \"{title}\" — the terminal tab now reads [\"{title}\"] …"
90 )),
91 Err(error) => CommandResult::error(error),
92 }
93 }
94
95 #[cfg(test)]
96 mod tests {
97 use super::super::control_test_support::message;
98 use super::*;
99 use codewhale_command_contract::facets::TitleReport;
100
101 fn control_fake() -> super::super::control_test_support::FakeControl {
102 super::super::control_test_support::FakeControl::default()
103 }
104
105 #[test]
106 fn title_bare_reports_effective_title_and_source() {
107 let mut fake = control_fake();
108 fake.title_report = Some(TitleReport {
109 effective: "task-7".to_string(),
110 source: TitleSource::Session,
111 });
112 let result = title_portable(&mut fake, None);
113 assert_eq!(message(&result), "Window title: [task-7] (session)");
114
115 fake.title_report = Some(TitleReport {
116 effective: "workspace-x".to_string(),
117 source: TitleSource::ConfigDefault,
118 });
119 let result = title_portable(&mut fake, None);
120 assert_eq!(
121 message(&result),
122 "Window title: [workspace-x] (config default)"
123 );
124
125 fake.title_report = Some(TitleReport {
126 effective: "unset".to_string(),
127 source: TitleSource::None,
128 });
129 let result = title_portable(&mut fake, None);
130 assert_eq!(message(&result), "Window title: [unset]");
131 }
132
133 #[test]
134 fn title_synonyms_clear_and_set_messages_are_exact() {
135 let mut fake = control_fake();
136 fake.clear_title = Some(Ok(()));
137 for synonym in ["off", "clear", "none"] {
138 let result = title_portable(&mut fake, Some(synonym));
139 assert!(!result.is_error, "{synonym}");
140 assert_eq!(
141 message(&result),
142 "Window title cleared (the config default still applies if set)"
143 );
144 }
145 assert_eq!(fake.calls.borrow().len(), 3);
146 assert!(
147 fake.calls
148 .borrow()
149 .iter()
150 .all(|call| call == "clear_window_title")
151 );
152
153 let mut fake = control_fake();
154 fake.set_title = Some(Ok(()));
155 let result = title_portable(&mut fake, Some("task-7"));
156 assert!(!result.is_error);
157 assert_eq!(
158 message(&result),
159 "Window title set to \"task-7\" — the terminal tab now reads [\"task-7\"] …"
160 );
161 assert_eq!(
162 fake.calls.borrow().as_slice(),
163 ["sanitize_session_title(task-7)", "set_window_title(task-7)"]
164 );
165 assert!(result.action.is_none(), "/title emits no action");
166 }
167
168 #[test]
169 fn title_oversized_and_host_errors_are_exact() {
170 let mut fake = control_fake();
171 let result = title_portable(
172 &mut fake,
173 Some(&"x".repeat(super::super::MAX_TITLE_LEN + 1)),
174 );
175 assert!(result.is_error);
176 assert!(
177 result
178 .message
179 .as_deref()
180 .unwrap()
181 .contains("Title too long (max 100 characters)")
182 );
183 assert!(
184 fake.calls.borrow().is_empty(),
185 "length check precedes the delegate"
186 );
187
188 let mut fake = control_fake();
189 fake.sanitized_title = Some(String::new());
190 let result = title_portable(&mut fake, Some("\u{1b}\u{7}\u{200b}"));
191 assert!(result.is_error);
192 assert_eq!(
193 message(&result),
194 "Title cannot be empty; use /title off to clear a session title"
195 );
196 assert_eq!(
197 fake.calls.borrow().as_slice(),
198 ["sanitize_session_title(\u{1b}\u{7}\u{200b})"],
199 "sanitized-empty validation precedes host mutation"
200 );
201
202 let mut fake = control_fake();
203 fake.set_title = Some(Err("Could not save session: set failed".to_string()));
204 let result = title_portable(&mut fake, Some("task-7"));
205 assert!(result.is_error);
206 assert_eq!(message(&result), "Could not save session: set failed");
207
208 let mut fake = control_fake();
209 fake.clear_title = Some(Err("Could not save session: clear failed".to_string()));
210 let result = title_portable(&mut fake, Some("off"));
211 assert!(result.is_error);
212 assert_eq!(message(&result), "Could not save session: clear failed");
213 }
214
215 #[test]
216 fn title_missing_control_authority_fails_safely() {
217 let contexts = codewhale_command_contract::handler::CommandContexts::empty();
218 let result = title_contextual(contexts, None);
219 assert!(result.is_error);
220 assert_eq!(
221 message(&result),
222 "Command capability unavailable: session_control"
223 );
224 }
225 }
226
226 lines RUST