kbolt-core 0.1.7

Core engine for kbolt local-first retrieval
Documentation
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};

use kbolt_types::{KboltError, ScheduleDefinition};
use serde::{Deserialize, Serialize};

use crate::error::Result;

const SCHEDULES_FILENAME: &str = "schedules.toml";

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct ScheduleCatalog {
    #[serde(default = "default_next_id")]
    pub(crate) next_id: u32,
    #[serde(default)]
    pub(crate) schedules: Vec<ScheduleDefinition>,
}

impl Default for ScheduleCatalog {
    fn default() -> Self {
        Self {
            next_id: default_next_id(),
            schedules: Vec::new(),
        }
    }
}

impl ScheduleCatalog {
    pub(crate) fn load(config_dir: &Path) -> Result<Self> {
        fs::create_dir_all(config_dir)?;

        let schedule_file = Self::file_path(config_dir);
        let raw = match fs::read_to_string(&schedule_file) {
            Ok(raw) => raw,
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
            Err(err) => return Err(err.into()),
        };

        let mut catalog: Self = toml::from_str(&raw).map_err(|err| {
            KboltError::Config(format!(
                "invalid schedule file {}: {err}",
                schedule_file.display()
            ))
        })?;
        catalog.normalize(&schedule_file)?;
        Ok(catalog)
    }

    pub(crate) fn save(&self, config_dir: &Path) -> Result<()> {
        fs::create_dir_all(config_dir)?;

        let schedule_file = Self::file_path(config_dir);
        let mut normalized = self.clone();
        normalized.normalize(&schedule_file)?;

        let serialized = toml::to_string_pretty(&normalized)?;
        let content = if serialized.ends_with('\n') {
            serialized
        } else {
            format!("{serialized}\n")
        };

        let tmp_file = config_dir.join(format!("{SCHEDULES_FILENAME}.tmp"));
        fs::write(&tmp_file, content)?;
        fs::rename(tmp_file, schedule_file)?;
        Ok(())
    }

    pub(crate) fn file_path(config_dir: &Path) -> PathBuf {
        config_dir.join(SCHEDULES_FILENAME)
    }

    fn normalize(&mut self, schedule_file: &Path) -> Result<()> {
        let mut seen_ids = HashSet::new();
        let mut max_schedule_id = 0u32;

        for schedule in &self.schedules {
            let id_number = parse_schedule_id(&schedule.id, schedule_file)?;
            if !seen_ids.insert(id_number) {
                return Err(KboltError::Config(format!(
                    "invalid schedule file {}: duplicate schedule id {}",
                    schedule_file.display(),
                    schedule.id
                ))
                .into());
            }
            max_schedule_id = max_schedule_id.max(id_number);
        }

        let recovered_next_id = max_schedule_id
            .checked_add(1)
            .ok_or_else(|| {
                KboltError::Config(format!(
                    "invalid schedule file {}: schedule ids exceeded supported range",
                    schedule_file.display()
                ))
            })?
            .max(default_next_id());

        self.next_id = self.next_id.max(recovered_next_id);
        Ok(())
    }
}

fn default_next_id() -> u32 {
    1
}

fn parse_schedule_id(id: &str, schedule_file: &Path) -> Result<u32> {
    let Some(raw_number) = id.strip_prefix('s') else {
        return Err(invalid_schedule_id_error(schedule_file, id).into());
    };

    let parsed = raw_number
        .parse::<u32>()
        .map_err(|_| invalid_schedule_id_error(schedule_file, id))?;

    if parsed == 0 {
        return Err(invalid_schedule_id_error(schedule_file, id).into());
    }

    Ok(parsed)
}

fn invalid_schedule_id_error(schedule_file: &Path, id: &str) -> KboltError {
    KboltError::Config(format!(
        "invalid schedule file {}: schedule ids must use the form s<number>: {id}",
        schedule_file.display()
    ))
}

#[cfg(test)]
mod tests {
    use std::fs;

    use kbolt_types::{
        ScheduleDefinition, ScheduleInterval, ScheduleIntervalUnit, ScheduleScope, ScheduleTrigger,
        ScheduleWeekday,
    };
    use tempfile::tempdir;

    use super::{ScheduleCatalog, SCHEDULES_FILENAME};

    #[test]
    fn load_returns_default_catalog_when_schedule_file_is_missing() {
        let tmp = tempdir().expect("create tempdir");
        let config_dir = tmp.path().join("config");

        let catalog = ScheduleCatalog::load(&config_dir).expect("load catalog");

        assert_eq!(catalog, ScheduleCatalog::default());
        assert!(config_dir.is_dir());
        assert!(!config_dir.join(SCHEDULES_FILENAME).exists());
    }

