pmat 3.15.0

PMAT - Zero-config AI context generation and code quality toolkit (CLI, MCP, HTTP)
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
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
// Bug report handler tests
// Included from bug_report_handler.rs — shares parent module scope (no use imports here)

#[cfg_attr(coverage_nightly, coverage(off))]
#[cfg(test)]
mod tests {
    use super::*;
    use serial_test::serial;

    // Tests below that call `capture_command_error*`, `clear_error`, or
    // `handle_bug_report` all touch the same on-disk file at
    // ~/.pmat/last_error.json. Under cargo's threaded runner they race —
    // a capture test writing the file between `clear_error()` and
    // `handle_bug_report()` flips the latter's expected Err into Ok.
    // `#[serial(bug_report_error_file)]` pins them to one thread.

    #[tokio::test]
    #[serial(bug_report_error_file)]
    async fn test_handle_bug_report_no_error() {
        // Clear any existing error first
        let _ = clear_error();

        let result = handle_bug_report(None, true, false, false).await;
        assert!(result.is_err());
        assert!(result
            .unwrap_err()
            .to_string()
            .contains("No captured error found"));
    }

    #[tokio::test]
    #[serial(bug_report_error_file)]
    #[ignore = "Requires HOME directory to be set in test environment"]
    async fn test_handle_bug_report_clear() {
        // Clear should always succeed (no-op if file doesn't exist)
        let result = handle_bug_report(None, false, false, true).await;
        assert!(result.is_ok());
    }

    #[test]
    #[serial(bug_report_error_file)]
    fn test_capture_command_error() {
        // This should not panic
        capture_command_error("pmat", &["test".to_string()], "test error");
    }

    #[test]
    #[serial(bug_report_error_file)]
    fn test_capture_command_error_with_empty_args() {
        // Should handle empty args without panic
        capture_command_error("pmat", &[], "error with no args");
    }

    #[test]
    #[serial(bug_report_error_file)]
    fn test_capture_command_error_with_multiple_args() {
        // Should handle multiple args
        capture_command_error(
            "pmat",
            &[
                "work".to_string(),
                "status".to_string(),
                "--verbose".to_string(),
            ],
            "complex error",
        );
    }

    #[test]
    #[serial(bug_report_error_file)]
    fn test_capture_command_error_with_long_error_message() {
        // Should handle long error messages
        let long_error = "A".repeat(10000);
        capture_command_error("pmat", &["test".to_string()], &long_error);
    }

    #[test]
    #[serial(bug_report_error_file)]
    fn test_capture_command_error_with_unicode() {
        // Should handle unicode in error messages
        capture_command_error("pmat", &["test".to_string()], "Error: 日本語 эррор 🚫");
    }

    #[test]
    #[serial(bug_report_error_file)]
    fn test_capture_command_error_with_code() {
        // Test capturing error with exit code
        capture_command_error_with_code("pmat", &["work".to_string()], "exit error", 1);
    }

    #[test]
    #[serial(bug_report_error_file)]
    fn test_capture_command_error_with_code_zero() {
        // Edge case: zero exit code
        capture_command_error_with_code("pmat", &["work".to_string()], "success but captured", 0);
    }

    #[test]
    #[serial(bug_report_error_file)]
    fn test_capture_command_error_with_code_negative() {
        // Edge case: negative exit code (signal-based termination)
        capture_command_error_with_code("pmat", &["work".to_string()], "signal error", -9);
    }

    #[test]
    #[serial(bug_report_error_file)]
    fn test_capture_command_error_with_code_large() {
        // Edge case: large exit code
        capture_command_error_with_code("pmat", &["work".to_string()], "error", 255);
    }

    #[test]
    #[serial(bug_report_error_file)]
    fn test_capture_command_error_with_special_characters() {
        // Error message with special characters that might break markdown
        capture_command_error(
            "pmat",
            &["test".to_string()],
            "Error: `backticks` and **bold** and <html>tags</html>",
        );
    }

    #[test]
    #[serial(bug_report_error_file)]
    fn test_capture_command_error_with_newlines() {
        // Error message with newlines (multi-line errors)
        capture_command_error(
            "pmat",
            &["test".to_string()],
            "Line 1: Error\nLine 2: Details\nLine 3: Stack trace",
        );
    }

    #[test]
    #[serial(bug_report_error_file)]
    fn test_capture_command_error_with_tabs_and_whitespace() {
        // Error message with various whitespace
        capture_command_error(
            "pmat",
            &["test".to_string()],
            "Error:\t\tTabbed\n    Spaces    \r\nCRLF",
        );
    }

    /// Test parsing of issue content format - title extraction
    #[test]
    fn test_issue_content_parsing_with_title() {
        // Simulate the format returned by generate_issue_markdown
        let content = "TITLE: My Bug Title\n---\n## Summary\n\nBody content here.";
        let parts: Vec<&str> = content.splitn(2, "\n---\n").collect();

        let title = parts
            .first()
            .unwrap_or(&"Bug report")
            .strip_prefix("TITLE: ")
            .unwrap_or("Bug report");
        let body = parts.get(1).unwrap_or(&"");

        assert_eq!(title, "My Bug Title");
        assert_eq!(*body, "## Summary\n\nBody content here.");
    }

