use std::path::Path;
use crate::core::agent_manifest::atomic_write;
use crate::core::bundle::OUTPUT_STYLES;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct OutputStyleDeployResult {
pub deployed: Vec<String>,
pub unchanged: Vec<String>,
}
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();
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;
}
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;
#[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();
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"
);
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
);
}
}
#[test]
fn deploy_output_styles_idempotent() {
let tmp = TempDir::new().unwrap();
let cfg = tmp.path().to_path_buf();
deploy_output_styles(&cfg).unwrap();
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
);
}
#[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();
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();
assert!(
result.deployed.contains(&first.file_name.to_string()),
"stale style must be refreshed; deployed: {:?}",
result.deployed
);
let content = std::fs::read_to_string(&target).unwrap();
assert_eq!(
content, first.content,
"refreshed file content must match bundled constant"
);
}
#[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();
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();
assert!(
result.deployed.contains(&first.file_name.to_string()),
"non-UTF-8 on-disk file must be refreshed; deployed: {:?}",
result.deployed
);
let refreshed = std::fs::read_to_string(&target).unwrap();
assert_eq!(
refreshed, first.content,
"after refresh, content must match bundled constant"
);
}
}