1use crate::config;
18use crate::constants::versions::README_VERSION;
19use crate::fsutil;
20use crate::prompts;
21use anyhow::{Context, Result};
22use std::fs;
23use std::path::Path;
24use thiserror::Error;
25
26#[derive(Error, Debug, Clone, PartialEq, Eq)]
28pub enum ReadmeVersionError {
29 #[error("no version marker found")]
31 NoMarker,
32
33 #[error("malformed version marker: missing closing '-->'")]
35 InvalidFormat,
36
37 #[error("invalid version value: '{value}' is not a valid non-negative integer")]
39 ParseError { value: String },
40}
41
42const DEFAULT_RALPH_README: &str = include_str!(concat!(
43 env!("CARGO_MANIFEST_DIR"),
44 "/assets/ralph_readme.md"
45));
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum ReadmeCheckResult {
50 Current(u32),
52 Outdated {
54 current_version: u32,
55 embedded_version: u32,
56 },
57 Missing,
59 NotApplicable,
61}
62
63pub fn extract_readme_version(content: &str) -> Result<u32, ReadmeVersionError> {
66 let marker_start = "<!-- RALPH_README_VERSION:";
67
68 let Some(start_idx) = content.find(marker_start) else {
70 return Err(ReadmeVersionError::NoMarker);
71 };
72
73 let after_marker = &content[start_idx + marker_start.len()..];
74
75 let Some(end_idx) = after_marker.find("-->") else {
77 return Err(ReadmeVersionError::InvalidFormat);
78 };
79
80 let version_str = &after_marker[..end_idx];
81 let trimmed = version_str.trim();
82
83 match trimmed.parse::<u32>() {
85 Ok(version) => Ok(version),
86 Err(_) => Err(ReadmeVersionError::ParseError {
87 value: trimmed.to_string(),
88 }),
89 }
90}
91
92pub fn check_readme_current(resolved: &config::Resolved) -> Result<ReadmeCheckResult> {
95 check_readme_current_from_root(&resolved.repo_root)
96}
97
98pub fn check_readme_current_from_root(repo_root: &std::path::Path) -> Result<ReadmeCheckResult> {
101 if !prompts::prompts_reference_readme(repo_root)? {
103 return Ok(ReadmeCheckResult::NotApplicable);
104 }
105
106 let readme_path = repo_root.join(".ralph/README.md");
107
108 if !readme_path.exists() {
109 return Ok(ReadmeCheckResult::Missing);
110 }
111
112 let content = fs::read_to_string(&readme_path)
113 .with_context(|| format!("read {}", readme_path.display()))?;
114
115 let current_version = match extract_readme_version(&content) {
116 Ok(version) => version,
117 Err(ReadmeVersionError::NoMarker) => 1, Err(e) => {
119 return Err(anyhow::anyhow!(e).context(format!(
120 "README version marker in {} is malformed",
121 readme_path.display()
122 )));
123 }
124 };
125
126 if current_version >= README_VERSION {
127 Ok(ReadmeCheckResult::Current(current_version))
128 } else {
129 Ok(ReadmeCheckResult::Outdated {
130 current_version,
131 embedded_version: README_VERSION,
132 })
133 }
134}
135
136pub fn write_readme(
139 path: &Path,
140 force: bool,
141 update: bool,
142) -> Result<(super::FileInitStatus, Option<u32>)> {
143 if path.exists() && !force && !update {
144 let content =
146 fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
147 let version = match extract_readme_version(&content) {
148 Ok(v) => Some(v),
149 Err(ReadmeVersionError::NoMarker) => None,
150 Err(e) => {
151 return Err(anyhow::anyhow!(e).context(format!(
152 "README version marker in {} is malformed",
153 path.display()
154 )));
155 }
156 };
157 return Ok((super::FileInitStatus::Valid, version));
158 }
159
160 let should_update = if update && path.exists() && !force {
162 let content =
163 fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
164 let current_version = match extract_readme_version(&content) {
165 Ok(version) => version,
166 Err(ReadmeVersionError::NoMarker) => 1,
167 Err(e) => {
168 return Err(anyhow::anyhow!(e).context(format!(
169 "README version marker in {} is malformed",
170 path.display()
171 )));
172 }
173 };
174 current_version < README_VERSION
175 } else {
176 true };
178
179 if !should_update {
180 let content =
182 fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
183 let version = match extract_readme_version(&content) {
184 Ok(v) => Some(v),
185 Err(ReadmeVersionError::NoMarker) => None,
186 Err(e) => {
187 return Err(anyhow::anyhow!(e).context(format!(
188 "README version marker in {} is malformed",
189 path.display()
190 )));
191 }
192 };
193 return Ok((super::FileInitStatus::Valid, version));
194 }
195
196 if let Some(parent) = path.parent() {
197 fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
198 }
199
200 let is_update = path.exists();
201 fsutil::write_atomic(path, DEFAULT_RALPH_README.as_bytes())
202 .with_context(|| format!("write readme {}", path.display()))?;
203
204 if is_update {
205 Ok((super::FileInitStatus::Updated, Some(README_VERSION)))
206 } else {
207 Ok((super::FileInitStatus::Created, Some(README_VERSION)))
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use crate::contracts::Config;
215 use tempfile::TempDir;
216
217 fn resolved_for(dir: &TempDir) -> config::Resolved {
218 let repo_root = dir.path().to_path_buf();
219 let queue_path = repo_root.join(".ralph/queue.json");
220 let done_path = repo_root.join(".ralph/done.json");
221 let project_config_path = Some(repo_root.join(".ralph/config.json"));
222 config::Resolved {
223 config: Config::default(),
224 repo_root,
225 queue_path,
226 done_path,
227 id_prefix: "RQ".to_string(),
228 id_width: 4,
229 global_config_path: None,
230 project_config_path,
231 }
232 }
233
234 #[test]
235 fn extract_readme_version_finds_version_marker() {
236 let content = "<!-- RALPH_README_VERSION: 6 -->\n# Heading";
237 assert_eq!(extract_readme_version(content), Ok(6));
238
239 let content_v2 = "<!-- RALPH_README_VERSION: 2 -->\n# Ralph";
240 assert_eq!(extract_readme_version(content_v2), Ok(2));
241 }
242
243 #[test]
244 fn extract_readme_version_returns_error_for_no_marker() {
245 let content = "# Ralph runtime files\nSome content";
246 assert!(matches!(
248 extract_readme_version(content),
249 Err(ReadmeVersionError::NoMarker)
250 ));
251 }
252
253 #[test]
254 fn extract_readme_version_returns_error_for_invalid_version() {
255 let content = "<!-- RALPH_README_VERSION: invalid -->\n# Heading";
256 let result = extract_readme_version(content);
257 assert!(
258 matches!(result, Err(ReadmeVersionError::ParseError { value }) if value == "invalid")
259 );
260 }
261
262 #[test]
263 fn extract_readme_version_returns_error_for_malformed_marker() {
264 let content = "<!-- RALPH_README_VERSION: 6 \n# Heading"; let result = extract_readme_version(content);
266 assert!(matches!(result, Err(ReadmeVersionError::InvalidFormat)));
267 }
268
269 #[test]
270 fn extract_readme_version_handles_whitespace() {
271 let content = "<!-- RALPH_README_VERSION: 3 -->\n# Heading";
272 assert_eq!(extract_readme_version(content), Ok(3));
273 }
274
275 #[test]
276 fn extract_readme_version_rejects_negative_numbers() {
277 let content = "<!-- RALPH_README_VERSION: -1 -->\n# Heading";
278 let result = extract_readme_version(content);
279 assert!(matches!(result, Err(ReadmeVersionError::ParseError { value }) if value == "-1"));
280 }
281
282 #[test]
283 fn extract_readme_version_rejects_floats() {
284 let content = "<!-- RALPH_README_VERSION: 1.5 -->\n# Heading";
285 let result = extract_readme_version(content);
286 assert!(matches!(result, Err(ReadmeVersionError::ParseError { value }) if value == "1.5"));
287 }
288
289 #[test]
290 fn write_readme_creates_new_file_with_version() -> Result<()> {
291 let dir = TempDir::new()?;
292 let readme_path = dir.path().join("README.md");
293
294 let (status, version) = write_readme(&readme_path, false, false)?;
295
296 assert_eq!(status, super::super::FileInitStatus::Created);
297 assert_eq!(version, Some(README_VERSION));
298 assert!(readme_path.exists());
299
300 let content = std::fs::read_to_string(&readme_path)?;
301 assert!(content.contains("RALPH_README_VERSION"));
302 Ok(())
303 }
304
305 #[test]
306 fn write_readme_preserves_existing_when_no_update() -> Result<()> {
307 let dir = TempDir::new()?;
308 let readme_path = dir.path().join("README.md");
309
310 let old_content = "<!-- RALPH_README_VERSION: 1 -->\n# Old content";
312 std::fs::write(&readme_path, old_content)?;
313
314 let (status, version) = write_readme(&readme_path, false, false)?;
315
316 assert_eq!(status, super::super::FileInitStatus::Valid);
317 assert_eq!(version, Some(1));
318
319 let content = std::fs::read_to_string(&readme_path)?;
321 assert!(content.contains("Old content"));
322 Ok(())
323 }
324
325 #[test]
326 fn write_readme_updates_when_version_mismatch() -> Result<()> {
327 let dir = TempDir::new()?;
328 let readme_path = dir.path().join("README.md");
329
330 let old_content = "<!-- RALPH_README_VERSION: 1 -->\n# Old content";
332 std::fs::write(&readme_path, old_content)?;
333
334 let (status, version) = write_readme(&readme_path, false, true)?;
335
336 assert_eq!(status, super::super::FileInitStatus::Updated);
337 assert_eq!(version, Some(README_VERSION));
338
339 let content = std::fs::read_to_string(&readme_path)?;
341 assert!(!content.contains("Old content"));
342 assert!(content.contains("Ralph runtime files"));
343 Ok(())
344 }
345
346 #[test]
347 fn write_readme_skips_update_when_current() -> Result<()> {
348 let dir = TempDir::new()?;
349 let readme_path = dir.path().join("README.md");
350
351 let current_content = format!(
353 "<!-- RALPH_README_VERSION: {} -->\n# Current",
354 README_VERSION
355 );
356 std::fs::write(&readme_path, ¤t_content)?;
357
358 let (status, version) = write_readme(&readme_path, false, true)?;
359
360 assert_eq!(status, super::super::FileInitStatus::Valid);
362 assert_eq!(version, Some(README_VERSION));
363
364 let content = std::fs::read_to_string(&readme_path)?;
366 assert!(content.contains("Current"));
367 Ok(())
368 }
369
370 #[test]
371 fn write_readme_force_overwrites_regardless() -> Result<()> {
372 let dir = TempDir::new()?;
373 let readme_path = dir.path().join("README.md");
374
375 std::fs::write(&readme_path, "<!-- RALPH_README_VERSION: 99 -->\n# Custom")?;
377
378 let (status, version) = write_readme(&readme_path, true, false)?;
379
380 assert_eq!(status, super::super::FileInitStatus::Updated);
382 assert_eq!(version, Some(README_VERSION));
383
384 let content = std::fs::read_to_string(&readme_path)?;
386 assert!(!content.contains("Custom"));
387 Ok(())
388 }
389
390 #[test]
391 fn check_readme_current_detects_missing() -> Result<()> {
392 let dir = TempDir::new()?;
393 let resolved = resolved_for(&dir);
394
395 let result = check_readme_current(&resolved)?;
396
397 assert!(matches!(result, ReadmeCheckResult::Missing));
399 Ok(())
400 }
401
402 #[test]
403 fn check_readme_current_detects_outdated() -> Result<()> {
404 let dir = TempDir::new()?;
405 let resolved = resolved_for(&dir);
406
407 fs::create_dir_all(resolved.repo_root.join(".ralph"))?;
409 let old_readme = "<!-- RALPH_README_VERSION: 1 -->\n# Old";
410 fs::write(resolved.repo_root.join(".ralph/README.md"), old_readme)?;
411
412 let result = check_readme_current(&resolved)?;
413
414 assert!(
415 matches!(result, ReadmeCheckResult::Outdated { current_version: 1, embedded_version } if embedded_version == README_VERSION)
416 );
417 Ok(())
418 }
419
420 #[test]
421 fn check_readme_current_detects_current() -> Result<()> {
422 let dir = TempDir::new()?;
423 let resolved = resolved_for(&dir);
424
425 fs::create_dir_all(resolved.repo_root.join(".ralph"))?;
427 let current_readme = format!(
428 "<!-- RALPH_README_VERSION: {} -->\n# Current",
429 README_VERSION
430 );
431 fs::write(resolved.repo_root.join(".ralph/README.md"), current_readme)?;
432
433 let result = check_readme_current(&resolved)?;
434
435 assert!(matches!(result, ReadmeCheckResult::Current(v) if v == README_VERSION));
436 Ok(())
437 }
438}