Skip to main content

sloc_git/
schedule.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (C) 2026 Nima Shafie <nimzshafie@gmail.com>
3
4use std::path::Path;
5
6use anyhow::Result;
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use uuid::Uuid;
10
11// ── enums ─────────────────────────────────────────────────────────────────────
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
14#[serde(rename_all = "snake_case")]
15pub enum ScanScheduleKind {
16    Webhook,
17    Poll,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21pub enum ScanScheduleProvider {
22    #[serde(rename = "github")]
23    GitHub,
24    #[serde(rename = "gitlab")]
25    GitLab,
26    #[serde(rename = "bitbucket")]
27    Bitbucket,
28    #[serde(rename = "any")]
29    Any,
30}
31
32impl ScanScheduleProvider {
33    #[must_use]
34    pub const fn display_name(&self) -> &'static str {
35        match self {
36            Self::GitHub => "GitHub",
37            Self::GitLab => "GitLab",
38            Self::Bitbucket => "Bitbucket",
39            Self::Any => "Any / Poll",
40        }
41    }
42}
43
44// ── schedule ──────────────────────────────────────────────────────────────────
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct ScanSchedule {
48    pub id: Uuid,
49    pub label: String,
50    pub repo_url: String,
51    pub branch: String,
52    pub kind: ScanScheduleKind,
53    pub provider: ScanScheduleProvider,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub webhook_secret: Option<String>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub interval_secs: Option<u64>,
58    pub last_scan_sha: Option<String>,
59    pub last_scan_at: Option<DateTime<Utc>>,
60    pub last_run_id: Option<String>,
61    /// Idempotency marker for `/webhooks/ci`: the `<system>:<job>:<build_id>`
62    /// key of the most recently processed upstream build. A repeat delivery of
63    /// the same key is treated as a duplicate and does not re-trigger a scan.
64    #[serde(default)]
65    pub last_ci_build: Option<String>,
66    pub enabled: bool,
67}
68
69impl ScanSchedule {
70    #[must_use]
71    pub fn new_webhook(
72        repo_url: String,
73        branch: String,
74        provider: ScanScheduleProvider,
75        label: String,
76        webhook_secret: Option<String>,
77    ) -> Self {
78        Self {
79            id: Uuid::new_v4(),
80            label,
81            repo_url,
82            branch,
83            kind: ScanScheduleKind::Webhook,
84            provider,
85            webhook_secret: Some(webhook_secret.unwrap_or_else(generate_secret)),
86            interval_secs: None,
87            last_scan_sha: None,
88            last_scan_at: None,
89            last_run_id: None,
90            last_ci_build: None,
91            enabled: true,
92        }
93    }
94
95    #[must_use]
96    pub fn new_poll(repo_url: String, branch: String, interval_secs: u64, label: String) -> Self {
97        Self {
98            id: Uuid::new_v4(),
99            label,
100            repo_url,
101            branch,
102            kind: ScanScheduleKind::Poll,
103            provider: ScanScheduleProvider::Any,
104            webhook_secret: None,
105            interval_secs: Some(interval_secs),
106            last_scan_sha: None,
107            last_scan_at: None,
108            last_run_id: None,
109            last_ci_build: None,
110            enabled: true,
111        }
112    }
113}
114
115fn generate_secret() -> String {
116    format!("{}-{}", Uuid::new_v4().simple(), Uuid::new_v4().simple())
117}
118
119// ── store ─────────────────────────────────────────────────────────────────────
120
121#[derive(Debug, Default, Serialize, Deserialize)]
122pub struct ScheduleStore {
123    pub schedules: Vec<ScanSchedule>,
124}
125
126impl ScheduleStore {
127    #[must_use]
128    pub fn load(path: &Path) -> Self {
129        std::fs::read_to_string(path)
130            .ok()
131            .and_then(|s| serde_json::from_str(&s).ok())
132            .unwrap_or_default()
133    }
134
135    /// # Errors
136    /// Returns an error if serialization or writing to disk fails.
137    pub fn save(&self, path: &Path) -> Result<()> {
138        let json = serde_json::to_string_pretty(self)?;
139        std::fs::write(path, json)?;
140        Ok(())
141    }
142
143    #[must_use]
144    pub fn find_matching<'a>(&'a self, repo_url: &str, branch: &str) -> Vec<&'a ScanSchedule> {
145        self.schedules
146            .iter()
147            .filter(|s| s.enabled && urls_match(&s.repo_url, repo_url) && s.branch == branch)
148            .collect()
149    }
150
151    pub fn by_id_mut(&mut self, id: Uuid) -> Option<&mut ScanSchedule> {
152        self.schedules.iter_mut().find(|s| s.id == id)
153    }
154
155    pub fn remove(&mut self, id: Uuid) {
156        self.schedules.retain(|s| s.id != id);
157    }
158}
159
160fn urls_match(a: &str, b: &str) -> bool {
161    normalize_url(a) == normalize_url(b)
162}
163
164fn normalize_url(url: &str) -> String {
165    url.trim_end_matches('/')
166        .trim_end_matches(".git")
167        .to_lowercase()
168}