Skip to main content

ralph/commands/init/
readme.rs

1//! README file version management for Ralph initialization.
2//!
3//! Responsibilities:
4//! - Track README template versions via embedded version markers.
5//! - Detect outdated README files and support updates.
6//! - Create new README files from embedded template.
7//!
8//! Not handled here:
9//! - Queue/config file creation (see `super::writers`).
10//! - Prompt content validation (handled by `crate::prompts`).
11//!
12//! Invariants/assumptions:
13//! - README_VERSION is incremented when template changes.
14//! - Version marker format: `<!-- RALPH_README_VERSION: X -->`
15//! - Legacy files without markers are treated as version 1.
16
17use 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/// Errors that can occur when extracting README version.
27#[derive(Error, Debug, Clone, PartialEq, Eq)]
28pub enum ReadmeVersionError {
29    /// No version marker found in the file (legacy file).
30    #[error("no version marker found")]
31    NoMarker,
32
33    /// Version marker is malformed (e.g., missing closing `-->`).
34    #[error("malformed version marker: missing closing '-->'")]
35    InvalidFormat,
36
37    /// Version value could not be parsed as a non-negative integer.
38    #[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/// Result of checking if README is current.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum ReadmeCheckResult {
50    /// README is current with the specified version.
51    Current(u32),
52    /// README is outdated (has older version).
53    Outdated {
54        current_version: u32,
55        embedded_version: u32,
56    },
57    /// README is missing.
58    Missing,
59    /// README not applicable (prompts don't reference it).
60    NotApplicable,
61}
62
63/// Extract version from README content.
64/// Looks for `<!-- RALPH_README_VERSION: X -->` marker.
65pub fn extract_readme_version(content: &str) -> Result<u32, ReadmeVersionError> {
66    let marker_start = "<!-- RALPH_README_VERSION:";
67
68    // No marker found - this is a legacy file
69    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    // Found marker start but no closing -->
76    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    // Parse the version number
84    match trimmed.parse::<u32>() {
85        Ok(version) => Ok(version),
86        Err(_) => Err(ReadmeVersionError::ParseError {
87            value: trimmed.to_string(),
88        }),
89    }
90}
91
92/// Check if README is current without modifying it.
93/// Returns the check result with version information.
94pub fn check_readme_current(resolved: &config::Resolved) -> Result<ReadmeCheckResult> {
95    check_readme_current_from_root(&resolved.repo_root)
96}
97
98/// Check if README is current from a repo root path.
99/// This is a helper for migration context that doesn't need full Resolved config.
100pub fn check_readme_current_from_root(repo_root: &std::path::Path) -> Result<ReadmeCheckResult> {
101    // First check if README is applicable
102    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, // Legacy file, treat as v1
118        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
136/// Write README file, handling version checks and updates.
137/// Returns (status, version) tuple - version is Some if README was read/created.
138pub 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        // Check version for reporting purposes
145        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    // Check if we need to update (version mismatch)
161    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 // Create new or force overwrite
177    };
178
179    if !should_update {
180        // Version is current, no update needed
181        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        // Legacy content without marker returns NoMarker error
247        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"; // Missing -->
265        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        // Create an existing README with old version
311        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        // Content should be preserved
320        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        // Create an existing README with old version
331        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        // Content should be updated
340        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        // Create an existing README with current version
352        let current_content = format!(
353            "<!-- RALPH_README_VERSION: {} -->\n# Current",
354            README_VERSION
355        );
356        std::fs::write(&readme_path, &current_content)?;
357
358        let (status, version) = write_readme(&readme_path, false, true)?;
359
360        // Should be Valid since version is current
361        assert_eq!(status, super::super::FileInitStatus::Valid);
362        assert_eq!(version, Some(README_VERSION));
363
364        // Content should be preserved
365        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        // Create an existing README
376        std::fs::write(&readme_path, "<!-- RALPH_README_VERSION: 99 -->\n# Custom")?;
377
378        let (status, version) = write_readme(&readme_path, true, false)?;
379
380        // When force-overwriting an existing file, status is Updated
381        assert_eq!(status, super::super::FileInitStatus::Updated);
382        assert_eq!(version, Some(README_VERSION));
383
384        // Content should be overwritten
385        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        // README is applicable but missing
398        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        // Create README with old version
408        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        // Create README with current version
426        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}