fundaia 0.26.0

Command line for the Fundaia deployment platform: projects, services, variables, deployments, logs and metrics
//! What each version brought, read out of the file that ships inside this
//! binary.
//!
//! `include_str!` rather than a fetch, and that is the whole design. The
//! binaries hang off a **private** repository — GitHub serves those release
//! assets to nobody without a credential, which is why `update/install.rs`
//! falls through to `cargo install` — so a changelog that had to be downloaded
//! would be one that fails to arrive on exactly the machines this tool runs on.
//! Carried instead, it costs 8 KiB in the executable and answers with no
//! network, no token and no server.
//!
//! What it therefore cannot show is a version *newer* than this binary. That is
//! not a gap: the tool updates itself while it is being used, so the version
//! somebody wants the notes for is nearly always the one that just replaced
//! itself under them.

pub mod history;

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

/// The file itself, at compile time. The path is relative to this source file,
/// so moving the module moves the reference with it.
const CHANGELOG: &str = include_str!("../../CHANGELOG.md");

/// One published version and everything written under its heading.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Release {
    pub version: String,
    /// Absent when the heading carried no date, which the parser tolerates
    /// rather than refuses — a missing date is not a reason to hide an entry.
    pub date: Option<String>,
    pub body: String,
}

/// Every release in the file, newest first, which is the order it is written in.
pub fn releases() -> Vec<Release> {
    parse(CHANGELOG)
}

/// The headings that count, and the one that deliberately does not.
///
/// `## Unreleased` is skipped. In a published binary it is always empty — the
/// commit hook stamps it with the number it just calculated and opens a fresh
/// one above — so the only build where it holds anything is somebody's working
/// copy, and offering "Unreleased" as an answer to "what does 0.18.0 bring"
/// would be answering with a version that does not exist.
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(),
            }),
            // Everything until the next heading belongs to the last one, and
            // anything before the first heading belongs to nobody.
            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
}

/// `## v0.18.0 — 2026-08-13`, or the same without the date.
///
/// Anchored at `## ` and at the `v`, so a `### ` inside an entry is body and
/// stays body. The separator is matched as "whatever is left after the number"
/// rather than as an em dash, because the file is written by hand and a hyphen
/// typed on a day somebody was in a hurry should not lose a version.
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, ""),
    };

    // A heading whose number is not one is a heading that says something else.
    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()),
    ))
}

/// The entry for one version, by its number, with or without the `v`.
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)
}

/// Everything published after a version — what somebody upgrading *from* it got.
///
/// Exclusive of the version named, because that one is the code they were
/// already running.
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()
}

/// What the running binary brings, or the newest entry when the file has never
/// heard of it.
///
/// The fallback is the case of a binary built from a working copy: its manifest
/// has already been raised past everything written down. Showing the newest
/// entry beats showing nothing, and the caller says which of the two happened.
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"));
    }

    /// It holds nothing in a published binary, and a version nobody can install
    /// is the wrong answer to every question this module gets asked.
    #[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())
        );
    }

    /// `fundaia --version` prints one with no `v`; a tag carries one. Both are
    /// what somebody pastes.
    #[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())
        );
    }

    /// A working copy whose manifest was raised past the file it carries.
    #[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());
    }

    /// The file travels inside the binary, so it describes the version it was
    /// built as or something older — never something newer, which would be
    /// notes for a release nobody has made.
    #[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);
    }
}