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)]
21#[serde(rename_all = "snake_case")]
22pub enum ScanScheduleProvider {
23    GitHub,
24    GitLab,
25    Bitbucket,
26    Any,
27}
28
29impl ScanScheduleProvider {
30    pub fn display_name(&self) -> &'static str {
31        match self {
32            Self::GitHub => "GitHub",
33            Self::GitLab => "GitLab",
34            Self::Bitbucket => "Bitbucket",
35            Self::Any => "Any / Poll",
36        }
37    }
38}
39
40// ── schedule ──────────────────────────────────────────────────────────────────
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct ScanSchedule {
44    pub id: Uuid,
45    pub label: String,
46    pub repo_url: String,
47    pub branch: String,
48    pub kind: ScanScheduleKind,
49    pub provider: ScanScheduleProvider,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub webhook_secret: Option<String>,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub interval_secs: Option<u64>,
54    pub last_scan_sha: Option<String>,
55    pub last_scan_at: Option<DateTime<Utc>>,
56    pub last_run_id: Option<String>,
57    pub enabled: bool,
58}
59
60impl ScanSchedule {
61    pub fn new_webhook(
62        repo_url: String,
63        branch: String,
64        provider: ScanScheduleProvider,
65        label: String,
66    ) -> Self {
67        Self {
68            id: Uuid::new_v4(),
69            label,
70            repo_url,
71            branch,
72            kind: ScanScheduleKind::Webhook,
73            provider,
74            webhook_secret: Some(generate_secret()),
75            interval_secs: None,
76            last_scan_sha: None,
77            last_scan_at: None,
78            last_run_id: None,
79            enabled: true,
80        }
81    }
82
83    pub fn new_poll(repo_url: String, branch: String, interval_secs: u64, label: String) -> Self {
84        Self {
85            id: Uuid::new_v4(),
86            label,
87            repo_url,
88            branch,
89            kind: ScanScheduleKind::Poll,
90            provider: ScanScheduleProvider::Any,
91            webhook_secret: None,
92            interval_secs: Some(interval_secs),
93            last_scan_sha: None,
94            last_scan_at: None,
95            last_run_id: None,
96            enabled: true,
97        }
98    }
99}
100
101fn generate_secret() -> String {
102    format!("{}-{}", Uuid::new_v4().simple(), Uuid::new_v4().simple())
103}
104
105// ── store ─────────────────────────────────────────────────────────────────────
106
107#[derive(Debug, Default, Serialize, Deserialize)]
108pub struct ScheduleStore {
109    pub schedules: Vec<ScanSchedule>,
110}
111
112impl ScheduleStore {
113    pub fn load(path: &Path) -> Self {
114        std::fs::read_to_string(path)
115            .ok()
116            .and_then(|s| serde_json::from_str(&s).ok())
117            .unwrap_or_default()
118    }
119
120    pub fn save(&self, path: &Path) -> Result<()> {
121        let json = serde_json::to_string_pretty(self)?;
122        std::fs::write(path, json)?;
123        Ok(())
124    }
125
126    pub fn find_matching<'a>(&'a self, repo_url: &str, branch: &str) -> Vec<&'a ScanSchedule> {
127        self.schedules
128            .iter()
129            .filter(|s| s.enabled && urls_match(&s.repo_url, repo_url) && s.branch == branch)
130            .collect()
131    }
132
133    pub fn by_id_mut(&mut self, id: Uuid) -> Option<&mut ScanSchedule> {
134        self.schedules.iter_mut().find(|s| s.id == id)
135    }
136
137    pub fn remove(&mut self, id: Uuid) {
138        self.schedules.retain(|s| s.id != id);
139    }
140}
141
142fn urls_match(a: &str, b: &str) -> bool {
143    normalize_url(a) == normalize_url(b)
144}
145
146fn normalize_url(url: &str) -> String {
147    url.trim_end_matches('/')
148        .trim_end_matches(".git")
149        .to_lowercase()
150}