anvilsign in

collin/anvil

1//! Periodic background jobs: repository scanning, disk usage caching, etc.
2//!
3//! Jobs run on configurable intervals and log errors without affecting the
4//! server. The runner is spawned in `anvil-cli/src/main.rs::serve()` alongside
5//! the HTTP server and CI worker.
6
7use std::{
8 path::Path,
9 time::Duration,
10};
11
12use async_trait::async_trait;
13use tokio::time::interval;
14
15use crate::{
16 App,
17 Result,
18 language,
19 models::Repository,
20 preview_images,
21 repos,
22 usage,
23};
24
25/// A periodic task to be run on an interval.
26#[async_trait]
27pub trait PeriodicJob: Send + Sync {
28 /// Run the job once. Errors are logged but do not stop the runner.
29 async fn run(&self, app: &App) -> Result<()>;
30
31 /// Human-readable name for logging.
32 fn name(&self) -> &str;
33}
34
35/// Spawns a background task that runs jobs on their configured intervals.
36/// The task runs until the app shuts down.
37pub async fn spawn_runner(app: App, jobs: Vec<(Duration, Box<dyn PeriodicJob>)>) {
38 tokio::spawn(async move {
39 let mut tasks = Vec::new();
40 for (interval_duration, job) in jobs {
41 let mut ticker = interval(interval_duration);
42 let app = app.clone();
43 tasks.push(tokio::spawn(async move {
44 loop {
45 ticker.tick().await;
46 if let Err(e) = job.run(&app).await {
47 tracing::error!(
48 job = job.name(),
49 error = %e,
50 "periodic job failed"
51 );
52 }
53 }
54 }));
55 }
56 // All tasks are spawned and run forever; if any exits, others continue.
57 // We could join them here with `futures::future::join_all(tasks).await`,
58 // but detaching is fine — tasks clean up automatically on shutdown.
59 });
60}
61
62/// Job that detects programming languages in repositories.
63pub struct LanguageDetectionJob;
64
65#[async_trait::async_trait]
66impl PeriodicJob for LanguageDetectionJob {
67 async fn run(&self, app: &App) -> Result<()> {
68 let repos_dir = app.config.repositories_dir();
69 let mut db = app.db.clone();
70 let repos_list = Repository::all().exec(&mut db).await?;
71 let repo_count = repos_list.len();
72
73 for repo in repos_list {
74 let repo_path = repos_dir
75 .join(repo.owner_id.to_string())
76 .join(&repo.name)
77 .join(".git");
78 if repo_path.exists()
79 && let Ok(detection) = language::detect(&repo_path).await
80 {
81 let _ =
82 repos::update_languages(&app.db, repo.id, &detection.primary, &detection.json)
83 .await;
84 }
85 }
86 tracing::info!("language detection job completed for {} repos", repo_count);
87 Ok(())
88 }
89
90 fn name(&self) -> &str {
91 "language_detection"
92 }
93}
94
95/// Job that extracts preview images from repository READMEs.
96pub struct PreviewImageJob;
97
98#[async_trait::async_trait]
99impl PeriodicJob for PreviewImageJob {
100 async fn run(&self, app: &App) -> Result<()> {
101 let repos_dir = app.config.repositories_dir();
102 let mut db = app.db.clone();
103 let repos_list = Repository::all().exec(&mut db).await?;
104
105 let mut updated = 0;
106 for repo in repos_list {
107 let repo_git_path = repos_dir
108 .join(repo.owner_id.to_string())
109 .join(&repo.name)
110 .join(".git");
111 if repo_git_path.exists() {
112 // Try to extract preview image from README in the repository
113 if let Some(image_url) = extract_readme_image(&repo_git_path).await {
114 // TODO: download and store the image, or just reference it
115 // For now, we'll just store the URL as a hash placeholder
116 if !image_url.is_empty() {
117 let _ = repos::update_preview_image(&app.db, repo.id, &image_url).await;
118 updated += 1;
119 }
120 }
121 }
122 }
123 tracing::info!("preview image job completed: {} repos updated", updated);
124 Ok(())
125 }
126
127 fn name(&self) -> &str {
128 "preview_images"
129 }
130}
131
132/// Job that caches disk usage for the admin dashboard.
133pub struct DiskUsageCacheJob;
134
135#[async_trait::async_trait]
136impl PeriodicJob for DiskUsageCacheJob {
137 async fn run(&self, app: &App) -> Result<()> {
138 // Compute disk usage and cache it
139 if let Ok(usage_data) = usage::compute(app).await {
140 let json = serde_json::to_string(&usage_data)?;
141 // Store in admin_cache table
142 crate::admin_cache::upsert(&app.db, "disk_usage", &json).await?;
143 tracing::info!("disk usage cache job completed");
144 }
145 Ok(())
146 }
147
148 fn name(&self) -> &str {
149 "disk_usage_cache"
150 }
151}
152
153/// Try to extract the first image URL from a repository's README file.
154/// Scans the repository for a README file, reads it, and returns the first image URL found.
155async fn extract_readme_image(repo_path: &Path) -> Option<String> {
156 // Find README file in the repository (case-insensitive)
157 let readme_names = [
158 "README.md",
159 "readme.md",
160 "README.MD",
161 "README.rst",
162 "README.txt",
163 ];
164
165 for readme_name in &readme_names {
166 let readme_path = repo_path.parent()?.join(readme_name);
167 if readme_path.exists()
168 && let Ok(content) = std::fs::read_to_string(&readme_path)
169 && let Some(url) = preview_images::extract_preview_url(&content)
170 {
171 return Some(url);
172 }
173 }
174
175 None
176}