返回 CodeWhale
feedback.rs
根目录 / crates / tui / src / commands / groups / core / feedback.rs
1 use super::CommandResult;
2 use crate::commands::traits::{CommandInfo, RegisterCommand};
3 use crate::localization::MessageId;
4 use crate::tui::app::{App, AppAction};
5
6 const SECURITY_POLICY_URL: &str = "https://github.com/Hmbown/CodeWhale/security/policy";
7
8 pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
9 name: "feedback",
10 aliases: &[],
11 usage: "/feedback [bug|feature|security]",
12 description_id: MessageId::CmdFeedbackDescription,
13 };
14
15 pub(in crate::commands) struct FeedbackCmd;
16
17 impl RegisterCommand for FeedbackCmd {
18 fn info() -> &'static CommandInfo {
19 &COMMAND_INFO
20 }
21
22 fn execute(app: &mut App, arg: Option<&str>) -> CommandResult {
23 feedback(app, arg)
24 }
25 }
26
27 pub fn feedback(_app: &mut App, arg: Option<&str>) -> CommandResult {
28 let raw = arg.map(str::trim).unwrap_or("");
29 if raw.is_empty() {
30 return CommandResult::action(AppAction::OpenFeedbackPicker);
31 }
32 if matches!(raw, "help" | "--help" | "-h") {
33 return CommandResult::message(feedback_help());
34 }
35
36 let kind = match parse_feedback_kind(raw) {
37 Some(parsed) => parsed,
38 None => {
39 return CommandResult::error(
40 "Unknown feedback type. Use `/feedback` to list feedback options.",
41 );
42 }
43 };
44
45 if matches!(kind, FeedbackKind::Security) {
46 return CommandResult::with_message_and_action(
47 format!(
48 "Review the project's security policy before reporting a vulnerability.\n\n\
49 Trying to open it in your browser. If that fails, open this URL manually:\n\n\
50 {SECURITY_POLICY_URL}\n\n\
51 Do not include sensitive security details in a public issue.",
52 ),
53 AppAction::OpenExternalUrl {
54 url: SECURITY_POLICY_URL.to_string(),
55 label: "GitHub security policy".to_string(),
56 },
57 );
58 }
59
60 let url = kind.issue_url();
61 let mut message = format!(
62 "Trying to open GitHub {} template in your browser. If that fails, open this URL manually:\n\n{}",
63 kind.label().to_ascii_lowercase(),
64 url,
65 );
66 if matches!(kind, FeedbackKind::Bug) {
67 message.push_str("\n\n");
68 message.push_str(bug_report_diagnostics_hint());
69 }
70
71 CommandResult::with_message_and_action(
72 message,
73 AppAction::OpenExternalUrl {
74 url,
75 label: format!("GitHub {}", kind.label().to_ascii_lowercase()),
76 },
77 )
78 }
79
80 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
81 enum FeedbackKind {
82 Bug,
83 Feature,
84 Security,
85 }
86
87 impl FeedbackKind {
88 fn label(self) -> &'static str {
89 match self {
90 Self::Bug => "Bug report",
91 Self::Feature => "Feature request",
92 Self::Security => "Security vulnerability",
93 }
94 }
95
96 fn description(self) -> &'static str {
97 match self {
98 Self::Bug => "Report a problem or regression",
99 Self::Feature => "Suggest an idea or improvement",
100 Self::Security => "Review the security policy",
101 }
102 }
103
104 fn issue_url_base(self) -> &'static str {
105 match self {
106 Self::Bug => "https://github.com/Hmbown/CodeWhale/issues/new?template=bug_report.md",
107 Self::Feature => {
108 "https://github.com/Hmbown/CodeWhale/issues/new?template=feature_request.md"
109 }
110 Self::Security => SECURITY_POLICY_URL,
111 }
112 }
113
114 fn issue_url(self) -> String {
115 self.issue_url_base().to_string()
116 }
117 }
118
119 fn feedback_help() -> String {
120 let rows = [
121 ("1", FeedbackKind::Bug),
122 ("2", FeedbackKind::Feature),
123 ("3", FeedbackKind::Security),
124 ];
125 let mut message = String::from("Choose a feedback type:\n\n");
126 for (number, kind) in rows {
127 message.push_str(&format!(
128 "{number}. {} {}\n",
129 kind.label(),
130 kind.description()
131 ));
132 }
133 message.push_str("\nUsage:\n");
134 for (number, kind) in rows {
135 message.push_str(&format!("/feedback {number} {}\n", kind.label()));
136 }
137 message.push_str("/feedback bug\n");
138 message.push_str("/feedback feature\n");
139 message.push_str("/feedback security\n");
140 message
141 }
142
143 fn bug_report_diagnostics_hint() -> &'static str {
144 "Before filing, first check whether this looks like a model issue or an environment/tool issue: \
145 command exit, network/service, sandbox/approval, missing dependency/path, timeout, or an unclosed turn. \
146 If you have a local JSONL log, run `codewhale session-diagnostics <path>` and include the redacted category summary. \
147 Include the Codewhale version, OS/terminal, the tool name, and redacted timestamps or log handles when available. \
148 Do not paste prompts, secrets, raw command output, full local paths, or conversation transcripts."
149 }
150
151 fn parse_feedback_kind(input: &str) -> Option<FeedbackKind> {
152 Some(match input.to_ascii_lowercase().as_str() {
153 "1" | "bug" | "bug-report" | "bug_report" => FeedbackKind::Bug,
154 "2" | "feature" | "feature-request" | "feature_request" | "enhancement" => {
155 FeedbackKind::Feature
156 }
157 "3" | "security" | "vulnerability" | "private" => FeedbackKind::Security,
158 _ => return None,
159 })
160 }
161
162 #[cfg(test)]
163 mod tests {
164 use super::*;
165 use crate::config::Config;
166 use crate::tui::app::{App, TuiOptions};
167 use tempfile::TempDir;
168
169 fn test_app() -> (App, TempDir) {
170 let tmpdir = TempDir::new().expect("tempdir");
171 let workspace = tmpdir.path().to_path_buf();
172 let options = TuiOptions {
173 skills_dir: workspace.join("skills"),
174 memory_path: workspace.join("memory.md"),
175 notes_path: workspace.join("notes.txt"),
176 mcp_config_path: workspace.join("mcp.json"),
177 ..crate::test_support::test_tui_options(workspace.clone())
178 };
179 let mut app = App::new(options, &Config::default());
180 app.current_session_id = Some("session-123".to_string());
181 (app, tmpdir)
182 }
183
184 fn external_url(result: &CommandResult) -> &str {
185 match result.action.as_ref() {
186 Some(AppAction::OpenExternalUrl { url, .. }) => url,
187 other => panic!("expected external URL action, got {other:?}"),
188 }
189 }
190
191 #[test]
192 fn feedback_without_args_opens_feedback_picker() {
193 let (mut app, _tmpdir) = test_app();
194 let result = feedback(&mut app, None);
195 assert_eq!(result.action, Some(AppAction::OpenFeedbackPicker));
196 assert!(result.message.is_none());
197 assert!(!result.is_error);
198 }
199
200 #[test]
201 fn feedback_help_lists_feedback_types() {
202 let (mut app, _tmpdir) = test_app();
203 let result = feedback(&mut app, Some("--help"));
204 let message = result.message.expect("feedback help");
205 assert!(message.contains("1. Bug report"));
206 assert!(message.contains("2. Feature request"));
207 assert!(message.contains("3. Security vulnerability"));
208 assert!(!message.contains("Blank issue"));
209 assert!(message.contains("/feedback bug"));
210 assert!(!message.contains("<description>"));
211 }
212
213 #[test]
214 fn feedback_bug_opens_bug_template_url_without_prefilled_body() {
215 let (mut app, _tmpdir) = test_app();
216 let result = feedback(&mut app, Some("bug"));
217 assert!(!result.is_error);
218 let message = result
219 .message
220 .as_deref()
221 .expect("feedback command returns guidance");
222 let url = external_url(&result);
223
224 assert!(message.contains("Trying to open GitHub bug report template"));
225 assert!(message.contains("open this URL manually"));
226 assert!(message.contains("Before filing, first check whether this looks like"));
227 assert!(message.contains("network/service"));
228 assert!(message.contains("sandbox/approval"));
229 assert!(message.contains("missing dependency/path"));
230 assert!(message.contains("timeout"));
231 assert!(message.contains("codewhale session-diagnostics <path>"));
232 assert!(message.contains("Do not paste prompts, secrets, raw command output"));
233 assert!(message.contains(url));
234 assert!(url.contains("template=bug_report.md"));
235 assert!(!url.contains("title="));
236 assert!(!url.contains("body="));
237 }
238
239 #[test]
240 fn feedback_feature_generates_feature_template_url() {
241 let (mut app, _tmpdir) = test_app();
242 let result = feedback(&mut app, Some("2"));
243 let message = result
244 .message
245 .as_deref()
246 .expect("feedback command returns guidance");
247 let url = external_url(&result);
248 assert!(message.contains("Trying to open GitHub feature request template"));
249 assert!(message.contains("open this URL manually"));
250 assert!(message.contains(url));
251 assert!(url.contains("template=feature_request.md"));
252 assert!(!url.contains("title="));
253 assert!(!url.contains("body="));
254 }
255
256 #[test]
257 fn feedback_template_urls_do_not_prefill_titles() {
258 let (mut app, _tmpdir) = test_app();
259 let bug = feedback(&mut app, Some("bug"));
260 let feature = feedback(&mut app, Some("feature"));
261
262 assert!(!external_url(&bug).contains("title="));
263 assert!(!external_url(&feature).contains("title="));
264 }
265
266 #[test]
267 fn feedback_urls_use_template_only() {
268 let bug = FeedbackKind::Bug.issue_url();
269 let feature = FeedbackKind::Feature.issue_url();
270
271 assert_eq!(
272 bug,
273 "https://github.com/Hmbown/CodeWhale/issues/new?template=bug_report.md"
274 );
275 assert_eq!(
276 feature,
277 "https://github.com/Hmbown/CodeWhale/issues/new?template=feature_request.md"
278 );
279 }
280
281 #[test]
282 fn feedback_security_uses_security_policy() {
283 let (mut app, _tmpdir) = test_app();
284 let result = feedback(&mut app, Some("security"));
285 let message = result
286 .message
287 .as_deref()
288 .expect("security feedback message");
289 assert_eq!(external_url(&result), SECURITY_POLICY_URL);
290 assert!(message.contains(SECURITY_POLICY_URL));
291 assert!(message.contains("Do not include sensitive security details"));
292 assert!(!message.contains("/issues/new"));
293 }
294
295 #[test]
296 fn feedback_unknown_type_returns_error() {
297 let (mut app, _tmpdir) = test_app();
298 let result = feedback(&mut app, Some("other thing"));
299 assert!(result.is_error);
300 let message = result.message.expect("error message");
301 assert!(message.contains("Unknown feedback type"));
302 }
303 }
304
304 lines RUST