1use std::path::Path;
19
20use crate::error::JoyError;
21
22const SECURITY_TEMPLATE: &str = include_str!("../templates/SECURITY.md");
23const BLOCK_START: &str = "<!-- joy:security begin -->";
24const BLOCK_END: &str = "<!-- joy:security end -->";
25
26pub fn rendered_body() -> &'static str {
32 SECURITY_TEMPLATE
33}
34
35pub fn render(path: &Path) -> Result<bool, JoyError> {
39 let body = rendered_body();
40 let block = format!("{BLOCK_START}\n{body}{BLOCK_END}\n");
41
42 let new_content = if path.is_file() {
43 let existing = std::fs::read_to_string(path).map_err(|e| JoyError::ReadFile {
44 path: path.to_path_buf(),
45 source: e,
46 })?;
47 merge_block(&existing, &block)
48 } else {
49 block.clone()
50 };
51
52 if path.is_file() {
53 let existing = std::fs::read_to_string(path).map_err(|e| JoyError::ReadFile {
54 path: path.to_path_buf(),
55 source: e,
56 })?;
57 if existing == new_content {
58 return Ok(false);
59 }
60 }
61
62 std::fs::write(path, new_content).map_err(|e| JoyError::WriteFile {
63 path: path.to_path_buf(),
64 source: e,
65 })?;
66 Ok(true)
67}
68
69pub fn is_current(path: &Path) -> Result<bool, JoyError> {
71 if !path.is_file() {
72 return Ok(false);
73 }
74 let body = rendered_body();
75 let block = format!("{BLOCK_START}\n{body}{BLOCK_END}\n");
76 let existing = std::fs::read_to_string(path).map_err(|e| JoyError::ReadFile {
77 path: path.to_path_buf(),
78 source: e,
79 })?;
80 Ok(existing == merge_block(&existing, &block))
81}
82
83fn merge_block(existing: &str, block: &str) -> String {
84 if let (Some(start), Some(end_pos)) = (existing.find(BLOCK_START), existing.find(BLOCK_END)) {
85 let end = end_pos + BLOCK_END.len();
86 let mut out = String::new();
87 out.push_str(&existing[..start]);
88 out.push_str(block.trim_end());
89 let tail = &existing[end..];
91 if !tail.is_empty() {
92 out.push('\n');
93 out.push_str(tail.trim_start_matches('\n'));
94 } else {
95 out.push('\n');
96 }
97 out
98 } else {
99 let trimmed = existing.trim_end();
101 if trimmed.is_empty() {
102 block.to_string()
103 } else {
104 format!("{trimmed}\n\n{block}")
105 }
106 }
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112 use std::fs;
113 use tempfile::tempdir;
114
115 #[test]
116 fn render_creates_file_when_missing() {
117 let dir = tempdir().unwrap();
118 let path = dir.path().join("SECURITY.md");
119 let changed = render(&path).unwrap();
120 assert!(changed);
121 let content = fs::read_to_string(&path).unwrap();
122 assert!(content.contains(BLOCK_START));
123 assert!(content.contains(BLOCK_END));
124 assert!(content.contains("verify_key"));
125 }
126
127 #[test]
128 fn render_is_idempotent() {
129 let dir = tempdir().unwrap();
130 let path = dir.path().join("SECURITY.md");
131 render(&path).unwrap();
132 let changed = render(&path).unwrap();
133 assert!(!changed);
134 }
135
136 #[test]
137 fn render_preserves_user_content_outside_block() {
138 let dir = tempdir().unwrap();
139 let path = dir.path().join("SECURITY.md");
140 let user_content = "# My SECURITY policy\n\nUser-authored intro.\n\n";
141 fs::write(&path, user_content).unwrap();
142 render(&path).unwrap();
143 let content = fs::read_to_string(&path).unwrap();
144 assert!(content.starts_with("# My SECURITY policy"));
145 assert!(content.contains("User-authored intro."));
146 assert!(content.contains(BLOCK_START));
147 }
148
149 #[test]
150 fn render_updates_existing_block_in_place() {
151 let dir = tempdir().unwrap();
152 let path = dir.path().join("SECURITY.md");
153 let stale =
155 format!("# Title\n\n{BLOCK_START}\nold content\n{BLOCK_END}\n\nFooter content.\n",);
156 fs::write(&path, &stale).unwrap();
157 render(&path).unwrap();
158 let content = fs::read_to_string(&path).unwrap();
159 assert!(content.starts_with("# Title"));
160 assert!(content.contains("Footer content."));
161 assert!(!content.contains("old content"));
162 assert!(content.contains("verify_key"));
163 }
164
165 #[test]
166 fn is_current_reports_false_for_missing_file() {
167 let dir = tempdir().unwrap();
168 let path = dir.path().join("SECURITY.md");
169 assert!(!is_current(&path).unwrap());
170 }
171
172 #[test]
173 fn is_current_reports_true_after_render() {
174 let dir = tempdir().unwrap();
175 let path = dir.path().join("SECURITY.md");
176 render(&path).unwrap();
177 assert!(is_current(&path).unwrap());
178 }
179}