fundaia 0.52.2

Command line for the Fundaia deployment platform: projects, services, variables, deployments, logs and metrics
pub mod history;

use crate::update::version::Version;

const CHANGELOG: &str = include_str!("../../CHANGELOG.md");

#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Release {
    pub version: String,
    pub date: Option<String>,
    pub body: String,
}

pub fn releases() -> Vec<Release> {
    parse(CHANGELOG)
}

pub fn parse(text: &str) -> Vec<Release> {
    let mut releases: Vec<Release> = Vec::new();

    for line in text.lines() {
        match heading(line) {
            Some((version, date)) => releases.push(Release {
                version,
                date,
                body: String::new(),
            }),
            None => {
                if let Some(current) = releases.last_mut() {
                    current.body.push_str(line);
                    current.body.push('\n');
                }
            }
        }
    }

    for release in &mut releases {
        release.body = release.body.trim().to_string()
    }

    releases
}

fn heading(line: &str) -> Option<(String, Option<String>)> {
    let rest = line.strip_prefix("## ")?.trim().strip_prefix('v')?;

    let (number, tail) = match rest.find(char::is_whitespace) {
        Some(at) => rest.split_at(at),
        None => (rest, ""),
    };

    Version::parse(number)?;

    let date = tail
        .trim_start_matches(|c: char| c.is_whitespace() || c == '—' || c == '-' || c == '·')
        .trim();

    Some((
        number.to_string(),
        (!date.is_empty()).then(|| date.to_string()),
    ))
}

pub fn only(releases: Vec<Release>, wanted: &str) -> Option<Release> {
    let wanted = wanted.trim().trim_start_matches('v');
    releases
        .into_iter()
        .find(|release| release.version == wanted)
}

pub fn since(releases: Vec<Release>, floor: &str) -> Vec<Release> {
    let Some(floor) = Version::parse(floor.trim().trim_start_matches('v')) else {
        return Vec::new();
    };

    releases
        .into_iter()
        .filter(|release| Version::parse(&release.version).is_some_and(|version| version > floor))
        .collect()
}

pub fn running(releases: Vec<Release>, current: &str) -> Option<Release> {
    let current = current.trim().trim_start_matches('v');
    match releases
        .iter()
        .position(|release| release.version == current)
    {
        Some(at) => releases.into_iter().nth(at),
        None => releases.into_iter().next(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    const SAMPLE: &str = "# Changelog\n\nPreamble nobody asked for.\n\n## Unreleased\n\n### Something in flight\n\n## v0.18.0 — 2026-08-13\n\nThe canvas, from the terminal.\n\n### A sub-heading\n\nMore.\n\n## v0.17.0 — 2026-08-05\n\nWhat the agent is being asked.\n";

    #[test]
    fn it_should_read_every_published_version() {
        assert_eq!(parse(SAMPLE).len(), 2);
    }

    #[test]
    fn it_should_read_the_newest_version_first() {
        assert_eq!(parse(SAMPLE)[0].version, "0.18.0");
    }

    #[test]
    fn it_should_read_the_date_out_of_the_heading() {
        assert_eq!(parse(SAMPLE)[0].date.as_deref(), Some("2026-08-13"));
    }

    #[test]
    fn it_should_leave_the_unreleased_section_out() {
        assert!(!parse(SAMPLE)
            .iter()
            .any(|entry| entry.version == "Unreleased"));
    }

    #[test]
    fn it_should_keep_a_sub_heading_inside_the_entry_it_belongs_to() {
        assert!(parse(SAMPLE)[0].body.contains("### A sub-heading"));
    }

    #[test]
    fn it_should_leave_the_preamble_out_of_every_entry() {
        assert!(!parse(SAMPLE)
            .iter()
            .any(|entry| entry.body.contains("Preamble")));
    }

    #[test]
    fn it_should_read_a_heading_that_carries_no_date() {
        assert_eq!(parse("## v1.0.0\n\nShipped.\n")[0].date, None);
    }

    #[test]
    fn it_should_ignore_a_heading_whose_number_is_not_one() {
        assert!(parse("## vNext\n\nSoon.\n").is_empty());
    }

    #[test]
    fn it_should_find_one_version_by_its_number() {
        assert_eq!(
            only(parse(SAMPLE), "0.17.0").map(|entry| entry.version),
            Some("0.17.0".to_string())
        );
    }

    #[test]
    fn it_should_find_a_version_written_with_its_v() {
        assert!(only(parse(SAMPLE), "v0.17.0").is_some());
    }

    #[test]
    fn it_should_list_what_came_after_a_version() {
        assert_eq!(since(parse(SAMPLE), "0.17.0").len(), 1);
    }

    #[test]
    fn it_should_leave_out_the_version_somebody_was_already_running() {
        assert!(!since(parse(SAMPLE), "0.17.0")
            .iter()
            .any(|entry| entry.version == "0.17.0"));
    }

    #[test]
    fn it_should_list_nothing_after_a_version_that_is_not_one() {
        assert!(since(parse(SAMPLE), "las últimas").is_empty());
    }

    #[test]
    fn it_should_answer_with_the_entry_of_the_running_binary() {
        assert_eq!(
            running(parse(SAMPLE), "0.17.0").map(|entry| entry.version),
            Some("0.17.0".to_string())
        );
    }

    #[test]
    fn it_should_fall_back_to_the_newest_entry_it_knows() {
        assert_eq!(
            running(parse(SAMPLE), "9.9.9").map(|entry| entry.version),
            Some("0.18.0".to_string())
        );
    }

    #[test]
    fn it_should_carry_its_own_changelog() {
        assert!(!releases().is_empty());
    }

    #[test]
    fn it_should_not_describe_a_version_newer_than_this_binary() {
        let newest = Version::parse(&releases()[0].version).expect("the headings carry versions");
        let mine = Version::parse(env!("CARGO_PKG_VERSION")).expect("the manifest carries one");

        assert!(newest <= mine);
    }
}