    /// Test parsing with malformed content (no separator)
    #[test]
    fn test_issue_content_parsing_no_separator() {
        let content = "Just some content without proper format";
        let parts: Vec<&str> = content.splitn(2, "\n---\n").collect();

        let title = parts
            .first()
            .unwrap_or(&"Bug report")
            .strip_prefix("TITLE: ")
            .unwrap_or("Bug report");
        let body = parts.get(1).unwrap_or(&"");

        // Should fall back to the whole content as title (without TITLE: prefix)
        assert_eq!(title, "Bug report");
        // Body should be empty since there's no separator
        assert_eq!(*body, "");
    }

    /// Test parsing with empty content
    #[test]
    fn test_issue_content_parsing_empty() {
        let content = "";
        let parts: Vec<&str> = content.splitn(2, "\n---\n").collect();

        let title = parts
            .first()
            .unwrap_or(&"Bug report")
            .strip_prefix("TITLE: ")
            .unwrap_or("Bug report");
        let body = parts.get(1).unwrap_or(&"");

        assert_eq!(title, "Bug report");
        assert_eq!(*body, "");
    }

    /// Test parsing with only separator
    #[test]
    fn test_issue_content_parsing_only_separator() {
        let content = "\n---\n";
        let parts: Vec<&str> = content.splitn(2, "\n---\n").collect();

        let title = parts
            .first()
            .unwrap_or(&"Bug report")
            .strip_prefix("TITLE: ")
            .unwrap_or("Bug report");
        let body = parts.get(1).unwrap_or(&"");

        // First part is empty string, which doesn't have TITLE: prefix
        assert_eq!(title, "Bug report");
        // Body is also empty
        assert_eq!(*body, "");
    }

    /// Test parsing with multiple separators
    #[test]
    fn test_issue_content_parsing_multiple_separators() {
        let content = "TITLE: Title\n---\nFirst section\n---\nSecond section";
        let parts: Vec<&str> = content.splitn(2, "\n---\n").collect();

        let title = parts
            .first()
            .unwrap_or(&"Bug report")
            .strip_prefix("TITLE: ")
            .unwrap_or("Bug report");
        let body = parts.get(1).unwrap_or(&"");

        assert_eq!(title, "Title");
        // Body should include everything after first separator (including second separator)
        assert_eq!(*body, "First section\n---\nSecond section");
    }

    /// Test that CapturedError can be created and used for bug reports
    #[test]
    fn test_captured_error_creation_for_bug_report() {
        let error = CapturedError::new(
            "pmat work",
            &[
                "status".to_string(),
                "--format".to_string(),
                "json".to_string(),
            ],
            "Failed to connect to database",
        );

        assert_eq!(error.command, "pmat work");
        assert_eq!(error.args.len(), 3);
        assert_eq!(error.error_message, "Failed to connect to database");
        assert!(error.exit_code.is_none());
    }

    /// Test CapturedError with exit code for bug reports
    #[test]
    fn test_captured_error_with_exit_code_for_bug_report() {
        let error = CapturedError::new("pmat", &["analyze".to_string()], "Analysis failed")
            .with_exit_code(42);

        assert_eq!(error.exit_code, Some(42));
        assert_eq!(error.command, "pmat");
    }

    /// Test CapturedError with backtrace for bug reports
    #[test]
    fn test_captured_error_with_backtrace_for_bug_report() {
        let backtrace = "   0: pmat::main\n   1: std::rt::lang_start";
        let error =
            CapturedError::new("pmat", &["work".to_string()], "Panic").with_backtrace(backtrace);

        assert_eq!(error.backtrace, Some(backtrace.to_string()));
    }

    /// Test CapturedError chaining with_backtrace and with_exit_code
    #[test]
    fn test_captured_error_chaining() {
        let error = CapturedError::new("pmat", &[], "error")
            .with_exit_code(1)
            .with_backtrace("trace");

        assert_eq!(error.exit_code, Some(1));
        assert_eq!(error.backtrace, Some("trace".to_string()));
    }

    /// Test that version and OS are populated in CapturedError
    #[test]
    fn test_captured_error_metadata() {
        let error = CapturedError::new("pmat", &[], "test");

        // Version should match CARGO_PKG_VERSION
        assert!(!error.version.is_empty());
        // OS should be populated
        assert!(!error.os.is_empty());
        // Timestamp should be set
        assert!(!error.timestamp.is_empty());
        // Timestamp should be RFC3339 format (contains T and Z or timezone)
        assert!(error.timestamp.contains('T'));
    }