    #[test]
    fn save_and_load_roundtrip_schedule_catalog() {
        let tmp = tempdir().expect("create tempdir");
        let config_dir = tmp.path().join("config");
        let catalog = ScheduleCatalog {
            next_id: 4,
            schedules: vec![
                schedule_definition(
                    "s1",
                    ScheduleTrigger::Every {
                        interval: ScheduleInterval {
                            value: 30,
                            unit: ScheduleIntervalUnit::Minutes,
                        },
                    },
                    ScheduleScope::All,
                ),
                schedule_definition(
                    "s3",
                    ScheduleTrigger::Weekly {
                        weekdays: vec![ScheduleWeekday::Mon, ScheduleWeekday::Fri],
                        time: "15:00".to_string(),
                    },
                    ScheduleScope::Collections {
                        space: "work".to_string(),
                        collections: vec!["api".to_string(), "docs".to_string()],
                    },
                ),
            ],
        };

        catalog.save(&config_dir).expect("save catalog");
        let loaded = ScheduleCatalog::load(&config_dir).expect("load catalog");

        assert_eq!(loaded, catalog);
    }

    #[test]
    fn load_recovers_next_id_from_existing_schedule_ids() {
        let tmp = tempdir().expect("create tempdir");
        let config_dir = tmp.path().join("config");
        let schedule_file = config_dir.join(SCHEDULES_FILENAME);
        fs::create_dir_all(&config_dir).expect("create config dir");
        fs::write(
            &schedule_file,
            r#"
next_id = 1

[[schedules]]
id = "s2"

[schedules.trigger]
kind = "daily"
time = "03:00"

[schedules.scope]
kind = "space"
space = "work"

[[schedules]]
id = "s5"

[schedules.trigger]
kind = "weekly"
weekdays = ["mon", "fri"]
time = "15:00"

[schedules.scope]
kind = "collections"
space = "work"
collections = ["api", "docs"]
"#,
        )
        .expect("write schedule file");

        let catalog = ScheduleCatalog::load(&config_dir).expect("load catalog");

        assert_eq!(catalog.next_id, 6);
        assert_eq!(catalog.schedules.len(), 2);
    }

    #[test]
    fn load_rejects_invalid_schedule_id() {
        let tmp = tempdir().expect("create tempdir");
        let config_dir = tmp.path().join("config");
        let schedule_file = config_dir.join(SCHEDULES_FILENAME);
        fs::create_dir_all(&config_dir).expect("create config dir");
        fs::write(
            &schedule_file,
            r#"
next_id = 2

[[schedules]]
id = "schedule-1"

[schedules.trigger]
kind = "every"

[schedules.trigger.interval]
value = 30
unit = "minutes"

[schedules.scope]
kind = "all"
"#,
        )
        .expect("write schedule file");

        let err = ScheduleCatalog::load(&config_dir).expect_err("invalid schedule id should fail");
        let message = err.to_string();

        assert!(
            message.contains("invalid schedule file"),
            "unexpected message: {message}"
        );
        assert!(
            message.contains(&schedule_file.display().to_string()),
            "unexpected message: {message}"
        );
        assert!(
            message.contains("schedule ids must use the form s<number>"),
            "unexpected message: {message}"
        );
    }

    #[test]
    fn load_rejects_duplicate_schedule_ids() {
        let tmp = tempdir().expect("create tempdir");
        let config_dir = tmp.path().join("config");
        let schedule_file = config_dir.join(SCHEDULES_FILENAME);
        fs::create_dir_all(&config_dir).expect("create config dir");
        fs::write(
            &schedule_file,
            r#"
next_id = 3

[[schedules]]
id = "s1"

[schedules.trigger]
kind = "daily"
time = "03:00"

[schedules.scope]
kind = "all"

[[schedules]]
id = "s1"

[schedules.trigger]
kind = "daily"
time = "15:00"

[schedules.scope]
kind = "space"
space = "work"
"#,
        )
        .expect("write schedule file");

        let err = ScheduleCatalog::load(&config_dir).expect_err("duplicate ids should fail");
        let message = err.to_string();

        assert!(
            message.contains("duplicate schedule id s1"),
            "unexpected message: {message}"
        );
    }

    fn schedule_definition(
        id: &str,
        trigger: ScheduleTrigger,
        scope: ScheduleScope,
    ) -> ScheduleDefinition {
        ScheduleDefinition {
            id: id.to_string(),
            trigger,
            scope,
        }
    }
}