Skip to main content

devflow_core/
ship.rs

1//! Ship bookkeeping.
2//!
3//! Holds the Hermes cron-instructions manifest (used to resume a rate-limited
4//! DevFlow run later) plus the pure document-finalization transform
5//! (CHANGELOG) used on ship completion.
6
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9
10/// Manifest consumed by Hermes to resume a rate-limited DevFlow run later.
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12pub struct CronInstructions {
13    /// Absolute project root.
14    pub project: String,
15    /// Phase that should resume.
16    pub phase: u32,
17    /// Current handoff status, e.g. "rate_limited".
18    pub status: String,
19    /// Upstream retry timestamp or description.
20    pub retry_after: String,
21    /// DevFlow resume command.
22    pub resume: ResumeCommand,
23    /// Hermes cron job definition derived from the retry timestamp.
24    pub hermes_cron: HermesCronJob,
25}
26
27/// Command + args that resume the DevFlow workflow.
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29pub struct ResumeCommand {
30    /// Executable name.
31    pub command: String,
32    /// Command arguments.
33    pub args: Vec<String>,
34}
35
36/// Hermes one-shot cron job payload.
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
38pub struct HermesCronJob {
39    /// Cron schedule in `M H D M W` format.
40    pub schedule: String,
41    /// Stable job name.
42    pub name: String,
43    /// Shell command to execute.
44    pub command: String,
45    /// Whether Hermes should remove the job after it runs.
46    pub once: bool,
47}
48
49/// Errors produced by ship bookkeeping.
50#[derive(Debug, thiserror::Error)]
51pub enum ShipError {
52    /// Filesystem operation failed.
53    #[error("ship I/O failed: {0}")]
54    Io(#[from] std::io::Error),
55    /// JSON parse or serialization failed.
56    #[error("ship JSON failed: {0}")]
57    Json(#[from] serde_json::Error),
58    /// No last-ship record exists.
59    #[error("no last-ship record found — nothing to confirm or reject")]
60    Missing,
61}
62
63/// Path to a phase's cron-instructions record. Per-phase since 14a
64/// (13-DEFERRED-CR-03): the old single-slot `cron-instructions.json` let one
65/// phase's rate-limit record clobber another's under `devflow parallel`.
66pub fn cron_instructions_path(project_root: &Path, phase: u32) -> PathBuf {
67    project_root
68        .join(".devflow")
69        .join(format!("cron-instructions-{phase:02}.json"))
70}
71
72/// Path of the legacy single-slot record written by pre-14a binaries. Still
73/// read/deleted for compatibility; never written.
74fn legacy_cron_instructions_path(project_root: &Path) -> PathBuf {
75    project_root.join(".devflow").join("cron-instructions.json")
76}
77
78/// Persist Hermes cron instructions for the phase recorded inside them.
79pub fn write_cron_instructions(
80    project_root: &Path,
81    instructions: &CronInstructions,
82) -> Result<(), ShipError> {
83    let path = cron_instructions_path(project_root, instructions.phase);
84    if let Some(parent) = path.parent() {
85        std::fs::create_dir_all(parent)?;
86    }
87    std::fs::write(&path, serde_json::to_string_pretty(instructions)?)?;
88    Ok(())
89}
90
91/// Load a phase's Hermes cron instructions, or [`ShipError::Missing`] if
92/// absent. Falls back to a legacy single-slot record when it names this phase.
93pub fn load_cron_instructions(
94    project_root: &Path,
95    phase: u32,
96) -> Result<CronInstructions, ShipError> {
97    let path = cron_instructions_path(project_root, phase);
98    if path.exists() {
99        return Ok(serde_json::from_str(&std::fs::read_to_string(&path)?)?);
100    }
101    let legacy = legacy_cron_instructions_path(project_root);
102    if legacy.exists() {
103        let instructions: CronInstructions =
104            serde_json::from_str(&std::fs::read_to_string(&legacy)?)?;
105        if instructions.phase == phase {
106            return Ok(instructions);
107        }
108    }
109    Err(ShipError::Missing)
110}
111
112/// Every pending cron-instructions record (per-phase files plus a legacy
113/// single-slot one), sorted by phase. Unparsable files are skipped.
114pub fn list_cron_instructions(project_root: &Path) -> Vec<CronInstructions> {
115    let mut found = Vec::new();
116    if let Ok(entries) = std::fs::read_dir(project_root.join(".devflow")) {
117        for entry in entries.flatten() {
118            let name = entry.file_name();
119            let Some(name) = name.to_str() else { continue };
120            if !name.starts_with("cron-instructions") || !name.ends_with(".json") {
121                continue;
122            }
123            if let Ok(contents) = std::fs::read_to_string(entry.path())
124                && let Ok(instructions) = serde_json::from_str::<CronInstructions>(&contents)
125            {
126                found.push(instructions);
127            }
128        }
129    }
130    found.sort_by_key(|i| i.phase);
131    found.dedup_by_key(|i| i.phase);
132    found
133}
134
135/// Remove a phase's cron-instructions record (and a legacy single-slot record
136/// naming the same phase). Idempotent.
137pub fn delete_cron_instructions(project_root: &Path, phase: u32) -> Result<(), ShipError> {
138    let path = cron_instructions_path(project_root, phase);
139    if path.exists() {
140        std::fs::remove_file(path)?;
141    }
142    let legacy = legacy_cron_instructions_path(project_root);
143    if legacy.exists()
144        && let Ok(contents) = std::fs::read_to_string(&legacy)
145        && serde_json::from_str::<CronInstructions>(&contents)
146            .map(|i| i.phase == phase)
147            .unwrap_or(true)
148    {
149        std::fs::remove_file(&legacy)?;
150    }
151    Ok(())
152}
153
154/// Build a Hermes cron-instructions manifest for resuming `sequentagent`.
155pub fn build_cron_instructions(
156    project_root: &Path,
157    phase: u32,
158    retry_after: &str,
159    next_agents: &str,
160) -> CronInstructions {
161    let project = project_root.display().to_string();
162    let args = vec![
163        "sequentagent".to_string(),
164        "--phase".to_string(),
165        phase.to_string(),
166        "--agents".to_string(),
167        next_agents.to_string(),
168    ];
169    CronInstructions {
170        project: project.clone(),
171        phase,
172        status: "rate_limited".to_string(),
173        retry_after: retry_after.to_string(),
174        resume: ResumeCommand {
175            command: "devflow".to_string(),
176            args,
177        },
178        hermes_cron: HermesCronJob {
179            schedule: cron_schedule_from_retry_after(retry_after).unwrap_or_default(),
180            name: format!("devflow-phase-{phase:02}-resume"),
181            command: format!(
182                "cd {} && devflow sequentagent --phase {phase} --agents {next_agents}",
183                shell_quote(&project)
184            ),
185            once: true,
186        },
187    }
188}
189
190/// Convert a retry timestamp to `M H D M W` cron syntax, rounding up to the
191/// nearest minute. Supports RFC3339-like timestamps and Unix epoch seconds.
192pub fn cron_schedule_from_retry_after(retry_after: &str) -> Option<String> {
193    // WR-06: never turn unparseable agent output into an every-minute cron.
194    parse_retry_timestamp(retry_after).map(|ts| ts.round_up_minute().to_cron())
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198struct RetryTimestamp {
199    year: i32,
200    month: u32,
201    day: u32,
202    hour: u32,
203    minute: u32,
204    second: u32,
205}
206
207impl RetryTimestamp {
208    fn round_up_minute(self) -> Self {
209        if self.second == 0 {
210            return self;
211        }
212        Self::from_epoch_minutes(self.to_epoch_minutes() + 1)
213    }
214
215    fn to_cron(self) -> String {
216        format!(
217            "{} {} {} {} *",
218            self.minute, self.hour, self.day, self.month
219        )
220    }
221
222    fn to_epoch_minutes(self) -> i64 {
223        let days = days_from_civil(self.year, self.month, self.day);
224        days * 24 * 60 + i64::from(self.hour) * 60 + i64::from(self.minute)
225    }
226
227    fn from_epoch_minutes(minutes: i64) -> Self {
228        let days = minutes.div_euclid(24 * 60);
229        let minute_of_day = minutes.rem_euclid(24 * 60);
230        let (year, month, day) = civil_from_days(days);
231        Self {
232            year,
233            month,
234            day,
235            hour: (minute_of_day / 60) as u32,
236            minute: (minute_of_day % 60) as u32,
237            second: 0,
238        }
239    }
240}
241
242fn parse_retry_timestamp(input: &str) -> Option<RetryTimestamp> {
243    parse_unix_seconds(input).or_else(|| parse_rfc3339ish(input))
244}
245
246fn parse_unix_seconds(input: &str) -> Option<RetryTimestamp> {
247    let seconds = input.trim().parse::<i64>().ok()?;
248    let minutes = seconds.div_euclid(60) + i64::from(seconds.rem_euclid(60) > 0);
249    Some(RetryTimestamp::from_epoch_minutes(minutes))
250}
251
252fn parse_rfc3339ish(input: &str) -> Option<RetryTimestamp> {
253    let input = input.trim();
254    let split_at = input.find('T').or_else(|| input.find(' '))?;
255    let (date, rest) = input.split_at(split_at);
256    let time = rest.get(1..)?;
257    let mut date_parts = date.split('-');
258    let year = date_parts.next()?.parse::<i32>().ok()?;
259    let month = date_parts.next()?.parse::<u32>().ok()?;
260    let day = date_parts.next()?.parse::<u32>().ok()?;
261    if date_parts.next().is_some() {
262        return None;
263    }
264
265    let (time, offset_minutes) = split_time_and_offset(time);
266    let mut time_parts = time.split(':');
267    let hour = time_parts.next()?.parse::<u32>().ok()?;
268    let minute = time_parts.next()?.parse::<u32>().ok()?;
269    let second = time_parts
270        .next()
271        .map(|s| s.split('.').next().unwrap_or_default().parse::<u32>().ok())
272        .unwrap_or(Some(0))?;
273    if month == 0 || month > 12 || day == 0 || day > 31 || hour > 23 || minute > 59 || second > 60 {
274        return None;
275    }
276
277    let ts = RetryTimestamp {
278        year,
279        month,
280        day,
281        hour,
282        minute,
283        second,
284    };
285    let utc_minutes = ts.to_epoch_minutes() - i64::from(offset_minutes);
286    let mut normalized = RetryTimestamp::from_epoch_minutes(utc_minutes);
287    // `to_epoch_minutes`/`from_epoch_minutes` normalize at whole-minute
288    // granularity (the offset subtraction above only ever shifts whole
289    // minutes, since `offset_minutes` is itself an integer minute count),
290    // so `from_epoch_minutes` always zeroes `second`. A timezone offset never
291    // carries a sub-minute component, so the original `second` is
292    // timezone-invariant and safe to restore verbatim here.
293    normalized.second = second;
294    Some(normalized)
295}
296
297fn split_time_and_offset(time: &str) -> (&str, i32) {
298    let trimmed = time.trim_end_matches('Z');
299    if trimmed.len() > 6 {
300        if let Some(idx) = trimmed.rfind('+') {
301            return (
302                &trimmed[..idx],
303                parse_offset_minutes(&trimmed[idx..]).unwrap_or(0),
304            );
305        }
306        if let Some(idx) = trimmed.rfind('-')
307            && idx > 0
308        {
309            return (
310                &trimmed[..idx],
311                parse_offset_minutes(&trimmed[idx..]).unwrap_or(0),
312            );
313        }
314    }
315    (trimmed, 0)
316}
317
318fn parse_offset_minutes(offset: &str) -> Option<i32> {
319    // WR-07 (13-REVIEW.md), revised: accept the three ISO-8601 offset forms
320    // — ±HH:MM, ±HHMM, and hour-only ±HH — with bound-checked values.
321    // Requiring a colon (the first WR-07 fix) silently rescheduled valid
322    // ±HH/±HHMM timestamps to UTC through the callers' `unwrap_or(0)`,
323    // firing the resume cron hours off; the original pre-WR-07 code misread
324    // ±HHMM as HHMM *hours*. Anything else (wrong digit count, out-of-range
325    // values) still fails safe as None. `retry_after` is raw agent output,
326    // so no producer guarantees one form.
327    const MAX_OFFSET_HOURS: i32 = 23;
328    const MAX_OFFSET_MINUTES: i32 = 59;
329    let sign = if offset.starts_with('-') { -1 } else { 1 };
330    let rest = offset.get(1..)?;
331    let (hours_part, minutes_part) = match rest.split_once(':') {
332        Some((hours, minutes)) => (hours, minutes),
333        None => match rest.len() {
334            2 => (rest, "0"),              // ±HH
335            4 => (&rest[..2], &rest[2..]), // ±HHMM
336            _ => return None,
337        },
338    };
339    let hours = hours_part.parse::<i32>().ok()?;
340    let minutes = minutes_part.parse::<i32>().ok()?;
341    if !(0..=MAX_OFFSET_HOURS).contains(&hours) || !(0..=MAX_OFFSET_MINUTES).contains(&minutes) {
342        return None;
343    }
344    Some(sign * (hours * 60 + minutes))
345}
346
347fn days_from_civil(year: i32, month: u32, day: u32) -> i64 {
348    let year = year - i32::from(month <= 2);
349    let era = i64::from(year).div_euclid(400);
350    let yoe = i64::from(year) - era * 400;
351    let month = i64::from(month);
352    let doy = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + i64::from(day) - 1;
353    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
354    era * 146_097 + doe - 719_468
355}
356
357fn civil_from_days(days: i64) -> (i32, u32, u32) {
358    let z = days + 719_468;
359    let era = z.div_euclid(146_097);
360    let doe = z - era * 146_097;
361    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096).div_euclid(365);
362    let year = yoe + era * 400;
363    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
364    let mp = (5 * doy + 2).div_euclid(153);
365    let day = doy - (153 * mp + 2).div_euclid(5) + 1;
366    let month = mp + if mp < 10 { 3 } else { -9 };
367    let year = year + i64::from(month <= 2);
368    (year as i32, month as u32, day as u32)
369}
370
371fn shell_quote(value: &str) -> String {
372    // Characters that never need quoting in a POSIX shell word: alphanumerics
373    // plus the common punctuation used in paths, versions, and identifiers
374    // (`/ . _ -`) and additional unambiguously-safe characters (`~ : @ + = %`)
375    // that have no special meaning to the shell when unquoted. Anything not
376    // in this set falls through to single-quote wrapping below, so widening
377    // this list only reduces over-quoting — it can never under-quote.
378    if value.chars().all(|c| {
379        c.is_ascii_alphanumeric()
380            || matches!(c, '/' | '.' | '_' | '-' | '~' | ':' | '@' | '+' | '=' | '%')
381    }) {
382        value.to_string()
383    } else {
384        format!("'{}'", value.replace('\'', "'\\''"))
385    }
386}
387
388/// Prepend a CHANGELOG entry for `version`, creating a standard header if the
389/// file did not exist. Pure transform over the existing CHANGELOG contents.
390pub fn prepend_changelog(existing: &str, version: &str, date: &str) -> String {
391    const HEADER: &str = "# Changelog\n\n\
392        All notable changes to this project are documented here.\n";
393    let entry = format!("## {version} — {date}\n\n- Released phase via DevFlow.\n");
394
395    if existing.trim().is_empty() {
396        return format!("{HEADER}\n{entry}");
397    }
398    // Insert the new entry after the header block (first blank line after the
399    // top-level title), or at the top if no header is recognized.
400    if let Some(idx) = existing.find("\n\n") {
401        let (head, tail) = existing.split_at(idx + 2);
402        format!("{head}{entry}\n{tail}")
403    } else {
404        format!("{entry}\n{existing}")
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn cron_instructions_save_load_round_trips() {
414        let dir = tempfile::tempdir().unwrap();
415        let record = build_cron_instructions(dir.path(), 7, "2026-06-18T15:45:30Z", "codex,claude");
416
417        write_cron_instructions(dir.path(), &record).unwrap();
418
419        assert_eq!(load_cron_instructions(dir.path(), 7).unwrap(), record);
420    }
421
422    #[test]
423    fn delete_cron_instructions_is_idempotent() {
424        let dir = tempfile::tempdir().unwrap();
425        let record = build_cron_instructions(dir.path(), 7, "2026-06-18T15:45:30Z", "codex,claude");
426        write_cron_instructions(dir.path(), &record).unwrap();
427
428        delete_cron_instructions(dir.path(), 7).unwrap();
429        assert!(!cron_instructions_path(dir.path(), 7).exists());
430        delete_cron_instructions(dir.path(), 7).unwrap();
431    }
432
433    /// 13-DEFERRED-CR-03 re-check: two phases' rate-limit records must
434    /// coexist — the old single-slot file let one clobber the other.
435    #[test]
436    fn cron_instructions_are_per_phase() {
437        let dir = tempfile::tempdir().unwrap();
438        let a = build_cron_instructions(dir.path(), 7, "2026-06-18T15:45:30Z", "claude,codex");
439        let b = build_cron_instructions(dir.path(), 8, "2026-06-18T16:45:30Z", "codex,claude");
440        write_cron_instructions(dir.path(), &a).unwrap();
441        write_cron_instructions(dir.path(), &b).unwrap();
442
443        assert_eq!(load_cron_instructions(dir.path(), 7).unwrap(), a);
444        assert_eq!(load_cron_instructions(dir.path(), 8).unwrap(), b);
445        let listed = list_cron_instructions(dir.path());
446        assert_eq!(listed.iter().map(|i| i.phase).collect::<Vec<_>>(), [7, 8]);
447
448        delete_cron_instructions(dir.path(), 7).unwrap();
449        assert!(load_cron_instructions(dir.path(), 7).is_err());
450        assert_eq!(load_cron_instructions(dir.path(), 8).unwrap(), b);
451    }
452
453    /// Upgrade path: a legacy single-slot `cron-instructions.json` written by
454    /// an older binary is still loadable/listable/deletable for its phase.
455    #[test]
456    fn legacy_cron_instructions_are_read_and_deleted() {
457        let dir = tempfile::tempdir().unwrap();
458        let record = build_cron_instructions(dir.path(), 5, "2026-06-18T15:45:30Z", "claude,codex");
459        let legacy = legacy_cron_instructions_path(dir.path());
460        std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
461        std::fs::write(&legacy, serde_json::to_string_pretty(&record).unwrap()).unwrap();
462
463        assert_eq!(load_cron_instructions(dir.path(), 5).unwrap(), record);
464        assert!(load_cron_instructions(dir.path(), 6).is_err());
465        assert_eq!(list_cron_instructions(dir.path()).len(), 1);
466
467        delete_cron_instructions(dir.path(), 5).unwrap();
468        assert!(!legacy.exists());
469    }
470
471    #[test]
472    fn cron_schedule_rounds_up_to_nearest_minute() {
473        assert_eq!(
474            cron_schedule_from_retry_after("2026-06-18T15:45:30Z"),
475            Some("46 15 18 6 *".to_string())
476        );
477        assert_eq!(
478            cron_schedule_from_retry_after("2026-06-18T15:45:00Z"),
479            Some("45 15 18 6 *".to_string())
480        );
481    }
482
483    #[test]
484    fn cron_schedule_normalizes_negative_offset() {
485        // 15:45:30 local at UTC-5 → 20:45:30 UTC → round up to 20:46.
486        assert_eq!(
487            cron_schedule_from_retry_after("2026-06-18T15:45:30-05:00"),
488            Some("46 20 18 6 *".to_string())
489        );
490        // 15:45:00 local at UTC-5:30 → 21:15:00 UTC, no rounding needed.
491        assert_eq!(
492            cron_schedule_from_retry_after("2026-06-18T15:45:00-05:30"),
493            Some("15 21 18 6 *".to_string())
494        );
495    }
496
497    /// WR-07 (13-REVIEW.md), revised: all three ISO-8601 offset forms must
498    /// parse to their real value. The pre-WR-07 code misread "+0530" as 530
499    /// *hours*; the first WR-07 fix rejected everything without a colon, so
500    /// valid ±HHMM and hour-only ±HH offsets silently fell back to UTC via
501    /// `split_time_and_offset`'s `unwrap_or(0)` — scheduling the resume cron
502    /// hours away from when the rate limit actually lifts.
503    #[test]
504    fn cron_schedule_parses_all_iso8601_offset_forms() {
505        // ±HHMM: 15:45:30 at +05:30 → 10:15:30 UTC → 10:16 (seconds round up).
506        assert_eq!(
507            cron_schedule_from_retry_after("2026-06-18T15:45:30+0530"),
508            cron_schedule_from_retry_after("2026-06-18T15:45:30+05:30"),
509        );
510        // Hour-only ±HH: 15:45:30 at -05 → 20:45:30 UTC → 20:46.
511        assert_eq!(
512            cron_schedule_from_retry_after("2026-06-18T15:45:30-05"),
513            Some("46 20 18 6 *".to_string())
514        );
515    }
516
517    #[test]
518    fn parse_offset_minutes_bounds_and_forms() {
519        assert_eq!(parse_offset_minutes("+05:30"), Some(330));
520        assert_eq!(parse_offset_minutes("+0530"), Some(330));
521        assert_eq!(parse_offset_minutes("-0530"), Some(-330));
522        assert_eq!(parse_offset_minutes("+05"), Some(300));
523        assert_eq!(parse_offset_minutes("-05"), Some(-300));
524        // Out-of-range and wrong digit counts fail safe.
525        assert_eq!(parse_offset_minutes("+24"), None);
526        assert_eq!(parse_offset_minutes("+05:60"), None);
527        assert_eq!(parse_offset_minutes("+5"), None);
528        assert_eq!(parse_offset_minutes("+530"), None);
529        assert_eq!(parse_offset_minutes("+abcd"), None);
530    }
531
532    #[test]
533    fn cron_schedule_formats_unix_seconds() {
534        assert_eq!(
535            cron_schedule_from_retry_after("1766678401"),
536            Some("1 16 25 12 *".to_string())
537        );
538    }
539
540    #[test]
541    fn shell_quote_leaves_common_safe_chars_unquoted() {
542        assert_eq!(
543            shell_quote("user@host:1.2.3+build"),
544            "user@host:1.2.3+build"
545        );
546        assert_eq!(shell_quote("~/proj/build=1_2%3"), "~/proj/build=1_2%3");
547    }
548
549    #[test]
550    fn shell_quote_quotes_unsafe_input() {
551        assert_eq!(shell_quote("a b"), "'a b'");
552        assert_eq!(shell_quote("it's"), "'it'\\''s'");
553    }
554
555    #[test]
556    fn cron_instructions_include_resume_command() {
557        let dir = tempfile::tempdir().unwrap();
558        let record = build_cron_instructions(dir.path(), 7, "2026-06-18T15:45:30Z", "codex,claude");
559
560        assert_eq!(record.resume.command, "devflow");
561        assert_eq!(
562            record.resume.args,
563            ["sequentagent", "--phase", "7", "--agents", "codex,claude"]
564        );
565        assert!(
566            record
567                .hermes_cron
568                .command
569                .contains("devflow sequentagent --phase 7 --agents codex,claude")
570        );
571        assert!(record.hermes_cron.once);
572    }
573
574    #[test]
575    fn cron_instructions_reject_unparseable_retry_time() {
576        let dir = tempfile::tempdir().unwrap();
577        let record = build_cron_instructions(dir.path(), 7, "unknown", "codex,claude");
578
579        assert_ne!(record.hermes_cron.schedule, "* * * * *");
580        assert!(record.hermes_cron.schedule.is_empty());
581    }
582
583    #[test]
584    fn prepend_changelog_creates_header_when_empty() {
585        let out = prepend_changelog("", "0.5.2", "2026-06-18");
586        assert!(out.starts_with("# Changelog"));
587        assert!(out.contains("## 0.5.2 — 2026-06-18"));
588    }
589
590    #[test]
591    fn prepend_changelog_inserts_after_header() {
592        let existing = "# Changelog\n\n## 0.5.1 — 2026-06-17\n\n- old\n";
593        let out = prepend_changelog(existing, "0.5.2", "2026-06-18");
594        let new_idx = out.find("0.5.2").unwrap();
595        let old_idx = out.find("0.5.1").unwrap();
596        assert!(new_idx < old_idx, "new entry should come before old");
597        assert!(out.starts_with("# Changelog"));
598    }
599}