fundaia 0.26.0

Command line for the Fundaia deployment platform: projects, services, variables, deployments, logs and metrics
//! Which versions this machine has actually run, and since when.
//!
//! A different question from the changelog, and worth keeping apart from it:
//! the changelog says what was published, this says what was *here*. It is the
//! one that answers "since when have I had this behaviour" — after a tool that
//! replaces itself while you use it, that is not a question anybody can answer
//! from memory.
//!
//! **Written on startup rather than on upgrade**, which is the decision that
//! makes it true. A binary arrives three ways — `cargo install`, `install.sh`
//! and the self-update — and only the third passes through the code that
//! installs. Recording what is *running* covers all three, and it is also the
//! only reading that survives somebody downgrading on purpose.
//!
//! A row is appended only when the version changed, so the common invocation
//! reads a small file and writes nothing.

use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

use serde::{Deserialize, Serialize};

use crate::config::config_path;

/// How many are kept. Long enough to cover months of a tool that updates itself
/// daily, short enough that the file stays something a person can read.
const KEPT: usize = 50;

/// One version, and the first time this machine ran it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Seen {
    pub version: String,
    /// Unix seconds. Absent in a file written by a machine with no clock worth
    /// trusting, and rendered as "sin fecha" rather than as 1970.
    #[serde(default)]
    pub at: u64,
}

#[derive(Debug, Default, Serialize, Deserialize)]
struct Log {
    #[serde(default)]
    seen: Vec<Seen>,
}

/// Notes that this version is running, if it is not already the last one noted.
///
/// Every failure is swallowed: a read-only home directory, a full disk or a
/// file somebody hand-edited into nonsense are not reasons for a deploy command
/// to fail. The worst case is a history with a gap in it.
pub fn record(version: &str) {
    let Some(path) = log_path() else { return };

    let mut log = read(&path);
    if log.seen.last().is_some_and(|last| last.version == version) {
        return;
    }

    log.seen.push(Seen {
        version: version.to_owned(),
        at: now_seconds(),
    });

    // From the front, so what is dropped is the oldest.
    if log.seen.len() > KEPT {
        log.seen.drain(..log.seen.len() - KEPT);
    }

    write(&path, &log);
}

/// What has run here, newest first.
pub fn seen() -> Vec<Seen> {
    let Some(path) = log_path() else {
        return Vec::new();
    };

    let mut history = read(&path).seen;
    history.reverse();
    history
}

/// Anything unreadable is treated as empty, exactly as the update cache is: this
/// file is a convenience the tool wrote itself, and refusing to run over it
/// would be absurd.
fn read(path: &Path) -> Log {
    std::fs::read_to_string(path)
        .ok()
        .and_then(|text| toml::from_str(&text).ok())
        .unwrap_or_default()
}

fn write(path: &Path, log: &Log) -> Option<()> {
    let directory = path.parent()?;
    std::fs::create_dir_all(directory).ok()?;
    std::fs::write(path, toml::to_string(log).ok()?).ok()
}

/// Beside the profiles and the update stamp, so `FUNDAIA_CONFIG` moves all
/// three together.
fn log_path() -> Option<PathBuf> {
    Some(beside(&config_path().ok()?))
}

fn beside(config: &Path) -> PathBuf {
    config.with_file_name("versions.toml")
}

fn now_seconds() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|since| since.as_secs())
        .unwrap_or(0)
}

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

    fn log_of(versions: &[&str]) -> Log {
        Log {
            seen: versions
                .iter()
                .map(|version| Seen {
                    version: (*version).to_string(),
                    at: 1,
                })
                .collect(),
        }
    }

    /// The rule `record` turns on, tested where it can be: the same version
    /// twice in a row is one row.
    fn would_append(log: &Log, version: &str) -> bool {
        log.seen.last().is_none_or(|last| last.version != version)
    }

    #[test]
    fn it_should_note_a_version_it_has_never_seen() {
        assert!(would_append(&log_of(&["0.17.0"]), "0.18.0"));
    }

    #[test]
    fn it_should_not_note_the_version_it_is_already_on() {
        assert!(!would_append(&log_of(&["0.17.0", "0.18.0"]), "0.18.0"));
    }

    /// Somebody who pins an older binary on purpose has run it, and the history
    /// is about what ran rather than about what is newest.
    #[test]
    fn it_should_note_a_version_that_is_older_than_the_last_one() {
        assert!(would_append(&log_of(&["0.18.0"]), "0.17.0"));
    }

    #[test]
    fn it_should_keep_the_file_readable_by_a_person() {
        let text = toml::to_string(&log_of(&["0.18.0"])).expect("the log serialises");
        assert!(text.contains("version = \"0.18.0\""));
    }

    #[test]
    fn it_should_read_back_what_it_wrote() {
        let text = toml::to_string(&log_of(&["0.18.0"])).expect("the log serialises");
        let read: Log = toml::from_str(&text).expect("what it wrote is what it reads");

        assert_eq!(read.seen[0].version, "0.18.0");
    }

    #[test]
    fn it_should_treat_a_corrupt_log_as_empty() {
        assert!(toml::from_str::<Log>("this is not toml {{").is_err());
    }

    #[test]
    fn it_should_keep_the_log_beside_the_profiles() {
        assert_eq!(
            beside(Path::new("/somewhere/fundaia/config.toml")),
            PathBuf::from("/somewhere/fundaia/versions.toml"),
        );
    }
}