    /// Test redact_paths functionality
    #[test]
    fn test_captured_error_redact_paths_in_message() {
        // Set up environment for test
        let home = std::env::var("HOME").unwrap_or_else(|_| "/home/testuser".to_string());

        let mut error =
            CapturedError::new("pmat", &[], &format!("Error at {}/project/file.rs", home));

        error.redact_paths();

        // Home should be redacted
        assert!(
            error.error_message.contains("~"),
            "Error message should contain ~ after redaction: {}",
            error.error_message
        );
        assert!(
            !error.error_message.contains(&home),
            "Error message should not contain home path after redaction"
        );
    }

    /// Test redact_paths with backtrace
    #[test]
    fn test_captured_error_redact_paths_in_backtrace() {
        let home = std::env::var("HOME").unwrap_or_else(|_| "/home/testuser".to_string());

        let mut error = CapturedError::new("pmat", &[], "error")
            .with_backtrace(&format!("  at {}/project/src/main.rs:42", home));

        error.redact_paths();

        if let Some(bt) = &error.backtrace {
            assert!(bt.contains("~"), "Backtrace should contain ~");
            assert!(!bt.contains(&home), "Backtrace should not contain home");
        }
    }

    /// Test redact_paths with project_path
    #[test]
    fn test_captured_error_redact_project_path() {
        let home = std::env::var("HOME").unwrap_or_else(|_| "/home/testuser".to_string());

        let mut error = CapturedError::new("pmat", &[], "error");
        // Manually set project_path that includes home
        error.project_path = Some(format!("{}/project", home));

        error.redact_paths();

        if let Some(path) = &error.project_path {
            assert!(path.contains("~"), "Project path should contain ~");
            assert!(
                !path.contains(&home),
                "Project path should not contain home"
            );
        }
    }

    /// Test generate_issue_markdown with empty args
    #[test]
    fn test_generate_issue_markdown_empty_args() {
        let error = CapturedError::new("pmat analyze", &[], "Analysis failed");

        let md = generate_issue_markdown(&error, Some("Empty Args Bug"));

        assert!(md.contains("TITLE: Empty Args Bug"));
        assert!(md.contains("pmat analyze"));
        // Command section should just show the command without trailing space
        assert!(md.contains("```bash\npmat analyze\n```"));
    }

    /// Test generate_issue_markdown with args
    #[test]
    fn test_generate_issue_markdown_with_args() {
        let error =
            CapturedError::new("pmat", &["work".to_string(), "status".to_string()], "Error");

        let md = generate_issue_markdown(&error, Some("Test"));

        assert!(md.contains("pmat work status"));
    }

    /// Test generate_issue_markdown includes all sections
    #[test]
    fn test_generate_issue_markdown_all_sections() {
        let error = CapturedError::new("pmat", &["test".to_string()], "Test error")
            .with_exit_code(1)
            .with_backtrace("backtrace content");

        let md = generate_issue_markdown(&error, None);

        // Check all required sections exist
        assert!(md.contains("## Summary"));
        assert!(md.contains("## Environment"));
        assert!(md.contains("## Command Executed"));
        assert!(md.contains("## Error Output"));
        assert!(md.contains("<details>"));
        assert!(md.contains("Backtrace"));
        assert!(md.contains("**Exit Code**: 1"));
        assert!(md.contains("## Steps to Reproduce"));
        assert!(md.contains("## Expected Behavior"));
        assert!(md.contains("Generated automatically by `pmat bug-report`"));
    }

    /// Test generate_issue_markdown default title
    #[test]
    fn test_generate_issue_markdown_default_title_multiword_command() {
        let error = CapturedError::new("pmat work status", &[], "Error");

        let md = generate_issue_markdown(&error, None);

        // Default title should take first two words of command
        assert!(md.contains("TITLE: Bug: pmat work fails with error"));
    }

    /// Test generate_issue_markdown without optional fields
    #[test]
    fn test_generate_issue_markdown_no_optional_fields() {
        let mut error = CapturedError::new("pmat", &[], "Error");
        error.project_path = None; // Clear project path

        let md = generate_issue_markdown(&error, Some("Simple Bug"));

        // Should not contain backtrace section
        assert!(!md.contains("<details>"));
        // Should not contain exit code
        assert!(!md.contains("Exit Code"));
    }

    /// Test issue markdown with project path included
    #[test]
    fn test_generate_issue_markdown_with_project_path() {
        let mut error = CapturedError::new("pmat", &[], "Error");
        error.project_path = Some("/path/to/project".to_string());

        let md = generate_issue_markdown(&error, Some("Bug"));

        assert!(md.contains("**Project Path**: `/path/to/project`"));
    }

    /// Test markdown escaping concerns
    #[test]
    fn test_generate_issue_markdown_special_chars() {
        let error = CapturedError::new(
            "pmat",
            &["--option=value".to_string()],
            "Error: `special` <chars> *markdown* _underscore_",
        );

        let md = generate_issue_markdown(&error, Some("Special Characters"));

        // Content should be preserved (inside code blocks)
        assert!(md.contains("`special`"));
        assert!(md.contains("<chars>"));
    }
}