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
//! Guard test to prevent stdout leaks in command code
//!
//! stdout carries content the user may want to pipe, redirect, or capture:
//! data (JSON, tables), rendered views (`wt config show`, `wt hook show`),
//! and shell integration output. Interactive status, progress, and errors
//! go to stderr. When shell integration is active (directive env vars set),
//! directives are written to files, not stdout.
//!
//! This test enforces: **No accidental stdout writes in command code**
//!
//! Allowed:
//! - `eprintln!` / `eprint!` (stderr is safe)
//! - `println!` / `print!` in files listed in `STDOUT_ALLOWED_PATHS`
//!
//! When adding stdout output:
//! - Use `worktrunk::styling::println` for color-aware output
//! - Add the file path to `STDOUT_ALLOWED_PATHS` with a comment explaining why
use std::fs;
use std::path::Path;
use path_slash::PathExt as _;
/// Paths (relative to src/commands/) that are allowed to use println!/print! for stdout.
/// These intentionally output data to stdout for scripting/piping.
const STDOUT_ALLOWED_PATHS: &[&str] = &[
// Shell integration code for: eval "$(wt config shell init bash)"
"init.rs",
// Status line text for shell prompts (PS1)
"statusline.rs",
// Table and summary output for wt list
"list/collect/mod.rs",
// JSON output for wt list --format=json
"list/mod.rs",
// State data output (branch names, previous worktree, etc.)
"config/state.rs",
// Hint list output
"config/hints.rs",
// Alias introspection output (show / dry-run), intended to be pipeable
"config/alias.rs",
// Alias --help hint output (conventional `--help` destination)
"alias.rs",
// Template evaluation output for scripting
"eval.rs",
// LLM prompt output for wt step commit --show-prompt
"step_commands.rs",
// --no-cd flag: branch name output for scripting
"picker/mod.rs",
// JSON output for wt switch --format=json
"handle_switch.rs",
// JSON output for wt config show --format=json
"config/show.rs",
// Migrated TOML output for wt config update --print (pipeable)
"config/update.rs",
// JSON output for wt step for-each --format=json
"for_each.rs",
// JSON output for wt merge --format=json
"merge.rs",
// Hook listing output for wt hook show (paged)
"hook_commands.rs",
];
/// Substrings that indicate the line is a special case (e.g., in a comment or test reference)
const ALLOWED_LINE_PATTERNS: &[&str] = &[
"spacing_test.rs", // Test file reference
];
#[test]
fn check_no_stdout_in_commands() {
let project_root = env!("CARGO_MANIFEST_DIR");
let commands_dir = Path::new(project_root).join("src/commands");
// Forbidden tokens that write to stdout
let stdout_tokens = ["print!", "println!"];
let mut violations = Vec::new();
// Recursively scan all .rs files under src/commands/
scan_directory(
&commands_dir,
&stdout_tokens,
&mut violations,
&commands_dir,
);
if !violations.is_empty() {
panic!(
"Unexpected stdout writes in command code:\n\n{}\n\n\
stdout is reserved for data output (JSON, tables).\n\
Use worktrunk::styling::println for stdout, eprintln for stderr.\n\
Add file path to STDOUT_ALLOWED_PATHS if stdout is intentional.",
violations.join("\n")
);
}
}
fn scan_directory(dir: &Path, tokens: &[&str], violations: &mut Vec<String>, commands_dir: &Path) {
let entries = match fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
scan_directory(&path, tokens, violations, commands_dir);
} else if path.extension().and_then(|s| s.to_str()) == Some("rs") {
check_file(&path, tokens, violations, commands_dir);
}
}
}
fn check_file(path: &Path, tokens: &[&str], violations: &mut Vec<String>, commands_dir: &Path) {
// Get path relative to src/commands/ for matching against STDOUT_ALLOWED_PATHS
let relative_path = path
.strip_prefix(commands_dir)
.map(|p| p.to_slash_lossy())
.unwrap_or_default();
// Skip files that are allowed to use stdout
if STDOUT_ALLOWED_PATHS.contains(&relative_path.as_ref()) {
return;
}
let contents = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return,
};
let relative_path = path
.strip_prefix(env!("CARGO_MANIFEST_DIR"))
.unwrap_or(path)
.display();
for (line_num, line) in contents.lines().enumerate() {
// Skip lines with allowed patterns
if ALLOWED_LINE_PATTERNS
.iter()
.any(|pattern| line.contains(pattern))
{
continue;
}
for token in tokens {
if let Some(pos) = line.find(token) {
// Skip eprint!/eprintln! - they go to stderr and are safe
// When we match print!/println!, check if preceded by 'e' (part of eprint/eprintln)
// Also verify the 'e' is at a word boundary (start of line, or after non-alphanumeric)
if pos > 0 {
let prev_char = line.as_bytes()[pos - 1];
if prev_char == b'e' {
// Check this 'e' is at a word boundary (not part of some_eprint)
if pos == 1
|| !line.as_bytes()[pos - 2].is_ascii_alphanumeric()
&& line.as_bytes()[pos - 2] != b'_'
{
continue;
}
}
}
// Skip if the token is in a comment
if let Some(comment_pos) = line.find("//")
&& comment_pos < pos
{
continue;
}
violations.push(format!(
"{}:{}: {}",
relative_path,
line_num + 1,
line.trim()
));
break; // Only report once per line
}
}
}
}