1use std::path::Path;
5
6use anyhow::Result;
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use uuid::Uuid;
10
11#[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#[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 #[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#[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 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}