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
//! Output-style filesystem deployer for the managed CLAUDE_CONFIG_DIR (WI-2 follow-up).
//!
//! Why: the managed config dir (`<managed_root>/claude-config/`) starts empty.
//! Without deploying the bundled output-style definitions into it, Claude Code
//! sessions launched via `tm run`/`tm load`/`tm login` cannot resolve the
//! `"outputStyle": "trusty-mpm"` key in `settings.json` — Claude Code only
//! honours that key if a matching file exists under the config dir's
//! `output-styles/` subdirectory (refs #1553, epic #1548 WI-2 follow-up).
//! What: [`deploy_output_styles`] writes every style in [`OUTPUT_STYLES`] to
//! `<claude_config_dir>/output-styles/<file_name>`, idempotently — it only
//! writes when content differs from the on-disk copy. Source is the
//! compile-time–bundled constants in [`crate::core::bundle`] so no
//! framework-root installation is required before the deploy runs (unlike the
//! agent/skill deployers which read from `<managed_root>/framework/`).
//! Test: inline `#[cfg(test)]` block; isolation invariant in
//! `tests/standalone_isolation.rs`.
use std::path::Path;
use crate::core::agent_manifest::atomic_write;
use crate::core::bundle::OUTPUT_STYLES;
/// Summary of one [`deploy_output_styles`] run.
///
/// Why: callers (and tests) need to know which styles were freshly written vs.
/// left untouched, so they can assert the deployer behaved correctly without
/// inspecting the filesystem directly.
/// What: counts split into freshly written and unchanged (checksum already
/// matched). There is no "skipped" category — output styles are always
/// framework-owned, so the deployer unconditionally overwrites stale copies.
/// Test: every test in this module asserts on these fields.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct OutputStyleDeployResult {
/// File names successfully (re)written this run.
pub deployed: Vec<String>,
/// File names left untouched because their checksum already matched.
pub unchanged: Vec<String>,
}
/// Deploy all bundled output styles into `<claude_config_dir>/output-styles/`.
///
/// Why: `tm run`/`tm load`/`tm login` sessions set `"outputStyle": "trusty-mpm"`
/// in `settings.json`, but Claude Code only resolves that name against style files
/// present under the active `CLAUDE_CONFIG_DIR/output-styles/`. Deploying the
/// bundled styles on every managed-driver bootstrap ensures the setting is always
/// honoured, without requiring the user to first run `tm install` (closes #1553).
/// What: creates `<claude_config_dir>/output-styles/` if absent, then iterates
/// [`OUTPUT_STYLES`], comparing each style's content to the on-disk copy via a
/// byte-exact comparison (reads raw bytes so the check is valid even for
/// non-UTF-8 on-disk content, and surfaces IO errors rather than swallowing them).
/// Files whose bytes already match the bundled content are skipped (idempotent);
/// others are written atomically (temp-then-rename) via [`atomic_write`]. All
/// logging goes to stderr.
/// Test: `deploy_output_styles_populates_output_styles_dir` (happy path),
/// `deploy_output_styles_idempotent` (no spurious write on second call),
/// `deploy_output_styles_refreshes_stale_file` (overwrite when source changed).
pub fn deploy_output_styles(claude_config_dir: &Path) -> anyhow::Result<OutputStyleDeployResult> {
let styles_dir = claude_config_dir.join("output-styles");
std::fs::create_dir_all(&styles_dir)?;
let mut result = OutputStyleDeployResult::default();
for style in OUTPUT_STYLES {
let target = styles_dir.join(style.file_name);
let bundled_bytes = style.content.as_bytes();
// Idempotency guard: read the on-disk bytes and compare directly.
// Using `read` (raw bytes) rather than `read_to_string` avoids two
// hazards: (a) silently treating non-UTF-8 on-disk content as absent
// (which would trigger a spurious rewrite), and (b) swallowing genuine
// IO errors (e.g. permissions) via `.ok()`. Only `NotFound` (the file
// genuinely does not exist yet) is treated as "needs write"; all other
// IO errors are propagated so the caller sees them.
let needs_write = match std::fs::read(&target) {
Ok(disk_bytes) => disk_bytes != bundled_bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => true,
Err(e) => {
return Err(anyhow::anyhow!(
"failed to read on-disk style '{}' for idempotency check: {e}",
target.display()
));
}
};
if !needs_write {
result.unchanged.push(style.file_name.to_string());
continue;
}
// Write atomically (temp-then-rename) so a crash between writes cannot
// leave a half-written style file.
atomic_write(&target, style.content).map_err(|e| {
anyhow::anyhow!(
"failed to deploy output style '{}' to {}: {e}",
style.id,
target.display()
)
})?;
result.deployed.push(style.file_name.to_string());
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
/// Deploy to a fresh directory must write all three bundled styles.
///
/// Why: confirms the happy-path deploy populates the output-styles dir.
/// What: calls deploy_output_styles on an empty temp dir and asserts every
/// bundled style is written with the correct content.
/// Test: this function IS the test.
#[test]
fn deploy_output_styles_populates_output_styles_dir() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().to_path_buf();
let result = deploy_output_styles(&cfg).unwrap();
// All three bundled styles must be deployed on a first run.
assert_eq!(
result.deployed.len(),
OUTPUT_STYLES.len(),
"all bundled styles must be deployed on first run; got {:?}",
result.deployed
);
assert!(
result.unchanged.is_empty(),
"no file should be unchanged on first deploy"
);
// Every style file must exist and contain the bundled content.
let styles_dir = cfg.join("output-styles");
for style in OUTPUT_STYLES {
let target = styles_dir.join(style.file_name);
assert!(
target.exists(),
"output-styles/{} must exist after deploy",
style.file_name
);
let content = std::fs::read_to_string(&target).unwrap();
assert_eq!(
content, style.content,
"deployed content of {} must match the bundled constant",
style.file_name
);
}
}
/// A second deploy with no source changes must not rewrite any file.
///
/// Why: idempotency is the key safety property — avoids spurious mtime
/// bumps and thrashing on every managed-session bootstrap.
/// What: calls deploy_output_styles twice and asserts the second call
/// returns `deployed.is_empty()` and `unchanged.len() == OUTPUT_STYLES.len()`.
/// Relies on the `OutputStyleDeployResult` fields rather than mtime
/// comparisons (mtime assertions are racy on coarse-grained CI filesystems).
/// Test: this function IS the test.
#[test]
fn deploy_output_styles_idempotent() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().to_path_buf();
// First call populates the directory.
deploy_output_styles(&cfg).unwrap();
// Second call must leave files untouched — assert via result fields,
// not mtime (mtime checks are racy on coarse-grained filesystems).
let result = deploy_output_styles(&cfg).unwrap();
assert!(
result.deployed.is_empty(),
"second deploy must not overwrite any file; deployed: {:?}",
result.deployed
);
assert_eq!(
result.unchanged.len(),
OUTPUT_STYLES.len(),
"all files must be reported unchanged on idempotent call; \
got unchanged={:?} deployed={:?}",
result.unchanged,
result.deployed
);
}
/// A stale on-disk copy (content differs from bundled) must be overwritten.
///
/// Why: when the framework upgrades a style, the deployer must replace the
/// stale on-disk copy, not leave it in place.
/// What: writes deliberately stale content to the first style's target path,
/// calls deploy_output_styles, and asserts the result reports the file as
/// deployed and the on-disk content now matches the bundle.
/// Test: this function IS the test.
#[test]
fn deploy_output_styles_refreshes_stale_file() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().to_path_buf();
let styles_dir = cfg.join("output-styles");
std::fs::create_dir_all(&styles_dir).unwrap();
// Write a stale copy of the first style.
let first = &OUTPUT_STYLES[0];
let target = styles_dir.join(first.file_name);
std::fs::write(&target, "stale content that does not match the bundle").unwrap();
let result = deploy_output_styles(&cfg).unwrap();
// The stale file must be refreshed (deployed).
assert!(
result.deployed.contains(&first.file_name.to_string()),
"stale style must be refreshed; deployed: {:?}",
result.deployed
);
// Content must now match the bundle.
let content = std::fs::read_to_string(&target).unwrap();
assert_eq!(
content, first.content,
"refreshed file content must match bundled constant"
);
}
/// Non-UTF-8 bytes on disk must trigger a rewrite, not a spurious OK.
///
/// Why: the idempotency guard reads raw bytes (not UTF-8 string) so that
/// a corrupt or binary on-disk file is correctly detected as "stale" and
/// replaced, rather than silently swallowed.
/// What: writes raw non-UTF-8 bytes to the first style's path, calls
/// deploy_output_styles, and asserts the file is reported as deployed.
/// Test: this function IS the test.
#[test]
fn deploy_output_styles_handles_non_utf8_on_disk() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().to_path_buf();
let styles_dir = cfg.join("output-styles");
std::fs::create_dir_all(&styles_dir).unwrap();
// Write non-UTF-8 bytes (invalid in UTF-8) to the first style path.
let first = &OUTPUT_STYLES[0];
let target = styles_dir.join(first.file_name);
std::fs::write(&target, b"\xff\xfe invalid utf-8 bytes \x80\x81").unwrap();
let result = deploy_output_styles(&cfg).unwrap();
// The file with non-UTF-8 content must be treated as stale and refreshed.
assert!(
result.deployed.contains(&first.file_name.to_string()),
"non-UTF-8 on-disk file must be refreshed; deployed: {:?}",
result.deployed
);
// After refresh, content must match the bundled constant.
let refreshed = std::fs::read_to_string(&target).unwrap();
assert_eq!(
refreshed, first.content,
"after refresh, content must match bundled constant"
);
}
}